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:
parent
e20e1b74dc
commit
a00485cf13
6 changed files with 239 additions and 4 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
88
apps/web/features/issues/utils/redact.test.ts
Normal file
88
apps/web/features/issues/utils/redact.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
37
apps/web/features/issues/utils/redact.ts
Normal file
37
apps/web/features/issues/utils/redact.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue