mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(ci): preserve incomplete dependency evidence as unavailable
This commit is contained in:
1 parent
08e8501d7b
commit
8465514782
5 files changed
+116
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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";
|
||||
})[];
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user