Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions companion/src/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
13 changes: 13 additions & 0 deletions companion/test/wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
4 changes: 4 additions & 0 deletions electron/diagnostics.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions electron/diagnostics.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
14 changes: 14 additions & 0 deletions server/redact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions server/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
DivyamTalwar marked this conversation as resolved.
/\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;
Expand Down