From f9888137024720c2118c99a5536b68359f669bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 09:41:21 +0100 Subject: [PATCH 01/25] Fix false secret matches in source maps Decode embedded sources separately so escaped quotes cannot combine code into an opaque secret match. Preserve other fields and duplicate keys for scanning. --- src/scan-build.test.ts | 131 +++++++++++++++++++++++++++++++++++++++++ src/scan-build.ts | 66 ++++++++++++++++++++- 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index 29cc1ca0..1b86b074 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -107,6 +107,137 @@ describe("scanBuildArtifacts browser output", () => { }); describe("scanBuildArtifacts credential shapes", () => { + it("does not join code in an encoded source map into a secret", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build", "server"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync( + join(dir, "build", "server", "index.js.map"), + JSON.stringify({ + version: 3, + sources: ["input.js"], + sourcesContent: [source], + mappings: "", + }), + ); + assert.deepEqual(await scanBuildArtifacts(dir), []); + } finally { + rmTemp(dir); + } + }); + + it("finds credentials in source maps and keeps source files separate", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + const map = { + version: 3, + sources: ["one.js", "two.js"], + sourcesContent: [ + 'const item = {key: "." + key};\nconsole.log("ordinary code");', + `const config = {api_key: "${opaque}"};`, + ], + mappings: "", + }; + writeFileSync(join(dir, "build", "client.js.map"), JSON.stringify(map)); + const hits = await scanBuildArtifacts(dir); + assert.equal(hits.length, 1); + assert.ok(hits[0].findings.some((finding) => finding.label === "keyed-secret")); + } finally { + rmTemp(dir); + } + }); + + it("scans other map fields and malformed maps", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + writeFileSync( + join(dir, "build", "valid.js.map"), + JSON.stringify({ api_key: opaque, sourcesContent: ["safe"] }), + ); + writeFileSync(join(dir, "build", "broken.js.map"), `{"api_key":"${opaque}"`); + const hits = await scanBuildArtifacts(dir); + assert.deepEqual(hits.map((hit) => hit.file).sort(), [ + "build/broken.js.map", + "build/valid.js.map", + ]); + assert.ok( + hits.every((hit) => hit.findings.some((finding) => finding.label === "keyed-secret")), + ); + } finally { + rmTemp(dir); + } + }); + + it("scans every occurrence of duplicate map fields", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + const source = JSON.stringify(`const config = {api_key: "${opaque}"};`); + writeFileSync( + join(dir, "build", "fields.js.map"), + `{"api_key":"${opaque}","api_key":"safe","sourcesContent":[]}`, + ); + writeFileSync( + join(dir, "build", "sources.js.map"), + `{"sourcesContent":[${source}],"sourcesContent":["safe"]}`, + ); + const hits = await scanBuildArtifacts(dir); + assert.deepEqual(hits.map((hit) => hit.file).sort(), [ + "build/fields.js.map", + "build/sources.js.map", + ]); + assert.ok( + hits.every((hit) => hit.findings.some((finding) => finding.label === "keyed-secret")), + ); + } finally { + rmTemp(dir); + } + }); + + it("scans a large source map without falling back to encoded text", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; + const sourcesContent = [...Array(145_000).fill(""), source]; + const encoded = JSON.stringify({ sourcesContent }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", "large.js.map"), encoded); + const hits = await scanBuildArtifacts(dir); + assert.equal(hits.length, 1); + assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); + } finally { + rmTemp(dir); + } + }); + + it("scans sources in nested map sections", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + const map = { + sections: [{ map: { sourcesContent: [null, `const x = {api_key: "${opaque}"};`] } }], + }; + writeFileSync(join(dir, "build", "section.js.map"), JSON.stringify(map)); + const hits = await scanBuildArtifacts(dir); + assert.equal(hits.length, 1); + assert.ok(hits[0].findings.some((finding) => finding.label === "keyed-secret")); + } finally { + rmTemp(dir); + } + }); + it("does not treat ordinary client property names as credentials", async () => { const dir = makeRepo(); try { diff --git a/src/scan-build.ts b/src/scan-build.ts index ab41459c..bd4b26a0 100644 --- a/src/scan-build.ts +++ b/src/scan-build.ts @@ -89,6 +89,70 @@ function findBuildSecrets(content: string): SecretFinding[] { return findings; } +type SourceRange = { start: number; end: number }; + +function readSourceArray( + content: string, + start: number, +): { end: number; sources: string[]; ranges: SourceRange[] } | undefined { + const strings = /"(?:\\.|[^"\\])*"/g; + const sources: string[] = []; + const ranges: SourceRange[] = []; + let cursor = start; + while (true) { + cursor += /^\s*/.exec(content.slice(cursor))![0].length; + if (content[cursor] === "]") return { end: cursor + 1, sources, ranges }; + if (content[cursor] === '"') { + strings.lastIndex = cursor; + const source = strings.exec(content)!; + const decoded = JSON.parse(source[0]) as string; + if (decoded) { + sources.push(decoded); + ranges.push({ start: cursor, end: strings.lastIndex }); + } + cursor = strings.lastIndex; + } else if (content.startsWith("null", cursor)) { + cursor += 4; + } else return undefined; + cursor += /^\s*/.exec(content.slice(cursor))![0].length; + if (content[cursor] === ",") cursor++; + else if (content[cursor] !== "]") return undefined; + } +} + +// Scan decoded sources separately so escaped quotes cannot join unrelated code. +// Keep the original remaining JSON, including duplicate fields, for the scan. +function findArtifactSecrets(content: string, file: string): SecretFinding[] { + if (!file.endsWith(".map")) return findBuildSecrets(content); + try { + JSON.parse(content); + } catch { + return findBuildSecrets(content); + } + const sources: string[] = []; + const ranges: SourceRange[] = []; + const strings = /"(?:\\.|[^"\\])*"/g; + let token: RegExpExecArray | null; + while ((token = strings.exec(content)) !== null) { + if (JSON.parse(token[0]) !== "sourcesContent") continue; + const prefix = /^\s*:\s*\[/.exec(content.slice(strings.lastIndex)); + if (!prefix) continue; + const array = readSourceArray(content, strings.lastIndex + prefix[0].length); + if (!array) continue; + for (const source of array.sources) sources.push(source); + for (const range of array.ranges) ranges.push(range); + strings.lastIndex = array.end; + } + const rest: string[] = []; + let from = 0; + for (const range of ranges) { + rest.push(content.slice(from, range.start), '""'); + from = range.end; + } + rest.push(content.slice(from)); + return [rest.join(""), ...sources].flatMap(findBuildSecrets); +} + /** Next.js server modules are deployed to the runtime, not downloaded by visitors. */ function isBrowserVisibleNextArtifact(file: string): boolean { const parts = file.split("/"); @@ -154,7 +218,7 @@ export async function scanBuildArtifacts( } catch { continue; } - const findings = findBuildSecrets(content); + const findings = findArtifactSecrets(content, rel); if (findings.length > 0) { // Report repository-style paths so diagnostics and baselines are stable // across Windows and POSIX hosts. From b04dee9d3bc674eba54a76eaea99dd66258f671b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 09:47:02 +0100 Subject: [PATCH 02/25] Scan source maps incrementally Keep source contents separate without retaining every source in memory. Split tests to satisfy the standards gate. --- src/scan-build.test.ts | 28 ++++++++++++++++++++++++++-- src/scan-build.ts | 31 ++++++++++++++----------------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index 1b86b074..ceb64b67 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -106,7 +106,7 @@ describe("scanBuildArtifacts browser output", () => { }); }); -describe("scanBuildArtifacts credential shapes", () => { +describe("scanBuildArtifacts source maps", () => { it("does not join code in an encoded source map into a secret", async () => { const dir = makeRepo(); try { @@ -151,7 +151,9 @@ describe("scanBuildArtifacts credential shapes", () => { rmTemp(dir); } }); +}); +describe("scanBuildArtifacts source map fields", () => { it("scans other map fields and malformed maps", async () => { const dir = makeRepo(); try { @@ -202,12 +204,32 @@ describe("scanBuildArtifacts credential shapes", () => { } }); + it("retains decoded findings when source content contains an unexpected object", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; + const encoded = JSON.stringify({ sourcesContent: [source, { nested: true }] }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", "mixed.js.map"), encoded); + const hits = await scanBuildArtifacts(dir); + assert.equal(hits.length, 1); + assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); + } finally { + rmTemp(dir); + } + }); +}); + +describe("scanBuildArtifacts source map size and nesting", () => { it("scans a large source map without falling back to encoded text", async () => { const dir = makeRepo(); try { mkdirSync(join(dir, "build"), { recursive: true }); const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; - const sourcesContent = [...Array(145_000).fill(""), source]; + const sourcesContent = [...Array(145_000).fill("a"), source]; const encoded = JSON.stringify({ sourcesContent }).replace( "sk_" + "live_", "\\u0073k_" + "live_", @@ -237,7 +259,9 @@ describe("scanBuildArtifacts credential shapes", () => { rmTemp(dir); } }); +}); +describe("scanBuildArtifacts credential shapes", () => { it("does not treat ordinary client property names as credentials", async () => { const dir = makeRepo(); try { diff --git a/src/scan-build.ts b/src/scan-build.ts index bd4b26a0..3168fc90 100644 --- a/src/scan-build.ts +++ b/src/scan-build.ts @@ -94,29 +94,25 @@ type SourceRange = { start: number; end: number }; function readSourceArray( content: string, start: number, -): { end: number; sources: string[]; ranges: SourceRange[] } | undefined { +): { end?: number; findings: SecretFinding[] } { const strings = /"(?:\\.|[^"\\])*"/g; - const sources: string[] = []; - const ranges: SourceRange[] = []; + const findings: SecretFinding[] = []; let cursor = start; while (true) { cursor += /^\s*/.exec(content.slice(cursor))![0].length; - if (content[cursor] === "]") return { end: cursor + 1, sources, ranges }; + if (content[cursor] === "]") return { end: cursor + 1, findings }; if (content[cursor] === '"') { strings.lastIndex = cursor; const source = strings.exec(content)!; const decoded = JSON.parse(source[0]) as string; - if (decoded) { - sources.push(decoded); - ranges.push({ start: cursor, end: strings.lastIndex }); - } + for (const finding of findBuildSecrets(decoded)) findings.push(finding); cursor = strings.lastIndex; } else if (content.startsWith("null", cursor)) { cursor += 4; - } else return undefined; + } else return { findings }; cursor += /^\s*/.exec(content.slice(cursor))![0].length; if (content[cursor] === ",") cursor++; - else if (content[cursor] !== "]") return undefined; + else if (content[cursor] !== "]") return { findings }; } } @@ -129,7 +125,7 @@ function findArtifactSecrets(content: string, file: string): SecretFinding[] { } catch { return findBuildSecrets(content); } - const sources: string[] = []; + const findings: SecretFinding[] = []; const ranges: SourceRange[] = []; const strings = /"(?:\\.|[^"\\])*"/g; let token: RegExpExecArray | null; @@ -137,20 +133,21 @@ function findArtifactSecrets(content: string, file: string): SecretFinding[] { if (JSON.parse(token[0]) !== "sourcesContent") continue; const prefix = /^\s*:\s*\[/.exec(content.slice(strings.lastIndex)); if (!prefix) continue; - const array = readSourceArray(content, strings.lastIndex + prefix[0].length); - if (!array) continue; - for (const source of array.sources) sources.push(source); - for (const range of array.ranges) ranges.push(range); + const arrayStart = strings.lastIndex + prefix[0].length; + const array = readSourceArray(content, arrayStart); + for (const finding of array.findings) findings.push(finding); + if (array.end === undefined) continue; + ranges.push({ start: arrayStart, end: array.end - 1 }); strings.lastIndex = array.end; } const rest: string[] = []; let from = 0; for (const range of ranges) { - rest.push(content.slice(from, range.start), '""'); + rest.push(content.slice(from, range.start)); from = range.end; } rest.push(content.slice(from)); - return [rest.join(""), ...sources].flatMap(findBuildSecrets); + return findings.concat(findBuildSecrets(rest.join(""))); } /** Next.js server modules are deployed to the runtime, not downloaded by visitors. */ From fc536ffa45e81df05386075e99eeaff341c087d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 09:49:50 +0100 Subject: [PATCH 03/25] Handle unusual source map entries safely Continue scanning after non-string entries and avoid running credential patterns for strings too short to match. --- src/scan-build.test.ts | 21 ++++++++++++++------- src/scan-build.ts | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index ceb64b67..fd24d914 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -209,14 +209,21 @@ describe("scanBuildArtifacts source map fields", () => { try { mkdirSync(join(dir, "build"), { recursive: true }); const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; - const encoded = JSON.stringify({ sourcesContent: [source, { nested: true }] }).replace( - "sk_" + "live_", - "\\u0073k_" + "live_", - ); - writeFileSync(join(dir, "build", "mixed.js.map"), encoded); + for (const [index, sourcesContent] of [ + [source, { nested: true }], + [{ nested: [true, { deep: "value" }] }, source], + ].entries()) { + const encoded = JSON.stringify({ sourcesContent }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", `mixed${index}.js.map`), encoded); + } const hits = await scanBuildArtifacts(dir); - assert.equal(hits.length, 1); - assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); + assert.equal(hits.length, 2); + assert.ok( + hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), + ); } finally { rmTemp(dir); } diff --git a/src/scan-build.ts b/src/scan-build.ts index 3168fc90..653b9f06 100644 --- a/src/scan-build.ts +++ b/src/scan-build.ts @@ -91,6 +91,28 @@ function findBuildSecrets(content: string): SecretFinding[] { type SourceRange = { start: number; end: number }; +// The complete JSON has already been validated. Skip a non-string array item +// while leaving it in the raw map for the normal scan. +function skipJsonValue(content: string, start: number): number { + if (content[start] !== "{" && content[start] !== "[") { + const scalar = /^(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( + content.slice(start), + ); + if (!scalar) throw new Error("Invalid source map value"); + return start + scalar[0].length; + } + const tokens = /"(?:\\.|[^"\\])*"|[{}[\]]/g; + tokens.lastIndex = start; + let depth = 0; + let token: RegExpExecArray | null; + while ((token = tokens.exec(content)) !== null) { + if (token[0] === "{" || token[0] === "[") depth++; + else if (token[0] === "}" || token[0] === "]") depth--; + if (depth === 0) return tokens.lastIndex; + } + throw new Error("Unclosed source map value"); +} + function readSourceArray( content: string, start: number, @@ -98,18 +120,25 @@ function readSourceArray( const strings = /"(?:\\.|[^"\\])*"/g; const findings: SecretFinding[] = []; let cursor = start; + let ordinary = true; while (true) { cursor += /^\s*/.exec(content.slice(cursor))![0].length; - if (content[cursor] === "]") return { end: cursor + 1, findings }; + if (content[cursor] === "]") return { end: ordinary ? cursor + 1 : undefined, findings }; if (content[cursor] === '"') { strings.lastIndex = cursor; const source = strings.exec(content)!; const decoded = JSON.parse(source[0]) as string; - for (const finding of findBuildSecrets(decoded)) findings.push(finding); + // No current credential or personal-number pattern can match fewer than eight characters. + if (decoded.length >= 8) { + for (const finding of findBuildSecrets(decoded)) findings.push(finding); + } cursor = strings.lastIndex; } else if (content.startsWith("null", cursor)) { cursor += 4; - } else return { findings }; + } else { + ordinary = false; + cursor = skipJsonValue(content, cursor); + } cursor += /^\s*/.exec(content.slice(cursor))![0].length; if (content[cursor] === ",") cursor++; else if (content[cursor] !== "]") return { findings }; From 837ce3faaa7d9709be31c53c92df680ab3b52889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 09:52:18 +0100 Subject: [PATCH 04/25] Bound source map scan work Report an incomplete check when a source map has too many entries, instead of allowing unbounded per-source work. --- src/scan-build.test.ts | 35 ++++++++++++++++++++++++++++++++++- src/scan-build.ts | 9 ++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index fd24d914..d676b457 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { scanBuildArtifacts } from "./scan-build.js"; +import { checkBuiltBundleSecrets } from "./check-client-exposure.js"; import { rmTemp } from "./temp-cleanup.test-support.js"; function makeRepo(): string { @@ -236,7 +237,7 @@ describe("scanBuildArtifacts source map size and nesting", () => { try { mkdirSync(join(dir, "build"), { recursive: true }); const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; - const sourcesContent = [...Array(145_000).fill("a"), source]; + const sourcesContent = [...Array(50_000).fill("a"), source]; const encoded = JSON.stringify({ sourcesContent }).replace( "sk_" + "live_", "\\u0073k_" + "live_", @@ -250,6 +251,18 @@ describe("scanBuildArtifacts source map size and nesting", () => { } }); + it("fails the scan when source maps exceed the entry limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const sourcesContent = Array(100_001).fill("export{}"); + writeFileSync(join(dir, "build", "too-many.js.map"), JSON.stringify({ sourcesContent })); + await assert.rejects(scanBuildArtifacts(dir), /source map exceeds 100000 source entries/); + } finally { + rmTemp(dir); + } + }); + it("scans sources in nested map sections", async () => { const dir = makeRepo(); try { @@ -268,6 +281,26 @@ describe("scanBuildArtifacts source map size and nesting", () => { }); }); +describe("source map scan limit reporting", () => { + it("reports an incomplete security check when a map exceeds the limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + writeFileSync( + join(dir, "dist", "large.js.map"), + JSON.stringify({ sourcesContent: Array(100_001).fill("export{}") }), + ); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.match(result.detail, /source map exceeds 100000 source entries/); + } finally { + rmTemp(dir); + } + }); +}); + describe("scanBuildArtifacts credential shapes", () => { it("does not treat ordinary client property names as credentials", async () => { const dir = makeRepo(); diff --git a/src/scan-build.ts b/src/scan-build.ts index 653b9f06..2c7cb540 100644 --- a/src/scan-build.ts +++ b/src/scan-build.ts @@ -67,6 +67,7 @@ const BUILD_IRRELEVANT_LABELS = new Set(["tfstate-value", "terraform-sensitive"] const BUILD_GENERIC_LABELS = new Set(["keyed-secret", "url-query-token"]); const OPAQUE_MIN_LENGTH = 20; const OPAQUE_MIN_ENTROPY = 4.2; +const MAX_MAP_SOURCES = 100_000; function findBuildSecrets(content: string): SecretFinding[] { const findings = findSecrets(content).filter( @@ -116,6 +117,7 @@ function skipJsonValue(content: string, start: number): number { function readSourceArray( content: string, start: number, + budget: { sources: number }, ): { end?: number; findings: SecretFinding[] } { const strings = /"(?:\\.|[^"\\])*"/g; const findings: SecretFinding[] = []; @@ -124,6 +126,10 @@ function readSourceArray( while (true) { cursor += /^\s*/.exec(content.slice(cursor))![0].length; if (content[cursor] === "]") return { end: ordinary ? cursor + 1 : undefined, findings }; + budget.sources++; + if (budget.sources > MAX_MAP_SOURCES) { + throw new Error("source map exceeds 100000 source entries; scan could not finish"); + } if (content[cursor] === '"') { strings.lastIndex = cursor; const source = strings.exec(content)!; @@ -155,6 +161,7 @@ function findArtifactSecrets(content: string, file: string): SecretFinding[] { return findBuildSecrets(content); } const findings: SecretFinding[] = []; + const budget = { sources: 0 }; const ranges: SourceRange[] = []; const strings = /"(?:\\.|[^"\\])*"/g; let token: RegExpExecArray | null; @@ -163,7 +170,7 @@ function findArtifactSecrets(content: string, file: string): SecretFinding[] { const prefix = /^\s*:\s*\[/.exec(content.slice(strings.lastIndex)); if (!prefix) continue; const arrayStart = strings.lastIndex + prefix[0].length; - const array = readSourceArray(content, arrayStart); + const array = readSourceArray(content, arrayStart, budget); for (const finding of array.findings) findings.push(finding); if (array.end === undefined) continue; ranges.push({ start: arrayStart, end: array.end - 1 }); From 095e3480df67a1961f89107d3258c6027a3097f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 09:58:59 +0100 Subject: [PATCH 05/25] Report incomplete scans without hiding findings Bound nested source-map work and report oversized maps. Keep partial findings in the security check and direct scan command. --- docs/COMMANDS.md | 3 + src/check-client-exposure.ts | 9 +- src/commands/security.ts | 12 ++- src/scan-build-sourcemap.ts | 160 +++++++++++++++++++++++++++++++ src/scan-build.test.ts | 71 ++++++++++++++ src/scan-build.ts | 176 ++++++++--------------------------- 6 files changed, 287 insertions(+), 144 deletions(-) create mode 100644 src/scan-build-sourcemap.ts diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 8e73af03..0139159a 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -254,6 +254,9 @@ max_bytes = 12000 | `kit security prescan [--deep] [--format=json] [--vs-baseline=

]` | Multi-repo baseline sweep (secrets, gitignore, branch-protect; `--deep` adds CVE / workflow-drift / bumblebee). | | `kit security prescan-diff ` | Diff two prescan reports — surface new regressions + fixed findings. | +The build scan reports an incomplete check for source maps over 5 MiB or 100,000 source entries. +It also stops maps that exceed the nesting work limit. The report includes credentials found before a limit. + ### Code guardrails in the security check `kit check --category security` scans shell scripts, GitHub workflow commands, Dockerfiles, and source files. diff --git a/src/check-client-exposure.ts b/src/check-client-exposure.ts index 9c3f3458..6301105d 100644 --- a/src/check-client-exposure.ts +++ b/src/check-client-exposure.ts @@ -500,18 +500,21 @@ export async function checkBuiltBundleSecrets(root: string): Promise>; try { hits = await scanBuildArtifacts(root, present); } catch (e) { // A scan that crashed is not a clean scan: didNotRun makes the CI gate treat it as such. + const known = + e instanceof BuildScanIncompleteError ? e.hits.filter((h) => !isTestArtifact(h.file)) : []; return { ...base, status: "fail", - severity: "medium", + severity: known.length ? "critical" : "medium", didNotRun: true, - detail: `bundle scan could not run: ${(e as Error).message.slice(0, 80)}`, + detail: `bundle scan could not finish: ${(e as Error).message.slice(0, 80)}${known.length ? `; credentials also found in ${known.map((h) => h.file).join(", ")}` : ""}`, + ...(known.length ? { files: known.map((h) => h.file) } : {}), }; } diff --git a/src/commands/security.ts b/src/commands/security.ts index 2ec027b2..d63f7e6c 100644 --- a/src/commands/security.ts +++ b/src/commands/security.ts @@ -26,7 +26,7 @@ import { import { clearBumblebeeCache } from "../bumblebee.js"; import { scanStagedFiles } from "../scan-staged.js"; import { existsSync } from "node:fs"; -import { scanBuildArtifacts } from "../scan-build.js"; +import { BuildScanIncompleteError, scanBuildArtifacts } from "../scan-build.js"; import { SECRET_SHAPE_COUNT } from "../utils/redactSecrets.js"; import { scanTranscripts } from "../scan-transcripts.js"; import { sampleCosts } from "../cost-monitor.js"; @@ -512,7 +512,15 @@ async function cmdSecurityScanArtifact(): Promise { async function cmdSecurityScanBuild(): Promise { // Optional positional: extra dirs to scan beyond the defaults. const extras = process.argv.slice(4).filter((a) => !a.startsWith("--")); - const hits = await scanBuildArtifacts(process.cwd(), extras.length > 0 ? extras : undefined); + let hits: Awaited>; + try { + hits = await scanBuildArtifacts(process.cwd(), extras.length > 0 ? extras : undefined); + } catch (error) { + if (!(error instanceof BuildScanIncompleteError)) throw error; + console.error(`${c.red}✗ scan-build: incomplete: ${error.message}${c.reset}`); + hits = error.hits; + if (hits.length === 0) return false; + } if (hits.length === 0) { // The denominator belongs in the claim: "no patterns found" reads as "no secrets there", and // the honest statement is "none of the shapes kit knows". diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts new file mode 100644 index 00000000..68060e56 --- /dev/null +++ b/src/scan-build-sourcemap.ts @@ -0,0 +1,160 @@ +import { + findSecrets, + SECRET_PATTERNS, + shannonEntropy, + type SecretFinding, +} from "./utils/redactSecrets.js"; + +export class ArtifactIncompleteError extends Error { + constructor( + message: string, + readonly findings: SecretFinding[], + ) { + super(message); + } +} + +export const MAX_BYTES = 5 * 1024 * 1024; // 5 MiB + +// Terraform / tfstate finding labels do not apply to build artifacts and +// false-positive on framework manifests. For example, Next.js `routes-manifest.json` +// has many `"value":"…"` route entries that match the tfstate `"value"` rule. +// A build-artifact leak is an inlined CREDENTIAL (sk_/JWT/AWS/…), not an HCL or +// state construct, so these labels are filtered out here. +const BUILD_IRRELEVANT_LABELS = new Set(["tfstate-value", "terraform-sensitive"]); +const BUILD_GENERIC_LABELS = new Set(["keyed-secret", "url-query-token"]); +const OPAQUE_MIN_LENGTH = 20; +const OPAQUE_MIN_ENTROPY = 4.2; +const MAX_MAP_SOURCES = 100_000; + +function findBuildSecrets(content: string): SecretFinding[] { + const findings = findSecrets(content).filter( + (finding) => + !BUILD_IRRELEVANT_LABELS.has(finding.label) && !BUILD_GENERIC_LABELS.has(finding.label), + ); + + // Minified UI code routinely contains ordinary property names and example URLs. + // Generic names only justify a build failure when their values look opaque. + for (const { re, label } of SECRET_PATTERNS) { + if (!BUILD_GENERIC_LABELS.has(label)) continue; + for (const match of content.matchAll(new RegExp(re.source, re.flags))) { + const value = + label === "keyed-secret" ? (match[3] ?? match[2]) : match[0].slice(match[1].length); + if (value.length < OPAQUE_MIN_LENGTH || shannonEntropy(value) < OPAQUE_MIN_ENTROPY) continue; + findings.push({ label, preview: "[REDACTED]" }); + } + } + + return findings; +} + +type SourceRange = { start: number; end: number }; +type SourceBudget = { sources: number; nestedBytes: number }; + +// The complete JSON has already been validated. Skip a non-string array item +// while leaving it in the raw map for the normal scan. +function skipJsonValue(content: string, start: number, budget: SourceBudget): number { + if (content[start] !== "{" && content[start] !== "[") { + const scalar = /^(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( + content.slice(start), + ); + if (!scalar) throw new Error("Invalid source map value"); + return start + scalar[0].length; + } + const tokens = /"(?:\\.|[^"\\])*"|[{}[\]]/g; + tokens.lastIndex = start; + let depth = 0; + let previous = start; + let token: RegExpExecArray | null; + while ((token = tokens.exec(content)) !== null) { + budget.nestedBytes += tokens.lastIndex - previous; + previous = tokens.lastIndex; + if (budget.nestedBytes > MAX_BYTES) { + throw new Error("source map nesting exceeds scan work limit"); + } + if (token[0] === "{" || token[0] === "[") depth++; + else if (token[0] === "}" || token[0] === "]") depth--; + if (depth === 0) return tokens.lastIndex; + } + throw new Error("Unclosed source map value"); +} + +function readSourceArray( + content: string, + start: number, + budget: SourceBudget, + findings: SecretFinding[], +): number | undefined { + const strings = /"(?:\\.|[^"\\])*"/g; + let cursor = start; + let ordinary = true; + while (true) { + cursor += /^\s*/.exec(content.slice(cursor))![0].length; + if (content[cursor] === "]") return ordinary ? cursor + 1 : undefined; + budget.sources++; + if (budget.sources > MAX_MAP_SOURCES) { + throw new Error("source map exceeds 100000 source entries; scan could not finish"); + } + if (content[cursor] === '"') { + strings.lastIndex = cursor; + const source = strings.exec(content)!; + const decoded = JSON.parse(source[0]) as string; + // No current credential or personal-number pattern can match fewer than eight characters. + if (decoded.length >= 8) { + for (const finding of findBuildSecrets(decoded)) findings.push(finding); + } + cursor = strings.lastIndex; + } else if (content.startsWith("null", cursor)) { + cursor += 4; + } else { + ordinary = false; + const end = skipJsonValue(content, cursor, budget); + const normalized = content + .slice(cursor, end) + .replace(/"(?:\\.|[^"\\])*"/g, (token) => JSON.stringify(JSON.parse(token))); + for (const finding of findBuildSecrets(normalized)) findings.push(finding); + cursor = end; + } + cursor += /^\s*/.exec(content.slice(cursor))![0].length; + if (content[cursor] === ",") cursor++; + else if (content[cursor] !== "]") return undefined; + } +} + +// Scan decoded sources separately so escaped quotes cannot join unrelated code. +// Keep the original remaining JSON, including duplicate fields, for the scan. +export function findArtifactSecrets(content: string, file: string): SecretFinding[] { + if (!file.endsWith(".map")) return findBuildSecrets(content); + try { + JSON.parse(content); + } catch { + return findBuildSecrets(content); + } + const findings: SecretFinding[] = []; + const budget = { sources: 0, nestedBytes: 0 }; + const ranges: SourceRange[] = []; + const strings = /"(?:\\.|[^"\\])*"/g; + let token: RegExpExecArray | null; + try { + while ((token = strings.exec(content)) !== null) { + if (JSON.parse(token[0]) !== "sourcesContent") continue; + const prefix = /^\s*:\s*\[/.exec(content.slice(strings.lastIndex)); + if (!prefix) continue; + const arrayStart = strings.lastIndex + prefix[0].length; + const arrayEnd = readSourceArray(content, arrayStart, budget, findings); + if (arrayEnd === undefined) continue; + ranges.push({ start: arrayStart, end: arrayEnd - 1 }); + strings.lastIndex = arrayEnd; + } + } catch (error) { + throw new ArtifactIncompleteError((error as Error).message, findings); + } + const rest: string[] = []; + let from = 0; + for (const range of ranges) { + rest.push(content.slice(from, range.start)); + from = range.end; + } + rest.push(content.slice(from)); + return findings.concat(findBuildSecrets(rest.join(""))); +} diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index d676b457..5052b487 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -299,6 +299,77 @@ describe("source map scan limit reporting", () => { rmTemp(dir); } }); + + it("preserves a detected credential when another map is too large", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + writeFileSync(join(dir, "dist", "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"); + writeFileSync(join(dir, "dist", "large.js.map"), " ".repeat(5 * 1024 * 1024 + 1)); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.equal(result.severity, "critical"); + assert.ok(result.files?.includes("dist/leak.js")); + assert.match(result.detail, /source map exceeds 5 MiB/); + } finally { + rmTemp(dir); + } + }); + + it("reports excessive nesting as an incomplete scan", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + const nested = '{"sourcesContent":['.repeat(8_000) + "null" + "]}".repeat(8_000); + writeFileSync(join(dir, "dist", "nested.js.map"), nested); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.match(result.detail, /source map nesting exceeds scan work limit/); + } finally { + rmTemp(dir); + } + }); + + it("finds an encoded credential inside an unexpected source item", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const map = JSON.stringify({ sourcesContent: ["safe", { data: secret }] }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "dist", "mixed.js.map"), map); + const hits = await scanBuildArtifacts(dir); + assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map partial findings", () => { + it("preserves a credential found before the same map reaches its limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const sourcesContent = [secret, ...Array(100_000).fill("a")]; + writeFileSync(join(dir, "dist", "large.js.map"), JSON.stringify({ sourcesContent })); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.equal(result.severity, "critical"); + assert.ok(result.files?.includes("dist/large.js.map")); + } finally { + rmTemp(dir); + } + }); }); describe("scanBuildArtifacts credential shapes", () => { diff --git a/src/scan-build.ts b/src/scan-build.ts index 2c7cb540..ba0a7261 100644 --- a/src/scan-build.ts +++ b/src/scan-build.ts @@ -1,11 +1,7 @@ import { readFile, readdir, stat } from "node:fs/promises"; import { resolve, join, relative, sep } from "node:path"; -import { - findSecrets, - SECRET_PATTERNS, - shannonEntropy, - type SecretFinding, -} from "./utils/redactSecrets.js"; +import type { SecretFinding } from "./utils/redactSecrets.js"; +import { ArtifactIncompleteError, findArtifactSecrets, MAX_BYTES } from "./scan-build-sourcemap.js"; /** * Walks built-artifact directories looking for leaked credentials. The @@ -24,6 +20,16 @@ export interface BuildHit { findings: SecretFinding[]; } +export class BuildScanIncompleteError extends Error { + constructor( + message: string, + readonly hits: BuildHit[], + ) { + super(message); + this.name = "BuildScanIncompleteError"; + } +} + const DEFAULT_BUILD_DIRS = [ ".next", "dist", @@ -56,136 +62,6 @@ const SCANNABLE_EXTS = new Set([ const SKIP_DIRS = new Set(["node_modules", ".git", ".pnpm-store", "cache"]); -const MAX_BYTES = 5 * 1024 * 1024; // 5 MiB - -// Terraform / tfstate finding labels do not apply to build artifacts and -// false-positive on framework manifests — e.g. Next.js `routes-manifest.json` -// has many `"value":"…"` route entries that match the tfstate `"value"` rule. -// A build-artifact leak is an inlined CREDENTIAL (sk_/JWT/AWS/…), not an HCL or -// state construct, so these labels are filtered out here. -const BUILD_IRRELEVANT_LABELS = new Set(["tfstate-value", "terraform-sensitive"]); -const BUILD_GENERIC_LABELS = new Set(["keyed-secret", "url-query-token"]); -const OPAQUE_MIN_LENGTH = 20; -const OPAQUE_MIN_ENTROPY = 4.2; -const MAX_MAP_SOURCES = 100_000; - -function findBuildSecrets(content: string): SecretFinding[] { - const findings = findSecrets(content).filter( - (finding) => - !BUILD_IRRELEVANT_LABELS.has(finding.label) && !BUILD_GENERIC_LABELS.has(finding.label), - ); - - // Minified UI code routinely contains `key:"description"` and example URLs. - // Generic names only justify a build failure when their values look opaque. - for (const { re, label } of SECRET_PATTERNS) { - if (!BUILD_GENERIC_LABELS.has(label)) continue; - for (const match of content.matchAll(new RegExp(re.source, re.flags))) { - const value = - label === "keyed-secret" ? (match[3] ?? match[2]) : match[0].slice(match[1].length); - if (value.length < OPAQUE_MIN_LENGTH || shannonEntropy(value) < OPAQUE_MIN_ENTROPY) continue; - findings.push({ label, preview: "[REDACTED]" }); - } - } - - return findings; -} - -type SourceRange = { start: number; end: number }; - -// The complete JSON has already been validated. Skip a non-string array item -// while leaving it in the raw map for the normal scan. -function skipJsonValue(content: string, start: number): number { - if (content[start] !== "{" && content[start] !== "[") { - const scalar = /^(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( - content.slice(start), - ); - if (!scalar) throw new Error("Invalid source map value"); - return start + scalar[0].length; - } - const tokens = /"(?:\\.|[^"\\])*"|[{}[\]]/g; - tokens.lastIndex = start; - let depth = 0; - let token: RegExpExecArray | null; - while ((token = tokens.exec(content)) !== null) { - if (token[0] === "{" || token[0] === "[") depth++; - else if (token[0] === "}" || token[0] === "]") depth--; - if (depth === 0) return tokens.lastIndex; - } - throw new Error("Unclosed source map value"); -} - -function readSourceArray( - content: string, - start: number, - budget: { sources: number }, -): { end?: number; findings: SecretFinding[] } { - const strings = /"(?:\\.|[^"\\])*"/g; - const findings: SecretFinding[] = []; - let cursor = start; - let ordinary = true; - while (true) { - cursor += /^\s*/.exec(content.slice(cursor))![0].length; - if (content[cursor] === "]") return { end: ordinary ? cursor + 1 : undefined, findings }; - budget.sources++; - if (budget.sources > MAX_MAP_SOURCES) { - throw new Error("source map exceeds 100000 source entries; scan could not finish"); - } - if (content[cursor] === '"') { - strings.lastIndex = cursor; - const source = strings.exec(content)!; - const decoded = JSON.parse(source[0]) as string; - // No current credential or personal-number pattern can match fewer than eight characters. - if (decoded.length >= 8) { - for (const finding of findBuildSecrets(decoded)) findings.push(finding); - } - cursor = strings.lastIndex; - } else if (content.startsWith("null", cursor)) { - cursor += 4; - } else { - ordinary = false; - cursor = skipJsonValue(content, cursor); - } - cursor += /^\s*/.exec(content.slice(cursor))![0].length; - if (content[cursor] === ",") cursor++; - else if (content[cursor] !== "]") return { findings }; - } -} - -// Scan decoded sources separately so escaped quotes cannot join unrelated code. -// Keep the original remaining JSON, including duplicate fields, for the scan. -function findArtifactSecrets(content: string, file: string): SecretFinding[] { - if (!file.endsWith(".map")) return findBuildSecrets(content); - try { - JSON.parse(content); - } catch { - return findBuildSecrets(content); - } - const findings: SecretFinding[] = []; - const budget = { sources: 0 }; - const ranges: SourceRange[] = []; - const strings = /"(?:\\.|[^"\\])*"/g; - let token: RegExpExecArray | null; - while ((token = strings.exec(content)) !== null) { - if (JSON.parse(token[0]) !== "sourcesContent") continue; - const prefix = /^\s*:\s*\[/.exec(content.slice(strings.lastIndex)); - if (!prefix) continue; - const arrayStart = strings.lastIndex + prefix[0].length; - const array = readSourceArray(content, arrayStart, budget); - for (const finding of array.findings) findings.push(finding); - if (array.end === undefined) continue; - ranges.push({ start: arrayStart, end: array.end - 1 }); - strings.lastIndex = array.end; - } - const rest: string[] = []; - let from = 0; - for (const range of ranges) { - rest.push(content.slice(from, range.start)); - from = range.end; - } - rest.push(content.slice(from)); - return findings.concat(findBuildSecrets(rest.join(""))); -} - /** Next.js server modules are deployed to the runtime, not downloaded by visitors. */ function isBrowserVisibleNextArtifact(file: string): boolean { const parts = file.split("/"); @@ -240,23 +116,45 @@ export async function scanBuildArtifacts( } const hits: BuildHit[] = []; + const incomplete: string[] = []; for (const path of files) { const rel = relative(cwd, path).split(sep).join("/"); if (!isBrowserVisibleNextArtifact(rel)) continue; - let content: string; + let size: number; try { const st = await stat(path); - if (st.size > MAX_BYTES) continue; + size = st.size; + } catch { + if (rel.endsWith(".map")) incomplete.push(`${rel}: source map could not be inspected`); + continue; + } + if (size > MAX_BYTES) { + if (rel.endsWith(".map")) incomplete.push(`${rel}: source map exceeds 5 MiB`); + continue; + } + let content: string; + try { content = await readFile(path, "utf-8"); } catch { + if (rel.endsWith(".map")) incomplete.push(`${rel}: source map could not be read`); + continue; + } + let findings: SecretFinding[]; + try { + findings = findArtifactSecrets(content, rel); + } catch (error) { + if (error instanceof ArtifactIncompleteError && error.findings.length) { + hits.push({ file: rel, findings: error.findings }); + } + incomplete.push(`${rel}: ${(error as Error).message}`); continue; } - const findings = findArtifactSecrets(content, rel); if (findings.length > 0) { // Report repository-style paths so diagnostics and baselines are stable // across Windows and POSIX hosts. hits.push({ file: rel, findings }); } } + if (incomplete.length) throw new BuildScanIncompleteError(incomplete[0], hits); return hits; } From 5dc69c6155d398841e5f654e82cd243b7bab7f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:03:55 +0100 Subject: [PATCH 06/25] Handle invalid and escaped source map fields Report invalid maps as incomplete and preserve metadata findings when a source limit is reached. --- src/scan-build-sourcemap.ts | 52 +++++++++++----- src/scan-build.test.ts | 114 ++++++++++++++++++++++++++++++------ 2 files changed, 133 insertions(+), 33 deletions(-) diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index 68060e56..d5caf681 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -49,7 +49,7 @@ function findBuildSecrets(content: string): SecretFinding[] { } type SourceRange = { start: number; end: number }; -type SourceBudget = { sources: number; nestedBytes: number }; +type SourceBudget = { sources: number; nestedBytes: number; malformed: boolean }; // The complete JSON has already been validated. Skip a non-string array item // while leaving it in the raw map for the normal scan. @@ -87,10 +87,9 @@ function readSourceArray( ): number | undefined { const strings = /"(?:\\.|[^"\\])*"/g; let cursor = start; - let ordinary = true; while (true) { cursor += /^\s*/.exec(content.slice(cursor))![0].length; - if (content[cursor] === "]") return ordinary ? cursor + 1 : undefined; + if (content[cursor] === "]") return cursor + 1; budget.sources++; if (budget.sources > MAX_MAP_SOURCES) { throw new Error("source map exceeds 100000 source entries; scan could not finish"); @@ -107,7 +106,7 @@ function readSourceArray( } else if (content.startsWith("null", cursor)) { cursor += 4; } else { - ordinary = false; + budget.malformed = true; const end = skipJsonValue(content, cursor, budget); const normalized = content .slice(cursor, end) @@ -121,6 +120,33 @@ function readSourceArray( } } +function scanMapMetadata(content: string): SecretFinding[] { + const strings = /"(?:\\.|[^"\\])*"/g; + const ranges: SourceRange[] = []; + const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false }; + let token: RegExpExecArray | null; + while ((token = strings.exec(content)) !== null) { + if (JSON.parse(token[0]) !== "sourcesContent") continue; + const prefix = /^\s*:\s*\[/.exec(content.slice(strings.lastIndex)); + if (!prefix) continue; + const start = strings.lastIndex + prefix[0].length; + const end = skipJsonValue(content, start - 1, budget); + ranges.push({ start, end: end - 1 }); + strings.lastIndex = end; + } + const rest: string[] = []; + let from = 0; + for (const range of ranges) { + rest.push(content.slice(from, range.start)); + from = range.end; + } + rest.push(content.slice(from)); + const normalized = rest + .join("") + .replace(/"(?:\\.|[^"\\])*"/g, (part) => JSON.stringify(JSON.parse(part))); + return findBuildSecrets(normalized); +} + // Scan decoded sources separately so escaped quotes cannot join unrelated code. // Keep the original remaining JSON, including duplicate fields, for the scan. export function findArtifactSecrets(content: string, file: string): SecretFinding[] { @@ -128,11 +154,10 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin try { JSON.parse(content); } catch { - return findBuildSecrets(content); + throw new ArtifactIncompleteError("source map has invalid JSON", findBuildSecrets(content)); } const findings: SecretFinding[] = []; - const budget = { sources: 0, nestedBytes: 0 }; - const ranges: SourceRange[] = []; + const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false }; const strings = /"(?:\\.|[^"\\])*"/g; let token: RegExpExecArray | null; try { @@ -143,18 +168,15 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin const arrayStart = strings.lastIndex + prefix[0].length; const arrayEnd = readSourceArray(content, arrayStart, budget, findings); if (arrayEnd === undefined) continue; - ranges.push({ start: arrayStart, end: arrayEnd - 1 }); strings.lastIndex = arrayEnd; } } catch (error) { + for (const finding of scanMapMetadata(content)) findings.push(finding); throw new ArtifactIncompleteError((error as Error).message, findings); } - const rest: string[] = []; - let from = 0; - for (const range of ranges) { - rest.push(content.slice(from, range.start)); - from = range.end; + for (const finding of scanMapMetadata(content)) findings.push(finding); + if (budget.malformed) { + throw new ArtifactIncompleteError("source map has non-string source content", findings); } - rest.push(content.slice(from)); - return findings.concat(findBuildSecrets(rest.join(""))); + return findings; } diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index 5052b487..a984e274 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { scanBuildArtifacts } from "./scan-build.js"; +import { BuildScanIncompleteError, scanBuildArtifacts } from "./scan-build.js"; import { checkBuiltBundleSecrets } from "./check-client-exposure.js"; import { rmTemp } from "./temp-cleanup.test-support.js"; @@ -165,14 +165,19 @@ describe("scanBuildArtifacts source map fields", () => { JSON.stringify({ api_key: opaque, sourcesContent: ["safe"] }), ); writeFileSync(join(dir, "build", "broken.js.map"), `{"api_key":"${opaque}"`); - const hits = await scanBuildArtifacts(dir); - assert.deepEqual(hits.map((hit) => hit.file).sort(), [ - "build/broken.js.map", - "build/valid.js.map", - ]); - assert.ok( - hits.every((hit) => hit.findings.some((finding) => finding.label === "keyed-secret")), - ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits.map((hit) => hit.file).sort(), [ + "build/broken.js.map", + "build/valid.js.map", + ]); + assert.ok( + error.hits.every((hit) => + hit.findings.some((finding) => finding.label === "keyed-secret"), + ), + ); + return true; + }); } finally { rmTemp(dir); } @@ -204,7 +209,9 @@ describe("scanBuildArtifacts source map fields", () => { rmTemp(dir); } }); +}); +describe("scanBuildArtifacts mixed source content", () => { it("retains decoded findings when source content contains an unexpected object", async () => { const dir = makeRepo(); try { @@ -220,11 +227,14 @@ describe("scanBuildArtifacts source map fields", () => { ); writeFileSync(join(dir, "build", `mixed${index}.js.map`), encoded); } - const hits = await scanBuildArtifacts(dir); - assert.equal(hits.length, 2); - assert.ok( - hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), - ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 2); + assert.ok( + error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), + ); + return true; + }); } finally { rmTemp(dir); } @@ -318,7 +328,7 @@ describe("source map scan limit reporting", () => { } }); - it("reports excessive nesting as an incomplete scan", async () => { + it("reports deeply nested invalid sources as an incomplete scan", async () => { const dir = makeRepo(); try { mkdirSync(join(dir, "dist"), { recursive: true }); @@ -328,7 +338,7 @@ describe("source map scan limit reporting", () => { const result = await checkBuiltBundleSecrets(dir); assert.equal(result.status, "fail"); assert.equal(result.didNotRun, true); - assert.match(result.detail, /source map nesting exceeds scan work limit/); + assert.match(result.detail, /source map has non-string source content/); } finally { rmTemp(dir); } @@ -344,8 +354,11 @@ describe("source map scan limit reporting", () => { "\\u0073k_" + "live_", ); writeFileSync(join(dir, "dist", "mixed.js.map"), map); - const hits = await scanBuildArtifacts(dir); - assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); + return true; + }); } finally { rmTemp(dir); } @@ -372,6 +385,71 @@ describe("source map partial findings", () => { }); }); +describe("source map metadata and invalid content", () => { + it("detects an encoded credential in a metadata field", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const map = JSON.stringify({ custom: secret, sourcesContent: [] }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", "metadata.js.map"), map); + const hits = await scanBuildArtifacts(dir); + assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); + } finally { + rmTemp(dir); + } + }); + + it("keeps metadata findings before and after a source limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const sourcesContent = Array(100_001).fill("a"); + for (const [index, map] of [ + { custom: secret, sourcesContent }, + { sourcesContent, custom: secret }, + ].entries()) { + writeFileSync(join(dir, "build", `metadata${index}.js.map`), JSON.stringify(map)); + } + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 2); + assert.ok( + error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); + + it("reports invalid maps without a false credential finding", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync( + join(dir, "build", "mixed.js.map"), + JSON.stringify({ sourcesContent: [source, {}] }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + describe("scanBuildArtifacts credential shapes", () => { it("does not treat ordinary client property names as credentials", async () => { const dir = makeRepo(); From a0b47c6377b0b4036e6ba79a07ac6bbd5d888650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:09:03 +0100 Subject: [PATCH 07/25] Reject invalid source map formats Report non-array source content as incomplete and scan decoded source text without treating it as metadata. --- src/scan-build-json.ts | 38 ++++++++++++++++++++++++ src/scan-build-sourcemap.ts | 58 +++++++++++++------------------------ src/scan-build.test.ts | 30 ++++++++++++++++++- 3 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 src/scan-build-json.ts diff --git a/src/scan-build-json.ts b/src/scan-build-json.ts new file mode 100644 index 00000000..4e4371c8 --- /dev/null +++ b/src/scan-build-json.ts @@ -0,0 +1,38 @@ +export const MAX_BYTES = 5 * 1024 * 1024; // 5 MiB + +export type SourceBudget = { sources: number; nestedBytes: number; malformed: boolean }; + +// The complete JSON has already been validated. Skip a non-string array item +// while leaving it in the raw map for the normal scan. +export function skipJsonValue(content: string, start: number, budget: SourceBudget): number { + if (content[start] === '"') { + const strings = /"(?:\\.|[^"\\])*"/g; + strings.lastIndex = start; + const value = strings.exec(content); + if (!value || value.index !== start) throw new Error("Invalid source map string"); + return strings.lastIndex; + } + if (content[start] !== "{" && content[start] !== "[") { + const scalar = /^(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( + content.slice(start), + ); + if (!scalar) throw new Error("Invalid source map value"); + return start + scalar[0].length; + } + const tokens = /"(?:\\.|[^"\\])*"|[{}[\]]/g; + tokens.lastIndex = start; + let depth = 0; + let previous = start; + let token: RegExpExecArray | null; + while ((token = tokens.exec(content)) !== null) { + budget.nestedBytes += tokens.lastIndex - previous; + previous = tokens.lastIndex; + if (budget.nestedBytes > MAX_BYTES) { + throw new Error("source map nesting exceeds scan work limit"); + } + if (token[0] === "{" || token[0] === "[") depth++; + else if (token[0] === "}" || token[0] === "]") depth--; + if (depth === 0) return tokens.lastIndex; + } + throw new Error("Unclosed source map value"); +} diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index d5caf681..495256e4 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -14,7 +14,8 @@ export class ArtifactIncompleteError extends Error { } } -export const MAX_BYTES = 5 * 1024 * 1024; // 5 MiB +import { skipJsonValue, type SourceBudget } from "./scan-build-json.js"; +export { MAX_BYTES } from "./scan-build-json.js"; // Terraform / tfstate finding labels do not apply to build artifacts and // false-positive on framework manifests. For example, Next.js `routes-manifest.json` @@ -49,36 +50,6 @@ function findBuildSecrets(content: string): SecretFinding[] { } type SourceRange = { start: number; end: number }; -type SourceBudget = { sources: number; nestedBytes: number; malformed: boolean }; - -// The complete JSON has already been validated. Skip a non-string array item -// while leaving it in the raw map for the normal scan. -function skipJsonValue(content: string, start: number, budget: SourceBudget): number { - if (content[start] !== "{" && content[start] !== "[") { - const scalar = /^(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( - content.slice(start), - ); - if (!scalar) throw new Error("Invalid source map value"); - return start + scalar[0].length; - } - const tokens = /"(?:\\.|[^"\\])*"|[{}[\]]/g; - tokens.lastIndex = start; - let depth = 0; - let previous = start; - let token: RegExpExecArray | null; - while ((token = tokens.exec(content)) !== null) { - budget.nestedBytes += tokens.lastIndex - previous; - previous = tokens.lastIndex; - if (budget.nestedBytes > MAX_BYTES) { - throw new Error("source map nesting exceeds scan work limit"); - } - if (token[0] === "{" || token[0] === "[") depth++; - else if (token[0] === "}" || token[0] === "]") depth--; - if (depth === 0) return tokens.lastIndex; - } - throw new Error("Unclosed source map value"); -} - function readSourceArray( content: string, start: number, @@ -127,17 +98,17 @@ function scanMapMetadata(content: string): SecretFinding[] { let token: RegExpExecArray | null; while ((token = strings.exec(content)) !== null) { if (JSON.parse(token[0]) !== "sourcesContent") continue; - const prefix = /^\s*:\s*\[/.exec(content.slice(strings.lastIndex)); + const prefix = /^\s*:\s*/.exec(content.slice(strings.lastIndex)); if (!prefix) continue; const start = strings.lastIndex + prefix[0].length; - const end = skipJsonValue(content, start - 1, budget); - ranges.push({ start, end: end - 1 }); + const end = skipJsonValue(content, start, budget); + ranges.push({ start, end }); strings.lastIndex = end; } const rest: string[] = []; let from = 0; for (const range of ranges) { - rest.push(content.slice(from, range.start)); + rest.push(content.slice(from, range.start), "null"); from = range.end; } rest.push(content.slice(from)); @@ -163,9 +134,20 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin try { while ((token = strings.exec(content)) !== null) { if (JSON.parse(token[0]) !== "sourcesContent") continue; - const prefix = /^\s*:\s*\[/.exec(content.slice(strings.lastIndex)); + const prefix = /^\s*:\s*/.exec(content.slice(strings.lastIndex)); if (!prefix) continue; - const arrayStart = strings.lastIndex + prefix[0].length; + const valueStart = strings.lastIndex + prefix[0].length; + if (content[valueStart] !== "[") { + budget.malformed = true; + const end = skipJsonValue(content, valueStart, budget); + if (content[valueStart] === '"') { + const decoded = JSON.parse(content.slice(valueStart, end)) as string; + for (const finding of findBuildSecrets(decoded)) findings.push(finding); + } + strings.lastIndex = end; + continue; + } + const arrayStart = valueStart + 1; const arrayEnd = readSourceArray(content, arrayStart, budget, findings); if (arrayEnd === undefined) continue; strings.lastIndex = arrayEnd; @@ -176,7 +158,7 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin } for (const finding of scanMapMetadata(content)) findings.push(finding); if (budget.malformed) { - throw new ArtifactIncompleteError("source map has non-string source content", findings); + throw new ArtifactIncompleteError("source map has invalid source content", findings); } return findings; } diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index a984e274..8211640c 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -338,7 +338,7 @@ describe("source map scan limit reporting", () => { const result = await checkBuiltBundleSecrets(dir); assert.equal(result.status, "fail"); assert.equal(result.didNotRun, true); - assert.match(result.detail, /source map has non-string source content/); + assert.match(result.detail, /source map has invalid source content/); } finally { rmTemp(dir); } @@ -386,6 +386,34 @@ describe("source map partial findings", () => { }); describe("source map metadata and invalid content", () => { + it("reports non-array source content as incomplete without false findings", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync( + join(dir, "build", "string.js.map"), + JSON.stringify({ sourcesContent: source }), + ); + writeFileSync( + join(dir, "build", "boolean.js.map"), + JSON.stringify({ sourcesContent: false }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.match(error.message, /invalid source content/); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map metadata findings", () => { it("detects an encoded credential in a metadata field", async () => { const dir = makeRepo(); try { From 5e582c660c97b344f41bd4b4da9b3fbeeef71924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:13:56 +0100 Subject: [PATCH 08/25] Scan invalid source content without false matches Inspect decoded strings in malformed objects and retain detected credentials while marking the scan incomplete. --- src/scan-build-json.ts | 1 + src/scan-build-malformed.ts | 19 +++++++++++++++++++ src/scan-build-sourcemap.ts | 14 +++++++------- src/scan-build.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 src/scan-build-malformed.ts diff --git a/src/scan-build-json.ts b/src/scan-build-json.ts index 4e4371c8..15f7c6ac 100644 --- a/src/scan-build-json.ts +++ b/src/scan-build-json.ts @@ -1,4 +1,5 @@ export const MAX_BYTES = 5 * 1024 * 1024; // 5 MiB +export const MAX_MAP_SOURCES = 100_000; export type SourceBudget = { sources: number; nestedBytes: number; malformed: boolean }; diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts new file mode 100644 index 00000000..e2f56162 --- /dev/null +++ b/src/scan-build-malformed.ts @@ -0,0 +1,19 @@ +import type { SecretFinding } from "./utils/redactSecrets.js"; +import { MAX_MAP_SOURCES, type SourceBudget } from "./scan-build-json.js"; + +export function scanMalformedObject( + content: string, + findings: SecretFinding[], + budget: SourceBudget, + scan: (value: string) => SecretFinding[], +): void { + for (const token of content.matchAll(/"(?:\\.|[^"\\])*"/g)) { + budget.sources++; + if (budget.sources > MAX_MAP_SOURCES) { + throw new Error("source map exceeds 100000 source entries; scan could not finish"); + } + const decoded = JSON.parse(token[0]) as string; + if (decoded.length < 8) continue; + for (const finding of scan(decoded)) findings.push(finding); + } +} diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index 495256e4..d803be9c 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -14,7 +14,8 @@ export class ArtifactIncompleteError extends Error { } } -import { skipJsonValue, type SourceBudget } from "./scan-build-json.js"; +import { MAX_MAP_SOURCES, skipJsonValue, type SourceBudget } from "./scan-build-json.js"; +import { scanMalformedObject } from "./scan-build-malformed.js"; export { MAX_BYTES } from "./scan-build-json.js"; // Terraform / tfstate finding labels do not apply to build artifacts and @@ -26,7 +27,6 @@ const BUILD_IRRELEVANT_LABELS = new Set(["tfstate-value", "terraform-sensitive"] const BUILD_GENERIC_LABELS = new Set(["keyed-secret", "url-query-token"]); const OPAQUE_MIN_LENGTH = 20; const OPAQUE_MIN_ENTROPY = 4.2; -const MAX_MAP_SOURCES = 100_000; function findBuildSecrets(content: string): SecretFinding[] { const findings = findSecrets(content).filter( @@ -50,6 +50,7 @@ function findBuildSecrets(content: string): SecretFinding[] { } type SourceRange = { start: number; end: number }; + function readSourceArray( content: string, start: number, @@ -79,10 +80,7 @@ function readSourceArray( } else { budget.malformed = true; const end = skipJsonValue(content, cursor, budget); - const normalized = content - .slice(cursor, end) - .replace(/"(?:\\.|[^"\\])*"/g, (token) => JSON.stringify(JSON.parse(token))); - for (const finding of findBuildSecrets(normalized)) findings.push(finding); + scanMalformedObject(content.slice(cursor, end), findings, budget, findBuildSecrets); cursor = end; } cursor += /^\s*/.exec(content.slice(cursor))![0].length; @@ -142,7 +140,9 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin const end = skipJsonValue(content, valueStart, budget); if (content[valueStart] === '"') { const decoded = JSON.parse(content.slice(valueStart, end)) as string; - for (const finding of findBuildSecrets(decoded)) findings.push(finding); + findBuildSecrets(decoded).forEach((finding) => findings.push(finding)); + } else if (content[valueStart] === "{") { + scanMalformedObject(content.slice(valueStart, end), findings, budget, findBuildSecrets); } strings.lastIndex = end; continue; diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index 8211640c..e67c1bf5 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -386,6 +386,33 @@ describe("source map partial findings", () => { }); describe("source map metadata and invalid content", () => { + it("finds a credential in non-array object source content", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const map = JSON.stringify({ sourcesContent: { data: secret } }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", "object.js.map"), map); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync( + join(dir, "build", "safe-object.js.map"), + JSON.stringify({ sourcesContent: { data: source } }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 1); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); + return true; + }); + } finally { + rmTemp(dir); + } + }); it("reports non-array source content as incomplete without false findings", async () => { const dir = makeRepo(); try { From b573c9a5f44473febf6e1e2b1e746c80be379ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:16:23 +0100 Subject: [PATCH 09/25] Retain generic credentials in invalid source maps Keep key-value context for opaque credentials while scanning decoded source strings separately. --- src/scan-build-malformed.ts | 16 +++++++++++++++- src/scan-build.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index e2f56162..888fb52d 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -1,4 +1,4 @@ -import type { SecretFinding } from "./utils/redactSecrets.js"; +import { SECRET_PATTERNS, type SecretFinding } from "./utils/redactSecrets.js"; import { MAX_MAP_SOURCES, type SourceBudget } from "./scan-build-json.js"; export function scanMalformedObject( @@ -13,6 +13,20 @@ export function scanMalformedObject( throw new Error("source map exceeds 100000 source entries; scan could not finish"); } const decoded = JSON.parse(token[0]) as string; + const valueToken = /^\s*:\s*("(?:\\.|[^"\\])*")/.exec( + content.slice(token.index! + token[0].length), + ); + if (valueToken) { + const value = JSON.parse(valueToken[1]) as string; + const probe = `${JSON.stringify(decoded)}:"${"A".repeat(20)}"`; + const keyed = SECRET_PATTERNS.some( + ({ label, re }) => label === "keyed-secret" && new RegExp(re.source, re.flags).test(probe), + ); + if (keyed && /^[A-Za-z0-9_+./~%=-]+$/.test(value)) { + for (const finding of scan(`${JSON.stringify(decoded)}:${JSON.stringify(value)}`)) + findings.push(finding); + } + } if (decoded.length < 8) continue; for (const finding of scan(decoded)) findings.push(finding); } diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index e67c1bf5..c9863848 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -386,6 +386,36 @@ describe("source map partial findings", () => { }); describe("source map metadata and invalid content", () => { + it("finds generic credentials in malformed object sources", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + writeFileSync( + join(dir, "build", "object.js.map"), + JSON.stringify({ sourcesContent: { api_key: opaque } }), + ); + writeFileSync( + join(dir, "build", "array.js.map"), + JSON.stringify({ sourcesContent: [{ api_key: opaque }] }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 2); + assert.ok( + error.hits.every((hit) => + hit.findings.some((finding) => finding.label === "keyed-secret"), + ), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map malformed object findings", () => { it("finds a credential in non-array object source content", async () => { const dir = makeRepo(); try { From af19b51dfe53218c3e25867d0a8b0492f1757566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:19:40 +0100 Subject: [PATCH 10/25] Decode metadata and incomplete source maps Inspect decoded text separately to avoid false positives and retain recognizable findings from incomplete maps. --- src/scan-build-malformed.ts | 2 +- src/scan-build-sourcemap.ts | 32 +++++++++++++++++++------- src/scan-build.test.ts | 45 +++++++++++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index 888fb52d..b57424e8 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -22,7 +22,7 @@ export function scanMalformedObject( const keyed = SECRET_PATTERNS.some( ({ label, re }) => label === "keyed-secret" && new RegExp(re.source, re.flags).test(probe), ); - if (keyed && /^[A-Za-z0-9_+./~%=-]+$/.test(value)) { + if (keyed) { for (const finding of scan(`${JSON.stringify(decoded)}:${JSON.stringify(value)}`)) findings.push(finding); } diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index d803be9c..cc46e56b 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -89,7 +89,7 @@ function readSourceArray( } } -function scanMapMetadata(content: string): SecretFinding[] { +function scanMapMetadata(content: string, findings: SecretFinding[]): void { const strings = /"(?:\\.|[^"\\])*"/g; const ranges: SourceRange[] = []; const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false }; @@ -110,10 +110,7 @@ function scanMapMetadata(content: string): SecretFinding[] { from = range.end; } rest.push(content.slice(from)); - const normalized = rest - .join("") - .replace(/"(?:\\.|[^"\\])*"/g, (part) => JSON.stringify(JSON.parse(part))); - return findBuildSecrets(normalized); + scanMalformedObject(rest.join(""), findings, budget, findBuildSecrets); } // Scan decoded sources separately so escaped quotes cannot join unrelated code. @@ -123,7 +120,18 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin try { JSON.parse(content); } catch { - throw new ArtifactIncompleteError("source map has invalid JSON", findBuildSecrets(content)); + const findings: SecretFinding[] = []; + try { + scanMalformedObject( + content, + findings, + { sources: 0, nestedBytes: 0, malformed: true }, + findBuildSecrets, + ); + } catch { + // The incomplete result still reports findings collected before an invalid token or limit. + } + throw new ArtifactIncompleteError("source map has invalid JSON", findings); } const findings: SecretFinding[] = []; const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false }; @@ -153,10 +161,18 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin strings.lastIndex = arrayEnd; } } catch (error) { - for (const finding of scanMapMetadata(content)) findings.push(finding); + try { + scanMapMetadata(content, findings); + } catch { + // The incomplete result still reports findings collected before the metadata limit. + } + throw new ArtifactIncompleteError((error as Error).message, findings); + } + try { + scanMapMetadata(content, findings); + } catch (error) { throw new ArtifactIncompleteError((error as Error).message, findings); } - for (const finding of scanMapMetadata(content)) findings.push(finding); if (budget.malformed) { throw new ArtifactIncompleteError("source map has invalid source content", findings); } diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index c9863848..febcbbac 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -134,7 +134,7 @@ describe("scanBuildArtifacts source maps", () => { const dir = makeRepo(); try { mkdirSync(join(dir, "build"), { recursive: true }); - const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + const opaque = ["A0b1C2d3", "E4f5G6h7!", "I8j9K0l1", "M2n3O4p5"].join(""); const map = { version: 3, sources: ["one.js", "two.js"], @@ -390,7 +390,7 @@ describe("source map metadata and invalid content", () => { const dir = makeRepo(); try { mkdirSync(join(dir, "build"), { recursive: true }); - const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + const opaque = ["A0b1C2d3", "E4f5G6h7!", "I8j9K0l1", "M2n3O4p5"].join(""); writeFileSync( join(dir, "build", "object.js.map"), JSON.stringify({ sourcesContent: { api_key: opaque } }), @@ -415,6 +415,47 @@ describe("source map metadata and invalid content", () => { }); }); +describe("source map encoded metadata and broken JSON", () => { + it("does not flag safe code in metadata or broken JSON", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync(join(dir, "build", "metadata.js.map"), JSON.stringify({ custom: source })); + const broken = JSON.stringify({ sourcesContent: [source] }).slice(0, -1); + writeFileSync(join(dir, "build", "broken.js.map"), broken); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); + + it("retains a decoded credential from broken JSON", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const broken = JSON.stringify({ sourcesContent: [secret] }) + .replace("sk_" + "live_", "\\u0073k_" + "live_") + .slice(0, -1); + writeFileSync(join(dir, "build", "broken.js.map"), broken); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + describe("source map malformed object findings", () => { it("finds a credential in non-array object source content", async () => { const dir = makeRepo(); From 8038a199cae09dcc32c4e0c73239d2b623d8012b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:23:11 +0100 Subject: [PATCH 11/25] Report ambiguous source-map values Avoid treating encoded code as a credential when a keyed value cannot be safely classified. --- docs/COMMANDS.md | 3 ++- src/scan-build-malformed.ts | 7 +++++-- src/scan-build.test.ts | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0139159a..4d7b3810 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -255,7 +255,8 @@ max_bytes = 12000 | `kit security prescan-diff ` | Diff two prescan reports — surface new regressions + fixed findings. | The build scan reports an incomplete check for source maps over 5 MiB or 100,000 source entries. -It also stops maps that exceed the nesting work limit. The report includes credentials found before a limit. +It also reports incomplete scans for invalid maps and ambiguous keyed values. +The report includes credentials found before a limit. ### Code guardrails in the security check diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index b57424e8..b338037b 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -1,4 +1,4 @@ -import { SECRET_PATTERNS, type SecretFinding } from "./utils/redactSecrets.js"; +import { SECRET_PATTERNS, shannonEntropy, type SecretFinding } from "./utils/redactSecrets.js"; import { MAX_MAP_SOURCES, type SourceBudget } from "./scan-build-json.js"; export function scanMalformedObject( @@ -22,7 +22,10 @@ export function scanMalformedObject( const keyed = SECRET_PATTERNS.some( ({ label, re }) => label === "keyed-secret" && new RegExp(re.source, re.flags).test(probe), ); - if (keyed) { + if (keyed && /[\s"'\\]/.test(value) && value.length >= 20 && shannonEntropy(value) >= 4.2) { + throw new Error("source map contains an ambiguous keyed value"); + } + if (keyed && !/[\s"'\\]/.test(value)) { for (const finding of scan(`${JSON.stringify(decoded)}:${JSON.stringify(value)}`)) findings.push(finding); } diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index febcbbac..dd6da664 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -416,6 +416,27 @@ describe("source map metadata and invalid content", () => { }); describe("source map encoded metadata and broken JSON", () => { + it("reports ambiguous keyed code as incomplete without a false credential", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync(join(dir, "build", "metadata.js.map"), JSON.stringify({ key: source })); + writeFileSync( + join(dir, "build", "object.js.map"), + JSON.stringify({ sourcesContent: { key: source } }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); it("does not flag safe code in metadata or broken JSON", async () => { const dir = makeRepo(); try { From 012b1b284d6df54897aadbc69e82aa6c1f6f69fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:27:49 +0100 Subject: [PATCH 12/25] Continue scans past ambiguous values and fixtures Retain later findings and exclude incomplete test artifacts from the automatic bundle check. --- src/check-client-exposure.ts | 70 ++++++++++++++++++++++++------------ src/scan-build-json.ts | 7 +++- src/scan-build-malformed.ts | 2 +- src/scan-build-sourcemap.ts | 10 ++++-- src/scan-build.test.ts | 67 ++++++++++++++++++++++++++++++++++ src/scan-build.ts | 24 ++++++++----- 6 files changed, 144 insertions(+), 36 deletions(-) diff --git a/src/check-client-exposure.ts b/src/check-client-exposure.ts index 6301105d..6371a935 100644 --- a/src/check-client-exposure.ts +++ b/src/check-client-exposure.ts @@ -457,6 +457,37 @@ const BUILD_DIRS = [ ".output", ]; +async function presentBuildDirs(root: string, builds: ClientBuild[]): Promise { + const present: string[] = []; + for (const build of builds) { + for (const dir of BUILD_DIRS) { + const rel = build.dir ? `${build.dir}/${dir}` : dir; + try { + await access(join(root, rel)); + present.push(rel); + } catch { + /* not built here */ + } + } + } + return present; +} + +function incompleteBundleResult( + base: Omit, + reason: string, + known: Array<{ file: string }>, +): SecurityCheckResult { + return { + ...base, + status: "fail", + severity: known.length ? "critical" : "medium", + didNotRun: true, + detail: `bundle scan could not finish: ${reason.slice(0, 80)}${known.length ? `; credentials also found in ${known.map((h) => h.file).join(", ")}` : ""}`, + ...(known.length ? { files: known.map((h) => h.file) } : {}), + }; +} + /** * Scan the built output for credential shapes. * @@ -480,18 +511,7 @@ export async function checkBuiltBundleSecrets(root: string): Promise b.framework))].join("+"); - const present: string[] = []; - for (const build of builds) { - for (const dir of BUILD_DIRS) { - const rel = build.dir ? `${build.dir}/${dir}` : dir; - try { - await access(join(root, rel)); - present.push(rel); - } catch { - /* not built here */ - } - } - } + const present = await presentBuildDirs(root, builds); if (present.length === 0) { return { ...base, @@ -502,24 +522,28 @@ export async function checkBuiltBundleSecrets(root: string): Promise>; + let ignoredIncomplete: string[] = []; try { hits = await scanBuildArtifacts(root, present); } catch (e) { // A scan that crashed is not a clean scan: didNotRun makes the CI gate treat it as such. - const known = - e instanceof BuildScanIncompleteError ? e.hits.filter((h) => !isTestArtifact(h.file)) : []; - return { - ...base, - status: "fail", - severity: known.length ? "critical" : "medium", - didNotRun: true, - detail: `bundle scan could not finish: ${(e as Error).message.slice(0, 80)}${known.length ? `; credentials also found in ${known.map((h) => h.file).join(", ")}` : ""}`, - ...(known.length ? { files: known.map((h) => h.file) } : {}), - }; + if (!(e instanceof BuildScanIncompleteError)) { + return incompleteBundleResult(base, (e as Error).message, []); + } + const issues = e.issues.filter((issue) => !isTestArtifact(issue.file)); + if (issues.length) { + const known = e.hits.filter((hit) => !isTestArtifact(hit.file)); + return incompleteBundleResult(base, issues[0].detail, known); + } + hits = e.hits; + ignoredIncomplete = e.issues.map((issue) => issue.file); } const shipped = hits.filter((h) => !isTestArtifact(h.file)); - const excluded = hits.length - shipped.length; + const excluded = new Set([ + ...hits.filter((hit) => isTestArtifact(hit.file)).map((hit) => hit.file), + ...ignoredIncomplete, + ]).size; const aside = excluded > 0 ? ` (${excluded} test fixture(s) not counted)` : ""; if (shipped.length === 0) { diff --git a/src/scan-build-json.ts b/src/scan-build-json.ts index 15f7c6ac..832c8949 100644 --- a/src/scan-build-json.ts +++ b/src/scan-build-json.ts @@ -1,7 +1,12 @@ export const MAX_BYTES = 5 * 1024 * 1024; // 5 MiB export const MAX_MAP_SOURCES = 100_000; -export type SourceBudget = { sources: number; nestedBytes: number; malformed: boolean }; +export type SourceBudget = { + sources: number; + nestedBytes: number; + malformed: boolean; + ambiguous: boolean; +}; // The complete JSON has already been validated. Skip a non-string array item // while leaving it in the raw map for the normal scan. diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index b338037b..1fea67d7 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -23,7 +23,7 @@ export function scanMalformedObject( ({ label, re }) => label === "keyed-secret" && new RegExp(re.source, re.flags).test(probe), ); if (keyed && /[\s"'\\]/.test(value) && value.length >= 20 && shannonEntropy(value) >= 4.2) { - throw new Error("source map contains an ambiguous keyed value"); + budget.ambiguous = true; } if (keyed && !/[\s"'\\]/.test(value)) { for (const finding of scan(`${JSON.stringify(decoded)}:${JSON.stringify(value)}`)) diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index cc46e56b..98314d5b 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -92,7 +92,7 @@ function readSourceArray( function scanMapMetadata(content: string, findings: SecretFinding[]): void { const strings = /"(?:\\.|[^"\\])*"/g; const ranges: SourceRange[] = []; - const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false }; + const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false, ambiguous: false }; let token: RegExpExecArray | null; while ((token = strings.exec(content)) !== null) { if (JSON.parse(token[0]) !== "sourcesContent") continue; @@ -111,6 +111,7 @@ function scanMapMetadata(content: string, findings: SecretFinding[]): void { } rest.push(content.slice(from)); scanMalformedObject(rest.join(""), findings, budget, findBuildSecrets); + if (budget.ambiguous) throw new Error("source map contains an ambiguous keyed value"); } // Scan decoded sources separately so escaped quotes cannot join unrelated code. @@ -125,7 +126,7 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin scanMalformedObject( content, findings, - { sources: 0, nestedBytes: 0, malformed: true }, + { sources: 0, nestedBytes: 0, malformed: true, ambiguous: false }, findBuildSecrets, ); } catch { @@ -134,7 +135,7 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin throw new ArtifactIncompleteError("source map has invalid JSON", findings); } const findings: SecretFinding[] = []; - const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false }; + const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false, ambiguous: false }; const strings = /"(?:\\.|[^"\\])*"/g; let token: RegExpExecArray | null; try { @@ -176,5 +177,8 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin if (budget.malformed) { throw new ArtifactIncompleteError("source map has invalid source content", findings); } + if (budget.ambiguous) { + throw new ArtifactIncompleteError("source map contains an ambiguous keyed value", findings); + } return findings; } diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index dd6da664..3d6a6da5 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -385,6 +385,42 @@ describe("source map partial findings", () => { }); }); +describe("source map test fixtures", () => { + it("excludes incomplete fixtures from the automatic check", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist", "fixtures"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + writeFileSync(join(dir, "dist", "fixtures", "broken.js.map"), "{"); + writeFileSync(join(dir, "dist", "fixtures", "large.js.map"), " ".repeat(5 * 1024 * 1024 + 1)); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "pass"); + assert.match(result.detail, /2 test fixture\(s\) not counted/); + } finally { + rmTemp(dir); + } + }); + + it("still reports a real credential and an incomplete non-fixture map", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist", "fixtures"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + writeFileSync(join(dir, "dist", "fixtures", "broken.js.map"), "{"); + writeFileSync(join(dir, "dist", "live.js.map"), "{"); + writeFileSync(join(dir, "dist", "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.equal(result.severity, "critical"); + assert.match(result.detail, /dist\/live\.js\.map/); + assert.ok(result.files?.includes("dist/leak.js")); + } finally { + rmTemp(dir); + } + }); +}); + describe("source map metadata and invalid content", () => { it("finds generic credentials in malformed object sources", async () => { const dir = makeRepo(); @@ -416,6 +452,34 @@ describe("source map metadata and invalid content", () => { }); describe("source map encoded metadata and broken JSON", () => { + it("continues scanning when a keyed value is ambiguous", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const maps = [ + { key: source, custom: secret }, + { sourcesContent: { key: source, custom: secret } }, + { key: source + secret }, + ]; + for (const [index, map] of maps.entries()) { + writeFileSync(join(dir, "build", `ambiguous${index}.js.map`), JSON.stringify(map)); + } + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 3); + assert.ok( + error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); it("reports ambiguous keyed code as incomplete without a false credential", async () => { const dir = makeRepo(); try { @@ -437,6 +501,9 @@ describe("source map encoded metadata and broken JSON", () => { rmTemp(dir); } }); +}); + +describe("source map metadata with incomplete JSON", () => { it("does not flag safe code in metadata or broken JSON", async () => { const dir = makeRepo(); try { diff --git a/src/scan-build.ts b/src/scan-build.ts index ba0a7261..0419845c 100644 --- a/src/scan-build.ts +++ b/src/scan-build.ts @@ -20,12 +20,17 @@ export interface BuildHit { findings: SecretFinding[]; } +export interface BuildScanIssue { + file: string; + detail: string; +} + export class BuildScanIncompleteError extends Error { constructor( - message: string, + readonly issues: BuildScanIssue[], readonly hits: BuildHit[], ) { - super(message); + super(issues[0]?.detail ?? "build scan incomplete"); this.name = "BuildScanIncompleteError"; } } @@ -116,7 +121,7 @@ export async function scanBuildArtifacts( } const hits: BuildHit[] = []; - const incomplete: string[] = []; + const incomplete: BuildScanIssue[] = []; for (const path of files) { const rel = relative(cwd, path).split(sep).join("/"); if (!isBrowserVisibleNextArtifact(rel)) continue; @@ -125,18 +130,21 @@ export async function scanBuildArtifacts( const st = await stat(path); size = st.size; } catch { - if (rel.endsWith(".map")) incomplete.push(`${rel}: source map could not be inspected`); + if (rel.endsWith(".map")) + incomplete.push({ file: rel, detail: `${rel}: source map could not be inspected` }); continue; } if (size > MAX_BYTES) { - if (rel.endsWith(".map")) incomplete.push(`${rel}: source map exceeds 5 MiB`); + if (rel.endsWith(".map")) + incomplete.push({ file: rel, detail: `${rel}: source map exceeds 5 MiB` }); continue; } let content: string; try { content = await readFile(path, "utf-8"); } catch { - if (rel.endsWith(".map")) incomplete.push(`${rel}: source map could not be read`); + if (rel.endsWith(".map")) + incomplete.push({ file: rel, detail: `${rel}: source map could not be read` }); continue; } let findings: SecretFinding[]; @@ -146,7 +154,7 @@ export async function scanBuildArtifacts( if (error instanceof ArtifactIncompleteError && error.findings.length) { hits.push({ file: rel, findings: error.findings }); } - incomplete.push(`${rel}: ${(error as Error).message}`); + incomplete.push({ file: rel, detail: `${rel}: ${(error as Error).message}` }); continue; } if (findings.length > 0) { @@ -155,6 +163,6 @@ export async function scanBuildArtifacts( hits.push({ file: rel, findings }); } } - if (incomplete.length) throw new BuildScanIncompleteError(incomplete[0], hits); + if (incomplete.length) throw new BuildScanIncompleteError(incomplete, hits); return hits; } From 7790fc447ec90c78f3cd21e1a11dc3b21aff9e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:31:27 +0100 Subject: [PATCH 13/25] Exclude source maps for test modules Apply the test-artifact exclusion to the source maps of compiled tests and specs. --- src/check-client-exposure.test.ts | 3 +++ src/check-client-exposure.ts | 2 +- src/scan-build.test.ts | 7 ++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/check-client-exposure.test.ts b/src/check-client-exposure.test.ts index a3ee732e..a4fb8be0 100644 --- a/src/check-client-exposure.test.ts +++ b/src/check-client-exposure.test.ts @@ -375,9 +375,12 @@ describe("checkBuiltBundleSecrets", () => { it("classifies test artifacts by path, including mocks and fixtures", () => { assert.equal(isTestArtifact("dist/a.test.js"), true); assert.equal(isTestArtifact("dist/a.spec.mjs"), true); + assert.equal(isTestArtifact("dist/a.test.js.map"), true); + assert.equal(isTestArtifact("dist/a.spec.mjs.map"), true); assert.equal(isTestArtifact("dist/__tests__/a.js"), true); assert.equal(isTestArtifact("dist/fixtures/a.js"), true); assert.equal(isTestArtifact("dist/assets/index-abc.js"), false); assert.equal(isTestArtifact("dist/latest.js"), false, "a name containing 'test' is not a test"); + assert.equal(isTestArtifact("dist/latest.js.map"), false); }); }); diff --git a/src/check-client-exposure.ts b/src/check-client-exposure.ts index 6371a935..46e72616 100644 --- a/src/check-client-exposure.ts +++ b/src/check-client-exposure.ts @@ -441,7 +441,7 @@ async function candidatePackageDirs(root: string): Promise { export function isTestArtifact(file: string): boolean { return ( /(^|[\\/])(__tests__|__mocks__|fixtures)[\\/]/.test(file) || - /\.(test|spec)\.[cm]?[jt]sx?$/.test(file) + /\.(test|spec)\.[cm]?[jt]sx?(?:\.map)?$/.test(file) ); } diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index 3d6a6da5..df6dc983 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -393,9 +393,14 @@ describe("source map test fixtures", () => { writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); writeFileSync(join(dir, "dist", "fixtures", "broken.js.map"), "{"); writeFileSync(join(dir, "dist", "fixtures", "large.js.map"), " ".repeat(5 * 1024 * 1024 + 1)); + writeFileSync(join(dir, "dist", "widget.test.js.map"), "{"); + writeFileSync( + join(dir, "dist", "widget.spec.mjs.map"), + JSON.stringify({ sourcesContent: ["sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"] }), + ); const result = await checkBuiltBundleSecrets(dir); assert.equal(result.status, "pass"); - assert.match(result.detail, /2 test fixture\(s\) not counted/); + assert.match(result.detail, /4 test fixture\(s\) not counted/); } finally { rmTemp(dir); } From 2bab99870f400b5a2ca22cff8c1a2dc3fed6f0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:53:44 +0100 Subject: [PATCH 14/25] Preserve findings from incomplete bundle scans Report scan coverage and confirmed findings separately across CLI, JSON, handoff, and scan comparisons. Keep scanner tests within project gates. --- src/check-client-exposure.ts | 2 + src/check-mcp-summary.test.ts | 28 ++ src/check-run.ts | 23 +- src/check-security.ts | 2 +- src/ci.test.ts | 29 ++ src/cli-checks-shared.ts | 4 + src/commands/ci.ts | 6 +- src/commands/security-scan-build.test.ts | 76 +++ src/commands/security-scan-build.ts | 40 ++ src/commands/security.ts | 39 +- src/hitl.test.ts | 37 ++ src/hitl.ts | 24 +- src/output.test.ts | 53 +++ src/output.ts | 21 +- src/scan-build-json.test.ts | 216 +++++++++ src/scan-build-malformed.test.ts | 141 ++++++ src/scan-build-malformed.ts | 14 +- src/scan-build-sourcemap.test.ts | 273 +++++++++++ src/scan-build-sourcemap.ts | 4 +- src/scan-build.test.ts | 565 +---------------------- src/scan-diff.test.ts | 38 ++ src/scan-diff.ts | 60 ++- 22 files changed, 1064 insertions(+), 631 deletions(-) create mode 100644 src/commands/security-scan-build.test.ts create mode 100644 src/commands/security-scan-build.ts create mode 100644 src/scan-build-json.test.ts create mode 100644 src/scan-build-malformed.test.ts create mode 100644 src/scan-build-sourcemap.test.ts diff --git a/src/check-client-exposure.ts b/src/check-client-exposure.ts index 46e72616..32c82952 100644 --- a/src/check-client-exposure.ts +++ b/src/check-client-exposure.ts @@ -483,6 +483,8 @@ function incompleteBundleResult( status: "fail", severity: known.length ? "critical" : "medium", didNotRun: true, + scanIncomplete: true, + ...(known.length ? { hasFindings: true } : {}), detail: `bundle scan could not finish: ${reason.slice(0, 80)}${known.length ? `; credentials also found in ${known.map((h) => h.file).join(", ")}` : ""}`, ...(known.length ? { files: known.map((h) => h.file) } : {}), }; diff --git a/src/check-mcp-summary.test.ts b/src/check-mcp-summary.test.ts index bf6e961a..7247bff9 100644 --- a/src/check-mcp-summary.test.ts +++ b/src/check-mcp-summary.test.ts @@ -89,7 +89,35 @@ describe("summarizeCheckRun", () => { const s = summarizeCheckRun(runFixture()); assert.equal(s.findings.find((f) => f.name === "npm audit")?.severity, "high"); }); +}); + +describe("summarizeCheckRun incomplete findings", () => { + it("preserves known findings on an incomplete check", () => { + const summary = summarizeCheckRun( + runFixture({ + security: [ + { + category: "exposure", + name: "bundle", + status: "fail", + detail: "finding and incomplete scan", + didNotRun: true, + scanIncomplete: true, + hasFindings: true, + severity: "critical", + }, + ], + }), + ); + const bundle = summary.findings.find((finding) => finding.name === "bundle"); + assert.equal(bundle?.didNotRun, true); + assert.equal(bundle?.scanIncomplete, true); + assert.equal(bundle?.hasFindings, true); + assert.equal(bundle?.severity, "critical"); + }); +}); +describe("summarizeCheckRun scope and detail", () => { it("carries scope, so a narrowed green cannot read as a full one", () => { const s = summarizeCheckRun(runFixture({ scope: ["security"], ok: true })); assert.deepEqual(s.scope, ["security"]); diff --git a/src/check-run.ts b/src/check-run.ts index adeed6e1..c558e6c9 100644 --- a/src/check-run.ts +++ b/src/check-run.ts @@ -279,6 +279,20 @@ export async function runCheckGate(opts: RunCheckOptions = {}): Promise ({ @@ -339,14 +353,7 @@ export function checkRunToJsonChecks(r: CheckRunResult): JsonCheck[] { // `severity` and `didNotRun` are carried through deliberately: JsonCheck has always // declared severity and this projection silently dropped it, and didNotRun is what lets // a consumer tell "stopped failing" from "stopped looking" (see scan-diff.ts). - ...r.security.map((s) => ({ - name: s.name, - status: s.status, - detail: s.detail, - category: `security/${s.category}`, - ...(s.severity ? { severity: s.severity } : {}), - ...(s.didNotRun ? { didNotRun: true as const } : {}), - })), + ...r.security.map(securityCheckToJson), ...r.tests.map((t) => ({ name: t.name, status: t.status, diff --git a/src/check-security.ts b/src/check-security.ts index 2a734c60..9e356560 100644 --- a/src/check-security.ts +++ b/src/check-security.ts @@ -93,7 +93,7 @@ export const SECURITY_CATEGORIES = [ export type SecurityCategory = (typeof SECURITY_CATEGORIES)[number] | `self-audit/${string}`; -export interface SecurityCheckResult { +export interface SecurityCheckResult extends Partial<{ hasFindings: true; scanIncomplete: true }> { category: SecurityCategory; name: string; status: "pass" | "fail" | "warn" | "skip"; diff --git a/src/ci.test.ts b/src/ci.test.ts index 792ea082..71762552 100644 --- a/src/ci.test.ts +++ b/src/ci.test.ts @@ -66,6 +66,35 @@ describe("kit ci", () => { assert(typeof parsed.ok === "boolean"); }); + it("preserves an incomplete security scan and its finding in JSON", async () => { + const dist = join(testDir, "dist"); + try { + await mkdir(dist, { recursive: true }); + await writeFile( + join(testDir, "package.json"), + JSON.stringify({ devDependencies: { vite: "^5" } }), + ); + await writeFile(join(dist, "broken.js.map"), "{"); + await writeFile(join(dist, "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"); + const { stdout } = await runCi(["--json", "--no-auto-install"], { KIT_BUMBLEBEE: "0" }); + const output = JSON.parse(stdout); + const check = output.checks.find( + (row: { name: string }) => row.name === "built bundle secrets", + ); + assert.ok(check); + assert.equal(check.status, "fail"); + assert.equal(check.severity, "critical"); + assert.equal(check.didNotRun, true); + assert.equal(check.scanIncomplete, true); + assert.equal(check.hasFindings, true); + assert.match(check.detail, /dist\/leak\.js/); + assert.doesNotMatch(check.detail, /--lenient to downgrade/); + } finally { + await rm(dist, { recursive: true, force: true }); + await rm(join(testDir, "package.json"), { force: true }); + } + }); + it("--format=text outputs a summary line", async () => { const { stdout } = await runCi(["--format=text"]); assert(stdout.includes("kit ci:")); diff --git a/src/cli-checks-shared.ts b/src/cli-checks-shared.ts index 2bea326e..ea164437 100644 --- a/src/cli-checks-shared.ts +++ b/src/cli-checks-shared.ts @@ -69,6 +69,10 @@ export interface JsonCheck { * "stopped looking" — see `scan-diff.ts`, which ranks lost coverage above a regression. */ didNotRun?: boolean; + /** True when the check also reported a finding despite incomplete coverage. */ + hasFindings?: boolean; + /** True when the scan started but could not finish. */ + scanIncomplete?: boolean; } /** The full `--json` document for check / ci. */ diff --git a/src/commands/ci.ts b/src/commands/ci.ts index 925f549e..accf3ea9 100644 --- a/src/commands/ci.ts +++ b/src/commands/ci.ts @@ -18,6 +18,7 @@ import { checkServices } from "../check-services.js"; import { checkSecrets } from "../check-secrets.js"; import { checkSkills } from "../check-skills.js"; import { checkSecurity, gateStatus } from "../check-security.js"; +import { securityCheckToJson } from "../check-run.js"; import { checkLockFiles } from "../check-lock.js"; import { evaluatePolicy } from "../policy-check.js"; import { @@ -177,15 +178,14 @@ export async function cmdCi(): Promise { category: "lock", })), ...securityResults.map((s) => ({ - name: s.name, + ...securityCheckToJson(s), // Scanner-health strict by default: a check that could not run fails the // gate unless --lenient. A finding-warn (the check ran + flagged) is untouched. status: gateStatus(s, { lenient, failOnWarning }) as JsonCheck["status"], detail: - s.didNotRun && !lenient + s.didNotRun && !lenient && s.status === "warn" ? `${s.detail} [did not run — strict default; --lenient to downgrade to warn]` : s.detail, - category: `security/${s.category}`, })), ...(policyReport.present ? [ diff --git a/src/commands/security-scan-build.test.ts b/src/commands/security-scan-build.test.ts new file mode 100644 index 00000000..0426dc4e --- /dev/null +++ b/src/commands/security-scan-build.test.ts @@ -0,0 +1,76 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cmdSecurityScanBuild } from "./security-scan-build.js"; +import { rmTemp } from "../temp-cleanup.test-support.js"; + +async function runScan(dir: string): Promise<{ ok: boolean; output: string }> { + const originalCwd = process.cwd(); + const originalArgs = process.argv; + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + try { + process.chdir(dir); + process.argv = ["node", "kit", "security", "scan-build"]; + console.log = (...args: unknown[]) => void output.push(args.join(" ")); + console.error = (...args: unknown[]) => void output.push(args.join(" ")); + return { ok: await cmdSecurityScanBuild(), output: output.join("\n") }; + } finally { + process.chdir(originalCwd); + process.argv = originalArgs; + console.log = originalLog; + console.error = originalError; + } +} + +describe("kit security scan-build", () => { + it("fails an incomplete scan without claiming no matches", async () => { + const dir = mkdtempSync(join(tmpdir(), "kit-scan-build-cmd-")); + try { + mkdirSync(join(dir, "dist")); + writeFileSync(join(dir, "dist", "broken.js.map"), "{"); + const result = await runScan(dir); + assert.equal(result.ok, false); + assert.match(result.output, /scan-build: incomplete/); + assert.match(result.output, /dist\/broken\.js\.map/); + assert.doesNotMatch(result.output, /no matches/); + } finally { + rmTemp(dir); + } + }); + + it("reports known findings when another artifact cannot be scanned", async () => { + const dir = mkdtempSync(join(tmpdir(), "kit-scan-build-cmd-")); + try { + mkdirSync(join(dir, "dist")); + writeFileSync(join(dir, "dist", "broken.js.map"), "{"); + writeFileSync(join(dir, "dist", "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"); + const result = await runScan(dir); + assert.equal(result.ok, false); + assert.match(result.output, /scan-build: incomplete/); + assert.match(result.output, /dist\/broken\.js\.map/); + assert.match(result.output, /dist\/leak\.js/); + assert.match(result.output, /stripe-key/); + assert.doesNotMatch(result.output, /no matches/); + } finally { + rmTemp(dir); + } + }); + + it("reports no matches when a complete scan is clean", async () => { + const dir = mkdtempSync(join(tmpdir(), "kit-scan-build-cmd-")); + try { + mkdirSync(join(dir, "dist")); + writeFileSync(join(dir, "dist", "safe.js"), "export const value = 1;"); + const result = await runScan(dir); + assert.equal(result.ok, true); + assert.match(result.output, /scan-build: no matches/); + assert.doesNotMatch(result.output, /incomplete/); + } finally { + rmTemp(dir); + } + }); +}); diff --git a/src/commands/security-scan-build.ts b/src/commands/security-scan-build.ts new file mode 100644 index 00000000..408825cc --- /dev/null +++ b/src/commands/security-scan-build.ts @@ -0,0 +1,40 @@ +import { c } from "../utils/colors.js"; +import { BuildScanIncompleteError, scanBuildArtifacts } from "../scan-build.js"; +import { SECRET_SHAPE_COUNT } from "../utils/redactSecrets.js"; + +export async function cmdSecurityScanBuild(): Promise { + // Optional positional: extra dirs to scan beyond the defaults. + const extras = process.argv.slice(4).filter((a) => !a.startsWith("--")); + let hits: Awaited>; + try { + hits = await scanBuildArtifacts(process.cwd(), extras.length > 0 ? extras : undefined); + } catch (error) { + if (!(error instanceof BuildScanIncompleteError)) throw error; + console.error(`${c.red}✗ scan-build: incomplete: ${error.message}${c.reset}`); + hits = error.hits; + if (hits.length === 0) return false; + } + if (hits.length === 0) { + // The denominator belongs in the claim: "no patterns found" reads as "no secrets there", and + // the honest statement is "none of the shapes kit knows". + console.log( + `${c.green}✓ scan-build: no matches for ${SECRET_SHAPE_COUNT} known credential shapes${c.reset}${c.dim}. Kit cannot detect other credential shapes.${c.reset}`, + ); + return true; + } + const total = hits.reduce((sum, h) => sum + h.findings.length, 0); + console.error( + `${c.red}✗ scan-build: ${total} potential secret(s) in ${hits.length} build file(s):${c.reset}`, + ); + for (const hit of hits.slice(0, 20)) { + const labels = hit.findings.map((f) => `${f.label}:${f.preview}`).join(", "); + console.error(` ${c.red}•${c.reset} ${hit.file} ${c.dim}${labels}${c.reset}`); + } + if (hits.length > 20) { + console.error(` ${c.dim}… and ${hits.length - 20} more${c.reset}`); + } + console.error( + `\n${c.dim}Typical cause: a server-only env var (e.g. STRIPE_SECRET_KEY) is referenced via NEXT_PUBLIC_* and got inlined into the client bundle. Rebuild after fixing.${c.reset}`, + ); + return false; +} diff --git a/src/commands/security.ts b/src/commands/security.ts index d63f7e6c..d92509b2 100644 --- a/src/commands/security.ts +++ b/src/commands/security.ts @@ -26,7 +26,7 @@ import { import { clearBumblebeeCache } from "../bumblebee.js"; import { scanStagedFiles } from "../scan-staged.js"; import { existsSync } from "node:fs"; -import { BuildScanIncompleteError, scanBuildArtifacts } from "../scan-build.js"; +import { cmdSecurityScanBuild } from "./security-scan-build.js"; import { SECRET_SHAPE_COUNT } from "../utils/redactSecrets.js"; import { scanTranscripts } from "../scan-transcripts.js"; import { sampleCosts } from "../cost-monitor.js"; @@ -509,43 +509,6 @@ async function cmdSecurityScanArtifact(): Promise { return false; } -async function cmdSecurityScanBuild(): Promise { - // Optional positional: extra dirs to scan beyond the defaults. - const extras = process.argv.slice(4).filter((a) => !a.startsWith("--")); - let hits: Awaited>; - try { - hits = await scanBuildArtifacts(process.cwd(), extras.length > 0 ? extras : undefined); - } catch (error) { - if (!(error instanceof BuildScanIncompleteError)) throw error; - console.error(`${c.red}✗ scan-build: incomplete: ${error.message}${c.reset}`); - hits = error.hits; - if (hits.length === 0) return false; - } - if (hits.length === 0) { - // The denominator belongs in the claim: "no patterns found" reads as "no secrets there", and - // the honest statement is "none of the shapes kit knows". - console.log( - `${c.green}✓ scan-build: no matches for ${SECRET_SHAPE_COUNT} known credential shapes${c.reset}${c.dim} — a shape outside that set is not detected${c.reset}`, - ); - return true; - } - const total = hits.reduce((sum, h) => sum + h.findings.length, 0); - console.error( - `${c.red}✗ scan-build: ${total} potential secret(s) in ${hits.length} build file(s):${c.reset}`, - ); - for (const hit of hits.slice(0, 20)) { - const labels = hit.findings.map((f) => `${f.label}:${f.preview}`).join(", "); - console.error(` ${c.red}•${c.reset} ${hit.file} ${c.dim}${labels}${c.reset}`); - } - if (hits.length > 20) { - console.error(` ${c.dim}… and ${hits.length - 20} more${c.reset}`); - } - console.error( - `\n${c.dim}Typical cause: a server-only env var (e.g. STRIPE_SECRET_KEY) is referenced via NEXT_PUBLIC_* and got inlined into the client bundle. Rebuild after fixing.${c.reset}`, - ); - return false; -} - async function cmdSecurityScanTranscripts(): Promise { const hits = await scanTranscripts(process.cwd()); if (hits.length === 0) { diff --git a/src/hitl.test.ts b/src/hitl.test.ts index 7f3fbc71..86a904c9 100644 --- a/src/hitl.test.ts +++ b/src/hitl.test.ts @@ -127,3 +127,40 @@ describe("HITL formatter", () => { assert.match(block.steps.join("\n"), /Install osv-scanner/); }); }); + +describe("HITL for incomplete scans with findings", () => { + it("distinguishes findings from incomplete scanner setup", () => { + const [block] = hitlBlocksForSecurity([ + { + category: "exposure", + name: "built bundle secrets", + status: "fail", + detail: "a bundle contains a credential; another map was not scanned", + didNotRun: true, + scanIncomplete: true, + hasFindings: true, + }, + ]); + assert.equal(block.reason, "security finding and incomplete scan"); + assert.match(block.blocker, /found an issue and did not finish/); + assert.match(block.steps.join("\n"), /Investigate the reported finding and complete the scan/); + assert.doesNotMatch(block.steps.join("\n"), /Fix setup/); + }); + + it("gives an incomplete scan its own handoff", () => { + const [block] = hitlBlocksForSecurity([ + { + category: "exposure", + name: "bundle", + status: "fail", + detail: "map exceeds limit", + didNotRun: true, + scanIncomplete: true, + }, + ]); + assert.equal(block.reason, "incomplete scan"); + assert.match(block.blocker, /did not finish/); + assert.match(block.steps.join("\n"), /Resolve the incomplete scan/); + assert.doesNotMatch(block.steps.join("\n"), /Fix setup/); + }); +}); diff --git a/src/hitl.ts b/src/hitl.ts index 1cda99fc..416e3c0f 100644 --- a/src/hitl.ts +++ b/src/hitl.ts @@ -144,14 +144,30 @@ export function hitlBlocksForSecurity(results: SecurityCheckResult[]): HitlBlock return results .filter((r) => r.didNotRun && r.status !== "pass" && r.status !== "skip") .map((r) => ({ - blocker: `${r.name} did not run`, + blocker: r.hasFindings + ? `${r.name} found an issue and did not finish` + : r.scanIncomplete + ? `${r.name} did not finish` + : `${r.name} did not run`, owner: "developer" as const, - reason: "scanner/check setup", + reason: r.hasFindings + ? "security finding and incomplete scan" + : r.scanIncomplete + ? "incomplete scan" + : "scanner/check setup", steps: [ - r.suggestion ?? `Fix setup so this check can run: ${r.detail}.`, + r.hasFindings + ? `Investigate the reported finding and complete the scan: ${r.detail}.` + : r.scanIncomplete + ? `Resolve the incomplete scan: ${r.detail}.` + : (r.suggestion ?? `Fix setup so this check can run: ${r.detail}.`), "Run `kit check --category security`.", ], - respondWith: `${r.name} runs or is intentionally unavailable; no secret values pasted`, + respondWith: r.hasFindings + ? `${r.name} findings addressed and scan complete; no secret values pasted` + : r.scanIncomplete + ? `${r.name} scan complete; no secret values pasted` + : `${r.name} runs or is intentionally unavailable; no secret values pasted`, agentContinuesWith: "kit check --category security", })); } diff --git a/src/output.test.ts b/src/output.test.ts index e3ca68f2..a88e4c5b 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -150,3 +150,56 @@ describe("printSummary", () => { assert.match(cap.text(), /no checks applied/); }); }); + +describe("printSummary incomplete findings", () => { + const sec = (name: string, status: SecurityCheckResult["status"]): SecurityCheckResult => ({ + category: "dependency", + name, + status, + detail: "", + }); + + it("reports an incomplete scan and its known finding together", () => { + const cap = captureStdout(); + try { + printSummary( + [], + [], + [], + [{ ...sec("bundle", "fail"), didNotRun: true, scanIncomplete: true, hasFindings: true }], + ); + } finally { + cap.restore(); + } + assert.match(cap.text(), /1 incomplete check/); + assert.match(cap.text(), /1 real issue/); + assert.doesNotMatch(cap.text(), /setup gap|kit install/); + }); + + it("reports an incomplete scan without treating it as setup or a finding", () => { + const cap = captureStdout(); + try { + printSummary( + [], + [], + [], + [{ ...sec("bundle", "fail"), didNotRun: true, scanIncomplete: true }], + ); + } finally { + cap.restore(); + } + assert.match(cap.text(), /1 incomplete check/); + assert.doesNotMatch(cap.text(), /setup gap|real issue|kit install/); + }); + + it("keeps a scan with no known findings as a setup gap", () => { + const cap = captureStdout(); + try { + printSummary([], [], [], [{ ...sec("scanner", "fail"), didNotRun: true }]); + } finally { + cap.restore(); + } + assert.match(cap.text(), /1 setup gap/); + assert.doesNotMatch(cap.text(), /real issue|incomplete check/); + }); +}); diff --git a/src/output.ts b/src/output.ts index 04c43a9c..610726f9 100644 --- a/src/output.ts +++ b/src/output.ts @@ -440,21 +440,26 @@ export function printSummary( // P5 — separate SETUP GAPS (a tool not installed, a service not logged in, a scanner that // couldn't run) from REAL issues (an actual finding a check produced). On a fresh clone the // gap is almost all setup, so a flat "8 issues" reads as failure when it isn't. Missing - // tools/auth/secrets are setup; a security result that FAILED because it couldn't run - // (didNotRun) is setup too; everything else non-passing is a real finding. + // tools/auth/secrets are setup. A security result can contain a real finding + // even when its scan is incomplete. Count both facts for that result. const setupGaps = tools.length - toolsOk + (services.length - servicesOk) + (secrets.length - secretsOk) + (deploy?.filter((d) => d.status !== "pass" && d.status !== "skip" && d.didNotRun).length ?? 0) + - (security?.filter((s) => s.status !== "pass" && s.status !== "skip" && s.didNotRun).length ?? - 0); + (security?.filter( + (s) => s.status !== "pass" && s.status !== "skip" && s.didNotRun && !s.scanIncomplete, + ).length ?? 0); + const incompleteScans = + security?.filter((s) => s.status !== "pass" && s.status !== "skip" && s.scanIncomplete) + .length ?? 0; const realIssues = (deploy?.filter((d) => d.status !== "pass" && d.status !== "skip" && !d.didNotRun).length ?? 0) + - (security?.filter((s) => s.status !== "pass" && s.status !== "skip" && !s.didNotRun).length ?? - 0); + (security?.filter( + (s) => s.status !== "pass" && s.status !== "skip" && (!s.didNotRun || s.hasFindings), + ).length ?? 0); if (total === 0) { // "All 0 checks passed" is the emptiest possible false green. @@ -475,6 +480,10 @@ export function printSummary( if (skipped > 0) parts.push(`${c.dim}${skipped} could not run${c.reset}`); if (setupGaps > 0) parts.push(`${c.yellow}${setupGaps} setup gap${setupGaps === 1 ? "" : "s"}${c.reset}`); + if (incompleteScans > 0) + parts.push( + `${c.yellow}${incompleteScans} incomplete check${incompleteScans === 1 ? "" : "s"}${c.reset}`, + ); if (realIssues > 0) parts.push(`${c.red}${realIssues} real issue${realIssues === 1 ? "" : "s"}${c.reset}`); console.log(parts.join(`${c.dim} · ${c.reset}`)); diff --git a/src/scan-build-json.test.ts b/src/scan-build-json.test.ts new file mode 100644 index 00000000..9c2736be --- /dev/null +++ b/src/scan-build-json.test.ts @@ -0,0 +1,216 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BuildScanIncompleteError, scanBuildArtifacts } from "./scan-build.js"; +import { checkBuiltBundleSecrets } from "./check-client-exposure.js"; +import { rmTemp } from "./temp-cleanup.test-support.js"; + +function makeRepo(): string { + return mkdtempSync(join(tmpdir(), "kit-scan-build-")); +} + +describe("scanBuildArtifacts source map size and nesting", () => { + it("reports scalar and array JSON as invalid source maps", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + writeFileSync(join(dir, "build", "null.js.map"), "null"); + writeFileSync(join(dir, "build", "array.js.map"), "[]"); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.issues.length, 2); + return true; + }); + } finally { + rmTemp(dir); + } + }); + it("scans a large source map without falling back to encoded text", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; + const sourcesContent = [...Array(50_000).fill("a"), source]; + const encoded = JSON.stringify({ sourcesContent }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", "large.js.map"), encoded); + const hits = await scanBuildArtifacts(dir); + assert.equal(hits.length, 1); + assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); + } finally { + rmTemp(dir); + } + }); + + it("fails the scan when source maps exceed the entry limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const sourcesContent = Array(100_001).fill("export{}"); + writeFileSync(join(dir, "build", "too-many.js.map"), JSON.stringify({ sourcesContent })); + await assert.rejects(scanBuildArtifacts(dir), /source map exceeds 100000 source entries/); + } finally { + rmTemp(dir); + } + }); + + it("scans sources in nested map sections", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + const map = { + sections: [{ map: { sourcesContent: [null, `const x = {api_key: "${opaque}"};`] } }], + }; + writeFileSync(join(dir, "build", "section.js.map"), JSON.stringify(map)); + const hits = await scanBuildArtifacts(dir); + assert.equal(hits.length, 1); + assert.ok(hits[0].findings.some((finding) => finding.label === "keyed-secret")); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map scan limit reporting", () => { + it("reports an incomplete security check when a map exceeds the limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + writeFileSync( + join(dir, "dist", "large.js.map"), + JSON.stringify({ sourcesContent: Array(100_001).fill("export{}") }), + ); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.equal(result.scanIncomplete, true); + assert.equal(result.hasFindings, undefined); + assert.match(result.detail, /source map exceeds 100000 source entries/); + } finally { + rmTemp(dir); + } + }); + + it("preserves a detected credential when another map is too large", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + writeFileSync(join(dir, "dist", "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"); + writeFileSync(join(dir, "dist", "large.js.map"), " ".repeat(5 * 1024 * 1024 + 1)); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.equal(result.severity, "critical"); + assert.equal(result.scanIncomplete, true); + assert.equal(result.hasFindings, true); + assert.ok(result.files?.includes("dist/leak.js")); + assert.match(result.detail, /source map exceeds 5 MiB/); + } finally { + rmTemp(dir); + } + }); + + it("reports deeply nested invalid sources as an incomplete scan", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + const nested = '{"sourcesContent":['.repeat(8_000) + "null" + "]}".repeat(8_000); + writeFileSync(join(dir, "dist", "nested.js.map"), nested); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.match(result.detail, /source map has invalid source content/); + } finally { + rmTemp(dir); + } + }); + + it("finds an encoded credential inside an unexpected source item", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const map = JSON.stringify({ sourcesContent: ["safe", { data: secret }] }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "dist", "mixed.js.map"), map); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map partial findings", () => { + it("preserves a credential found before the same map reaches its limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const sourcesContent = [secret, ...Array(100_000).fill("a")]; + writeFileSync(join(dir, "dist", "large.js.map"), JSON.stringify({ sourcesContent })); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.equal(result.severity, "critical"); + assert.ok(result.files?.includes("dist/large.js.map")); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map test fixtures", () => { + it("excludes incomplete fixtures from the automatic check", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist", "fixtures"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + writeFileSync(join(dir, "dist", "fixtures", "broken.js.map"), "{"); + writeFileSync(join(dir, "dist", "fixtures", "large.js.map"), " ".repeat(5 * 1024 * 1024 + 1)); + writeFileSync(join(dir, "dist", "widget.test.js.map"), "{"); + writeFileSync( + join(dir, "dist", "widget.spec.mjs.map"), + JSON.stringify({ sourcesContent: ["sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"] }), + ); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "pass"); + assert.match(result.detail, /4 test fixture\(s\) not counted/); + } finally { + rmTemp(dir); + } + }); + + it("still reports a real credential and an incomplete non-fixture map", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "dist", "fixtures"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); + writeFileSync(join(dir, "dist", "fixtures", "broken.js.map"), "{"); + writeFileSync(join(dir, "dist", "live.js.map"), "{"); + writeFileSync(join(dir, "dist", "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"); + const result = await checkBuiltBundleSecrets(dir); + assert.equal(result.status, "fail"); + assert.equal(result.didNotRun, true); + assert.equal(result.severity, "critical"); + assert.match(result.detail, /dist\/live\.js\.map/); + assert.ok(result.files?.includes("dist/leak.js")); + } finally { + rmTemp(dir); + } + }); +}); diff --git a/src/scan-build-malformed.test.ts b/src/scan-build-malformed.test.ts new file mode 100644 index 00000000..6e7e8b46 --- /dev/null +++ b/src/scan-build-malformed.test.ts @@ -0,0 +1,141 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BuildScanIncompleteError, scanBuildArtifacts } from "./scan-build.js"; +import { rmTemp } from "./temp-cleanup.test-support.js"; + +function makeRepo(): string { + return mkdtempSync(join(tmpdir(), "kit-scan-build-")); +} + +describe("scanBuildArtifacts mixed source content", () => { + it("continues past invalid JSON escapes and retains later credentials", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + writeFileSync(join(dir, "build", "broken.js.map"), `{"bad":"\\q","custom":"${secret}"}`); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); + return true; + }); + } finally { + rmTemp(dir); + } + }); + it("retains decoded findings when source content contains an unexpected object", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; + for (const [index, sourcesContent] of [ + [source, { nested: true }], + [{ nested: [true, { deep: "value" }] }, source], + ].entries()) { + const encoded = JSON.stringify({ sourcesContent }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", `mixed${index}.js.map`), encoded); + } + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 2); + assert.ok( + error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map metadata and invalid content", () => { + it("finds generic credentials in malformed object sources", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7!", "I8j9K0l1", "M2n3O4p5"].join(""); + writeFileSync( + join(dir, "build", "object.js.map"), + JSON.stringify({ sourcesContent: { api_key: opaque } }), + ); + writeFileSync( + join(dir, "build", "array.js.map"), + JSON.stringify({ sourcesContent: [{ api_key: opaque }] }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 2); + assert.ok( + error.hits.every((hit) => + hit.findings.some((finding) => finding.label === "keyed-secret"), + ), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map malformed object findings", () => { + it("finds a credential in non-array object source content", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const map = JSON.stringify({ sourcesContent: { data: secret } }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", "object.js.map"), map); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync( + join(dir, "build", "safe-object.js.map"), + JSON.stringify({ sourcesContent: { data: source } }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 1); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); + return true; + }); + } finally { + rmTemp(dir); + } + }); + it("reports non-array source content as incomplete without false findings", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync( + join(dir, "build", "string.js.map"), + JSON.stringify({ sourcesContent: source }), + ); + writeFileSync( + join(dir, "build", "boolean.js.map"), + JSON.stringify({ sourcesContent: false }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.match(error.message, /invalid source content/); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index 1fea67d7..6eaadcf3 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -12,12 +12,22 @@ export function scanMalformedObject( if (budget.sources > MAX_MAP_SOURCES) { throw new Error("source map exceeds 100000 source entries; scan could not finish"); } - const decoded = JSON.parse(token[0]) as string; + let decoded: string; + try { + decoded = JSON.parse(token[0]) as string; + } catch { + continue; + } const valueToken = /^\s*:\s*("(?:\\.|[^"\\])*")/.exec( content.slice(token.index! + token[0].length), ); if (valueToken) { - const value = JSON.parse(valueToken[1]) as string; + let value: string; + try { + value = JSON.parse(valueToken[1]) as string; + } catch { + continue; + } const probe = `${JSON.stringify(decoded)}:"${"A".repeat(20)}"`; const keyed = SECRET_PATTERNS.some( ({ label, re }) => label === "keyed-secret" && new RegExp(re.source, re.flags).test(probe), diff --git a/src/scan-build-sourcemap.test.ts b/src/scan-build-sourcemap.test.ts new file mode 100644 index 00000000..e2368951 --- /dev/null +++ b/src/scan-build-sourcemap.test.ts @@ -0,0 +1,273 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BuildScanIncompleteError, scanBuildArtifacts } from "./scan-build.js"; +import { rmTemp } from "./temp-cleanup.test-support.js"; + +function makeRepo(): string { + return mkdtempSync(join(tmpdir(), "kit-scan-build-")); +} + +describe("scanBuildArtifacts source maps", () => { + it("does not join code in an encoded source map into a secret", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build", "server"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync( + join(dir, "build", "server", "index.js.map"), + JSON.stringify({ + version: 3, + sources: ["input.js"], + sourcesContent: [source], + mappings: "", + }), + ); + assert.deepEqual(await scanBuildArtifacts(dir), []); + } finally { + rmTemp(dir); + } + }); + + it("finds credentials in source maps and keeps source files separate", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7!", "I8j9K0l1", "M2n3O4p5"].join(""); + const map = { + version: 3, + sources: ["one.js", "two.js"], + sourcesContent: [ + 'const item = {key: "." + key};\nconsole.log("ordinary code");', + `const config = {api_key: "${opaque}"};`, + ], + mappings: "", + }; + writeFileSync(join(dir, "build", "client.js.map"), JSON.stringify(map)); + const hits = await scanBuildArtifacts(dir); + assert.equal(hits.length, 1); + assert.ok(hits[0].findings.some((finding) => finding.label === "keyed-secret")); + } finally { + rmTemp(dir); + } + }); +}); + +describe("scanBuildArtifacts source map fields", () => { + it("scans other map fields and malformed maps", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + writeFileSync( + join(dir, "build", "valid.js.map"), + JSON.stringify({ api_key: opaque, sourcesContent: ["safe"] }), + ); + writeFileSync(join(dir, "build", "broken.js.map"), `{"api_key":"${opaque}"`); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits.map((hit) => hit.file).sort(), [ + "build/broken.js.map", + "build/valid.js.map", + ]); + assert.ok( + error.hits.every((hit) => + hit.findings.some((finding) => finding.label === "keyed-secret"), + ), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); + + it("scans every occurrence of duplicate map fields", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); + const source = JSON.stringify(`const config = {api_key: "${opaque}"};`); + writeFileSync( + join(dir, "build", "fields.js.map"), + `{"api_key":"${opaque}","api_key":"safe","sourcesContent":[]}`, + ); + writeFileSync( + join(dir, "build", "sources.js.map"), + `{"sourcesContent":[${source}],"sourcesContent":["safe"]}`, + ); + const hits = await scanBuildArtifacts(dir); + assert.deepEqual(hits.map((hit) => hit.file).sort(), [ + "build/fields.js.map", + "build/sources.js.map", + ]); + assert.ok( + hits.every((hit) => hit.findings.some((finding) => finding.label === "keyed-secret")), + ); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map encoded metadata and broken JSON", () => { + it("continues scanning when a keyed value is ambiguous", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const maps = [ + { key: source, custom: secret }, + { sourcesContent: { key: source, custom: secret } }, + { key: source + secret }, + ]; + for (const [index, map] of maps.entries()) { + writeFileSync(join(dir, "build", `ambiguous${index}.js.map`), JSON.stringify(map)); + } + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 3); + assert.ok( + error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); + it("reports ambiguous keyed code as incomplete without a false credential", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync(join(dir, "build", "metadata.js.map"), JSON.stringify({ key: source })); + writeFileSync( + join(dir, "build", "object.js.map"), + JSON.stringify({ sourcesContent: { key: source } }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map metadata with incomplete JSON", () => { + it("does not flag safe code in metadata or broken JSON", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync(join(dir, "build", "metadata.js.map"), JSON.stringify({ custom: source })); + const broken = JSON.stringify({ sourcesContent: [source] }).slice(0, -1); + writeFileSync(join(dir, "build", "broken.js.map"), broken); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); + + it("retains a decoded credential from broken JSON", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const broken = JSON.stringify({ sourcesContent: [secret] }) + .replace("sk_" + "live_", "\\u0073k_" + "live_") + .slice(0, -1); + writeFileSync(join(dir, "build", "broken.js.map"), broken); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); + +describe("source map metadata findings", () => { + it("detects an encoded credential in a metadata field", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const map = JSON.stringify({ custom: secret, sourcesContent: [] }).replace( + "sk_" + "live_", + "\\u0073k_" + "live_", + ); + writeFileSync(join(dir, "build", "metadata.js.map"), map); + const hits = await scanBuildArtifacts(dir); + assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); + } finally { + rmTemp(dir); + } + }); + + it("keeps metadata findings before and after a source limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + const sourcesContent = Array(100_001).fill("a"); + for (const [index, map] of [ + { custom: secret, sourcesContent }, + { sourcesContent, custom: secret }, + ].entries()) { + writeFileSync(join(dir, "build", `metadata${index}.js.map`), JSON.stringify(map)); + } + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal(error.hits.length, 2); + assert.ok( + error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); + + it("reports invalid maps without a false credential finding", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = + 'const value = {key: "." + key};\n' + + 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); + writeFileSync( + join(dir, "build", "mixed.js.map"), + JSON.stringify({ sourcesContent: [source, {}] }), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); +}); diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index 98314d5b..02641ff3 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -119,7 +119,9 @@ function scanMapMetadata(content: string, findings: SecretFinding[]): void { export function findArtifactSecrets(content: string, file: string): SecretFinding[] { if (!file.endsWith(".map")) return findBuildSecrets(content); try { - JSON.parse(content); + const map: unknown = JSON.parse(content); + if (!map || typeof map !== "object" || Array.isArray(map)) + throw new Error("invalid source map"); } catch { const findings: SecretFinding[] = []; try { diff --git a/src/scan-build.test.ts b/src/scan-build.test.ts index df6dc983..29cc1ca0 100644 --- a/src/scan-build.test.ts +++ b/src/scan-build.test.ts @@ -3,8 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { BuildScanIncompleteError, scanBuildArtifacts } from "./scan-build.js"; -import { checkBuiltBundleSecrets } from "./check-client-exposure.js"; +import { scanBuildArtifacts } from "./scan-build.js"; import { rmTemp } from "./temp-cleanup.test-support.js"; function makeRepo(): string { @@ -107,568 +106,6 @@ describe("scanBuildArtifacts browser output", () => { }); }); -describe("scanBuildArtifacts source maps", () => { - it("does not join code in an encoded source map into a secret", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build", "server"), { recursive: true }); - const source = - 'const value = {key: "." + key};\n' + - 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); - writeFileSync( - join(dir, "build", "server", "index.js.map"), - JSON.stringify({ - version: 3, - sources: ["input.js"], - sourcesContent: [source], - mappings: "", - }), - ); - assert.deepEqual(await scanBuildArtifacts(dir), []); - } finally { - rmTemp(dir); - } - }); - - it("finds credentials in source maps and keeps source files separate", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const opaque = ["A0b1C2d3", "E4f5G6h7!", "I8j9K0l1", "M2n3O4p5"].join(""); - const map = { - version: 3, - sources: ["one.js", "two.js"], - sourcesContent: [ - 'const item = {key: "." + key};\nconsole.log("ordinary code");', - `const config = {api_key: "${opaque}"};`, - ], - mappings: "", - }; - writeFileSync(join(dir, "build", "client.js.map"), JSON.stringify(map)); - const hits = await scanBuildArtifacts(dir); - assert.equal(hits.length, 1); - assert.ok(hits[0].findings.some((finding) => finding.label === "keyed-secret")); - } finally { - rmTemp(dir); - } - }); -}); - -describe("scanBuildArtifacts source map fields", () => { - it("scans other map fields and malformed maps", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); - writeFileSync( - join(dir, "build", "valid.js.map"), - JSON.stringify({ api_key: opaque, sourcesContent: ["safe"] }), - ); - writeFileSync(join(dir, "build", "broken.js.map"), `{"api_key":"${opaque}"`); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.deepEqual(error.hits.map((hit) => hit.file).sort(), [ - "build/broken.js.map", - "build/valid.js.map", - ]); - assert.ok( - error.hits.every((hit) => - hit.findings.some((finding) => finding.label === "keyed-secret"), - ), - ); - return true; - }); - } finally { - rmTemp(dir); - } - }); - - it("scans every occurrence of duplicate map fields", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); - const source = JSON.stringify(`const config = {api_key: "${opaque}"};`); - writeFileSync( - join(dir, "build", "fields.js.map"), - `{"api_key":"${opaque}","api_key":"safe","sourcesContent":[]}`, - ); - writeFileSync( - join(dir, "build", "sources.js.map"), - `{"sourcesContent":[${source}],"sourcesContent":["safe"]}`, - ); - const hits = await scanBuildArtifacts(dir); - assert.deepEqual(hits.map((hit) => hit.file).sort(), [ - "build/fields.js.map", - "build/sources.js.map", - ]); - assert.ok( - hits.every((hit) => hit.findings.some((finding) => finding.label === "keyed-secret")), - ); - } finally { - rmTemp(dir); - } - }); -}); - -describe("scanBuildArtifacts mixed source content", () => { - it("retains decoded findings when source content contains an unexpected object", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; - for (const [index, sourcesContent] of [ - [source, { nested: true }], - [{ nested: [true, { deep: "value" }] }, source], - ].entries()) { - const encoded = JSON.stringify({ sourcesContent }).replace( - "sk_" + "live_", - "\\u0073k_" + "live_", - ); - writeFileSync(join(dir, "build", `mixed${index}.js.map`), encoded); - } - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.equal(error.hits.length, 2); - assert.ok( - error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), - ); - return true; - }); - } finally { - rmTemp(dir); - } - }); -}); - -describe("scanBuildArtifacts source map size and nesting", () => { - it("scans a large source map without falling back to encoded text", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const source = 'const token = "' + "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123" + '";'; - const sourcesContent = [...Array(50_000).fill("a"), source]; - const encoded = JSON.stringify({ sourcesContent }).replace( - "sk_" + "live_", - "\\u0073k_" + "live_", - ); - writeFileSync(join(dir, "build", "large.js.map"), encoded); - const hits = await scanBuildArtifacts(dir); - assert.equal(hits.length, 1); - assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); - } finally { - rmTemp(dir); - } - }); - - it("fails the scan when source maps exceed the entry limit", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const sourcesContent = Array(100_001).fill("export{}"); - writeFileSync(join(dir, "build", "too-many.js.map"), JSON.stringify({ sourcesContent })); - await assert.rejects(scanBuildArtifacts(dir), /source map exceeds 100000 source entries/); - } finally { - rmTemp(dir); - } - }); - - it("scans sources in nested map sections", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const opaque = ["A0b1C2d3", "E4f5G6h7", "I8j9K0l1", "M2n3O4p5"].join(""); - const map = { - sections: [{ map: { sourcesContent: [null, `const x = {api_key: "${opaque}"};`] } }], - }; - writeFileSync(join(dir, "build", "section.js.map"), JSON.stringify(map)); - const hits = await scanBuildArtifacts(dir); - assert.equal(hits.length, 1); - assert.ok(hits[0].findings.some((finding) => finding.label === "keyed-secret")); - } finally { - rmTemp(dir); - } - }); -}); - -describe("source map scan limit reporting", () => { - it("reports an incomplete security check when a map exceeds the limit", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "dist"), { recursive: true }); - writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); - writeFileSync( - join(dir, "dist", "large.js.map"), - JSON.stringify({ sourcesContent: Array(100_001).fill("export{}") }), - ); - const result = await checkBuiltBundleSecrets(dir); - assert.equal(result.status, "fail"); - assert.equal(result.didNotRun, true); - assert.match(result.detail, /source map exceeds 100000 source entries/); - } finally { - rmTemp(dir); - } - }); - - it("preserves a detected credential when another map is too large", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "dist"), { recursive: true }); - writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); - writeFileSync(join(dir, "dist", "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"); - writeFileSync(join(dir, "dist", "large.js.map"), " ".repeat(5 * 1024 * 1024 + 1)); - const result = await checkBuiltBundleSecrets(dir); - assert.equal(result.status, "fail"); - assert.equal(result.didNotRun, true); - assert.equal(result.severity, "critical"); - assert.ok(result.files?.includes("dist/leak.js")); - assert.match(result.detail, /source map exceeds 5 MiB/); - } finally { - rmTemp(dir); - } - }); - - it("reports deeply nested invalid sources as an incomplete scan", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "dist"), { recursive: true }); - writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); - const nested = '{"sourcesContent":['.repeat(8_000) + "null" + "]}".repeat(8_000); - writeFileSync(join(dir, "dist", "nested.js.map"), nested); - const result = await checkBuiltBundleSecrets(dir); - assert.equal(result.status, "fail"); - assert.equal(result.didNotRun, true); - assert.match(result.detail, /source map has invalid source content/); - } finally { - rmTemp(dir); - } - }); - - it("finds an encoded credential inside an unexpected source item", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "dist"), { recursive: true }); - const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; - const map = JSON.stringify({ sourcesContent: ["safe", { data: secret }] }).replace( - "sk_" + "live_", - "\\u0073k_" + "live_", - ); - writeFileSync(join(dir, "dist", "mixed.js.map"), map); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); - return true; - }); - } finally { - rmTemp(dir); - } - }); -}); - -describe("source map partial findings", () => { - it("preserves a credential found before the same map reaches its limit", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "dist"), { recursive: true }); - writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); - const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; - const sourcesContent = [secret, ...Array(100_000).fill("a")]; - writeFileSync(join(dir, "dist", "large.js.map"), JSON.stringify({ sourcesContent })); - const result = await checkBuiltBundleSecrets(dir); - assert.equal(result.status, "fail"); - assert.equal(result.didNotRun, true); - assert.equal(result.severity, "critical"); - assert.ok(result.files?.includes("dist/large.js.map")); - } finally { - rmTemp(dir); - } - }); -}); - -describe("source map test fixtures", () => { - it("excludes incomplete fixtures from the automatic check", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "dist", "fixtures"), { recursive: true }); - writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); - writeFileSync(join(dir, "dist", "fixtures", "broken.js.map"), "{"); - writeFileSync(join(dir, "dist", "fixtures", "large.js.map"), " ".repeat(5 * 1024 * 1024 + 1)); - writeFileSync(join(dir, "dist", "widget.test.js.map"), "{"); - writeFileSync( - join(dir, "dist", "widget.spec.mjs.map"), - JSON.stringify({ sourcesContent: ["sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"] }), - ); - const result = await checkBuiltBundleSecrets(dir); - assert.equal(result.status, "pass"); - assert.match(result.detail, /4 test fixture\(s\) not counted/); - } finally { - rmTemp(dir); - } - }); - - it("still reports a real credential and an incomplete non-fixture map", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "dist", "fixtures"), { recursive: true }); - writeFileSync(join(dir, "package.json"), JSON.stringify({ devDependencies: { vite: "^5" } })); - writeFileSync(join(dir, "dist", "fixtures", "broken.js.map"), "{"); - writeFileSync(join(dir, "dist", "live.js.map"), "{"); - writeFileSync(join(dir, "dist", "leak.js"), "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"); - const result = await checkBuiltBundleSecrets(dir); - assert.equal(result.status, "fail"); - assert.equal(result.didNotRun, true); - assert.equal(result.severity, "critical"); - assert.match(result.detail, /dist\/live\.js\.map/); - assert.ok(result.files?.includes("dist/leak.js")); - } finally { - rmTemp(dir); - } - }); -}); - -describe("source map metadata and invalid content", () => { - it("finds generic credentials in malformed object sources", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const opaque = ["A0b1C2d3", "E4f5G6h7!", "I8j9K0l1", "M2n3O4p5"].join(""); - writeFileSync( - join(dir, "build", "object.js.map"), - JSON.stringify({ sourcesContent: { api_key: opaque } }), - ); - writeFileSync( - join(dir, "build", "array.js.map"), - JSON.stringify({ sourcesContent: [{ api_key: opaque }] }), - ); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.equal(error.hits.length, 2); - assert.ok( - error.hits.every((hit) => - hit.findings.some((finding) => finding.label === "keyed-secret"), - ), - ); - return true; - }); - } finally { - rmTemp(dir); - } - }); -}); - -describe("source map encoded metadata and broken JSON", () => { - it("continues scanning when a keyed value is ambiguous", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const source = - 'const value = {key: "." + key};\n' + - 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); - const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; - const maps = [ - { key: source, custom: secret }, - { sourcesContent: { key: source, custom: secret } }, - { key: source + secret }, - ]; - for (const [index, map] of maps.entries()) { - writeFileSync(join(dir, "build", `ambiguous${index}.js.map`), JSON.stringify(map)); - } - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.equal(error.hits.length, 3); - assert.ok( - error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), - ); - return true; - }); - } finally { - rmTemp(dir); - } - }); - it("reports ambiguous keyed code as incomplete without a false credential", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const source = - 'const value = {key: "." + key};\n' + - 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); - writeFileSync(join(dir, "build", "metadata.js.map"), JSON.stringify({ key: source })); - writeFileSync( - join(dir, "build", "object.js.map"), - JSON.stringify({ sourcesContent: { key: source } }), - ); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.deepEqual(error.hits, []); - return true; - }); - } finally { - rmTemp(dir); - } - }); -}); - -describe("source map metadata with incomplete JSON", () => { - it("does not flag safe code in metadata or broken JSON", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const source = - 'const value = {key: "." + key};\n' + - 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); - writeFileSync(join(dir, "build", "metadata.js.map"), JSON.stringify({ custom: source })); - const broken = JSON.stringify({ sourcesContent: [source] }).slice(0, -1); - writeFileSync(join(dir, "build", "broken.js.map"), broken); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.deepEqual(error.hits, []); - return true; - }); - } finally { - rmTemp(dir); - } - }); - - it("retains a decoded credential from broken JSON", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; - const broken = JSON.stringify({ sourcesContent: [secret] }) - .replace("sk_" + "live_", "\\u0073k_" + "live_") - .slice(0, -1); - writeFileSync(join(dir, "build", "broken.js.map"), broken); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); - return true; - }); - } finally { - rmTemp(dir); - } - }); -}); - -describe("source map malformed object findings", () => { - it("finds a credential in non-array object source content", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; - const map = JSON.stringify({ sourcesContent: { data: secret } }).replace( - "sk_" + "live_", - "\\u0073k_" + "live_", - ); - writeFileSync(join(dir, "build", "object.js.map"), map); - const source = - 'const value = {key: "." + key};\n' + - 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); - writeFileSync( - join(dir, "build", "safe-object.js.map"), - JSON.stringify({ sourcesContent: { data: source } }), - ); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.equal(error.hits.length, 1); - assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); - return true; - }); - } finally { - rmTemp(dir); - } - }); - it("reports non-array source content as incomplete without false findings", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const source = - 'const value = {key: "." + key};\n' + - 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); - writeFileSync( - join(dir, "build", "string.js.map"), - JSON.stringify({ sourcesContent: source }), - ); - writeFileSync( - join(dir, "build", "boolean.js.map"), - JSON.stringify({ sourcesContent: false }), - ); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.match(error.message, /invalid source content/); - assert.deepEqual(error.hits, []); - return true; - }); - } finally { - rmTemp(dir); - } - }); -}); - -describe("source map metadata findings", () => { - it("detects an encoded credential in a metadata field", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; - const map = JSON.stringify({ custom: secret, sourcesContent: [] }).replace( - "sk_" + "live_", - "\\u0073k_" + "live_", - ); - writeFileSync(join(dir, "build", "metadata.js.map"), map); - const hits = await scanBuildArtifacts(dir); - assert.ok(hits[0].findings.some((finding) => finding.label === "stripe-key")); - } finally { - rmTemp(dir); - } - }); - - it("keeps metadata findings before and after a source limit", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; - const sourcesContent = Array(100_001).fill("a"); - for (const [index, map] of [ - { custom: secret, sourcesContent }, - { sourcesContent, custom: secret }, - ].entries()) { - writeFileSync(join(dir, "build", `metadata${index}.js.map`), JSON.stringify(map)); - } - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.equal(error.hits.length, 2); - assert.ok( - error.hits.every((hit) => hit.findings.some((finding) => finding.label === "stripe-key")), - ); - return true; - }); - } finally { - rmTemp(dir); - } - }); - - it("reports invalid maps without a false credential finding", async () => { - const dir = makeRepo(); - try { - mkdirSync(join(dir, "build"), { recursive: true }); - const source = - 'const value = {key: "." + key};\n' + - 'console.log("AlphaBeta0123456789 punctuation and code");\n'.repeat(5); - writeFileSync( - join(dir, "build", "mixed.js.map"), - JSON.stringify({ sourcesContent: [source, {}] }), - ); - await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { - assert.ok(error instanceof BuildScanIncompleteError); - assert.deepEqual(error.hits, []); - return true; - }); - } finally { - rmTemp(dir); - } - }); -}); - describe("scanBuildArtifacts credential shapes", () => { it("does not treat ordinary client property names as credentials", async () => { const dir = makeRepo(); diff --git a/src/scan-diff.test.ts b/src/scan-diff.test.ts index e7bdbcd6..230e1553 100644 --- a/src/scan-diff.test.ts +++ b/src/scan-diff.test.ts @@ -38,6 +38,44 @@ describe("didRun", () => { }); }); +describe("incomplete scans with known findings", () => { + it("reports increased severity when both scans are incomplete", () => { + const before = chk({ name: "bundle", status: "fail", didNotRun: true, hasFindings: true, severity: "high" }); + const after = chk({ name: "bundle", status: "fail", didNotRun: true, hasFindings: true, severity: "critical" }); + const result = diffScans(doc([before]), doc([after])); + assert.equal(result.changes[0].kind, "regressed"); + assert.equal(result.worseThanBefore, true); + assert.equal(result.clean, false); + }); + it("reports a finding even when the scan lost coverage", () => { + const d = diffScans( + doc([chk({ name: "bundle", status: "pass" })]), + doc([chk({ name: "bundle", status: "fail", didNotRun: true, hasFindings: true })]), + ); + assert.equal(d.changes[0].kind, "coverage-lost"); + assert.match(d.changes[0].summary, /scan incomplete; confirmed findings remain/); + assert.equal(d.changes[0].after?.hasFindings, true); + }); + + it("detects findings that appear during repeated incomplete scans", () => { + const before = chk({ name: "bundle", status: "fail", didNotRun: true }); + const after = chk({ name: "bundle", status: "fail", didNotRun: true, hasFindings: true }); + const d = diffScans(doc([before]), doc([after])); + assert.equal(d.changes[0].kind, "regressed"); + assert.equal(d.worseThanBefore, true); + assert.match(d.changes[0].summary, /scan incomplete; confirmed findings reported/); + }); + + it("does not call a finding resolved when a later incomplete scan omits it", () => { + const before = chk({ name: "bundle", status: "fail", didNotRun: true, hasFindings: true }); + const after = chk({ name: "bundle", status: "fail", didNotRun: true }); + const d = diffScans(doc([before]), doc([after])); + assert.equal(d.changes[0].kind, "coverage-lost"); + assert.equal(d.worseThanBefore, true); + assert.match(d.changes[0].summary, /previous findings are not verified/); + }); +}); + describe("the coverage axis — the reason this is not a generic differ", () => { it("fail → skip is LOST COVERAGE, never an improvement", () => { const d = diffScans( diff --git a/src/scan-diff.ts b/src/scan-diff.ts index a0cc56e7..86d04d71 100644 --- a/src/scan-diff.ts +++ b/src/scan-diff.ts @@ -42,8 +42,20 @@ export interface ScanChange { category: string; name: string; kind: ScanChangeKind; - before?: { status: JsonCheck["status"]; severity?: JsonCheck["severity"]; didNotRun?: boolean }; - after?: { status: JsonCheck["status"]; severity?: JsonCheck["severity"]; didNotRun?: boolean }; + before?: { + status: JsonCheck["status"]; + severity?: JsonCheck["severity"]; + didNotRun?: boolean; + hasFindings?: boolean; + scanIncomplete?: boolean; + }; + after?: { + status: JsonCheck["status"]; + severity?: JsonCheck["severity"]; + didNotRun?: boolean; + hasFindings?: boolean; + scanIncomplete?: boolean; + }; /** Set when severity moved while the check kept running. */ severityChanged?: { from?: JsonCheck["severity"]; to?: JsonCheck["severity"] }; /** One line, deterministic, safe to print or to put in a commit message. */ @@ -87,7 +99,24 @@ function severityMoved( } function side(c: JsonCheck): NonNullable { - return { status: c.status, severity: c.severity, didNotRun: c.didNotRun }; + return { + status: c.status, + severity: c.severity, + didNotRun: c.didNotRun, + hasFindings: c.hasFindings, + scanIncomplete: c.scanIncomplete, + }; +} + +function incompleteChange(before: JsonCheck, after: JsonCheck): ScanChangeKind { + if (!before.hasFindings && after.hasFindings) return "regressed"; + if (before.hasFindings && !after.hasFindings) return "coverage-lost"; + if (before.hasFindings && after.hasFindings) { + const severity = (SEVERITY_RANK[after.severity!] ?? 0) - (SEVERITY_RANK[before.severity!] ?? 0); + if (severity > 0) return "regressed"; + if (severity < 0) return "improved"; + } + return "unchanged"; } /** Classify one check's movement. Pure; `before`/`after` may each be absent. */ @@ -103,7 +132,7 @@ export function classifyChange( // Coverage first: whether the check RAN dominates what it said. if (ranBefore && !ranAfter) return "coverage-lost"; if (!ranBefore && ranAfter) return "coverage-gained"; - if (!ranBefore && !ranAfter) return "unchanged"; + if (!ranBefore && !ranAfter) return incompleteChange(before, after); const d = OUTCOME_RANK[after.status] - OUTCOME_RANK[before.status]; if (d > 0) return "regressed"; if (d < 0) return after.status === "pass" ? "resolved" : "improved"; @@ -114,7 +143,30 @@ export function classifyChange( return "unchanged"; } +function incompleteDescription( + kind: ScanChangeKind, + key: string, + b?: JsonCheck, + a?: JsonCheck, +): string | undefined { + if (kind === "coverage-lost" && a?.hasFindings) + return `${key}: scan incomplete; confirmed findings remain`; + if (kind === "coverage-lost" && b?.didNotRun) + return `${key}: scan remains incomplete; previous findings are not verified`; + if (kind === "coverage-lost" && a?.scanIncomplete) + return `${key}: scan incomplete; findings are not verified`; + if (kind === "regressed" && a?.didNotRun && a.hasFindings) + return `${key}: scan incomplete; confirmed findings reported`; + if (kind === "appeared" && a?.scanIncomplete) + return `${key}: new check, scan incomplete${a.hasFindings ? "; confirmed findings reported" : ""}`; + if (kind === "unchanged" && a?.scanIncomplete) + return `${key}: scan remains incomplete${a.hasFindings ? "; confirmed findings remain" : ""}`; + return undefined; +} + function describe(kind: ScanChangeKind, key: string, b?: JsonCheck, a?: JsonCheck): string { + const incomplete = incompleteDescription(kind, key, b, a); + if (incomplete) return incomplete; const sev = (c?: JsonCheck) => (c?.severity ? ` [${c.severity}]` : ""); switch (kind) { case "coverage-lost": From 7803e73e32221d1961dd9566b27e5b7a29a6f68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 10:59:38 +0100 Subject: [PATCH 15/25] Align build scan test exclusions Exclude test artifacts by default in the direct scan and allow explicit inclusion. Verify the compiled flag surface. --- docs/COMMANDS.md | 1 + src/commands/security-scan-build.test.ts | 33 ++++++++++++++++++++++-- src/commands/security-scan-build.ts | 13 ++++++++-- src/commands/security.ts | 2 +- src/flag-surface.ts | 1 + src/scan-diff.test.ts | 16 ++++++++++-- 6 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 4d7b3810..745180e7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -257,6 +257,7 @@ max_bytes = 12000 The build scan reports an incomplete check for source maps over 5 MiB or 100,000 source entries. It also reports incomplete scans for invalid maps and ambiguous keyed values. The report includes credentials found before a limit. +The scan excludes test artifacts by default. Add `--include-tests` to scan them. ### Code guardrails in the security check diff --git a/src/commands/security-scan-build.test.ts b/src/commands/security-scan-build.test.ts index 0426dc4e..24c06c99 100644 --- a/src/commands/security-scan-build.test.ts +++ b/src/commands/security-scan-build.test.ts @@ -6,7 +6,10 @@ import { join } from "node:path"; import { cmdSecurityScanBuild } from "./security-scan-build.js"; import { rmTemp } from "../temp-cleanup.test-support.js"; -async function runScan(dir: string): Promise<{ ok: boolean; output: string }> { +async function runScan( + dir: string, + includeTests = false, +): Promise<{ ok: boolean; output: string }> { const originalCwd = process.cwd(); const originalArgs = process.argv; const originalLog = console.log; @@ -14,7 +17,13 @@ async function runScan(dir: string): Promise<{ ok: boolean; output: string }> { const output: string[] = []; try { process.chdir(dir); - process.argv = ["node", "kit", "security", "scan-build"]; + process.argv = [ + "node", + "kit", + "security", + "scan-build", + ...(includeTests ? ["--include-tests"] : []), + ]; console.log = (...args: unknown[]) => void output.push(args.join(" ")); console.error = (...args: unknown[]) => void output.push(args.join(" ")); return { ok: await cmdSecurityScanBuild(), output: output.join("\n") }; @@ -27,6 +36,26 @@ async function runScan(dir: string): Promise<{ ok: boolean; output: string }> { } describe("kit security scan-build", () => { + it("excludes test artifact findings and incomplete test maps", async () => { + const dir = mkdtempSync(join(tmpdir(), "kit-scan-build-cmd-")); + try { + mkdirSync(join(dir, "dist")); + writeFileSync(join(dir, "dist", "broken.test.js.map"), "{"); + writeFileSync( + join(dir, "dist", "safe.spec.mjs.map"), + JSON.stringify({ sourcesContent: ["sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"] }), + ); + const result = await runScan(dir); + assert.equal(result.ok, true); + assert.doesNotMatch(result.output, /incomplete|stripe-key/); + const included = await runScan(dir, true); + assert.equal(included.ok, false); + assert.match(included.output, /incomplete/); + assert.match(included.output, /stripe-key/); + } finally { + rmTemp(dir); + } + }); it("fails an incomplete scan without claiming no matches", async () => { const dir = mkdtempSync(join(tmpdir(), "kit-scan-build-cmd-")); try { diff --git a/src/commands/security-scan-build.ts b/src/commands/security-scan-build.ts index 408825cc..baa55f54 100644 --- a/src/commands/security-scan-build.ts +++ b/src/commands/security-scan-build.ts @@ -1,19 +1,28 @@ import { c } from "../utils/colors.js"; import { BuildScanIncompleteError, scanBuildArtifacts } from "../scan-build.js"; import { SECRET_SHAPE_COUNT } from "../utils/redactSecrets.js"; +import { isTestArtifact } from "../check-client-exposure.js"; export async function cmdSecurityScanBuild(): Promise { // Optional positional: extra dirs to scan beyond the defaults. const extras = process.argv.slice(4).filter((a) => !a.startsWith("--")); + const includeTests = process.argv.includes("--include-tests"); + const include = (file: string) => includeTests || !isTestArtifact(file); let hits: Awaited>; + let incomplete = false; try { hits = await scanBuildArtifacts(process.cwd(), extras.length > 0 ? extras : undefined); } catch (error) { if (!(error instanceof BuildScanIncompleteError)) throw error; - console.error(`${c.red}✗ scan-build: incomplete: ${error.message}${c.reset}`); + const issue = error.issues.find((item) => include(item.file)); + if (issue) { + incomplete = true; + console.error(`${c.red}✗ scan-build: incomplete: ${issue.detail}${c.reset}`); + } hits = error.hits; - if (hits.length === 0) return false; } + hits = hits.filter((hit) => include(hit.file)); + if (incomplete && hits.length === 0) return false; if (hits.length === 0) { // The denominator belongs in the claim: "no patterns found" reads as "no secrets there", and // the honest statement is "none of the shapes kit knows". diff --git a/src/commands/security.ts b/src/commands/security.ts index d92509b2..a13ac545 100644 --- a/src/commands/security.ts +++ b/src/commands/security.ts @@ -305,7 +305,7 @@ export async function cmdSecurity(): Promise { if (sub !== "policy") { console.error( - `${c.red}Usage: kit security policy [init|add |check] | scan-staged | scan-build [dir...] | advisories [--accept] | scan-artifact [--recursive] | scan-transcripts | costs | check-gitignore [--fix] | verify-pull [--base ] | prescan [--deep] | prescan-diff | clear-cache${c.reset}`, + `${c.red}Usage: kit security policy [init|add |check] | scan-staged | scan-build [dir...] [--include-tests] | advisories [--accept] | scan-artifact [--recursive] | scan-transcripts | costs | check-gitignore [--fix] | verify-pull [--base ] | prescan [--deep] | prescan-diff | clear-cache${c.reset}`, ); return false; } diff --git a/src/flag-surface.ts b/src/flag-surface.ts index e9f6660f..f3d002cc 100644 --- a/src/flag-surface.ts +++ b/src/flag-surface.ts @@ -590,6 +590,7 @@ export const COMMAND_FLAGS: Record = { "--fix", "--format", "--head", + "--include-tests", "--json", "--non-interactive", "--only", diff --git a/src/scan-diff.test.ts b/src/scan-diff.test.ts index 230e1553..dd0c7f14 100644 --- a/src/scan-diff.test.ts +++ b/src/scan-diff.test.ts @@ -40,8 +40,20 @@ describe("didRun", () => { describe("incomplete scans with known findings", () => { it("reports increased severity when both scans are incomplete", () => { - const before = chk({ name: "bundle", status: "fail", didNotRun: true, hasFindings: true, severity: "high" }); - const after = chk({ name: "bundle", status: "fail", didNotRun: true, hasFindings: true, severity: "critical" }); + const before = chk({ + name: "bundle", + status: "fail", + didNotRun: true, + hasFindings: true, + severity: "high", + }); + const after = chk({ + name: "bundle", + status: "fail", + didNotRun: true, + hasFindings: true, + severity: "critical", + }); const result = diffScans(doc([before]), doc([after])); assert.equal(result.changes[0].kind, "regressed"); assert.equal(result.worseThanBefore, true); From 7f894aa8de0199fd741191b87c87391822dd811b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:04:04 +0100 Subject: [PATCH 16/25] Keep findings in truncated maps and scan comparisons Detect provider credentials in unfinished JSON strings and treat newly confirmed findings as regressions. --- src/scan-build-sourcemap.test.ts | 15 +++++++++++++++ src/scan-build-sourcemap.ts | 6 +++++- src/scan-diff.test.ts | 22 ++++++++++++++++++++++ src/scan-diff.ts | 11 ++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/scan-build-sourcemap.test.ts b/src/scan-build-sourcemap.test.ts index e2368951..8d288392 100644 --- a/src/scan-build-sourcemap.test.ts +++ b/src/scan-build-sourcemap.test.ts @@ -115,6 +115,21 @@ describe("scanBuildArtifacts source map fields", () => { }); describe("source map encoded metadata and broken JSON", () => { + it("retains a recognizable credential when a JSON string is cut short", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + writeFileSync(join(dir, "build", "truncated.js.map"), `{"sourcesContent":["${secret}`); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "stripe-key")); + return true; + }); + } finally { + rmTemp(dir); + } + }); it("continues scanning when a keyed value is ambiguous", async () => { const dir = makeRepo(); try { diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index 02641ff3..23a59f36 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -123,7 +123,11 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin if (!map || typeof map !== "object" || Array.isArray(map)) throw new Error("invalid source map"); } catch { - const findings: SecretFinding[] = []; + // Raw provider patterns can still match a source string cut off mid-value. + // Generic key patterns need decoded delimiters and are scanned below. + const findings = findBuildSecrets(content).filter( + (finding) => !BUILD_GENERIC_LABELS.has(finding.label), + ); try { scanMalformedObject( content, diff --git a/src/scan-diff.test.ts b/src/scan-diff.test.ts index dd0c7f14..e7cb2b6f 100644 --- a/src/scan-diff.test.ts +++ b/src/scan-diff.test.ts @@ -39,6 +39,28 @@ describe("didRun", () => { }); describe("incomplete scans with known findings", () => { + it("reports increased severity when an incomplete scan becomes complete", () => { + const before = chk({ + name: "bundle", + status: "fail", + didNotRun: true, + hasFindings: true, + severity: "high", + }); + const after = chk({ name: "bundle", status: "fail", severity: "critical" }); + const result = diffScans(doc([before]), doc([after])); + assert.equal(result.changes[0].kind, "regressed"); + assert.equal(result.worseThanBefore, true); + }); + it("reports a new finding when an incomplete scan becomes complete", () => { + const before = chk({ name: "bundle", status: "fail", didNotRun: true, scanIncomplete: true }); + const after = chk({ name: "bundle", status: "fail", severity: "critical" }); + const result = diffScans(doc([before]), doc([after])); + assert.equal(result.changes[0].kind, "regressed"); + assert.equal(result.worseThanBefore, true); + assert.equal(result.clean, false); + assert.match(result.changes[0].summary, /scan completed; confirmed finding reported/); + }); it("reports increased severity when both scans are incomplete", () => { const before = chk({ name: "bundle", diff --git a/src/scan-diff.ts b/src/scan-diff.ts index 86d04d71..4ebd118f 100644 --- a/src/scan-diff.ts +++ b/src/scan-diff.ts @@ -119,6 +119,13 @@ function incompleteChange(before: JsonCheck, after: JsonCheck): ScanChangeKind { return "unchanged"; } +function completedChange(before: JsonCheck, after: JsonCheck): ScanChangeKind { + if (!before.hasFindings && after.status === "fail") return "regressed"; + const severity = (SEVERITY_RANK[after.severity!] ?? 0) - (SEVERITY_RANK[before.severity!] ?? 0); + if (before.hasFindings && after.status === "fail" && severity > 0) return "regressed"; + return "coverage-gained"; +} + /** Classify one check's movement. Pure; `before`/`after` may each be absent. */ export function classifyChange( before: JsonCheck | undefined, @@ -131,7 +138,7 @@ export function classifyChange( const ranAfter = didRun(after); // Coverage first: whether the check RAN dominates what it said. if (ranBefore && !ranAfter) return "coverage-lost"; - if (!ranBefore && ranAfter) return "coverage-gained"; + if (!ranBefore && ranAfter) return completedChange(before, after); if (!ranBefore && !ranAfter) return incompleteChange(before, after); const d = OUTCOME_RANK[after.status] - OUTCOME_RANK[before.status]; if (d > 0) return "regressed"; @@ -174,6 +181,8 @@ function describe(kind: ScanChangeKind, key: string, b?: JsonCheck, a?: JsonChec case "disappeared": return `${key}: present before (${b!.status}${sev(b)}), absent from the second run entirely — check removed or not executed`; case "regressed": + if (b?.didNotRun && !b.hasFindings && !a?.didNotRun) + return `${key}: scan completed; confirmed finding reported`; return `${key}: ${b!.status}${sev(b)} → ${a!.status}${sev(a)}`; case "appeared": return `${key}: new check, ${a!.status}${sev(a)}`; From 7b5f588b90998cda7f2a46942d0106f263524fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:06:28 +0100 Subject: [PATCH 17/25] Avoid cross-source matches in malformed maps Limit raw fallback to safe credential patterns and avoid counting the same decoded finding twice. --- src/scan-build-sourcemap.test.ts | 46 ++++++++++++++++++++++++++++++++ src/scan-build-sourcemap.ts | 17 ++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/scan-build-sourcemap.test.ts b/src/scan-build-sourcemap.test.ts index 8d288392..e0136b1b 100644 --- a/src/scan-build-sourcemap.test.ts +++ b/src/scan-build-sourcemap.test.ts @@ -115,6 +115,49 @@ describe("scanBuildArtifacts source map fields", () => { }); describe("source map encoded metadata and broken JSON", () => { + it("does not join separate sources into a private key in broken JSON", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const source = [ + 'const a = "-----BEGIN PRIVATE KEY-----";', + 'const b = "-----END PRIVATE KEY-----";', + ]; + writeFileSync( + join(dir, "build", "broken.js.map"), + JSON.stringify({ sourcesContent: source }).slice(0, -1), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.deepEqual(error.hits, []); + return true; + }); + } finally { + rmTemp(dir); + } + }); + + it("does not count the same broken-map credential twice", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const secret = "sk_" + "live_AbCdEfGhIjKlMnOpQrStUvWxYz123"; + writeFileSync( + join(dir, "build", "broken.js.map"), + JSON.stringify({ sourcesContent: [secret] }).slice(0, -1), + ); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.equal( + error.hits[0].findings.filter((finding) => finding.label === "stripe-key").length, + 1, + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); it("retains a recognizable credential when a JSON string is cut short", async () => { const dir = makeRepo(); try { @@ -130,6 +173,9 @@ describe("source map encoded metadata and broken JSON", () => { rmTemp(dir); } }); +}); + +describe("source map ambiguous metadata", () => { it("continues scanning when a keyed value is ambiguous", async () => { const dir = makeRepo(); try { diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index 23a59f36..964b8e4f 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -25,6 +25,12 @@ export { MAX_BYTES } from "./scan-build-json.js"; // state construct, so these labels are filtered out here. const BUILD_IRRELEVANT_LABELS = new Set(["tfstate-value", "terraform-sensitive"]); const BUILD_GENERIC_LABELS = new Set(["keyed-secret", "url-query-token"]); +const UNSAFE_RAW_LABELS = new Set([ + ...BUILD_GENERIC_LABELS, + "pem-private-key", + "gcp-private-key", + "url-credentials", +]); const OPAQUE_MIN_LENGTH = 20; const OPAQUE_MIN_ENTROPY = 4.2; @@ -125,9 +131,10 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin } catch { // Raw provider patterns can still match a source string cut off mid-value. // Generic key patterns need decoded delimiters and are scanned below. - const findings = findBuildSecrets(content).filter( - (finding) => !BUILD_GENERIC_LABELS.has(finding.label), + const raw = findBuildSecrets(content).filter( + (finding) => !UNSAFE_RAW_LABELS.has(finding.label), ); + const findings: SecretFinding[] = []; try { scanMalformedObject( content, @@ -138,6 +145,12 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin } catch { // The incomplete result still reports findings collected before an invalid token or limit. } + const seen = new Set(findings.map((finding) => `${finding.label}\0${finding.preview}`)); + for (const finding of raw) { + const key = `${finding.label}\0${finding.preview}`; + if (!seen.has(key)) findings.push(finding); + seen.add(key); + } throw new ArtifactIncompleteError("source map has invalid JSON", findings); } const findings: SecretFinding[] = []; From f7aeafdbe03ca35663af334496e59023a53631f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:08:10 +0100 Subject: [PATCH 18/25] Bound malformed JSON token scanning Read string tokens in one pass so unfinished escaped strings cannot stall the build scan. --- src/scan-build-malformed.test.ts | 13 +++++++++++++ src/scan-build-malformed.ts | 25 ++++++++++++++++++++++--- src/scan-build-sourcemap.test.ts | 4 ++-- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/scan-build-malformed.test.ts b/src/scan-build-malformed.test.ts index 6e7e8b46..b3e3f830 100644 --- a/src/scan-build-malformed.test.ts +++ b/src/scan-build-malformed.test.ts @@ -11,6 +11,19 @@ function makeRepo(): string { } describe("scanBuildArtifacts mixed source content", () => { + it("handles a long unfinished JSON string within the work limit", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const content = '{"sourcesContent":["' + '\\"'.repeat(100_000); + writeFileSync(join(dir, "build", "unfinished.js.map"), content); + const start = performance.now(); + await assert.rejects(scanBuildArtifacts(dir), /source map has invalid JSON/); + assert.ok(performance.now() - start < 5_000, "scan exceeded the work limit"); + } finally { + rmTemp(dir); + } + }); it("continues past invalid JSON escapes and retains later credentials", async () => { const dir = makeRepo(); try { diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index 6eaadcf3..5ba74a5e 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -1,25 +1,44 @@ import { SECRET_PATTERNS, shannonEntropy, type SecretFinding } from "./utils/redactSecrets.js"; import { MAX_MAP_SOURCES, type SourceBudget } from "./scan-build-json.js"; +function* jsonStringTokens(content: string): Generator<{ index: number; text: string }> { + let cursor = 0; + while (cursor < content.length) { + const start = content.indexOf('"', cursor); + if (start < 0) return; + cursor = start + 1; + let closed = false; + while (cursor < content.length) { + if (content[cursor] === "\\") cursor += 2; + else if (content[cursor++] === '"') { + closed = true; + break; + } + } + if (!closed) return; + yield { index: start, text: content.slice(start, cursor) }; + } +} + export function scanMalformedObject( content: string, findings: SecretFinding[], budget: SourceBudget, scan: (value: string) => SecretFinding[], ): void { - for (const token of content.matchAll(/"(?:\\.|[^"\\])*"/g)) { + for (const token of jsonStringTokens(content)) { budget.sources++; if (budget.sources > MAX_MAP_SOURCES) { throw new Error("source map exceeds 100000 source entries; scan could not finish"); } let decoded: string; try { - decoded = JSON.parse(token[0]) as string; + decoded = JSON.parse(token.text) as string; } catch { continue; } const valueToken = /^\s*:\s*("(?:\\.|[^"\\])*")/.exec( - content.slice(token.index! + token[0].length), + content.slice(token.index + token.text.length), ); if (valueToken) { let value: string; diff --git a/src/scan-build-sourcemap.test.ts b/src/scan-build-sourcemap.test.ts index e0136b1b..5c4d9c79 100644 --- a/src/scan-build-sourcemap.test.ts +++ b/src/scan-build-sourcemap.test.ts @@ -120,8 +120,8 @@ describe("source map encoded metadata and broken JSON", () => { try { mkdirSync(join(dir, "build"), { recursive: true }); const source = [ - 'const a = "-----BEGIN PRIVATE KEY-----";', - 'const b = "-----END PRIVATE KEY-----";', + 'const a = "' + "-----BEGIN " + 'PRIVATE KEY-----";', + 'const b = "' + "-----END " + 'PRIVATE KEY-----";', ]; writeFileSync( join(dir, "build", "broken.js.map"), From bc6f629921d9c991a3bc2ba6cbfbe42fcea10960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:13:25 +0100 Subject: [PATCH 19/25] Inspect recoverable unfinished source strings Decode a valid partial JSON string to retain credential findings, and document the test exclusion scope. --- docs/COMMANDS.md | 4 +++- src/scan-build-malformed.test.ts | 36 ++++++++++++++++++++++++++++++++ src/scan-build-malformed.ts | 7 ++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 745180e7..ae7fdd1e 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -257,7 +257,9 @@ max_bytes = 12000 The build scan reports an incomplete check for source maps over 5 MiB or 100,000 source entries. It also reports incomplete scans for invalid maps and ambiguous keyed values. The report includes credentials found before a limit. -The scan excludes test artifacts by default. Add `--include-tests` to scan them. +The scan excludes `__tests__`, `__mocks__`, and `fixtures` directories by default. +It also excludes `.test` and `.spec` JavaScript or TypeScript files and their source maps. +Add `--include-tests` to scan them. Directories named `test` or `tests` are still scanned. ### Code guardrails in the security check diff --git a/src/scan-build-malformed.test.ts b/src/scan-build-malformed.test.ts index b3e3f830..47ef7a41 100644 --- a/src/scan-build-malformed.test.ts +++ b/src/scan-build-malformed.test.ts @@ -11,6 +11,39 @@ function makeRepo(): string { } describe("scanBuildArtifacts mixed source content", () => { + it("retains a generic credential in an unfinished JSON string", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const value = ["A7mK9qR2", "vX4yZ8bP", "5nW3dC6f"].join(""); + const broken = `{"sourcesContent":["const PASSWORD = '${value}';`; + writeFileSync(join(dir, "build", "broken.js.map"), broken); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "keyed-secret")); + return true; + }); + } finally { + rmTemp(dir); + } + }); + it("retains a complete private-key block in an unfinished JSON string", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const pem = + "-----BEGIN " + "PRIVATE KEY-----\nFAKECONTENTFORTESTONLY\n-----END " + "PRIVATE KEY-----"; + const broken = JSON.stringify({ sourcesContent: [pem] }).slice(0, -3); + writeFileSync(join(dir, "build", "broken.js.map"), broken); + await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { + assert.ok(error instanceof BuildScanIncompleteError); + assert.ok(error.hits[0].findings.some((finding) => finding.label === "pem-private-key")); + return true; + }); + } finally { + rmTemp(dir); + } + }); it("handles a long unfinished JSON string within the work limit", async () => { const dir = makeRepo(); try { @@ -24,6 +57,9 @@ describe("scanBuildArtifacts mixed source content", () => { rmTemp(dir); } }); +}); + +describe("scanBuildArtifacts invalid source values", () => { it("continues past invalid JSON escapes and retains later credentials", async () => { const dir = makeRepo(); try { diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index 5ba74a5e..3856ebb0 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -15,7 +15,12 @@ function* jsonStringTokens(content: string): Generator<{ index: number; text: st break; } } - if (!closed) return; + if (!closed) { + // Try the unfinished string as a partial JSON value. Invalid trailing + // escapes remain incomplete and are rejected by JSON.parse below. + yield { index: start, text: content.slice(start) + '"' }; + return; + } yield { index: start, text: content.slice(start, cursor) }; } } From 092c1425a86096e42a6e57d6cbf0478768c92fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:16:24 +0100 Subject: [PATCH 20/25] Recover incomplete trailing escapes Retain findings before a dangling backslash in an unfinished source-map string. --- src/scan-build-malformed.test.ts | 8 +++++++- src/scan-build-malformed.ts | 10 ++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/scan-build-malformed.test.ts b/src/scan-build-malformed.test.ts index 47ef7a41..27cd53d9 100644 --- a/src/scan-build-malformed.test.ts +++ b/src/scan-build-malformed.test.ts @@ -35,9 +35,15 @@ describe("scanBuildArtifacts mixed source content", () => { "-----BEGIN " + "PRIVATE KEY-----\nFAKECONTENTFORTESTONLY\n-----END " + "PRIVATE KEY-----"; const broken = JSON.stringify({ sourcesContent: [pem] }).slice(0, -3); writeFileSync(join(dir, "build", "broken.js.map"), broken); + writeFileSync(join(dir, "build", "broken-escape.js.map"), broken + "\\"); await assert.rejects(scanBuildArtifacts(dir), (error: unknown) => { assert.ok(error instanceof BuildScanIncompleteError); - assert.ok(error.hits[0].findings.some((finding) => finding.label === "pem-private-key")); + assert.equal(error.hits.length, 2); + assert.ok( + error.hits.every((hit) => + hit.findings.some((finding) => finding.label === "pem-private-key"), + ), + ); return true; }); } finally { diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index 3856ebb0..a6860084 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -16,9 +16,15 @@ function* jsonStringTokens(content: string): Generator<{ index: number; text: st } } if (!closed) { - // Try the unfinished string as a partial JSON value. Invalid trailing + let end = content.length; + let trailingSlashes = 0; + while (end - trailingSlashes > start && content[end - trailingSlashes - 1] === "\\") { + trailingSlashes++; + } + if (trailingSlashes % 2 === 1) end--; + // A trailing backslash cannot complete an escape. Earlier invalid // escapes remain incomplete and are rejected by JSON.parse below. - yield { index: start, text: content.slice(start) + '"' }; + yield { index: start, text: content.slice(start, end) + '"' }; return; } yield { index: start, text: content.slice(start, cursor) }; From 07a15fa06a66e0fe9091176b3e65b036f4eba5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:20:34 +0100 Subject: [PATCH 21/25] Describe source-map metadata scan limits Distinguish the string-token limit from the number of source entries. --- docs/COMMANDS.md | 1 + src/scan-build-malformed.ts | 2 +- src/scan-build-sourcemap.test.ts | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index ae7fdd1e..a6472614 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -255,6 +255,7 @@ max_bytes = 12000 | `kit security prescan-diff ` | Diff two prescan reports — surface new regressions + fixed findings. | The build scan reports an incomplete check for source maps over 5 MiB or 100,000 source entries. +It also limits scans of metadata and invalid content to 100,000 string tokens per map. It also reports incomplete scans for invalid maps and ambiguous keyed values. The report includes credentials found before a limit. The scan excludes `__tests__`, `__mocks__`, and `fixtures` directories by default. diff --git a/src/scan-build-malformed.ts b/src/scan-build-malformed.ts index a6860084..c8e95191 100644 --- a/src/scan-build-malformed.ts +++ b/src/scan-build-malformed.ts @@ -40,7 +40,7 @@ export function scanMalformedObject( for (const token of jsonStringTokens(content)) { budget.sources++; if (budget.sources > MAX_MAP_SOURCES) { - throw new Error("source map exceeds 100000 source entries; scan could not finish"); + throw new Error("source map exceeds 100000 string tokens; scan could not finish"); } let decoded: string; try { diff --git a/src/scan-build-sourcemap.test.ts b/src/scan-build-sourcemap.test.ts index 5c4d9c79..f45852ff 100644 --- a/src/scan-build-sourcemap.test.ts +++ b/src/scan-build-sourcemap.test.ts @@ -269,6 +269,23 @@ describe("source map metadata with incomplete JSON", () => { }); describe("source map metadata findings", () => { + it("reports the metadata token limit without calling names source entries", async () => { + const dir = makeRepo(); + try { + mkdirSync(join(dir, "build"), { recursive: true }); + const map = { + version: 3, + sources: [], + sourcesContent: [], + names: Array(100_001).fill("a"), + mappings: "", + }; + writeFileSync(join(dir, "build", "large-names.js.map"), JSON.stringify(map)); + await assert.rejects(scanBuildArtifacts(dir), /source map exceeds 100000 string tokens/); + } finally { + rmTemp(dir); + } + }); it("detects an encoded credential in a metadata field", async () => { const dir = makeRepo(); try { @@ -286,6 +303,9 @@ describe("source map metadata findings", () => { } }); +}); + +describe("source map partial metadata findings", () => { it("keeps metadata findings before and after a source limit", async () => { const dir = makeRepo(); try { From b485170a386433b9b4b7a010320651297822c156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:23:41 +0100 Subject: [PATCH 22/25] Format source-map regression tests --- src/scan-build-sourcemap.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scan-build-sourcemap.test.ts b/src/scan-build-sourcemap.test.ts index f45852ff..7f5d188d 100644 --- a/src/scan-build-sourcemap.test.ts +++ b/src/scan-build-sourcemap.test.ts @@ -302,7 +302,6 @@ describe("source map metadata findings", () => { rmTemp(dir); } }); - }); describe("source map partial metadata findings", () => { From 698fb911635da5ed6afbb9d0d766ad1f9f18754f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:26:07 +0100 Subject: [PATCH 23/25] Preserve coverage classification for skipped checks Only treat a new finding as a regression after an incomplete scan, not an ordinary skipped check. --- src/scan-diff.test.ts | 7 +++++++ src/scan-diff.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/src/scan-diff.test.ts b/src/scan-diff.test.ts index e7cb2b6f..d1308d56 100644 --- a/src/scan-diff.test.ts +++ b/src/scan-diff.test.ts @@ -39,6 +39,13 @@ describe("didRun", () => { }); describe("incomplete scans with known findings", () => { + it("keeps the coverage-gained classification for a previously skipped check", () => { + const before = chk({ name: "bundle", status: "skip" }); + const after = chk({ name: "bundle", status: "fail", severity: "critical" }); + const result = diffScans(doc([before]), doc([after])); + assert.equal(result.changes[0].kind, "coverage-gained"); + assert.equal(result.worseThanBefore, false); + }); it("reports increased severity when an incomplete scan becomes complete", () => { const before = chk({ name: "bundle", diff --git a/src/scan-diff.ts b/src/scan-diff.ts index 4ebd118f..03881d07 100644 --- a/src/scan-diff.ts +++ b/src/scan-diff.ts @@ -120,6 +120,7 @@ function incompleteChange(before: JsonCheck, after: JsonCheck): ScanChangeKind { } function completedChange(before: JsonCheck, after: JsonCheck): ScanChangeKind { + if (!before.scanIncomplete && !before.hasFindings) return "coverage-gained"; if (!before.hasFindings && after.status === "fail") return "regressed"; const severity = (SEVERITY_RANK[after.severity!] ?? 0) - (SEVERITY_RANK[before.severity!] ?? 0); if (before.hasFindings && after.status === "fail" && severity > 0) return "regressed"; From a4b346245cfbc7ec32891e65a28ddb2cab8ca0d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:29:37 +0100 Subject: [PATCH 24/25] Fix CI review findings Use a private temporary directory for CI fixtures and separate invalid map reporting from the main scan. --- src/ci.test.ts | 5 ++-- src/scan-build-sourcemap.ts | 48 +++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/ci.test.ts b/src/ci.test.ts index 71762552..782d6436 100644 --- a/src/ci.test.ts +++ b/src/ci.test.ts @@ -2,7 +2,7 @@ import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { writeFile, rm, mkdir } from "node:fs/promises"; +import { writeFile, rm, mkdir, mkdtemp } from "node:fs/promises"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; @@ -26,8 +26,7 @@ async function runCi(args: string[] = [], env: Record = {}) { } before(async () => { - testDir = join(tmpdir(), `kit-ci-test-${Date.now()}`); - await mkdir(testDir, { recursive: true }); + testDir = await mkdtemp(join(tmpdir(), "kit-ci-test-")); // Minimal .kit.toml with no required tools/services/secrets await writeFile(join(testDir, ".kit.toml"), `[tools]\n`, "utf8"); }); diff --git a/src/scan-build-sourcemap.ts b/src/scan-build-sourcemap.ts index 964b8e4f..92b45841 100644 --- a/src/scan-build-sourcemap.ts +++ b/src/scan-build-sourcemap.ts @@ -120,6 +120,30 @@ function scanMapMetadata(content: string, findings: SecretFinding[]): void { if (budget.ambiguous) throw new Error("source map contains an ambiguous keyed value"); } +function reportInvalidMap(content: string): never { + // Raw provider patterns can still match a source string cut off mid-value. + // Generic key patterns need decoded delimiters and are scanned below. + const raw = findBuildSecrets(content).filter((finding) => !UNSAFE_RAW_LABELS.has(finding.label)); + const findings: SecretFinding[] = []; + try { + scanMalformedObject( + content, + findings, + { sources: 0, nestedBytes: 0, malformed: true, ambiguous: false }, + findBuildSecrets, + ); + } catch { + // The incomplete result still reports findings collected before an invalid token or limit. + } + const seen = new Set(findings.map((finding) => `${finding.label}\0${finding.preview}`)); + for (const finding of raw) { + const key = `${finding.label}\0${finding.preview}`; + if (!seen.has(key)) findings.push(finding); + seen.add(key); + } + throw new ArtifactIncompleteError("source map has invalid JSON", findings); +} + // Scan decoded sources separately so escaped quotes cannot join unrelated code. // Keep the original remaining JSON, including duplicate fields, for the scan. export function findArtifactSecrets(content: string, file: string): SecretFinding[] { @@ -129,29 +153,7 @@ export function findArtifactSecrets(content: string, file: string): SecretFindin if (!map || typeof map !== "object" || Array.isArray(map)) throw new Error("invalid source map"); } catch { - // Raw provider patterns can still match a source string cut off mid-value. - // Generic key patterns need decoded delimiters and are scanned below. - const raw = findBuildSecrets(content).filter( - (finding) => !UNSAFE_RAW_LABELS.has(finding.label), - ); - const findings: SecretFinding[] = []; - try { - scanMalformedObject( - content, - findings, - { sources: 0, nestedBytes: 0, malformed: true, ambiguous: false }, - findBuildSecrets, - ); - } catch { - // The incomplete result still reports findings collected before an invalid token or limit. - } - const seen = new Set(findings.map((finding) => `${finding.label}\0${finding.preview}`)); - for (const finding of raw) { - const key = `${finding.label}\0${finding.preview}`; - if (!seen.has(key)) findings.push(finding); - seen.add(key); - } - throw new ArtifactIncompleteError("source map has invalid JSON", findings); + reportInvalidMap(content); } const findings: SecretFinding[] = []; const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false, ambiguous: false }; From 71b44ea105747fec78d43af1d544af6b9c2064bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Sandstr=C3=B6m?= Date: Fri, 30 Oct 2026 11:41:32 +0100 Subject: [PATCH 25/25] Update build-scan command contract --- contracts/kit.opencli.json | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/kit.opencli.json b/contracts/kit.opencli.json index 3f16d756..97373ad6 100644 --- a/contracts/kit.opencli.json +++ b/contracts/kit.opencli.json @@ -2429,6 +2429,7 @@ "--format", "--head", "--help", + "--include-tests", "--json", "--non-interactive", "--only",