From 8216dc549aae11fac7f58d1af685efb54cd4cea3 Mon Sep 17 00:00:00 2001 From: Dev Chiniwala Date: Tue, 8 Sep 2026 18:26:07 +0530 Subject: [PATCH 1/6] fix(companion): prevent withheld keys leaking through SSE on scrub failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrubEvent had a single try-catch around JSON.parse and scrub(). When scrub() threw (e.g. RangeError on deeply nested JSON), the catch treated it the same as "not JSON" and returned the original unscrubbed line — sending resumeCursors and sshAlias to the device. Split the catch: parse failure still passes through (not JSON, nothing to scrub), scrub failure replaces the data with an empty object. This mirrors the fix already applied in the proxy's JSON response path (proxy.ts lines 520–548), whose comment explicitly calls the scenario "not hypothetical". --- companion/src/wire.ts | 14 +++++++++++--- companion/test/wire.test.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/companion/src/wire.ts b/companion/src/wire.ts index 95f8b30..6ba4840 100644 --- a/companion/src/wire.ts +++ b/companion/src/wire.ts @@ -136,13 +136,21 @@ function scrubEvent(event: string): string { if (!line.startsWith("data:")) return line; const raw = line.slice(5).trimStart(); if (!raw) return line; + let parsed; try { - return `data: ${JSON.stringify(scrub(JSON.parse(raw)))}`; + parsed = JSON.parse(raw); } catch { - // not JSON: pass it through rather than dropping it. A frame this - // code does not understand is still the harness's to send. return line; } + try { + return `data: ${JSON.stringify(scrub(parsed))}`; + } catch { + // scrub() recurses, so a body nested deep enough throws RangeError + // where JSON.parse handles it fine. Passing the original line through + // sends exactly what the scrubber exists to withhold — the same class + // of issue the proxy's JSON response path already handles. + return "data: {}"; + } }) .join(eol); } diff --git a/companion/test/wire.test.ts b/companion/test/wire.test.ts index c3af6c0..1db197d 100644 --- a/companion/test/wire.test.ts +++ b/companion/test/wire.test.ts @@ -166,4 +166,17 @@ describe("createSseScrubber", () => { it("still handles a bare CR, which the spec also allows", () => { expect(createSseScrubber()('data: {"a":1,"resumeCursors":{}}\r\r')).toBe('data: {"a":1}\r\r'); }); + + it("replaces unscrubable JSON data instead of leaking withheld keys", () => { + // scrub() recurses; a body nested a few thousand deep throws RangeError + // where JSON.parse handles it fine. A single try-catch around both treated + // that as "not JSON" and returned the original unscrubbed line — sending + // exactly what the scrubber exists to withhold. + let json = '{"resumeCursors":{"ghost":"leak-me"}}'; + for (let i = 0; i < 12_000; i++) json = `{"n":${json}}`; + const out = createSseScrubber()(`data: ${json}\n\n`); + expect(out).not.toContain("resumeCursors"); + expect(out).not.toContain("leak-me"); + expect(out).toBe("data: {}\n\n"); + }); }); From 9bbde0bfbd6f20fadf69f5d23f51b057c58aef7a Mon Sep 17 00:00:00 2001 From: Dev Chiniwala Date: Tue, 8 Sep 2026 18:33:17 +0530 Subject: [PATCH 2/6] fix(redact): add GitLab and PyPI token prefixes to secret detection The KEY_PREFIXES array covers content-shaped secrets that appear in bot replies, tool titles, and permission cards. Two well-documented credential prefixes were missing: - glpat- (GitLab personal access tokens) - pypi- (PyPI API tokens) Both are unmistakable and meet the "high precision on purpose" criterion stated in the module's header comment: a false positive on either prefix is essentially impossible. --- server/redact.test.ts | 2 ++ server/redact.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/server/redact.test.ts b/server/redact.test.ts index 98ee2e6..d5d697a 100644 --- a/server/redact.test.ts +++ b/server/redact.test.ts @@ -168,6 +168,8 @@ describe("redactSecretsInText", () => { [`aws ${"AKIA" + "IOSFODNN7EXAMPLE"} and more`, /IOSFODNN7EXAMPLE/], [`google ${"AIza" + "SyA-"}${alpha.slice(0, 32)}`, /AIza/], [`npm ${"npm" + "_"}${alpha}`, /npm_[a-z]/], + [`gitlab ${"glpat" + "-"}${alpha}`, /glpat-[a-z]/], + [`pypi ${"pypi" + "-"}${alpha.slice(0, 24)}`, /pypi-[a-z]/], ]; for (const [input, leak] of cases) { const out = redactSecretsInText(input); diff --git a/server/redact.ts b/server/redact.ts index c7d2126..9bb9806 100644 --- a/server/redact.ts +++ b/server/redact.ts @@ -52,6 +52,8 @@ const KEY_PREFIXES: RegExp[] = [ /\bAKIA[0-9A-Z]{16}\b/g, // aws access key id /\bAIza[0-9A-Za-z_-]{30,}/g, // google api key /\bnpm_[A-Za-z0-9]{20,}/g, // npm + /\bglpat-[A-Za-z0-9_-]{20,}/g, // gitlab personal access token + /\bpypi-[A-Za-z0-9_-]{16,}/g, // pypi api token /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // jwt ]; const BEARER = /(\bBearer\s+)([A-Za-z0-9._~+/=-]{12,})/g; From e004374b32c649f3f396e4080436b95cc31cc7d8 Mon Sep 17 00:00:00 2001 From: Dev Chiniwala Date: Tue, 8 Sep 2026 18:46:23 +0530 Subject: [PATCH 3/6] fix(redact): catch product-native credentials in content-shaped secret detection --- server/redact.test.ts | 11 ++++++++++- server/redact.ts | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/server/redact.test.ts b/server/redact.test.ts index d5d697a..79b8830 100644 --- a/server/redact.test.ts +++ b/server/redact.test.ts @@ -169,7 +169,9 @@ describe("redactSecretsInText", () => { [`google ${"AIza" + "SyA-"}${alpha.slice(0, 32)}`, /AIza/], [`npm ${"npm" + "_"}${alpha}`, /npm_[a-z]/], [`gitlab ${"glpat" + "-"}${alpha}`, /glpat-[a-z]/], - [`pypi ${"pypi" + "-"}${alpha.slice(0, 24)}`, /pypi-[a-z]/], + [`pypi ${"pypi" + "-"}${alpha}${alpha}`, /pypi-[a-z]/], + [`box ${"box_" + "live_"}abcdefghijklmnop`, /box_live_[a-z]/], + [`webhook ${"whsec" + "_"}${alpha.slice(0, 32)}`, /whsec_[a-z]/], ]; for (const [input, leak] of cases) { const out = redactSecretsInText(input); @@ -218,6 +220,13 @@ describe("redactSecretsInText", () => { "const token = await getToken(); // fetches later", "password: (leave blank to keep the current one)", "Bearer tokens are sent in the Authorization header", + "pypi-publishing-workflow", + "pypi-mirror-configuration", + "pypi-upload-action-v1", + "pypi-trusted-publisher-github-action-config", + "box_shadow_none", + "box_model_border", + "box_sizing_content", "sk-8", // too short to be a key ]) { expect(redactSecretsInText(s), s).toBe(s); diff --git a/server/redact.ts b/server/redact.ts index 9bb9806..ca8249c 100644 --- a/server/redact.ts +++ b/server/redact.ts @@ -53,7 +53,9 @@ const KEY_PREFIXES: RegExp[] = [ /\bAIza[0-9A-Za-z_-]{30,}/g, // google api key /\bnpm_[A-Za-z0-9]{20,}/g, // npm /\bglpat-[A-Za-z0-9_-]{20,}/g, // gitlab personal access token - /\bpypi-[A-Za-z0-9_-]{16,}/g, // pypi api token + /\bpypi-[A-Za-z0-9_-]{48,}/g, // pypi api token (macaroon payload) + /\bbox_[A-Za-z0-9_-]{16,}/g, // ascii.dev box api token + /\bwhsec_[A-Za-z0-9_-]{20,}/g, // helmryth webhook secret /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // jwt ]; const BEARER = /(\bBearer\s+)([A-Za-z0-9._~+/=-]{12,})/g; From 2d9f3fc39ac07a8324b9b7fbfe6bf805755dc919 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 17 Sep 2026 03:30:35 +0530 Subject: [PATCH 4/6] fix(redact): narrow Box token matching to documented qualifiers --- server/redact.test.ts | 2 ++ server/redact.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/redact.test.ts b/server/redact.test.ts index 79b8830..cce5dc3 100644 --- a/server/redact.test.ts +++ b/server/redact.test.ts @@ -171,6 +171,7 @@ describe("redactSecretsInText", () => { [`gitlab ${"glpat" + "-"}${alpha}`, /glpat-[a-z]/], [`pypi ${"pypi" + "-"}${alpha}${alpha}`, /pypi-[a-z]/], [`box ${"box_" + "live_"}abcdefghijklmnop`, /box_live_[a-z]/], + [`box ${"box_" + "test_"}ABCDEF0123456789`, /box_test_/], [`webhook ${"whsec" + "_"}${alpha.slice(0, 32)}`, /whsec_[a-z]/], ]; for (const [input, leak] of cases) { @@ -227,6 +228,7 @@ describe("redactSecretsInText", () => { "box_shadow_none", "box_model_border", "box_sizing_content", + "box_background_color", "sk-8", // too short to be a key ]) { expect(redactSecretsInText(s), s).toBe(s); diff --git a/server/redact.ts b/server/redact.ts index ca8249c..6123496 100644 --- a/server/redact.ts +++ b/server/redact.ts @@ -54,7 +54,7 @@ const KEY_PREFIXES: RegExp[] = [ /\bnpm_[A-Za-z0-9]{20,}/g, // npm /\bglpat-[A-Za-z0-9_-]{20,}/g, // gitlab personal access token /\bpypi-[A-Za-z0-9_-]{48,}/g, // pypi api token (macaroon payload) - /\bbox_[A-Za-z0-9_-]{16,}/g, // ascii.dev box api token + /\bbox_(?:live|test|prod)_[A-Za-z0-9_-]{16,}/g, // ascii.dev box api token /\bwhsec_[A-Za-z0-9_-]{20,}/g, // helmryth webhook secret /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // jwt ]; From 6188f01e3de54c3cb13d065503d3d3fd5f4a69de Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 17 Sep 2026 03:40:57 +0530 Subject: [PATCH 5/6] test(redact): cover production Box tokens --- server/redact.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/redact.test.ts b/server/redact.test.ts index cce5dc3..18c02e2 100644 --- a/server/redact.test.ts +++ b/server/redact.test.ts @@ -172,6 +172,7 @@ describe("redactSecretsInText", () => { [`pypi ${"pypi" + "-"}${alpha}${alpha}`, /pypi-[a-z]/], [`box ${"box_" + "live_"}abcdefghijklmnop`, /box_live_[a-z]/], [`box ${"box_" + "test_"}ABCDEF0123456789`, /box_test_/], + [`box ${"box_" + "prod_"}0123456789abcdef`, /box_prod_/], [`webhook ${"whsec" + "_"}${alpha.slice(0, 32)}`, /whsec_[a-z]/], ]; for (const [input, leak] of cases) { From cf1f95bb8156d7e36b9212a764379c0a5e901a5e Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 17 Sep 2026 03:53:02 +0530 Subject: [PATCH 6/6] fix(diagnostics): mirror credential redaction formats --- electron/diagnostics.mjs | 4 ++++ electron/diagnostics.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/electron/diagnostics.mjs b/electron/diagnostics.mjs index 598d433..2b36c61 100644 --- a/electron/diagnostics.mjs +++ b/electron/diagnostics.mjs @@ -35,6 +35,10 @@ const CREDENTIAL_TOKEN_FORMATS = [ /\bAKIA[0-9A-Z]{16}\b/g, /\bAIza[0-9A-Za-z_-]{30,}/g, /\bnpm_[A-Za-z0-9]{20,}/g, + /\bglpat-[A-Za-z0-9_-]{20,}/g, + /\bpypi-[A-Za-z0-9_-]{48,}/g, + /\bbox_(?:live|test|prod)_[A-Za-z0-9_-]{16,}/g, + /\bwhsec_[A-Za-z0-9_-]{20,}/g, ]; const KEY_VALUE_PAIR = /\b([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|password|passwd|authorization|auth[_-]?token|access[_-]?key|private[_-]?key)s?)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s"',;)\]}]+)/gi; diff --git a/electron/diagnostics.test.mjs b/electron/diagnostics.test.mjs index 151cde9..4e1b02a 100644 --- a/electron/diagnostics.test.mjs +++ b/electron/diagnostics.test.mjs @@ -113,6 +113,26 @@ describe("buildDiagnosticsReport", () => { expect(report).toContain("«redacted"); }); + it("keeps diagnostics redaction in parity with the server content-token formats", () => { + const alpha = "abcdefghijklmnopqrstuvwxyz0123456789"; + const values = [ + `glpat-${alpha}`, + `pypi-${alpha}${alpha}`, + `box_live_${alpha.slice(0, 20)}`, + `box_test_${alpha.slice(0, 20)}`, + `box_prod_${alpha.slice(0, 20)}`, + `whsec_${alpha}${alpha.slice(0, 8)}`, + ]; + const report = buildDiagnosticsReport({ + appInfo, + configSummary: {}, + logTail: values.join("\n"), + }); + for (const value of values) { + expect(report).not.toContain(value); + } + }); + it.each(["Bearer abcdefghijklmnop", "Basic dXNlcjpwYXNzd29yZA=="])( "masks the full Authorization credential for %s", (authorization) => {