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
|
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
|
||||||
|
|||||||
@@ -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";
|
||||||
})[];
|
})[];
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in new issue
Block a user