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
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
+1 -1
View File
@@ -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";
})[];
+3 -1
View File
@@ -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",
},
];
+33 -21
View File
@@ -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);
@@ -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 })