diff --git a/src/selfhost/sentry.ts b/src/selfhost/sentry.ts index 9017ce8b62..12d5af3221 100644 --- a/src/selfhost/sentry.ts +++ b/src/selfhost/sentry.ts @@ -127,10 +127,30 @@ const SUMMARY_SKIP_KEYS = new Set([ "pullNumber", "deliveryId", ]); +function redactSummaryValue(value: unknown, depth = 0): unknown { + if (!value || typeof value !== "object") return value; + if (depth >= 6) return "[redacted]"; + if (Array.isArray(value)) + return value.map((item) => redactSummaryValue(item, depth + 1)); + return Object.fromEntries( + Object.entries(value as Record).map(([key, nested]) => [ + key, + SECRET_KEY.test(key) + ? "[redacted]" + : redactSummaryValue(nested, depth + 1), + ]), + ); +} + function summarizeLogFields(obj: Record): string { return Object.entries(obj) - .filter(([k, v]) => !SUMMARY_SKIP_KEYS.has(k) && v !== null) - .map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : String(v)}`) + .filter( + ([k, v]) => !SUMMARY_SKIP_KEYS.has(k) && !SECRET_KEY.test(k) && v !== null, + ) + .map( + ([k, v]) => + `${k}=${typeof v === "object" ? JSON.stringify(redactSummaryValue(v)) : String(v)}`, + ) .filter((part) => part.length <= 90) // a long blob (id/body) belongs in the context, not the title .slice(0, 5) // a few salient fields, not a dump .join(", "); diff --git a/test/unit/selfhost-sentry.test.ts b/test/unit/selfhost-sentry.test.ts index 50bad0517f..896d61a28a 100644 --- a/test/unit/selfhost-sentry.test.ts +++ b/test/unit/selfhost-sentry.test.ts @@ -299,6 +299,69 @@ describe("forwardStructuredLogToSentry — central console.log → Sentry error "project=JSONbored/gittensory, closePrecision=0.6, floor=0.8", ); }); + + it("does not promote secret-keyed scalar fields into no-message titles (regression)", async () => { + await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToSentry( + JSON.stringify({ + level: "error", + event: "attacker_controlled_error", + token: "gts_SUPER_SECRET_TOKEN_12345", + apiKey: "shh", + repository: "owner/repo", + pullNumber: 7, + project: "safe-project", + }), + ); + + expect(lastCapturedError().name).toBe("attacker_controlled_error"); + expect(lastCapturedError().message).toBe( + "(owner/repo#7) project=safe-project", + ); + expect(lastCapturedError().message).not.toContain( + "gts_SUPER_SECRET_TOKEN_12345", + ); + expect(lastCapturedError().message).not.toContain("shh"); + }); + + it("redacts nested secret-keyed values before summarizing object fields", async () => { + await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToSentry( + JSON.stringify({ + level: "error", + event: "provider_metadata_failed", + provider: { name: "github", token: "nested-secret" }, + attempts: [ + { name: "first", privateKey: "nested-key" }, + "retry", + ], + }), + ); + + expect(lastCapturedError().name).toBe("provider_metadata_failed"); + expect(lastCapturedError().message).toBe( + 'provider={"name":"github","token":"[redacted]"}, attempts=[{"name":"first","privateKey":"[redacted]"},"retry"]', + ); + expect(lastCapturedError().message).not.toContain("nested-secret"); + expect(lastCapturedError().message).not.toContain("nested-key"); + }); + + it("redacts deeply nested summary objects instead of serializing past the depth cap", async () => { + await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToSentry( + JSON.stringify({ + level: "error", + event: "deep_provider_metadata_failed", + meta: { a: { b: { c: { d: { e: { f: { token: "deep-secret" } } } } } } }, + }), + ); + + expect(lastCapturedError().name).toBe("deep_provider_metadata_failed"); + expect(lastCapturedError().message).toBe( + 'meta={"a":{"b":{"c":{"d":{"e":{"f":"[redacted]"}}}}}}', + ); + expect(lastCapturedError().message).not.toContain("deep-secret"); + }); }); describe("installStructuredLogForwarding — central console sink instrumentation (#1468)", () => {