fix(ci): preserve incomplete dependency evidence as unavailable

This commit is contained in:
gensokyo-dev committed 2026-10-23 13:57:48 +00:00
1 parent 08e8501d7b
commit 8465514782
5 files changed
+116 -25

No files matched your search

+2
View File
@@ -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 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 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. 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. Before publishing, npm lookup failures and malformed metadata stop the job.
An existing version must match the qualified tarball's SHA-512 and intended An existing version must match the qualified tarball's SHA-512 and intended
+1 -1
View File
@@ -52,5 +52,5 @@ export function sourceIdentityDifferences(
reference: LockedPackage[], reference: LockedPackage[],
runtimeOnly: boolean, runtimeOnly: boolean,
): (InstalledPackage & { ): (InstalledPackage & {
reason: "integrity_differs" | "version_not_locked"; reason: "integrity_differs" | "integrity_unknown" | "version_not_locked";
})[]; })[];
+3 -1
View File
@@ -236,7 +236,9 @@ export function sourceIdentityDifferences(nodes, reference, runtimeOnly) {
...node, ...node,
source_identities: expected, source_identities: expected,
reason: sameVersion.length reason: sameVersion.length
? "integrity_differs" ? node.integrity === null
? "integrity_unknown"
: "integrity_differs"
: "version_not_locked", : "version_not_locked",
}, },
]; ];
+33 -21
View File
@@ -68,7 +68,7 @@ export async function checkDependencySecurity(
const retain = async (name, source) => { const retain = async (name, source) => {
const rendered = redactTransportAuthentication(source); const rendered = redactTransportAuthentication(source);
await writeFile(join(evidence, name), rendered); await writeFile(join(evidence, name), rendered);
return digest(rendered); return { sha256: digest(rendered), text: rendered };
}; };
const report = { const report = {
schema_version: 1, schema_version: 1,
@@ -100,8 +100,12 @@ export async function checkDependencySecurity(
}; };
const finish = async () => { const finish = async () => {
report.completed_at = new Date().toISOString(); report.completed_at = new Date().toISOString();
await retain("report.json", JSON.stringify(report, null, 2) + "\n"); const retained = await retain(
return report; "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 reference;
let selectedLockSha; let selectedLockSha;
@@ -126,10 +130,9 @@ export async function checkDependencySecurity(
selectedLockSha = digest(selectedBytes); selectedLockSha = digest(selectedBytes);
installedPackages = lockedPackages(JSON.parse(selectedBytes)); installedPackages = lockedPackages(JSON.parse(selectedBytes));
report.selected_lock_sha256 = selectedLockSha; report.selected_lock_sha256 = selectedLockSha;
report.retained_lock_sha256 = await retain( report.retained_lock_sha256 = (
"selected-package-lock.json", await retain("selected-package-lock.json", selectedBytes.toString("utf8"))
selectedBytes.toString("utf8"), ).sha256;
);
const product = JSON.parse( const product = JSON.parse(
await readFile( await readFile(
join( join(
@@ -173,18 +176,20 @@ export async function checkDependencySecurity(
const oversized = const oversized =
Buffer.byteLength(result.stdout) > SECURITY_OUTPUT_BYTES || Buffer.byteLength(result.stdout) > SECURITY_OUTPUT_BYTES ||
Buffer.byteLength(result.stderr) > 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.exitCode = null;
result.failure = "output_limit"; result.failure = "output_limit";
} }
const bounded = (value) => // execFile may already have cut a URL before its host/@ delimiter. An
Buffer.byteLength(value) <= SECURITY_OUTPUT_BYTES // incomplete capture cannot be safely rendered as a retained text prefix.
? value const omitted =
: Buffer.from(value) "Output not retained: capture incomplete or exceeded the evidence budget.\n";
.subarray(0, SECURITY_OUTPUT_BYTES) const stdout = incomplete ? omitted : result.stdout,
.toString("utf8"); stderr = incomplete ? omitted : result.stderr;
const stdout = bounded(result.stdout),
stderr = bounded(result.stderr);
const stdoutHash = await retain(`${name}.${json ? "json" : "txt"}`, stdout); const stdoutHash = await retain(`${name}.${json ? "json" : "txt"}`, stdout);
const stderrHash = await retain(`${name}.stderr.txt`, stderr); const stderrHash = await retain(`${name}.stderr.txt`, stderr);
report.commands.push({ report.commands.push({
@@ -192,12 +197,13 @@ export async function checkDependencySecurity(
exit_code: result.exitCode, exit_code: result.exitCode,
elapsed_ms: result.elapsedMs, elapsed_ms: result.elapsedMs,
failure: result.failure, failure: result.failure,
stdout_sha256: stdoutHash, output_retained: !incomplete,
stderr_sha256: stderrHash, stdout_sha256: stdoutHash.sha256,
stderr_sha256: stderrHash.sha256,
}); });
let value = null; let value = null;
try { try {
value = oversized ? null : json ? JSON.parse(stdout) : stdout.trim(); value = incomplete ? null : json ? JSON.parse(stdout) : stdout.trim();
} catch { } catch {
/* Raw malformed evidence is retained; never interpret it as zero. */ /* Raw malformed evidence is retained; never interpret it as zero. */
} }
@@ -301,8 +307,11 @@ export async function checkDependencySecurity(
const byteDifferences = differences.filter( const byteDifferences = differences.filter(
(entry) => entry.reason === "integrity_differs", (entry) => entry.reason === "integrity_differs",
); );
const unknownIntegrities = differences.filter(
(entry) => entry.reason === "integrity_unknown",
);
const status = const status =
graph.unknowns.length || tree.exitCode !== 0 graph.unknowns.length || unknownIntegrities.length || tree.exitCode !== 0
? "unavailable" ? "unavailable"
: byteDifferences.length || (mode === "source" && differences.length) : byteDifferences.length || (mode === "source" && differences.length)
? "failed" ? "failed"
@@ -314,8 +323,11 @@ export async function checkDependencySecurity(
optional_not_reported: graph.omissions.length, optional_not_reported: graph.omissions.length,
unknowns: graph.unknowns.length, unknowns: graph.unknowns.length,
content_graph_sha256: graph.fingerprint, 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, changed_locked_integrities: byteDifferences.length,
unknown_locked_integrities: unknownIntegrities.length,
}; };
} catch (error) { } catch (error) {
report.tree = unavailable(error.message); report.tree = unavailable(error.message);
@@ -418,6 +418,32 @@ it("does not expose a complete tree fingerprint after a failed npm tree command"
}); });
expect(report.checks_passed).toBe(false); 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 () => { it("bounds retained command output and marks overflow unavailable", async () => {
const fixture = await scanFixture(); const fixture = await scanFixture();
fixture.results.set("audit", { 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.checks_passed).toBe(false);
expect(report.advisory.status).toBe("unavailable"); expect(report.advisory.status).toBe("unavailable");
expect( expect(
(await readFile(join(report.evidence_directory, "audit.json"))).length, await readFile(join(report.evidence_directory, "audit.json"), "utf8"),
).toBe(SECURITY_OUTPUT_BYTES); ).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 () => { it("records floating consumer versions while rejecting changed bytes and unsupported registries", async () => {
const floating = await ( const floating = await (
await scanFixture({ consumer: true, changedVersion: true }) await scanFixture({ consumer: true, changedVersion: true })