From 1623a62ee879f4dce9f303f9b5986884283b597b Mon Sep 17 00:00:00 2001 From: h-kod Date: Sun, 23 Aug 2026 23:17:05 +0300 Subject: [PATCH 1/3] fix(orchestrate): redact quoted JSON keys and whole values in redactBody SENSITIVE_ASSIGNMENT_RE wrapped the key alternatives in \b, so a key that sits next to a quote character - exactly how it appears in a pasted JSON body like {"api_key": "sk-123"} or {"Authorization": "Bearer eyJ..."} - never matched, and the secret went out unredacted. The pattern also stopped the value at the first space (\S+), leaking the tail of `Bearer `-style values. Allow optional surrounding quotes on the key, and match the value lazily up to a real assignment boundary (end of line, a JSON-ish separator, or the closing quote) so the value is redacted whole. Keys without a sensitive match are returned unchanged; non-assignment prose ("the authorization header was missing") still passes through untouched. New tests cover the quoted-JSON case, values containing spaces, and the non-redaction of ordinary prose. --- .../scripts/__tests__/redact-body.test.ts | 16 +++++++++++++ .../orchestrate/scripts/core/redact-body.ts | 23 ++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts index a7fbbd4a..6966de2e 100644 --- a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts +++ b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts @@ -41,6 +41,22 @@ 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("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..cbfddc32 100644 --- a/orchestrate/skills/orchestrate/scripts/core/redact-body.ts +++ b/orchestrate/skills/orchestrate/scripts/core/redact-body.ts @@ -1,7 +1,12 @@ 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 lazily up to a separator that ends the assignment: +// end of line, an unquoted-word boundary followed by `,`/`;`/`}` (JSON-ish +// contexts), or a closing quote. Everything between belongs to the secret and +// gets redacted whole — including values with spaces (`Bearer eyJ... sig`). +const SENSITIVE_VALUE_RE = /[^,;}\n"']*(?:"[^"]*"?|'[^']*'?|$|\s(?=[\w"'])|$)/; const PATH_PATTERNS = [ { re: /^\/workspace\/\S*/gm, reason: "contains /workspace path" }, { re: /^\/Users\/\S*/gm, reason: "contains /Users path" }, @@ -33,14 +38,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 { From 2bfdfc9f44bf526bd131d720bcd6cb870efaff7f Mon Sep 17 00:00:00 2001 From: h-kod Date: Mon, 24 Aug 2026 00:13:45 +0300 Subject: [PATCH 2/3] fix(orchestrate): consume the value terminator in redactBody Cursor Bugbot flagged that SENSITIVE_VALUE_RE stopped at , ; } without consuming them, so for unquoted assignments the combined pattern failed to match entirely - no redaction ran and the raw secret passed through (password: hunter2, keep kept hunter2). Make the separator optional in the match so it is consumed, ending the value at the boundary. --- .../orchestrate/scripts/__tests__/redact-body.test.ts | 11 +++++++++++ .../skills/orchestrate/scripts/core/redact-body.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts index 6966de2e..934db9fe 100644 --- a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts +++ b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts @@ -57,6 +57,17 @@ describe("redactBody", () => { ); }); + 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("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 cbfddc32..5f0bf54f 100644 --- a/orchestrate/skills/orchestrate/scripts/core/redact-body.ts +++ b/orchestrate/skills/orchestrate/scripts/core/redact-body.ts @@ -6,7 +6,7 @@ const SENSITIVE_ASSIGNMENT_RE = // end of line, an unquoted-word boundary followed by `,`/`;`/`}` (JSON-ish // contexts), or a closing quote. Everything between belongs to the secret and // gets redacted whole — including values with spaces (`Bearer eyJ... sig`). -const SENSITIVE_VALUE_RE = /[^,;}\n"']*(?:"[^"]*"?|'[^']*'?|$|\s(?=[\w"'])|$)/; +const SENSITIVE_VALUE_RE = /[^,;}\n"']*(?:["'][^"']*["']?|[,;}]?)/; const PATH_PATTERNS = [ { re: /^\/workspace\/\S*/gm, reason: "contains /workspace path" }, { re: /^\/Users\/\S*/gm, reason: "contains /Users path" }, From f1be9ac5e88430411c0d2883ae48d5ea402cbc52 Mon Sep 17 00:00:00 2001 From: h-kod Date: Mon, 24 Aug 2026 01:29:55 +0300 Subject: [PATCH 3/3] fix(orchestrate): match quoted values with their opening quote Bugbot round 2 flagged that the first fix merged double and single quote handling into a single character class (["'][^"']*["']?), so a JSON double-quoted value containing an apostrophe ended at it and the secret tail leaked (e.g. {"password": "it's a secret123"} kept "s a secret123"). Split the value arms back apart and let the opening quote decide the closing one: a double-quoted value runs to its closing ", a single-quoted value to its closing ', both honoring backslash escapes, and an unquoted value still consumes one optional , ; } terminator (round 1 behaviour). --- .../scripts/__tests__/redact-body.test.ts | 12 ++++++++++++ .../skills/orchestrate/scripts/core/redact-body.ts | 13 ++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts index 934db9fe..cb990afe 100644 --- a/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts +++ b/orchestrate/skills/orchestrate/scripts/__tests__/redact-body.test.ts @@ -68,6 +68,18 @@ describe("redactBody", () => { ); }); + 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 5f0bf54f..00cf1e3a 100644 --- a/orchestrate/skills/orchestrate/scripts/core/redact-body.ts +++ b/orchestrate/skills/orchestrate/scripts/core/redact-body.ts @@ -2,11 +2,14 @@ const MAX_BODY_CHARS = 2_048; const SENSITIVE_KEY_RE = /token|secret|password|api[_-]?key|authorization/i; const SENSITIVE_ASSIGNMENT_RE = /"?((?:[\w.-]+\/)?(?:token|secret|password|api[_-]?key|authorization)[\w.-]*)"?\s*[:=]\s*/gi; -// The value is matched lazily up to a separator that ends the assignment: -// end of line, an unquoted-word boundary followed by `,`/`;`/`}` (JSON-ish -// contexts), or a closing quote. Everything between belongs to the secret and -// gets redacted whole — including values with spaces (`Bearer eyJ... sig`). -const SENSITIVE_VALUE_RE = /[^,;}\n"']*(?:["'][^"']*["']?|[,;}]?)/; +// 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" },