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", diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 8e73af03..a6472614 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -254,6 +254,14 @@ 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 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. +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 `kit check --category security` scans shell scripts, GitHub workflow commands, Dockerfiles, and source files. 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 9c3f3458..32c82952 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) ); } @@ -457,6 +457,39 @@ 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, + 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) } : {}), + }; +} + /** * Scan the built output for credential shapes. * @@ -480,18 +513,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, @@ -500,23 +522,30 @@ 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. - return { - ...base, - status: "fail", - severity: "medium", - didNotRun: true, - detail: `bundle scan could not run: ${(e as Error).message.slice(0, 80)}`, - }; + 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/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..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"); }); @@ -66,6 +65,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..24c06c99 --- /dev/null +++ b/src/commands/security-scan-build.test.ts @@ -0,0 +1,105 @@ +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, + includeTests = false, +): 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", + ...(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") }; + } finally { + process.chdir(originalCwd); + process.argv = originalArgs; + console.log = originalLog; + console.error = originalError; + } +} + +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 { + 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..baa55f54 --- /dev/null +++ b/src/commands/security-scan-build.ts @@ -0,0 +1,49 @@ +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; + 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; + } + 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". + 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 2ec027b2..a13ac545 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 { 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"; @@ -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; } @@ -509,35 +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("--")); - const hits = await scanBuildArtifacts(process.cwd(), extras.length > 0 ? extras : undefined); - 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/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/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-json.ts b/src/scan-build-json.ts new file mode 100644 index 00000000..832c8949 --- /dev/null +++ b/src/scan-build-json.ts @@ -0,0 +1,44 @@ +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; + 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. +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-malformed.test.ts b/src/scan-build-malformed.test.ts new file mode 100644 index 00000000..27cd53d9 --- /dev/null +++ b/src/scan-build-malformed.test.ts @@ -0,0 +1,196 @@ +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("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); + writeFileSync(join(dir, "build", "broken-escape.js.map"), broken + "\\"); + 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 === "pem-private-key"), + ), + ); + return true; + }); + } finally { + rmTemp(dir); + } + }); + 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); + } + }); +}); + +describe("scanBuildArtifacts invalid source values", () => { + 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 new file mode 100644 index 00000000..c8e95191 --- /dev/null +++ b/src/scan-build-malformed.ts @@ -0,0 +1,76 @@ +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) { + 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, end) + '"' }; + 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 jsonStringTokens(content)) { + budget.sources++; + if (budget.sources > MAX_MAP_SOURCES) { + throw new Error("source map exceeds 100000 string tokens; scan could not finish"); + } + let decoded: string; + try { + decoded = JSON.parse(token.text) as string; + } catch { + continue; + } + const valueToken = /^\s*:\s*("(?:\\.|[^"\\])*")/.exec( + content.slice(token.index + token.text.length), + ); + if (valueToken) { + 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), + ); + if (keyed && /[\s"'\\]/.test(value) && value.length >= 20 && shannonEntropy(value) >= 4.2) { + budget.ambiguous = true; + } + if (keyed && !/[\s"'\\]/.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-sourcemap.test.ts b/src/scan-build-sourcemap.test.ts new file mode 100644 index 00000000..7f5d188d --- /dev/null +++ b/src/scan-build-sourcemap.test.ts @@ -0,0 +1,353 @@ +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("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 { + 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); + } + }); +}); + +describe("source map ambiguous metadata", () => { + 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("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 { + 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); + } + }); +}); + +describe("source map partial metadata findings", () => { + 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 new file mode 100644 index 00000000..92b45841 --- /dev/null +++ b/src/scan-build-sourcemap.ts @@ -0,0 +1,205 @@ +import { + findSecrets, + SECRET_PATTERNS, + shannonEntropy, + type SecretFinding, +} from "./utils/redactSecrets.js"; + +export class ArtifactIncompleteError extends Error { + constructor( + message: string, + readonly findings: SecretFinding[], + ) { + super(message); + } +} + +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 +// 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 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; + +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 }; + +function readSourceArray( + content: string, + start: number, + budget: SourceBudget, + findings: SecretFinding[], +): number | undefined { + const strings = /"(?:\\.|[^"\\])*"/g; + let cursor = start; + while (true) { + cursor += /^\s*/.exec(content.slice(cursor))![0].length; + 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"); + } + 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 { + budget.malformed = true; + const end = skipJsonValue(content, cursor, budget); + scanMalformedObject(content.slice(cursor, end), findings, budget, findBuildSecrets); + cursor = end; + } + cursor += /^\s*/.exec(content.slice(cursor))![0].length; + if (content[cursor] === ",") cursor++; + else if (content[cursor] !== "]") return undefined; + } +} + +function scanMapMetadata(content: string, findings: SecretFinding[]): void { + const strings = /"(?:\\.|[^"\\])*"/g; + const ranges: SourceRange[] = []; + 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; + const prefix = /^\s*:\s*/.exec(content.slice(strings.lastIndex)); + if (!prefix) continue; + const start = strings.lastIndex + prefix[0].length; + 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), "null"); + from = range.end; + } + rest.push(content.slice(from)); + scanMalformedObject(rest.join(""), findings, budget, findBuildSecrets); + 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[] { + if (!file.endsWith(".map")) return findBuildSecrets(content); + try { + const map: unknown = JSON.parse(content); + if (!map || typeof map !== "object" || Array.isArray(map)) + throw new Error("invalid source map"); + } catch { + reportInvalidMap(content); + } + const findings: SecretFinding[] = []; + const budget: SourceBudget = { sources: 0, nestedBytes: 0, malformed: false, ambiguous: false }; + 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 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; + findBuildSecrets(decoded).forEach((finding) => findings.push(finding)); + } else if (content[valueStart] === "{") { + scanMalformedObject(content.slice(valueStart, end), findings, budget, findBuildSecrets); + } + strings.lastIndex = end; + continue; + } + const arrayStart = valueStart + 1; + const arrayEnd = readSourceArray(content, arrayStart, budget, findings); + if (arrayEnd === undefined) continue; + strings.lastIndex = arrayEnd; + } + } catch (error) { + 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); + } + 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.ts b/src/scan-build.ts index ab41459c..0419845c 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,21 @@ export interface BuildHit { findings: SecretFinding[]; } +export interface BuildScanIssue { + file: string; + detail: string; +} + +export class BuildScanIncompleteError extends Error { + constructor( + readonly issues: BuildScanIssue[], + readonly hits: BuildHit[], + ) { + super(issues[0]?.detail ?? "build scan incomplete"); + this.name = "BuildScanIncompleteError"; + } +} + const DEFAULT_BUILD_DIRS = [ ".next", "dist", @@ -56,39 +67,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; - -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; -} - /** Next.js server modules are deployed to the runtime, not downloaded by visitors. */ function isBrowserVisibleNextArtifact(file: string): boolean { const parts = file.split("/"); @@ -143,23 +121,48 @@ export async function scanBuildArtifacts( } const hits: BuildHit[] = []; + const incomplete: BuildScanIssue[] = []; 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({ file: rel, detail: `${rel}: source map could not be inspected` }); + continue; + } + if (size > MAX_BYTES) { + 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({ file: rel, detail: `${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({ file: rel, detail: `${rel}: ${(error as Error).message}` }); continue; } - const findings = findBuildSecrets(content); 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, hits); return hits; } diff --git a/src/scan-diff.test.ts b/src/scan-diff.test.ts index e7bdbcd6..d1308d56 100644 --- a/src/scan-diff.test.ts +++ b/src/scan-diff.test.ts @@ -38,6 +38,85 @@ 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", + 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", + 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..03881d07 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,32 @@ 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"; +} + +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"; + return "coverage-gained"; } /** Classify one check's movement. Pure; `before`/`after` may each be absent. */ @@ -102,8 +139,8 @@ 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 "unchanged"; + 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"; if (d < 0) return after.status === "pass" ? "resolved" : "improved"; @@ -114,7 +151,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": @@ -122,6 +182,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)}`;