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"); + }); }); 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) => { diff --git a/server/redact.test.ts b/server/redact.test.ts index 98ee2e6..18c02e2 100644 --- a/server/redact.test.ts +++ b/server/redact.test.ts @@ -168,6 +168,12 @@ 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}${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) { const out = redactSecretsInText(input); @@ -216,6 +222,14 @@ 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", + "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 c7d2126..6123496 100644 --- a/server/redact.ts +++ b/server/redact.ts @@ -52,6 +52,10 @@ 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_-]{48,}/g, // pypi api token (macaroon payload) + /\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 ]; const BEARER = /(\bBearer\s+)([A-Za-z0-9._~+/=-]{12,})/g;