diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts index a7fbbd4a..cb990afe 100644 --- a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts +++ b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts @@ -41,6 +41,45 @@ describe("redactBody", () => { expect(redactBody(`\`${sha}\``).reasons).toEqual([]); }); + test("redacts quoted JSON keys and values containing spaces", () => { + // The old pattern used \b around the key, so a quote-adjacent key like + // "api_key" in a pasted JSON body never matched and the secret went out + // unredacted. It also stopped the value at the first space, leaking + // `Bearer ` tails. + expect( + redactBody('{"Authorization": "Bearer eyJhbG.sig"}').text + ).toContain("Authorization=[redacted]"); + expect( + redactBody('{"api_key": "sk-123", "name": "x"}').text + ).not.toContain("sk-123"); + expect(redactBody('api_key = "super secret value"').text).toBe( + 'api_key=[redacted]' + ); + }); + + test("redacts unquoted values up to a separator, consuming it", () => { + // Cursor Bugbot flagged the first cut: the value pattern stopped at + // `,`/`;`/`}` without consuming them, so an unquoted assignment failed to + // match entirely and the raw secret passed through. + const result = redactBody("password: hunter2, keep"); + expect(result.text).toBe("password=[redacted] keep"); + expect(redactBody("token = abc123; next=1").text).toBe( + "token=[redacted] next=1" + ); + }); + + test("does not truncate a quoted value at an inner apostrophe", () => { + // Bugbot round 2: the first fix merged `"` and `'` into one character + // class, so a double-quoted value containing an apostrophe ended at it and + // leaked the secret tail. The opening quote must decide the closing quote. + expect( + redactBody(`{"password": "it's a secret123"}`).text + ).toBe("{password=[redacted]}"); + expect(redactBody(`{"token": "don't-panic"}`).text).toBe( + "{token=[redacted]}" + ); + }); + test("allows concise operational context", () => { const result = redactBody("blocked: docker rate-limit on redis:7"); diff --git a/orchestrate/skills/orchestrate/scripts/core/redact-body.ts b/orchestrate/skills/orchestrate/scripts/core/redact-body.ts index 4623f107..00cf1e3a 100644 --- a/orchestrate/skills/orchestrate/scripts/core/redact-body.ts +++ b/orchestrate/skills/orchestrate/scripts/core/redact-body.ts @@ -1,7 +1,15 @@ const MAX_BODY_CHARS = 2_048; const SENSITIVE_KEY_RE = /token|secret|password|api[_-]?key|authorization/i; const SENSITIVE_ASSIGNMENT_RE = - /\b(token|secret|password|api[_-]?key|authorization)\b\s*[:=]\s*\S+/gi; + /"?((?:[\w.-]+\/)?(?:token|secret|password|api[_-]?key|authorization)[\w.-]*)"?\s*[:=]\s*/gi; +// The value is matched up to a real assignment boundary and redacted whole: +// - a double-quoted value runs to its closing `"` (escaped quotes allowed), so a +// quoted value containing an apostrophe is not truncated at it; +// - a single-quoted value runs to its closing `'` (escaped quotes allowed); +// - an unquoted value runs to `,`/`;`/`}`/end-of-line, consuming one optional +// terminator so the assignment ends at a real boundary (Bugbot round 1). +const SENSITIVE_VALUE_RE = + /(?:"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|[^,;}\n]*[,;}]?)/; const PATH_PATTERNS = [ { re: /^\/workspace\/\S*/gm, reason: "contains /workspace path" }, { re: /^\/Users\/\S*/gm, reason: "contains /Users path" }, @@ -33,14 +41,16 @@ function redactSensitiveAssignments( text: string, reasons: Set ): string { - return text.replace(SENSITIVE_ASSIGNMENT_RE, match => { - const [key] = match.split(/\s*[:=]\s*/, 1); - if (SENSITIVE_KEY_RE.test(key ?? "")) { - reasons.add("contains sensitive key"); - return `${key}=[redacted]`; + return text.replace( + new RegExp(SENSITIVE_ASSIGNMENT_RE.source + SENSITIVE_VALUE_RE.source, "gi"), + (match, key: string | undefined) => { + if (SENSITIVE_KEY_RE.test(key ?? "")) { + reasons.add("contains sensitive key"); + return `${key}=[redacted]`; + } + return match; } - return match; - }); + ); } function redactPaths(text: string, reasons: Set): string {