Files
reverse-engineering-anything/tests/boundary/filesystem/dependencySecurity.test.ts
T

594 lines
18 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
advisoryFacts,
dependencyGraph as resolvedGraph,
registryIdentity,
signatureFacts,
} from "../../../scripts/lib/dependency-security-facts.mjs";
import {
checkDependencySecurity,
SECURITY_OUTPUT_BYTES,
} from "../../../scripts/lib/dependency-security.mjs";
import type { NpmObservation } from "../../../scripts/lib/dependency-security.mjs";
import { lockedPackages } from "../../../scripts/lib/npm-locked-packages.mjs";
import { redactTransportAuthentication } from "../../../scripts/lib/transport-authentication.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const sri = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
const registry = "https://registry.npmjs.org";
const clean = {
metadata: {
vulnerabilities: {
info: 0,
low: 0,
moderate: 0,
high: 0,
critical: 0,
total: 0,
},
},
};
const observation = (value: unknown, exitCode = 0): NpmObservation => ({
stdout: JSON.stringify(value),
stderr: "",
exitCode,
elapsedMs: 1,
failure: null,
});
it.each([
null,
{},
{
metadata: {
vulnerabilities: { ...clean.metadata.vulnerabilities, total: 1 },
},
},
{
metadata: {
vulnerabilities: { ...clean.metadata.vulnerabilities, info: -1 },
},
},
{ ...clean, error: {} },
])(
"keeps missing or contradictory advisory evidence unavailable: %j",
(value) => {
expect(advisoryFacts(value, 0)).toMatchObject({
status: "unavailable",
affected_packages: null,
});
},
);
it("distinguishes known vulnerabilities from command failure and never invents signature counts", () => {
expect(advisoryFacts(clean, 0)).toMatchObject({
status: "passed",
affected_packages: 0,
});
expect(advisoryFacts(clean, 1)).toMatchObject({ status: "unavailable" });
expect(
advisoryFacts(
{
metadata: {
vulnerabilities: {
...clean.metadata.vulnerabilities,
high: 1,
total: 1,
},
},
},
1,
),
).toMatchObject({ status: "failed", affected_packages: 1 });
expect(signatureFacts({ invalid: [], missing: [] }, 0)).toEqual({
status: "passed",
reason: null,
invalid: 0,
missing: 0,
verified: null,
});
expect(signatureFacts({ invalid: [], missing: [] }, 1).status).toBe(
"unavailable",
);
expect(signatureFacts({}, 0).status).toBe("unavailable");
expect(signatureFacts({ invalid: [{}], missing: [] }, 1).status).toBe(
"failed",
);
expect(signatureFacts({ invalid: [], missing: [{}] }, 1).status).toBe(
"failed",
);
});
it("accepts only supported registry identities and redacts only transport authentication", () => {
expect(registryIdentity(`${registry}/a/-/a-1.0.0.tgz`, sri)).toBe(true);
for (const url of [
"http://registry.npmjs.org/a",
"https://other.example/a",
"https://user:pass@registry.npmjs.org/a",
"file:///a",
])
expect(registryIdentity(url, sri)).toBe(false);
expect(registryIdentity(`${registry}/a`, "sha1-legacy")).toBe(false);
const text =
"name=secret token=caller-value /tmp/secret https://user:p%40ss@EXAMPLE.com/a?q=token#keep";
expect(redactTransportAuthentication(text)).toBe(
"name=secret token=caller-value /tmp/secret https://EXAMPLE.com/a?q=token#keep",
);
for (const scheme of ["HTTPS", "hTtPs", "HTTP"]) {
expect(
redactTransportAuthentication(
`${scheme}://user:fixture-secret@EXAMPLE.com/a?q=token#keep`,
),
).toBe(`${scheme}://EXAMPLE.com/a?q=token#keep`);
}
expect(() =>
lockedPackages({
lockfileVersion: 3,
packages: { "node_modules/../a": {} },
}),
).toThrow("path");
});
const packageNode = (directory: string, name: string, version = "1.0.0") => ({
name,
version,
path: join(directory, "node_modules", name),
integrity: sri,
resolved: `${registry}/${name}/-/${name}-${version}.tgz`,
});
const dependencyGraph = (value: unknown, directory: string) =>
resolvedGraph(
value,
directory,
lockedPackages({
lockfileVersion: 3,
packages: Object.fromEntries(
["a", "b"].map((name) => [
`node_modules/${name}`,
packageNode(directory, name),
]),
),
}),
);
it("keeps npm deduplication edges, optional omissions and stable content identities across workspaces", () => {
const make = (directory: string) => {
const a = packageNode(directory, "a");
return dependencyGraph(
{
name: "rea-agents",
version: "6.7.0",
path: directory,
dependencies: {
a,
b: {
...packageNode(directory, "b"),
peerDependenciesMeta: { optional: { optional: true } },
dependencies: { a, optional: {} },
},
},
},
directory,
);
};
const first = make("/tmp/first"),
second = make("/tmp/second");
expect(first.nodes).toHaveLength(3);
expect(first.edges).toContainEqual({
from: "node_modules/b",
name: "a",
to: "node_modules/a",
});
expect(first.omissions).toEqual([
{
from: "node_modules/b",
name: "optional",
reason: "optional_not_reported",
},
]);
expect(first.fingerprint).toBe(second.fingerprint);
expect(first.fingerprint).toMatch(/^[a-f0-9]{64}$/u);
});
it.each([
{},
{ name: "a", version: "1.0.0", path: "/outside", integrity: sri },
])("preserves unresolved installed identities as unknown: %j", (child) => {
const graph = dependencyGraph(
{
name: "rea-agents",
version: "6.7.0",
path: "/tmp/project",
dependencies: { a: child },
},
"/tmp/project",
);
expect(graph.unknowns).toHaveLength(1);
expect(graph.fingerprint).toBeNull();
});
it("rejects conflicting identities rather than choosing the last deduplicated record", () => {
const directory = "/tmp/project",
a = packageNode(directory, "a");
const graph = dependencyGraph(
{
name: "rea-agents",
version: "6.7.0",
path: directory,
dependencies: {
a,
b: {
...packageNode(directory, "b"),
dependencies: { a: { ...a, version: "2.0.0" } },
},
},
},
directory,
);
expect(graph.unknowns).toContainEqual({
id: "node_modules/a",
reason: "conflicting_package_identity",
});
expect(graph.fingerprint).toBeNull();
});
async function scanFixture(
options: {
consumer?: boolean;
changedVersion?: boolean;
changedIntegrity?: boolean;
mirror?: boolean;
authenticated?: boolean;
tarball?: boolean;
} = {},
) {
const root = await createTestTempDirectory("rea-security-evidence-");
const directory = join(root, "project");
await mkdir(directory);
const reference = {
lockfileVersion: 3,
packages: {
"": {},
"node_modules/a": {
name: "a",
version: "1.0.0",
resolved: `${registry}/a/-/a-1.0.0.tgz`,
integrity: sri,
},
},
};
const referenceLockfile = join(root, "reference.json");
await writeFile(referenceLockfile, JSON.stringify(reference));
await writeFile(
join(directory, "package-lock.json"),
JSON.stringify(reference),
);
await writeFile(
join(directory, "package.json"),
JSON.stringify({ name: "rea-agents", version: "6.7.0" }),
);
const a = packageNode(
directory,
"a",
options.changedVersion ? "2.0.0" : "1.0.0",
);
if (options.changedIntegrity)
a.integrity = `sha512-${Buffer.alloc(64, 2).toString("base64")}`;
if (options.mirror) a.resolved = "https://mirror.example/a.tgz";
if (options.authenticated)
a.resolved = `HTTPS://user:fixture-secret@registry.npmjs.org/a/-/a-${a.version}.tgz`;
const dependencies: Record<string, unknown> = { a };
let qualifiedTarball: string | undefined;
if (options.consumer) {
const product = packageNode(directory, "rea-agents", "6.7.0");
await mkdir(product.path, { recursive: true });
await writeFile(
join(product.path, "package.json"),
JSON.stringify({ name: "rea-agents", version: "6.7.0" }),
);
if (options.tarball) {
qualifiedTarball = join(root, "package.tgz");
const bytes = Buffer.from("qualified archive bytes");
await writeFile(qualifiedTarball, bytes);
product.integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
// npm retains a file specifier relative to the consumer lock directory.
product.resolved = "file:../package.tgz";
}
dependencies["rea-agents"] = product;
}
await writeFile(
join(directory, "package-lock.json"),
JSON.stringify({
lockfileVersion: 3,
packages: Object.fromEntries(
Object.entries(dependencies).map(([name, record]) => [
`node_modules/${name}`,
record,
]),
),
}),
);
const reported = structuredClone(dependencies);
if (options.authenticated) {
const selected = reported.a as { resolved?: string; integrity?: string };
delete selected.resolved;
delete selected.integrity;
}
const results = new Map([
[
"npm-version",
{
stdout: "11.16.0",
stderr: "",
exitCode: 0,
elapsedMs: 1,
failure: null,
} satisfies NpmObservation,
],
["audit", observation(clean)],
["signatures", observation({ invalid: [], missing: [] })],
[
"tree",
observation({
name: "rea-agents",
version: "6.7.0",
path: directory,
dependencies: reported,
}),
],
]);
const calls: string[][] = [];
const run = async (args: string[]) => {
calls.push(args);
const name =
args[0] === "--version"
? "npm-version"
: args[0] === "ls"
? "tree"
: args[1] === "signatures"
? "signatures"
: "audit";
const value = results.get(name);
if (!value) throw new Error(`Unexpected command ${name}`);
return { ...value };
};
const scan = () =>
checkDependencySecurity(
{
directory,
referenceLockfile,
evidenceDirectory: join(root, "evidence"),
mode: options.consumer ? "consumer" : "source",
expectedVersion: "6.7.0",
...(qualifiedTarball === undefined ? {} : { qualifiedTarball }),
environment: { REA_TEST_AMBIENT_MARKER: "do-not-persist" },
},
run,
);
return { scan, results, calls, qualifiedTarball };
}
it("retains full evidence with compact facts, fresh unique runs, hashes and no ambient environment", async () => {
const fixture = await scanFixture();
const first = await fixture.scan(),
second = await fixture.scan();
expect(first.checks_passed).toBe(true);
expect(first.signatures.verified).toBeNull();
expect(first.evidence_directory).not.toBe(second.evidence_directory);
expect(fixture.calls).toHaveLength(8);
for (const args of fixture.calls) {
expect(args).toContain("--ignore-scripts");
expect(args).toContain("--registry=https://registry.npmjs.org");
expect(args).toContain("--maxsockets=2");
}
const report = await readFile(
join(first.evidence_directory, "report.json"),
"utf8",
);
expect(report).not.toContain("do-not-persist");
expect(report).toContain("stdout_sha256");
expect(
await readFile(join(first.evidence_directory, "audit.json"), "utf8"),
).toBe(JSON.stringify(clean));
});
it.each(["audit", "signatures", "tree"])(
"fails closed for missing %s evidence without overwriting an earlier pass",
async (name) => {
const fixture = await scanFixture();
const prior = await fixture.scan();
fixture.results.set(name, {
stdout: "not JSON",
stderr: "network unavailable",
exitCode: 1,
elapsedMs: 1,
failure: "1",
});
const report = await fixture.scan();
expect(prior.checks_passed).toBe(true);
expect(report.checks_passed).toBe(false);
expect(report.evidence_directory).not.toBe(prior.evidence_directory);
},
);
it("does not expose a complete tree fingerprint after a failed npm tree command", async () => {
const fixture = await scanFixture(),
tree = fixture.results.get("tree");
if (!tree) throw new Error("Missing fixture tree");
tree.exitCode = 1;
const report = await fixture.scan();
expect(report.tree).toMatchObject({
status: "unavailable",
content_graph_sha256: null,
});
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", {
stdout: "x".repeat(SECURITY_OUTPUT_BYTES + 1),
stderr: "",
exitCode: 0,
elapsedMs: 1,
failure: null,
});
const report = await fixture.scan();
expect(report.checks_passed).toBe(false);
expect(report.advisory.status).toBe("unavailable");
expect(
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 })
).scan();
expect(floating.checks_passed).toBe(true);
expect(floating.tree.versions_outside_source_lock).toBe(1);
expect(
(await (await scanFixture({ changedVersion: true })).scan()).checks_passed,
).toBe(false);
expect(
(
await (
await scanFixture({ consumer: true, changedIntegrity: true })
).scan()
).checks_passed,
).toBe(false);
expect((await (await scanFixture({ mirror: true })).scan()).tree.status).toBe(
"unavailable",
);
});
it("binds the local consumer exception to the selected archive's SHA-512 bytes", async () => {
const fixture = await scanFixture({ consumer: true, tarball: true });
expect((await fixture.scan()).checks_passed).toBe(true);
if (!fixture.qualifiedTarball) throw new Error("Missing fixture tarball");
await writeFile(fixture.qualifiedTarball, "changed bytes");
expect((await fixture.scan()).checks_passed).toBe(false);
});
it("redacts raw lock credentials from every retained partial consumer representation", async () => {
const fixture = await scanFixture({
consumer: true,
authenticated: true,
changedVersion: true,
});
const report = await fixture.scan();
expect(report.checks_passed).toBe(false);
expect(report.tree.status).toBe("unavailable");
for (const name of [
"selected-package-lock.json",
"tree-facts.json",
"source-identity-differences.json",
"report.json",
"installed-tree.json",
]) {
const retained = await readFile(
join(report.evidence_directory, name),
"utf8",
);
expect(retained).not.toContain("fixture-secret");
if (
[
"selected-package-lock.json",
"tree-facts.json",
"source-identity-differences.json",
].includes(name)
)
expect(retained).toContain("HTTPS://registry.npmjs.org/a/-/a-2.0.0.tgz");
}
});
it("uses the selected lock for declared integrity when npm reads uncached package metadata", () => {
const directory = "/tmp/project";
const complete = packageNode(directory, "a");
const { integrity: _reported, resolved: _origin, ...uncached } = complete;
const root = (child: unknown) => ({
name: "rea-agents",
version: "6.7.0",
path: directory,
dependencies: { a: child },
});
const fromCache = dependencyGraph(root(complete), directory);
const fromPackages = dependencyGraph(root(uncached), directory);
expect(fromPackages.unknowns).toEqual([]);
expect(fromPackages.fingerprint).toBe(fromCache.fingerprint);
expect(fromPackages.nodes.find((node) => node.name === "a")?.integrity).toBe(
sri,
);
expect(
dependencyGraph(root({ ...uncached, version: "9.0.0" }), directory)
.fingerprint,
).toBeNull();
});