From a00485cf135a1c08f33fc7539657b6044d916819 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Mon, 30 Mar 2026 23:38:49 +0800 Subject: [PATCH] feat(security): redact sensitive information in agent live output Server-side (primary): Apply redact.Text/InputMap on task message content, output, and input fields before DB persistence and WebSocket broadcast. Extended redact package with GitLab tokens, JWTs, connection strings, and PASSWORD/SECRET/TOKEN env var patterns. Frontend (fallback): redactSecrets utility mirrors server patterns, applied in buildTimeline and ToolCallRow render as a safety net. --- .../issues/components/agent-live-card.tsx | 7 +- apps/web/features/issues/utils/redact.test.ts | 88 +++++++++++++++++++ apps/web/features/issues/utils/redact.ts | 37 ++++++++ server/internal/handler/daemon.go | 6 ++ server/pkg/redact/redact.go | 28 +++++- server/pkg/redact/redact_test.go | 77 ++++++++++++++++ 6 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 apps/web/features/issues/utils/redact.test.ts create mode 100644 apps/web/features/issues/utils/redact.ts diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index f47fa34d..6d1de879 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -8,6 +8,7 @@ import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload } from import type { AgentTask } from "@/shared/types/agent"; import { cn } from "@/lib/utils"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { redactSecrets } from "../utils/redact"; // ─── Shared types & helpers ───────────────────────────────────────────────── @@ -83,9 +84,9 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { seq: msg.seq, type: msg.type, tool: msg.tool, - content: msg.content, + content: msg.content ? redactSecrets(msg.content) : msg.content, input: msg.input, - output: msg.output, + output: msg.output ? redactSecrets(msg.output) : msg.output, }); } return items.sort((a, b) => a.seq - b.seq); @@ -425,7 +426,7 @@ function ToolCallRow({ item }: { item: TimelineItem }) { {hasInput && (
-            {JSON.stringify(item.input, null, 2)}
+            {redactSecrets(JSON.stringify(item.input, null, 2))}
           
)} diff --git a/apps/web/features/issues/utils/redact.test.ts b/apps/web/features/issues/utils/redact.test.ts new file mode 100644 index 00000000..9aee9f03 --- /dev/null +++ b/apps/web/features/issues/utils/redact.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { redactSecrets } from "./redact"; + +describe("redactSecrets", () => { + it("redacts AWS access key", () => { + const result = redactSecrets("key: AKIAIOSFODNN7EXAMPLE"); + expect(result).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(result).toContain("[REDACTED AWS KEY]"); + }); + + it("redacts AWS secret key", () => { + const result = redactSecrets("aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); + expect(result).not.toContain("wJalrXUtnFEMI"); + }); + + it("redacts PEM private keys", () => { + const input = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----"; + const result = redactSecrets(input); + expect(result).not.toContain("MIIEow"); + expect(result).toContain("[REDACTED PRIVATE KEY]"); + }); + + it("redacts GitHub tokens", () => { + const result = redactSecrets("GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"); + expect(result).not.toContain("ghp_"); + }); + + it("redacts GitLab tokens", () => { + const result = redactSecrets("glpat-AbCdEfGhIjKlMnOpQrStUvWx"); + expect(result).not.toContain("glpat-"); + expect(result).toContain("[REDACTED GITLAB TOKEN]"); + }); + + it("redacts OpenAI/Anthropic API keys", () => { + const result = redactSecrets("sk-proj-abc123def456ghi789jkl012mno345"); + expect(result).not.toContain("sk-proj"); + expect(result).toContain("[REDACTED API KEY]"); + }); + + it("redacts Slack tokens", () => { + const result = redactSecrets("xoxb-123456789012-1234567890123-AbCdEfGhIjKl"); + expect(result).not.toContain("xoxb-"); + }); + + it("redacts JWT tokens", () => { + const result = redactSecrets("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"); + expect(result).not.toContain("eyJhbGci"); + expect(result).toContain("[REDACTED JWT]"); + }); + + it("redacts Bearer tokens", () => { + const result = redactSecrets("Authorization: Bearer abc123xyz.def456"); + expect(result).toContain("Bearer [REDACTED]"); + expect(result).not.toContain("abc123xyz"); + }); + + it("redacts connection strings", () => { + const result = redactSecrets("postgres://admin:s3cret@db.example.com:5432/mydb"); + expect(result).not.toContain("s3cret"); + }); + + it("redacts generic credential env vars", () => { + for (const key of ["PASSWORD", "SECRET", "TOKEN", "DATABASE_URL", "API_KEY"]) { + const result = redactSecrets(`${key}=supersecretvalue123`); + expect(result).toContain("[REDACTED CREDENTIAL]"); + expect(result).not.toContain("supersecretvalue123"); + } + }); + + it("redacts multiple secrets in one string", () => { + const result = redactSecrets("AKIAIOSFODNN7EXAMPLE and ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"); + expect(result).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(result).not.toContain("ghp_"); + }); + + it("does not alter normal text", () => { + const inputs = [ + "This is a normal commit message about fixing a bug", + "The function returns skip-navigation as the class name", + "Created PR #42 for the authentication feature", + "Running tests in /tmp/test-workspace/project", + "The API endpoint /api/issues/123 was updated", + ]; + for (const input of inputs) { + expect(redactSecrets(input)).toBe(input); + } + }); +}); diff --git a/apps/web/features/issues/utils/redact.ts b/apps/web/features/issues/utils/redact.ts new file mode 100644 index 00000000..066c8fa6 --- /dev/null +++ b/apps/web/features/issues/utils/redact.ts @@ -0,0 +1,37 @@ +/** + * Client-side fallback for redacting sensitive information in agent output. + * The server performs primary redaction; this is a safety net for display. + */ + +const patterns: { re: RegExp; replacement: string }[] = [ + // AWS access key IDs + { re: /\bAKIA[0-9A-Z]{16}\b/g, replacement: "[REDACTED AWS KEY]" }, + // AWS secret access keys + { re: /(?:aws_secret_access_key|secret_?access_?key)\s*[=:]\s*[A-Za-z0-9/+=]{40}/gi, replacement: "[REDACTED AWS SECRET]" }, + // PEM private keys + { re: /-----BEGIN[A-Z\s]*PRIVATE KEY-----[\s\S]*?-----END[A-Z\s]*PRIVATE KEY-----/g, replacement: "[REDACTED PRIVATE KEY]" }, + // GitHub tokens + { re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/g, replacement: "[REDACTED GITHUB TOKEN]" }, + // GitLab personal access tokens + { re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED GITLAB TOKEN]" }, + // OpenAI / Anthropic API keys + { re: /\bsk-[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED API KEY]" }, + // Slack tokens + { re: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g, replacement: "[REDACTED SLACK TOKEN]" }, + // JWT tokens + { re: /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replacement: "[REDACTED JWT]" }, + // Bearer tokens + { re: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi, replacement: "Bearer [REDACTED]" }, + // Connection strings with embedded passwords + { re: /(?:postgres|mysql|mongodb|redis|amqp)(?:ql)?:\/\/[^:\s]+:[^@\s]+@/gi, replacement: "[REDACTED CONNECTION STRING]@" }, + // Generic key=value secret env vars + { re: /(?:API_KEY|API_SECRET|SECRET_KEY|SECRET|ACCESS_TOKEN|AUTH_TOKEN|PRIVATE_KEY|DATABASE_URL|DB_PASSWORD|DB_URL|REDIS_URL|PASSWORD|TOKEN)\s*[=:]\s*\S+/gi, replacement: "[REDACTED CREDENTIAL]" }, +]; + +export function redactSecrets(text: string): string { + let result = text; + for (const { re, replacement } of patterns) { + result = result.replace(re, replacement); + } + return result; +} diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index b5bf102c..d91b9376 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -11,6 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" + "github.com/multica-ai/multica/server/pkg/redact" ) // --------------------------------------------------------------------------- @@ -430,6 +431,11 @@ func (h *Handler) ReportTaskMessages(w http.ResponseWriter, r *http.Request) { } for _, msg := range req.Messages { + // Redact sensitive information before persisting or broadcasting. + msg.Content = redact.Text(msg.Content) + msg.Output = redact.Text(msg.Output) + msg.Input = redact.InputMap(msg.Input) + var inputJSON []byte if msg.Input != nil { inputJSON, _ = json.Marshal(msg.Input) diff --git a/server/pkg/redact/redact.go b/server/pkg/redact/redact.go index 878b88d8..6a64ca7b 100644 --- a/server/pkg/redact/redact.go +++ b/server/pkg/redact/redact.go @@ -35,11 +35,37 @@ var patterns = []secretPattern{ // Slack tokens {regexp.MustCompile(`\bxox[bporas]-[A-Za-z0-9\-]{10,}\b`), "[REDACTED SLACK TOKEN]"}, + // GitLab personal access tokens + {regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{20,}\b`), "[REDACTED GITLAB TOKEN]"}, + + // JWT tokens (three base64url segments) + {regexp.MustCompile(`\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`), "[REDACTED JWT]"}, + // Generic "Bearer " in output {regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b`), "Bearer [REDACTED]"}, + // Connection strings with embedded passwords + {regexp.MustCompile(`(?i)(?:postgres|mysql|mongodb|redis|amqp)(?:ql)?://[^:\s]+:[^@\s]+@`), "[REDACTED CONNECTION STRING]@"}, + // Generic key=value patterns for common secret env var names - {regexp.MustCompile(`(?i)(?:API_KEY|API_SECRET|SECRET_KEY|ACCESS_TOKEN|AUTH_TOKEN|PRIVATE_KEY|DATABASE_URL|DB_PASSWORD|REDIS_URL)\s*[=:]\s*\S+`), "[REDACTED CREDENTIAL]"}, + {regexp.MustCompile(`(?i)(?:API_KEY|API_SECRET|SECRET_KEY|SECRET|ACCESS_TOKEN|AUTH_TOKEN|PRIVATE_KEY|DATABASE_URL|DB_PASSWORD|DB_URL|REDIS_URL|PASSWORD|TOKEN)\s*[=:]\s*\S+`), "[REDACTED CREDENTIAL]"}, +} + +// InputMap returns a copy of m with all string values passed through Text. +// Non-string values are preserved as-is. +func InputMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + out := make(map[string]any, len(m)) + for k, v := range m { + if s, ok := v.(string); ok { + out[k] = Text(s) + } else { + out[k] = v + } + } + return out } // homeDir is resolved once at init for path redaction. diff --git a/server/pkg/redact/redact_test.go b/server/pkg/redact/redact_test.go index 13100624..3caab19f 100644 --- a/server/pkg/redact/redact_test.go +++ b/server/pkg/redact/redact_test.go @@ -126,6 +126,83 @@ func TestNoFalsePositivesOnNormalText(t *testing.T) { } } +func TestRedactGitLabToken(t *testing.T) { + t.Parallel() + input := "GITLAB_TOKEN=glpat-AbCdEfGhIjKlMnOpQrStUvWx" + got := Text(input) + if strings.Contains(got, "glpat-") { + t.Fatalf("GitLab token not redacted: %s", got) + } +} + +func TestRedactJWT(t *testing.T) { + t.Parallel() + input := "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + got := Text(input) + if strings.Contains(got, "eyJhbGci") { + t.Fatalf("JWT not redacted: %s", got) + } +} + +func TestRedactConnectionString(t *testing.T) { + t.Parallel() + input := "connecting to postgres://admin:s3cret@db.example.com:5432/mydb" + got := Text(input) + if strings.Contains(got, "s3cret") { + t.Fatalf("connection string password not redacted: %s", got) + } +} + +func TestRedactPasswordEnvVar(t *testing.T) { + t.Parallel() + cases := []struct { + name string + input string + }{ + {"PASSWORD", "PASSWORD=hunter2"}, + {"SECRET", "SECRET=mysecretvalue"}, + {"TOKEN", "TOKEN=abc123xyz"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Text(tc.input) + if !strings.Contains(got, "[REDACTED CREDENTIAL]") { + t.Fatalf("expected credential redaction for %s, got: %s", tc.name, got) + } + }) + } +} + +func TestInputMap(t *testing.T) { + t.Parallel() + m := map[string]any{ + "command": "echo sk-proj-abc123def456ghi789jkl012mno345", + "file_path": "/tmp/test.txt", + "count": 42, + } + got := InputMap(m) + if s, ok := got["command"].(string); ok { + if strings.Contains(s, "sk-proj") { + t.Fatalf("API key in input map not redacted: %s", s) + } + } + // Non-string values preserved + if got["count"] != 42 { + t.Fatalf("non-string value altered: %v", got["count"]) + } + // Clean strings unchanged + if got["file_path"] != "/tmp/test.txt" { + t.Fatalf("clean string altered: %v", got["file_path"]) + } +} + +func TestInputMapNil(t *testing.T) { + t.Parallel() + if got := InputMap(nil); got != nil { + t.Fatalf("expected nil, got: %v", got) + } +} + func TestRedactMultipleSecrets(t *testing.T) { t.Parallel() input := "Keys: AKIAIOSFODNN7EXAMPLE and ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"