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.
This commit is contained in:
Jiayuan 2026-03-30 23:38:49 +08:00
parent e20e1b74dc
commit a00485cf13
6 changed files with 239 additions and 4 deletions

View file

@ -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 && (
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-32 overflow-auto rounded bg-muted/50 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{JSON.stringify(item.input, null, 2)}
{redactSecrets(JSON.stringify(item.input, null, 2))}
</pre>
</CollapsibleContent>
)}

View file

@ -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);
}
});
});

View file

@ -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;
}

View file

@ -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)

View file

@ -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 <token>" 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.

View file

@ -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"