mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
353 lines
12 KiB
JavaScript
353 lines
12 KiB
JavaScript
import { redactTransportAuthentication } from "./transport-authentication.mjs";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtemp, mkdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
import { join, resolve, sep } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { verifyPublishedPackageIntegrity } from "../ci/qualified-package-envelope.mjs";
|
|
import { lockedPackages } from "./npm-locked-packages.mjs";
|
|
import {
|
|
advisoryFacts,
|
|
signatureFacts,
|
|
dependencyGraph,
|
|
sourceIdentityDifferences,
|
|
registryIdentity,
|
|
requireRegistryLock,
|
|
} from "./dependency-security-facts.mjs";
|
|
|
|
import { execNpm } from "./npm-command.mjs";
|
|
// CI evidence budget, not a package-format limit. Overflow is unavailable evidence.
|
|
export const SECURITY_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
const digest = (text) => createHash("sha256").update(text).digest("hex");
|
|
const unavailable = (reason) => ({ status: "unavailable", reason });
|
|
|
|
async function executeNpm(args, options) {
|
|
const started = performance.now();
|
|
try {
|
|
const result = await execNpm(args, {
|
|
...options,
|
|
encoding: "utf8",
|
|
maxBuffer: SECURITY_OUTPUT_BYTES,
|
|
timeout: 180_000,
|
|
});
|
|
return {
|
|
...result,
|
|
exitCode: 0,
|
|
elapsedMs: Math.round(performance.now() - started),
|
|
failure: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
stdout: error.stdout ?? "",
|
|
stderr: error.stderr ?? error.message,
|
|
exitCode: typeof error.code === "number" ? error.code : null,
|
|
elapsedMs: Math.round(performance.now() - started),
|
|
failure: String(error.code ?? error.signal ?? "command_failed"),
|
|
};
|
|
}
|
|
}
|
|
|
|
/** One scan implementation for CI source locks and the actual installed release consumer. */
|
|
export async function checkDependencySecurity(
|
|
{
|
|
directory,
|
|
evidenceDirectory,
|
|
referenceLockfile,
|
|
mode = "source",
|
|
sourceSha = null,
|
|
expectedVersion = null,
|
|
qualifiedTarball = null,
|
|
environment = process.env,
|
|
},
|
|
run = executeNpm,
|
|
) {
|
|
directory = resolve(directory);
|
|
await mkdir(evidenceDirectory, { recursive: true, mode: 0o700 });
|
|
const evidence = await mkdtemp(join(resolve(evidenceDirectory), "run-"));
|
|
// Keep parsed facts intact. Every retained representation crosses this one
|
|
// authentication-redaction boundary, including partial/failed graph facts.
|
|
const retain = async (name, source) => {
|
|
const rendered = redactTransportAuthentication(source);
|
|
await writeFile(join(evidence, name), rendered);
|
|
return { sha256: digest(rendered), text: rendered };
|
|
};
|
|
const report = {
|
|
schema_version: 1,
|
|
checks_passed: false,
|
|
mode,
|
|
started_at: new Date().toISOString(),
|
|
source_sha: sourceSha,
|
|
node_version: process.version,
|
|
npm_version: null,
|
|
platform: process.platform,
|
|
architecture: process.arch,
|
|
evidence_directory: evidence,
|
|
reference_lock_sha256: null,
|
|
advisory: unavailable("not_observed"),
|
|
signatures: { ...unavailable("not_observed"), verified: null },
|
|
tree: unavailable("not_observed"),
|
|
coverage: {
|
|
advisory: "selected project lock",
|
|
signatures:
|
|
"npm-supported installed registry dependencies on this platform; verified count not exposed",
|
|
graph:
|
|
"npm-reported installed identities/relationships bound to selected-lock declared origins/integrities; optional omissions retained; installed files not rehashed",
|
|
consumer_policy:
|
|
mode === "consumer"
|
|
? "semver resolution; source lock does not pin consumers"
|
|
: null,
|
|
},
|
|
commands: [],
|
|
};
|
|
const finish = async () => {
|
|
report.completed_at = new Date().toISOString();
|
|
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;
|
|
let installedPackages;
|
|
try {
|
|
const lockStat = await stat(referenceLockfile);
|
|
if (!lockStat.isFile() || lockStat.size > SECURITY_OUTPUT_BYTES)
|
|
throw new Error(
|
|
"Reference lock exceeds CI evidence budget or is not a file",
|
|
);
|
|
const bytes = await readFile(referenceLockfile);
|
|
report.reference_lock_sha256 = digest(bytes);
|
|
reference = lockedPackages(JSON.parse(bytes));
|
|
requireRegistryLock(reference);
|
|
if (!["source", "consumer"].includes(mode))
|
|
throw new Error("Unknown dependency security mode");
|
|
const localLock = join(directory, "package-lock.json");
|
|
const localStat = await stat(localLock);
|
|
if (!localStat.isFile() || localStat.size > SECURITY_OUTPUT_BYTES)
|
|
throw new Error("Selected project has no consumer lock");
|
|
const selectedBytes = await readFile(localLock);
|
|
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"))
|
|
).sha256;
|
|
const product = JSON.parse(
|
|
await readFile(
|
|
join(
|
|
directory,
|
|
...(mode === "consumer" ? ["node_modules", "rea-agents"] : []),
|
|
"package.json",
|
|
),
|
|
"utf8",
|
|
),
|
|
);
|
|
if (
|
|
product.name !== "rea-agents" ||
|
|
typeof product.version !== "string" ||
|
|
(expectedVersion !== null && product.version !== expectedVersion)
|
|
)
|
|
throw new Error(
|
|
"Installed REA identity differs from the selected release",
|
|
);
|
|
report.product = { name: product.name, version: product.version };
|
|
} catch (error) {
|
|
report.precondition = unavailable(error.message);
|
|
return finish();
|
|
}
|
|
const userconfig = join(evidence, ".empty-user.npmrc"),
|
|
globalconfig = join(evidence, ".empty-global.npmrc");
|
|
await writeFile(userconfig, "", { mode: 0o600 });
|
|
await writeFile(globalconfig, "", { mode: 0o600 });
|
|
const common = [
|
|
"--ignore-scripts",
|
|
"--registry=https://registry.npmjs.org",
|
|
"--maxsockets=2",
|
|
"--prefer-online",
|
|
`--userconfig=${userconfig}`,
|
|
`--globalconfig=${globalconfig}`,
|
|
];
|
|
const collect = async (name, args, json = true) => {
|
|
const result = await run([...args, ...common], {
|
|
cwd: directory,
|
|
env: environment,
|
|
});
|
|
const oversized =
|
|
Buffer.byteLength(result.stdout) > SECURITY_OUTPUT_BYTES ||
|
|
Buffer.byteLength(result.stderr) > SECURITY_OUTPUT_BYTES;
|
|
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";
|
|
}
|
|
// 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({
|
|
name,
|
|
exit_code: result.exitCode,
|
|
elapsed_ms: result.elapsedMs,
|
|
failure: result.failure,
|
|
output_retained: !incomplete,
|
|
stdout_sha256: stdoutHash.sha256,
|
|
stderr_sha256: stderrHash.sha256,
|
|
});
|
|
let value = null;
|
|
try {
|
|
value = incomplete ? null : json ? JSON.parse(stdout) : stdout.trim();
|
|
} catch {
|
|
/* Raw malformed evidence is retained; never interpret it as zero. */
|
|
}
|
|
return { value, exitCode: result.exitCode };
|
|
};
|
|
const version = await collect("npm-version", ["--version"], false);
|
|
if (
|
|
version.exitCode !== 0 ||
|
|
typeof version.value !== "string" ||
|
|
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/u.test(version.value)
|
|
) {
|
|
report.precondition = unavailable("npm_toolchain_unavailable");
|
|
return finish();
|
|
}
|
|
report.npm_version = version.value;
|
|
const audit = await collect("audit", [
|
|
"audit",
|
|
"--package-lock-only",
|
|
"--include=dev",
|
|
"--include=optional",
|
|
"--json",
|
|
]);
|
|
report.advisory = advisoryFacts(audit.value, audit.exitCode);
|
|
const signatures = await collect("signatures", [
|
|
"audit",
|
|
"signatures",
|
|
"--include=dev",
|
|
"--include=optional",
|
|
"--json",
|
|
]);
|
|
report.signatures = signatureFacts(signatures.value, signatures.exitCode);
|
|
const tree = await collect("installed-tree", [
|
|
"ls",
|
|
"--all",
|
|
"--include=dev",
|
|
"--include=optional",
|
|
"--json",
|
|
"--long",
|
|
]);
|
|
try {
|
|
const graph = dependencyGraph(tree.value, directory, installedPackages);
|
|
if (
|
|
graph.nodes.length < 2 ||
|
|
(mode === "source" &&
|
|
!graph.nodes.some(
|
|
(node) =>
|
|
node.id === "." &&
|
|
node.name === report.product.name &&
|
|
node.version === report.product.version,
|
|
)) ||
|
|
(mode === "consumer" &&
|
|
!graph.nodes.some(
|
|
(node) =>
|
|
node.id === "node_modules/rea-agents" &&
|
|
node.name === "rea-agents" &&
|
|
node.version === report.product.version,
|
|
))
|
|
)
|
|
throw new Error("Installed REA dependency tree is incomplete");
|
|
if (tree.exitCode !== 0) graph.fingerprint = null;
|
|
for (const node of graph.nodes.filter((node) => node.id !== ".")) {
|
|
if (
|
|
mode === "consumer" &&
|
|
node.id === "node_modules/rea-agents" &&
|
|
qualifiedTarball !== null
|
|
) {
|
|
if (
|
|
node.name !== "rea-agents" ||
|
|
node.version !== report.product.version ||
|
|
typeof node.source_url !== "string" ||
|
|
fileURLToPath(
|
|
new URL(node.source_url, pathToFileURL(directory + sep)),
|
|
) !== resolve(qualifiedTarball)
|
|
)
|
|
throw new Error(
|
|
"Consumer archive identity differs from the selected qualified tarball",
|
|
);
|
|
await verifyPublishedPackageIntegrity(qualifiedTarball, node.integrity);
|
|
report.qualified_archive = {
|
|
path: resolve(qualifiedTarball),
|
|
integrity: node.integrity,
|
|
};
|
|
} else if (!registryIdentity(node.source_url, node.integrity)) {
|
|
graph.unknowns.push({
|
|
id: node.id,
|
|
reason: "unsupported_registry_or_integrity",
|
|
});
|
|
}
|
|
}
|
|
if (graph.unknowns.length) graph.fingerprint = null;
|
|
const differences = sourceIdentityDifferences(
|
|
graph.nodes,
|
|
reference,
|
|
mode === "consumer",
|
|
);
|
|
await retain("tree-facts.json", JSON.stringify(graph, null, 2) + "\n");
|
|
await retain(
|
|
"source-identity-differences.json",
|
|
JSON.stringify(differences, null, 2) + "\n",
|
|
);
|
|
const byteDifferences = differences.filter(
|
|
(entry) => entry.reason === "integrity_differs",
|
|
);
|
|
const unknownIntegrities = differences.filter(
|
|
(entry) => entry.reason === "integrity_unknown",
|
|
);
|
|
const status =
|
|
graph.unknowns.length || unknownIntegrities.length || tree.exitCode !== 0
|
|
? "unavailable"
|
|
: byteDifferences.length || (mode === "source" && differences.length)
|
|
? "failed"
|
|
: "passed";
|
|
report.tree = {
|
|
status,
|
|
nodes: graph.nodes.length,
|
|
edges: graph.edges.length,
|
|
optional_not_reported: graph.omissions.length,
|
|
unknowns: graph.unknowns.length,
|
|
content_graph_sha256: graph.fingerprint,
|
|
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);
|
|
}
|
|
report.checks_passed = [
|
|
report.advisory,
|
|
report.signatures,
|
|
report.tree,
|
|
].every((value) => value.status === "passed");
|
|
try {
|
|
const lock = join(directory, "package-lock.json");
|
|
if (
|
|
(await stat(lock)).size > SECURITY_OUTPUT_BYTES ||
|
|
digest(await readFile(lock)) !== selectedLockSha
|
|
)
|
|
throw new Error("selected_lock_changed_during_scan");
|
|
} catch (error) {
|
|
report.checks_passed = false;
|
|
report.precondition = unavailable(error.message);
|
|
}
|
|
return finish();
|
|
}
|