From 8465514782441d5faad34e95cd078d3a16bec861 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:57:48 +0000 Subject: [PATCH] fix(ci): preserve incomplete dependency evidence as unavailable --- docs/releasing.md | 2 + scripts/lib/dependency-security-facts.d.mts | 2 +- scripts/lib/dependency-security-facts.mjs | 4 +- scripts/lib/dependency-security.mjs | 54 ++++++++----- .../filesystem/dependencySecurity.test.ts | 79 ++++++++++++++++++- 5 files changed, 116 insertions(+), 25 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index 5d67edf258..10a233c210 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -57,6 +57,8 @@ locked dependencies, or `--mode=published` for a fresh current-release consumer. registry concurrency to two connections and runs scans sequentially; each command has a three-minute deadline and a 16 MiB output budget. No prior pass is used in place of a live advisory or signature check. +Over-budget or interrupted command captures retain failure metadata rather than +an unsafe truncated prefix; that evidence is unavailable and cannot pass. Before publishing, npm lookup failures and malformed metadata stop the job. An existing version must match the qualified tarball's SHA-512 and intended diff --git a/scripts/lib/dependency-security-facts.d.mts b/scripts/lib/dependency-security-facts.d.mts index 248ad6577b..e6cf8daf5d 100644 --- a/scripts/lib/dependency-security-facts.d.mts +++ b/scripts/lib/dependency-security-facts.d.mts @@ -52,5 +52,5 @@ export function sourceIdentityDifferences( reference: LockedPackage[], runtimeOnly: boolean, ): (InstalledPackage & { - reason: "integrity_differs" | "version_not_locked"; + reason: "integrity_differs" | "integrity_unknown" | "version_not_locked"; })[]; diff --git a/scripts/lib/dependency-security-facts.mjs b/scripts/lib/dependency-security-facts.mjs index 2ac686e684..8199afd320 100644 --- a/scripts/lib/dependency-security-facts.mjs +++ b/scripts/lib/dependency-security-facts.mjs @@ -236,7 +236,9 @@ export function sourceIdentityDifferences(nodes, reference, runtimeOnly) { ...node, source_identities: expected, reason: sameVersion.length - ? "integrity_differs" + ? node.integrity === null + ? "integrity_unknown" + : "integrity_differs" : "version_not_locked", }, ]; diff --git a/scripts/lib/dependency-security.mjs b/scripts/lib/dependency-security.mjs index 97582402d8..c94ed1664c 100644 --- a/scripts/lib/dependency-security.mjs +++ b/scripts/lib/dependency-security.mjs @@ -68,7 +68,7 @@ export async function checkDependencySecurity( const retain = async (name, source) => { const rendered = redactTransportAuthentication(source); await writeFile(join(evidence, name), rendered); - return digest(rendered); + return { sha256: digest(rendered), text: rendered }; }; const report = { schema_version: 1, @@ -100,8 +100,12 @@ export async function checkDependencySecurity( }; const finish = async () => { report.completed_at = new Date().toISOString(); - await retain("report.json", JSON.stringify(report, null, 2) + "\n"); - return report; + const retained = await retain( + "report.json", + JSON.stringify(report, null, 2) + "\n", + ); + // CLI/programmatic summaries expose the same redacted view as the artifact. + return JSON.parse(retained.text); }; let reference; let selectedLockSha; @@ -126,10 +130,9 @@ export async function checkDependencySecurity( selectedLockSha = digest(selectedBytes); installedPackages = lockedPackages(JSON.parse(selectedBytes)); report.selected_lock_sha256 = selectedLockSha; - report.retained_lock_sha256 = await retain( - "selected-package-lock.json", - selectedBytes.toString("utf8"), - ); + report.retained_lock_sha256 = ( + await retain("selected-package-lock.json", selectedBytes.toString("utf8")) + ).sha256; const product = JSON.parse( await readFile( join( @@ -173,18 +176,20 @@ export async function checkDependencySecurity( const oversized = Buffer.byteLength(result.stdout) > SECURITY_OUTPUT_BYTES || Buffer.byteLength(result.stderr) > SECURITY_OUTPUT_BYTES; - if (oversized) { + const incomplete = + oversized || + result.exitCode === null || + result.failure === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; + if (oversized || result.failure === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") { result.exitCode = null; result.failure = "output_limit"; } - const bounded = (value) => - Buffer.byteLength(value) <= SECURITY_OUTPUT_BYTES - ? value - : Buffer.from(value) - .subarray(0, SECURITY_OUTPUT_BYTES) - .toString("utf8"); - const stdout = bounded(result.stdout), - stderr = bounded(result.stderr); + // execFile may already have cut a URL before its host/@ delimiter. An + // incomplete capture cannot be safely rendered as a retained text prefix. + const omitted = + "Output not retained: capture incomplete or exceeded the evidence budget.\n"; + const stdout = incomplete ? omitted : result.stdout, + stderr = incomplete ? omitted : result.stderr; const stdoutHash = await retain(`${name}.${json ? "json" : "txt"}`, stdout); const stderrHash = await retain(`${name}.stderr.txt`, stderr); report.commands.push({ @@ -192,12 +197,13 @@ export async function checkDependencySecurity( exit_code: result.exitCode, elapsed_ms: result.elapsedMs, failure: result.failure, - stdout_sha256: stdoutHash, - stderr_sha256: stderrHash, + output_retained: !incomplete, + stdout_sha256: stdoutHash.sha256, + stderr_sha256: stderrHash.sha256, }); let value = null; try { - value = oversized ? null : json ? JSON.parse(stdout) : stdout.trim(); + value = incomplete ? null : json ? JSON.parse(stdout) : stdout.trim(); } catch { /* Raw malformed evidence is retained; never interpret it as zero. */ } @@ -301,8 +307,11 @@ export async function checkDependencySecurity( const byteDifferences = differences.filter( (entry) => entry.reason === "integrity_differs", ); + const unknownIntegrities = differences.filter( + (entry) => entry.reason === "integrity_unknown", + ); const status = - graph.unknowns.length || tree.exitCode !== 0 + graph.unknowns.length || unknownIntegrities.length || tree.exitCode !== 0 ? "unavailable" : byteDifferences.length || (mode === "source" && differences.length) ? "failed" @@ -314,8 +323,11 @@ export async function checkDependencySecurity( optional_not_reported: graph.omissions.length, unknowns: graph.unknowns.length, content_graph_sha256: graph.fingerprint, - versions_outside_source_lock: differences.length - byteDifferences.length, + versions_outside_source_lock: differences.filter( + (entry) => entry.reason === "version_not_locked", + ).length, changed_locked_integrities: byteDifferences.length, + unknown_locked_integrities: unknownIntegrities.length, }; } catch (error) { report.tree = unavailable(error.message); diff --git a/tests/boundary/filesystem/dependencySecurity.test.ts b/tests/boundary/filesystem/dependencySecurity.test.ts index 9f4a4ca414..d0db6b8f88 100644 --- a/tests/boundary/filesystem/dependencySecurity.test.ts +++ b/tests/boundary/filesystem/dependencySecurity.test.ts @@ -418,6 +418,32 @@ it("does not expose a complete tree fingerprint after a failed npm tree command" }); expect(report.checks_passed).toBe(false); }); +it("keeps unbound integrity unknown instead of claiming changed bytes or a new version", async () => { + const fixture = await scanFixture(); + const original = fixture.results.get("tree"); + if (!original) throw new Error("Missing fixture tree"); + const tree = JSON.parse(original.stdout) as { + dependencies: { a: { resolved: string } }; + }; + tree.dependencies.a.resolved = + "https://registry.npmjs.org/a/-/another-source.tgz"; + fixture.results.set("tree", observation(tree)); + const report = await fixture.scan(); + expect(report.checks_passed).toBe(false); + expect(report.tree).toMatchObject({ + status: "unavailable", + changed_locked_integrities: 0, + unknown_locked_integrities: 1, + versions_outside_source_lock: 0, + content_graph_sha256: null, + }); + expect( + await readFile( + join(report.evidence_directory, "source-identity-differences.json"), + "utf8", + ), + ).toContain('"reason": "integrity_unknown"'); +}); it("bounds retained command output and marks overflow unavailable", async () => { const fixture = await scanFixture(); fixture.results.set("audit", { @@ -431,9 +457,58 @@ it("bounds retained command output and marks overflow unavailable", async () => expect(report.checks_passed).toBe(false); expect(report.advisory.status).toBe("unavailable"); expect( - (await readFile(join(report.evidence_directory, "audit.json"))).length, - ).toBe(SECURITY_OUTPUT_BYTES); + await readFile(join(report.evidence_directory, "audit.json"), "utf8"), + ).toContain("Output not retained"); }); +it.each(["stdout", "stderr"] as const)( + "does not retain credentials crossing the %s capture budget", + async (stream) => { + const fixture = await scanFixture(); + const output = observation(clean); + const partialUrl = "HTTPS://user:fixture-secret"; + output[stream] = + "x".repeat(SECURITY_OUTPUT_BYTES - partialUrl.length) + + partialUrl + + "@registry.npmjs.org/a"; + fixture.results.set("audit", output); + const report = await fixture.scan(); + expect(report.checks_passed).toBe(false); + for (const name of ["audit.json", "audit.stderr.txt"]) { + const retained = await readFile( + join(report.evidence_directory, name), + "utf8", + ); + expect(retained).not.toContain("fixture-secret"); + expect(retained).toContain("Output not retained"); + expect(Buffer.byteLength(retained)).toBeLessThan(128); + } + }, +); +it.each(["ERR_CHILD_PROCESS_STDIO_MAXBUFFER", "SIGTERM"])( + "does not retain a prefix already cut by %s", + async (failure) => { + const fixture = await scanFixture(); + fixture.results.set("audit", { + stdout: "HTTPS://user:fixture-secret", + stderr: "HTTPS://user:fixture-secret", + exitCode: null, + elapsedMs: 1, + failure, + }); + const report = await fixture.scan(); + expect(report.advisory.status).toBe("unavailable"); + expect(report.checks_passed).toBe(false); + const evidence = await readFile( + join(report.evidence_directory, "audit.json"), + "utf8", + ); + expect(evidence).toContain("Output not retained"); + expect(evidence).not.toContain("fixture-secret"); + expect( + await readFile(join(report.evidence_directory, "report.json"), "utf8"), + ).toContain('"output_retained": false'); + }, +); it("records floating consumer versions while rejecting changed bytes and unsupported registries", async () => { const floating = await ( await scanFixture({ consumer: true, changedVersion: true })