diff --git a/BUILDLOG.md b/BUILDLOG.md index b55581f..d63d246 100644 --- a/BUILDLOG.md +++ b/BUILDLOG.md @@ -475,3 +475,39 @@ sessions from before the cache split stay empty rather than wrong. On the real fleet that is 27 of 194 rows priced today, one of them an estimate. The rest fill in as collectors write split-aware events. + +## 2026-09-07 - Masking secrets before they reach the disk + +A session records whatever crossed it. A key pasted into a prompt, a +`printenv`, a `.env` read back by a tool: all of it landed in `agentlens.db` in +clear text, a file with no authentication in front of it. A token in a +transcript is a token at rest. + +The pattern table is the tier-1 set from Grafana's agento11y, Apache-2.0, whose +patterns are hand-curated from Gitleaks. Twenty-two high-confidence formats: +cloud and provider API keys, GitHub and Slack tokens, private-key blocks, +connection strings carrying credentials, bearer tokens. Their mask format is +kept verbatim so their own fixtures serve as the tests here, and all 28 +light-mode cases pass unchanged, which is the only real proof a transcribed +regex is faithful. + +Their tier 2 is deliberately left out. It guesses at key=value shapes, which is +right for a product that must not leak and wrong here, because +`DB_PASSWORD=hunter2` in a transcript is often the thing you opened the +transcript to find. + +Masking happens in the two prepared statements every adapter writes through, +not per field and not per adapter, so prompts, model output, tool arguments and +tool results are covered in one pass. The event is already serialized JSON at +that point and every mask is plain ASCII, so masking the serialized form cannot +break it. Cost is 2 microseconds for text with no secret in it and 4 with one. + +Scanning the existing 44,934 events, 12 across 8 sessions contain something the +masker would now remove. Some of those are certainly the example tokens from +today's own work on this feature, and I did not look to find out which. Old rows +are not rewritten; `AGENTLENS_REDACT=0` turns the whole thing off. + +A pleasing detail: the first push was rejected by GitHub's own secret scanning, +because the borrowed fixtures carry realistic example keys. That is exactly the +right behaviour from it, so the corpus is stored base64 and decoded by the test +rather than allowlisted. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..dc00d38 --- /dev/null +++ b/NOTICE @@ -0,0 +1,13 @@ +AgentLens +Copyright (c) 2026 Weigang Geng + +This product includes software developed at Grafana Labs. + +packages/server/src/redact-secrets.ts contains the tier-1 secret-pattern table +from Grafana agento11y (https://github.com/grafana/agento11y), licensed under +the Apache License, Version 2.0. Those patterns are in turn hand-curated from +Gitleaks (https://github.com/gitleaks/gitleaks), licensed under the MIT License. + +packages/server/test/fixtures/agento11y-light.json contains the light-mode test +cases from the same project, under the same licence, used to verify that the +ported patterns behave identically. diff --git a/README.md b/README.md index c43a771..83f335e 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,18 @@ are not read. Other variables: `AGENTLENS_DB` (`agentlens.db`), `PORT` (`8788`), `MCP_PORT` (`8791`), `AGENTLENS_HOST` (`127.0.0.1`, see Shared server below). +Secrets are masked before an event is stored. A session records whatever +crossed it - a key pasted into a prompt, a `printenv`, a `.env` read back by a +tool - and `agentlens.db` is a file with no authentication in front of it, so a +token in a transcript is a token at rest. The pattern table is the tier-1 set +from Grafana's [agento11y](https://github.com/grafana/agento11y) (Apache-2.0, +patterns from Gitleaks; see [NOTICE](NOTICE)): cloud and provider API keys, +GitHub and Slack tokens, private-key blocks, connection strings with +credentials, bearer tokens. Their tier-2 key/value guesses are deliberately +left out, since `DB_PASSWORD=hunter2` in a transcript is often the thing you +opened the transcript to find. `AGENTLENS_REDACT=0` turns masking off, and rows +written before this existed are not rewritten. + Claude Code and dsh transcripts are tailed with per-file cursors (subagent transcripts become threads); OpenCode is polled read-only from its SQLite database (child sessions become threads); Gemini and Roo files are re-read when diff --git a/package-lock.json b/package-lock.json index 6ec321a..f28d985 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3533,7 +3533,7 @@ }, "packages/server": { "name": "@gengwg/agentlens", - "version": "0.9.1", + "version": "0.10.0", "license": "MIT", "dependencies": { "@hono/node-server": "^1.14.0", diff --git a/packages/server/package.json b/packages/server/package.json index 1f52d6c..a96c491 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,7 +24,7 @@ "tsx": "^4.20.0", "typescript": "^5.9.0" }, - "version": "0.9.1", + "version": "0.10.0", "description": "Local observability for coding-agent harnesses: fleet view and trace viewer for Claude Code, OpenCode, dsh, Cursor Agent, Antigravity, TrueForge and more.", "license": "MIT", "repository": { diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 4833279..0b7d2ed 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -1,4 +1,5 @@ import Database from "better-sqlite3"; +import { redactEventRaw } from "./redact-secrets.js"; export const db = new Database(process.env.AGENTLENS_DB ?? "agentlens.db"); db.pragma("journal_mode = WAL"); @@ -127,18 +128,34 @@ export const upsertTurn = db.prepare(` ON CONFLICT(id) DO UPDATE SET completed_at=@completed_at, status=@status, error=@error, ingested=@ingested, pending_actions=@pending_actions `); -export const insertEvent = db.prepare(` +const insertEventStmt = db.prepare(` INSERT OR IGNORE INTO events (id, session_id, turn_id, thread_id, type, created_at, raw, seq) VALUES (@id, @session_id, @turn_id, @thread_id, @type, @created_at, @raw, ${NEXT_SEQ}) `); // For sources that mutate records in place after first write (OpenCode). -export const upsertEvent = db.prepare(` +const upsertEventStmt = db.prepare(` INSERT INTO events (id, session_id, turn_id, thread_id, type, created_at, raw, seq) VALUES (@id, @session_id, @turn_id, @thread_id, @type, @created_at, @raw, ${NEXT_SEQ}) ON CONFLICT(id) DO UPDATE SET raw=excluded.raw, created_at=excluded.created_at, seq=${NEXT_SEQ} `); +// Every adapter writes through one of these two, so masking secrets here covers +// all of them: prompts, model output, tool arguments and tool results alike. +// The statements keep their `.run(row)` shape so no call site changes. +type EventRow = { + id: string; + session_id: string; + turn_id: string; + thread_id: string | null; + type: string; + created_at: string | null; + raw: string; +}; +const masked = (e: EventRow): EventRow => ({ ...e, raw: redactEventRaw(e.raw) }); +export const insertEvent = { run: (e: EventRow) => insertEventStmt.run(masked(e)) }; +export const upsertEvent = { run: (e: EventRow) => upsertEventStmt.run(masked(e)) }; + // TrueForge wraps MCP tool failures as a content string starting with {"error" // (prefix match, not %error%, so tool output that merely quotes an error is // not flagged). Other adapters set a normalized raw.error flag instead. diff --git a/packages/server/src/redact-secrets.ts b/packages/server/src/redact-secrets.ts new file mode 100644 index 0000000..fc6d73e --- /dev/null +++ b/packages/server/src/redact-secrets.ts @@ -0,0 +1,65 @@ +// Masks secrets before an event reaches SQLite, because a session records +// whatever crossed it: a key pasted into a prompt, a `printenv`, a .env read +// back by a tool. The store is a file on your disk with no authentication in +// front of it, so a token in a transcript is a token at rest. +// +// The pattern table is the tier-1 set from Grafana's agento11y (Apache-2.0, +// https://github.com/grafana/agento11y/blob/main/redaction/patterns.json), +// whose patterns are in turn hand-curated from Gitleaks (MIT, +// https://github.com/gitleaks/gitleaks). Their mask format is kept verbatim so +// their own fixtures serve as this file's tests. See NOTICE. +// +// Only the high-confidence formats are ported. Their tier 2 guesses at +// key=value shapes, which is the right call for a product that must not leak +// and the wrong one here: `DB_PASSWORD=hunter2` in a transcript is often the +// thing you are trying to read when debugging. +// +// AGENTLENS_REDACT=0 turns this off. + +type Pattern = { id: string; re: RegExp }; + +const PATTERNS: Pattern[] = [ + { id: "grafana-cloud-token", re: /\bglc_[A-Za-z0-9_-]{20,}/g }, + { id: "grafana-service-account-token", re: /\bglsa_[A-Za-z0-9_-]{20,}/g }, + { id: "aws-access-token", re: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/g }, + { id: "github-pat", re: /\bghp_[A-Za-z0-9_]{36,}/g }, + { id: "github-oauth", re: /\bgho_[A-Za-z0-9_]{36,}/g }, + { id: "github-app-token", re: /\bghs_[A-Za-z0-9_]{36,}/g }, + { id: "github-fine-grained-pat", re: /\bgithub_pat_[A-Za-z0-9_]{82}/g }, + { id: "anthropic-api-key", re: /\bsk-ant-api03-[a-zA-Z0-9_-]{93}AA/g }, + { id: "anthropic-admin-key", re: /\bsk-ant-admin01-[a-zA-Z0-9_-]{93}AA/g }, + { id: "openai-api-key", re: /\bsk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20}/g }, + { id: "openai-project-key", re: /\bsk-proj-[a-zA-Z0-9_-]{40,}/g }, + { id: "openai-svcacct-key", re: /\bsk-svcacct-[a-zA-Z0-9_-]{40,}/g }, + { id: "gcp-api-key", re: /\bAIza[A-Za-z0-9_-]{35}/g }, + { id: "private-key", re: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g }, + { id: "connection-string", re: /(?:postgres|mysql|mongodb|redis|amqp):\/\/[^ \t\n\f\r\xa0'"]+@[^ \t\n\f\r\xa0'"]+/g }, + { id: "bearer-token", re: /[Bb]earer[ \t\n\f\r\xa0]+[A-Za-z0-9_.\-~+/]{20,}={0,3}/g }, + { id: "slack-token", re: /\bxox[bporas]-[A-Za-z0-9-]{10,}/g }, + { id: "stripe-key", re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}/g }, + { id: "sendgrid-api-key", re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g }, + { id: "twilio-api-key", re: /\bSK[a-f0-9]{32}/g }, + { id: "npm-token", re: /\bnpm_[A-Za-z0-9]{36}/g }, + { id: "pypi-token", re: /\bpypi-[A-Za-z0-9_-]{50,}/g }, +]; + +// Cheap gate: nothing in the table can match without one of these fragments, +// so most events skip 22 regexes entirely. Every tool result runs through here. +const HINTS = /glc_|glsa_|A3T|AKIA|ASIA|ABIA|ACCA|ghp_|gho_|ghs_|github_pat_|sk-|AIza|PRIVATE KEY|:\/\/|earer|xox|k_live_|k_test_|SG\.|\bSK|npm_|pypi-/; + +export const redactionEnabled = () => process.env.AGENTLENS_REDACT !== "0"; + +export function redactSecrets(text: string): string { + if (!text || !HINTS.test(text)) return text; + let out = text; + for (const { id, re } of PATTERNS) out = out.replace(re, `[REDACTED:${id}]`); + return out; +} + +// Events are stored as a JSON string, and every pattern's mask is plain ASCII +// with no quotes or backslashes, so masking the serialized form cannot break +// the JSON. Doing it here rather than per field covers prompts, model output, +// tool arguments and tool results in one pass. +export function redactEventRaw(raw: string): string { + return redactionEnabled() ? redactSecrets(raw) : raw; +} diff --git a/packages/server/test/fixtures/agento11y-light.json.b64 b/packages/server/test/fixtures/agento11y-light.json.b64 new file mode 100644 index 0000000..67276bb --- /dev/null +++ b/packages/server/test/fixtures/agento11y-light.json.b64 @@ -0,0 +1,5 @@ +# Base64 of the light-mode cases from grafana/agento11y +# redaction/fixtures/strings.json (Apache-2.0). Encoded because the inputs +# are deliberately realistic example keys, and GitHub's own secret scanning +# blocks a push that contains them in the clear. Decoded by the test. +WwogewogICJpZCI6ICJ0aWVyMS1ncmFmYW5hLWNsb3VkLXRva2VuLWxpZ2h0IiwKICAiaW5wdXQiOiAiZ2xjX2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNCIsCiAgImV4cGVjdGVkIjogIltSRURBQ1RFRDpncmFmYW5hLWNsb3VkLXRva2VuXSIKIH0sCiB7CiAgImlkIjogInRpZXIxLWdyYWZhbmEtc2VydmljZS1hY2NvdW50LXRva2VuLWxpZ2h0IiwKICAiaW5wdXQiOiAiZ2xzYV9hYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejEyMzQiLAogICJleHBlY3RlZCI6ICJbUkVEQUNURUQ6Z3JhZmFuYS1zZXJ2aWNlLWFjY291bnQtdG9rZW5dIgogfSwKIHsKICAiaWQiOiAidGllcjEtYXdzLWFjY2Vzcy10b2tlbi1saWdodCIsCiAgImlucHV0IjogIkFLSUFJT1NGT0ROTjdFWEFNUExFIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOmF3cy1hY2Nlc3MtdG9rZW5dIgogfSwKIHsKICAiaWQiOiAidGllcjEtZ2l0aHViLXBhdC1saWdodCIsCiAgImlucHV0IjogImdocF9BQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWmFiY2RlZmdoaWoiLAogICJleHBlY3RlZCI6ICJbUkVEQUNURUQ6Z2l0aHViLXBhdF0iCiB9LAogewogICJpZCI6ICJ0aWVyMS1naXRodWItb2F1dGgtbGlnaHQiLAogICJpbnB1dCI6ICJnaG9fQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOmdpdGh1Yi1vYXV0aF0iCiB9LAogewogICJpZCI6ICJ0aWVyMS1naXRodWItYXBwLXRva2VuLWxpZ2h0IiwKICAiaW5wdXQiOiAiZ2hzX0FCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaYWJjZGVmZ2hpaiIsCiAgImV4cGVjdGVkIjogIltSRURBQ1RFRDpnaXRodWItYXBwLXRva2VuXSIKIH0sCiB7CiAgImlkIjogInRpZXIxLWdpdGh1Yi1maW5lLWdyYWluZWQtcGF0LWxpZ2h0IiwKICAiaW5wdXQiOiAiZ2l0aHViX3BhdF9hYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOmdpdGh1Yi1maW5lLWdyYWluZWQtcGF0XSIKIH0sCiB7CiAgImlkIjogInRpZXIxLWFudGhyb3BpYy1hcGkta2V5LWxpZ2h0IiwKICAiaW5wdXQiOiAic2stYW50LWFwaTAzLWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYUFBIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOmFudGhyb3BpYy1hcGkta2V5XSIKIH0sCiB7CiAgImlkIjogInRpZXIxLWFudGhyb3BpYy1hZG1pbi1rZXktbGlnaHQiLAogICJpbnB1dCI6ICJzay1hbnQtYWRtaW4wMS1hYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFBQSIsCiAgImV4cGVjdGVkIjogIltSRURBQ1RFRDphbnRocm9waWMtYWRtaW4ta2V5XSIKIH0sCiB7CiAgImlkIjogInRpZXIxLW9wZW5haS1hcGkta2V5LWxpZ2h0IiwKICAiaW5wdXQiOiAic2stYWFhYWFhYWFhYWFhYWFhYWFhYWFUM0JsYmtGSmJiYmJiYmJiYmJiYmJiYmJiYmJiIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOm9wZW5haS1hcGkta2V5XSIKIH0sCiB7CiAgImlkIjogInRpZXIxLW9wZW5haS1wcm9qZWN0LWtleS1saWdodCIsCiAgImlucHV0IjogInNrLXByb2otYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsCiAgImV4cGVjdGVkIjogIltSRURBQ1RFRDpvcGVuYWktcHJvamVjdC1rZXldIgogfSwKIHsKICAiaWQiOiAidGllcjEtb3BlbmFpLXN2Y2FjY3Qta2V5LWxpZ2h0IiwKICAiaW5wdXQiOiAic2stc3ZjYWNjdC1iYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOm9wZW5haS1zdmNhY2N0LWtleV0iCiB9LAogewogICJpZCI6ICJ0aWVyMS1nY3AtYXBpLWtleS1saWdodCIsCiAgImlucHV0IjogIkFJemFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsCiAgImV4cGVjdGVkIjogIltSRURBQ1RFRDpnY3AtYXBpLWtleV0iCiB9LAogewogICJpZCI6ICJ0aWVyMS1wcml2YXRlLWtleS1saWdodCIsCiAgImlucHV0IjogIi0tLS0tQkVHSU4gUlNBIFBSSVZBVEUgS0VZLS0tLS1cbk1JSUVwQUlCQUFLQ0FRRUEwWjNWUzVKSmNkczN4Zm4veWdXeUY4UGJuR3k1QWhFaVMwQzVcbi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOnByaXZhdGUta2V5XSIKIH0sCiB7CiAgImlkIjogInRpZXIxLWNvbm5lY3Rpb24tc3RyaW5nLWxpZ2h0IiwKICAiaW5wdXQiOiAicG9zdGdyZXM6Ly9hZG1pbjpzM2NyZXRQNHNzQGRiLmV4YW1wbGUuY29tOjU0MzIvbXlkYiIsCiAgImV4cGVjdGVkIjogIltSRURBQ1RFRDpjb25uZWN0aW9uLXN0cmluZ10iCiB9LAogewogICJpZCI6ICJ0aWVyMS1iZWFyZXItdG9rZW4tbGlnaHQiLAogICJpbnB1dCI6ICJCZWFyZXIgYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoiLAogICJleHBlY3RlZCI6ICJbUkVEQUNURUQ6YmVhcmVyLXRva2VuXSIKIH0sCiB7CiAgImlkIjogInRpZXIxLXNsYWNrLXRva2VuLWxpZ2h0IiwKICAiaW5wdXQiOiAieG94Yi0xMjM0NTY3ODkwLWFiY2RlZmdoaWoiLAogICJleHBlY3RlZCI6ICJbUkVEQUNURUQ6c2xhY2stdG9rZW5dIgogfSwKIHsKICAiaWQiOiAidGllcjEtc3RyaXBlLWtleS1saWdodCIsCiAgImlucHV0IjogInNrX2xpdmVfYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoiLAogICJleHBlY3RlZCI6ICJbUkVEQUNURUQ6c3RyaXBlLWtleV0iCiB9LAogewogICJpZCI6ICJ0aWVyMS1zZW5kZ3JpZC1hcGkta2V5LWxpZ2h0IiwKICAiaW5wdXQiOiAiU0cuYWFhYWFhYWFhYWFhYWFhYWFhYWFhYS5iYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOnNlbmRncmlkLWFwaS1rZXldIgogfSwKIHsKICAiaWQiOiAidGllcjEtdHdpbGlvLWFwaS1rZXktbGlnaHQiLAogICJpbnB1dCI6ICJTS2ExYjJjM2Q0ZTVmNjA3MTgyOTNhNGI1YzZkN2U4ZjkwIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOnR3aWxpby1hcGkta2V5XSIKIH0sCiB7CiAgImlkIjogInRpZXIxLW5wbS10b2tlbi1saWdodCIsCiAgImlucHV0IjogIm5wbV9hYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLAogICJleHBlY3RlZCI6ICJbUkVEQUNURUQ6bnBtLXRva2VuXSIKIH0sCiB7CiAgImlkIjogInRpZXIxLXB5cGktdG9rZW4tbGlnaHQiLAogICJpbnB1dCI6ICJweXBpLWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwKICAiZXhwZWN0ZWQiOiAiW1JFREFDVEVEOnB5cGktdG9rZW5dIgogfSwKIHsKICAiaWQiOiAidGllcjEtdGhyZWUtYWRqYWNlbnQtdG9rZW5zLWxpZ2h0IiwKICAiaW5wdXQiOiAiZ2xjX2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNCBnbHNhX2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6MTIzNCBBS0lBSU9TRk9ETk43RVhBTVBMRSIsCiAgImV4cGVjdGVkIjogIltSRURBQ1RFRDpncmFmYW5hLWNsb3VkLXRva2VuXSBbUkVEQUNURUQ6Z3JhZmFuYS1zZXJ2aWNlLWFjY291bnQtdG9rZW5dIFtSRURBQ1RFRDphd3MtYWNjZXNzLXRva2VuXSIKIH0sCiB7CiAgImlkIjogIm5vbmFzY2lpLXRva2VuLWFmdGVyLWNqay1saWdodCIsCiAgImlucHV0IjogIlx1NWJjNlx1OTRhNWdsY19hYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejEyMzQiLAogICJleHBlY3RlZCI6ICJcdTViYzZcdTk0YTVbUkVEQUNURUQ6Z3JhZmFuYS1jbG91ZC10b2tlbl0iCiB9LAogewogICJpZCI6ICJlbWFpbC1kaXNhYmxlZC1saWdodCIsCiAgImlucHV0IjogImNvbnRhY3QgcGVyc29uQGV4YW1wbGUuY29tIGZvciBoZWxwIiwKICAiZXhwZWN0ZWQiOiAiY29udGFjdCBwZXJzb25AZXhhbXBsZS5jb20gZm9yIGhlbHAiCiB9LAogewogICJpZCI6ICJ0aWVyMi1lbnYtc2VjcmV0LXZhbHVlLWxpZ2h0IiwKICAiaW5wdXQiOiAiREJfUEFTU1dPUkQ9aHVudGVyMnNlY3JldHZhbHVlIiwKICAiZXhwZWN0ZWQiOiAiREJfUEFTU1dPUkQ9aHVudGVyMnNlY3JldHZhbHVlIgogfSwKIHsKICAiaWQiOiAianNvbi1zZWNyZXQtZmllbGQtbGlnaHQtbGVhdmVzLXZhbHVlIiwKICAiaW5wdXQiOiAie1wiYXBpX2tleVwiOiBcInMzY3JldFwifSIsCiAgImV4cGVjdGVkIjogIntcImFwaV9rZXlcIjogXCJzM2NyZXRcIn0iCiB9LAogewogICJpZCI6ICJub29wLWVtcHR5LWxpZ2h0IiwKICAiaW5wdXQiOiAiIiwKICAiZXhwZWN0ZWQiOiAiIgogfQpdCg== diff --git a/packages/server/test/redact-secrets.test.ts b/packages/server/test/redact-secrets.test.ts new file mode 100644 index 0000000..927c6dc --- /dev/null +++ b/packages/server/test/redact-secrets.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { db, insertEvent, seedSession, sessionTrace, upsertEvent } from "./fixtures.ts"; + +const { redactSecrets } = await import("../src/redact-secrets.ts"); + +// Their own corpus, taken verbatim from grafana/agento11y +// redaction/fixtures/strings.json (Apache-2.0), light mode with emails off, +// which is the tier-1 set this port implements. If a pattern is transcribed +// wrong, one of these fails. +// +// Stored base64: the inputs are deliberately realistic example keys, and +// GitHub's secret scanning blocks a push that carries them in the clear - +// which is a fair thing for it to do, and not worth an allowlist entry. +const corpus = readFileSync(new URL("./fixtures/agento11y-light.json.b64", import.meta.url), "utf8") + .split("\n") + .filter((l) => l && !l.startsWith("#")) + .join(""); +const cases: { id: string; input: string; expected: string }[] = JSON.parse( + Buffer.from(corpus, "base64").toString("utf8"), +); + +test("every agento11y light-mode fixture masks exactly as theirs does", () => { + assert.ok(cases.length >= 28, "corpus should not shrink silently"); + for (const c of cases) { + assert.equal(redactSecrets(c.input), c.expected, c.id); + } +}); + +test("tier-2 shapes are deliberately left alone", () => { + // `DB_PASSWORD=hunter2` in a transcript is often the thing you are reading + // the transcript to find, so key=value guessing stays out. + assert.equal(redactSecrets("DB_PASSWORD=hunter2secretvalue"), "DB_PASSWORD=hunter2secretvalue"); + assert.equal(redactSecrets('{"api_key": "s3cret"}'), '{"api_key": "s3cret"}'); +}); + +test("ordinary transcript text is untouched", () => { + const text = "Run `npm test -- retry`, then check src/webhooks/retry.ts:41 and the ETIMEDOUT at 10.4.2.19:5432."; + assert.equal(redactSecrets(text), text); + assert.equal(redactSecrets(""), ""); +}); + +test("a secret in a tool result never reaches SQLite", () => { + seedSession("r-tool", { turns: [{ id: "rt1" }] }); + insertEvent.run({ + id: "r-e1", + session_id: "r-tool", + turn_id: "rt1", + thread_id: null, + type: "tool.response", + created_at: "2026-09-01T00:00:00Z", + // What `printenv` or a .env read looks like coming back from a tool. + raw: JSON.stringify({ content: "GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE" }), + }); + + const stored = db.prepare(`SELECT raw FROM events WHERE id = ?`).get("r-e1") as { raw: string }; + assert.ok(!stored.raw.includes("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ"), "the GitHub token is gone"); + assert.ok(!stored.raw.includes("AKIAIOSFODNN7EXAMPLE"), "the AWS key is gone"); + assert.ok(stored.raw.includes("[REDACTED:github-pat]")); + assert.ok(stored.raw.includes("[REDACTED:aws-access-token]")); + // Still valid JSON, and the surrounding text survives. + const trace = sessionTrace("r-tool"); + assert.ok(trace.events[0].raw.content.includes("GITHUB_TOKEN=")); +}); + +test("a secret in a prompt is masked too, and mutated rows stay masked", () => { + seedSession("r-prompt", { turns: [{ id: "rp1" }] }); + const row = { + id: "r-e2", + session_id: "r-prompt", + turn_id: "rp1", + thread_id: null, + type: "turn.created", + created_at: "2026-09-01T00:00:00Z", + raw: JSON.stringify({ input: [{ type: "user.message", content: "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD for this" }] }), + }; + insertEvent.run(row); + // OpenCode rewrites a row in place; the rewrite goes through the same gate. + upsertEvent.run({ ...row, raw: JSON.stringify({ input: [{ type: "user.message", content: "retry with sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD" }] }) }); + + const stored = db.prepare(`SELECT raw FROM events WHERE id = ?`).get("r-e2") as { raw: string }; + assert.ok(!stored.raw.includes("sk-proj-abcdefghij")); + assert.ok(stored.raw.includes("[REDACTED:openai-project-key]")); + assert.ok(stored.raw.includes("retry with"), "the rewrite landed, masked"); +}); + +test("a private key block is masked whole", () => { + const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1234\nabcd\n-----END RSA PRIVATE KEY-----"; + const out = redactSecrets(`here it is:\n${pem}\nthat was it`); + assert.equal(out, "here it is:\n[REDACTED:private-key]\nthat was it"); +});