Add pre-install npm dependency guard

This commit is contained in:
2026-08-27 12:46:55 +01:00
parent 7574b31b8c
commit f28b9ee87d
22 changed files with 1321 additions and 137 deletions
+84
View File
@@ -0,0 +1,84 @@
import test from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { readFile } from "node:fs/promises";
import { checkProject } from "../lib/check.js";
import { DEFAULT_POLICY } from "../lib/constants.js";
import { jsonResponse, makeProject, packageDocument, passingRun } from "./helpers.js";
test("check runs only safe npm commands in the required order and writes evidence", async () => {
const directory = await makeProject();
const reportPath = join(directory, "evidence", "report.json");
const calls = [];
const document = packageDocument();
const gitHead = document.versions["1.2.3"].gitHead;
const fetch = async (url) => {
if (url.startsWith("https://registry.npmjs.org/alpha")) return jsonResponse(document);
throw new Error(`unexpected URL ${url}`);
};
const report = await checkProject({ path: directory, report: reportPath, policy: structuredClone(DEFAULT_POLICY) }, {
fetch,
remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }],
run: passingRun(calls),
now: () => Date.parse("2025-01-01T00:00:00Z"),
});
assert.equal(report.status, "pass");
assert.deepEqual(calls.map((call) => call.slice(0, 3)), [
["npm", "pack", "alpha@1.2.3"],
["npm", "ci", "--ignore-scripts"],
["npm", "audit", "signatures"],
["npm", "audit", "--omit=dev"],
]);
const saved = JSON.parse(await readFile(reportPath, "utf8"));
assert.equal(saved.commands.length, 4);
assert.equal(saved.status, "pass");
});
test("check records npm pack size violations without installing", async () => {
const directory = await makeProject();
const reportPath = join(directory, "report.json");
const document = packageDocument();
const gitHead = document.versions["1.2.3"].gitHead;
const report = await checkProject({ path: directory, report: reportPath, policy: { ...structuredClone(DEFAULT_POLICY), maxTarballSizeBytes: 10 }, skipInstall: true }, {
fetch: async (url) => {
if (url.startsWith("https://registry.npmjs.org/alpha")) return jsonResponse(document);
throw new Error(`unexpected URL ${url}`);
},
remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }],
run: passingRun(),
now: () => Date.parse("2025-01-01T00:00:00Z"),
});
assert.equal(report.status, "fail");
assert.ok(report.failures.some((message) => message.includes("tarball size")));
assert.equal(report.commands.length, 1);
});
test("check parses full npm pack JSON while truncating report evidence", async () => {
const directory = await makeProject();
const reportPath = join(directory, "report.json");
const document = packageDocument();
const gitHead = document.versions["1.2.3"].gitHead;
const packOutput = JSON.stringify([{
size: 100,
unpackedSize: 500,
files: Array.from({ length: 2_000 }, (_, index) => ({ path: `dist/file-${index}.js`, size: 1 })),
}]);
assert.ok(packOutput.length > 20_000);
const report = await checkProject({ path: directory, report: reportPath, policy: structuredClone(DEFAULT_POLICY), skipInstall: true }, {
fetch: async (url) => {
if (url.startsWith("https://registry.npmjs.org/alpha")) return jsonResponse(document);
throw new Error(`unexpected URL ${url}`);
},
remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }],
run: async (_command, _args, options) => {
assert.equal(options.maxOutputBytes, 25_000_000);
return { code: 0, stdout: packOutput, stderr: "", timedOut: false };
},
now: () => Date.parse("2025-01-01T00:00:00Z"),
});
assert.equal(report.status, "pass");
assert.equal(report.packages[0].tarballSizeBytes, 100);
assert.equal(report.commands[0].stdout.length, 20_000);
});
+59
View File
@@ -0,0 +1,59 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
export async function makeProject(overrides = {}) {
const directory = await mkdtemp(join(tmpdir(), "dependency-guard-test-"));
const manifest = overrides.manifest ?? {
name: "fixture",
version: "1.0.0",
dependencies: { alpha: "^1.0.0" },
};
const alphaEntry = {
version: "1.2.3",
resolved: "https://registry.npmjs.org/alpha/-/alpha-1.2.3.tgz",
integrity: `sha512-${Buffer.from("integrity").toString("base64")}`,
...overrides.alphaEntry,
};
const lock = overrides.lock ?? {
name: "fixture",
version: "1.0.0",
lockfileVersion: 3,
packages: { "": { name: "fixture", version: "1.0.0" }, "node_modules/alpha": alphaEntry },
};
await writeFile(join(directory, "package.json"), JSON.stringify(manifest));
await writeFile(join(directory, "package-lock.json"), JSON.stringify(lock));
return directory;
}
export function packageDocument(overrides = {}) {
const version = {
name: "alpha",
version: "1.2.3",
repository: { type: "git", url: "git+https://github.com/example/alpha.git" },
maintainers: [{ name: "alice" }],
dist: { signatures: [{ keyid: "SHA256:key", sig: "signature" }] },
gitHead: "abcdef1234567890abcdef1234567890abcdef12",
...overrides.version,
};
version.dist = { signatures: [{ keyid: "SHA256:key", sig: "signature" }], ...overrides.version?.dist };
return {
name: "alpha",
maintainers: [{ name: "alice" }],
time: { "1.2.3": "2024-01-01T00:00:00.000Z" },
versions: { "1.2.3": version },
...overrides.document,
};
}
export function jsonResponse(body, status = 200) {
return { ok: status >= 200 && status < 300, status, json: async () => body };
}
export function passingRun(calls = []) {
return async (command, args) => {
calls.push([command, ...args]);
if (args[0] === "pack") return { code: 0, stdout: JSON.stringify([{ size: 100, unpackedSize: 500 }]), stderr: "", timedOut: false };
return { code: 0, stdout: "ok", stderr: "", timedOut: false };
};
}
+24
View File
@@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { readFile } from "node:fs/promises";
import { initPolicy } from "../lib/init.js";
import { jsonResponse, makeProject, packageDocument } from "./helpers.js";
test("init pins repository and maintainers from the exact locked version", async () => {
const directory = await makeProject();
const output = join(directory, "policy.json");
const result = await initPolicy({ path: directory, output }, { fetch: async () => jsonResponse(packageDocument()) });
assert.equal(result.policy.trustedRepositories.alpha, "https://github.com/example/alpha");
assert.deepEqual(result.policy.trustedMaintainers.alpha, ["alice"]);
assert.deepEqual(JSON.parse(await readFile(output, "utf8")), result.policy);
});
test("init does not trust a package without a GitHub repository", async () => {
const directory = await makeProject();
const output = join(directory, "policy.json");
const document = packageDocument({ version: { repository: "https://gitlab.com/example/alpha" } });
const result = await initPolicy({ path: directory, output }, { fetch: async () => jsonResponse(document) });
assert.equal(result.policy.trustedRepositories.alpha, undefined);
assert.match(result.skipped[0], /not trusted/);
});
+35
View File
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { inspectLock, readProject } from "../lib/project.js";
import { DEFAULT_POLICY } from "../lib/constants.js";
import { makeProject } from "./helpers.js";
test("resolves exact direct dependency versions from a v3 lockfile", async () => {
const directory = await makeProject();
const project = await readProject(directory);
assert.deepEqual(project.direct, [{ name: "alpha", version: "1.2.3", dev: false }]);
});
test("lock inspection rejects unexpected install scripts and missing integrity", async () => {
const directory = await makeProject({ alphaEntry: { hasInstallScript: true, integrity: undefined } });
const project = await readProject(directory);
const result = inspectLock(project.lock, structuredClone(DEFAULT_POLICY));
assert.equal(result.failures.length, 2);
assert.match(result.failures[0], /hasInstallScript/);
assert.match(result.failures[1], /integrity/);
});
test("default lock allowlist permits fsevents install script", () => {
const lock = {
packages: {
"": {},
"node_modules/fsevents": {
version: "2.3.3",
resolved: "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
integrity: `sha512-${Buffer.from("value").toString("base64")}`,
hasInstallScript: true,
},
},
};
assert.deepEqual(inspectLock(lock, structuredClone(DEFAULT_POLICY)).failures, []);
});
+155
View File
@@ -0,0 +1,155 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DEFAULT_POLICY } from "../lib/constants.js";
import { verifyDirectPackage } from "../lib/verify.js";
import { jsonResponse, packageDocument } from "./helpers.js";
const item = { name: "alpha", version: "1.2.3", dev: false };
const now = () => Date.parse("2025-01-01T00:00:00Z");
test("verifies SLSA source repository, tag ref, and existing commit", async () => {
const commit = "abcdef1234567890abcdef1234567890abcdef12";
const statement = {
predicate: {
buildDefinition: {
externalParameters: { workflow: { repository: "https://github.com/example/alpha", ref: "refs/tags/v1.2.3" } },
resolvedDependencies: [{ uri: "git+https://github.com/example/alpha@refs/tags/v1.2.3", digest: { gitCommit: commit } }],
},
},
};
const document = packageDocument({ version: { dist: {
signatures: [{ keyid: "key", sig: "sig" }],
attestations: {
url: "https://registry.npmjs.org/-/npm/v1/attestations/alpha@1.2.3",
provenance: { predicateType: "https://slsa.dev/provenance/v1" },
},
} } });
const fetch = async (url) => {
if (url.includes("attestations")) return jsonResponse({ attestations: [{ predicateType: "https://slsa.dev/provenance/v1", bundle: { dsseEnvelope: { payload: Buffer.from(JSON.stringify(statement)).toString("base64") } } }] });
throw new Error(`unexpected URL ${url}`);
};
const result = await verifyDirectPackage(item, document, structuredClone(DEFAULT_POLICY), {
fetchImpl: fetch,
remoteRefs: async () => [{ sha: commit, ref: "refs/tags/v1.2.3" }],
now,
timeoutMs: 100,
});
assert.deepEqual(result.failures, []);
assert.equal(result.provenance, "slsa");
});
test("accepts branch-built provenance only when a version tag pins its commit", async () => {
const commit = "abcdef1234567890abcdef1234567890abcdef12";
const statement = {
predicate: {
buildDefinition: {
externalParameters: { workflow: { repository: "https://github.com/example/alpha", ref: "refs/heads/main" } },
resolvedDependencies: [{ uri: "git+https://github.com/example/alpha@refs/heads/main", digest: { gitCommit: commit } }],
},
},
};
const document = packageDocument({ version: { dist: {
signatures: [{ keyid: "key", sig: "sig" }],
attestations: {
url: "https://registry.npmjs.org/-/npm/v1/attestations/alpha@1.2.3",
provenance: { predicateType: "https://slsa.dev/provenance/v1" },
},
} } });
const result = await verifyDirectPackage(item, document, structuredClone(DEFAULT_POLICY), {
fetchImpl: async () => jsonResponse({ attestations: [{ predicateType: "https://slsa.dev/provenance/v1", bundle: { dsseEnvelope: { payload: Buffer.from(JSON.stringify(statement)).toString("base64") } } }] }),
remoteRefs: async () => [{ sha: commit, ref: "refs/tags/v1.2.3" }],
now,
timeoutMs: 100,
});
assert.deepEqual(result.failures, []);
});
test("rejects branch-built provenance when no version tag pins its commit", async () => {
const commit = "abcdef1234567890abcdef1234567890abcdef12";
const statement = {
predicate: {
buildDefinition: {
externalParameters: { workflow: { repository: "https://github.com/example/alpha", ref: "refs/heads/main" } },
resolvedDependencies: [{ uri: "git+https://github.com/example/alpha@refs/heads/main", digest: { gitCommit: commit } }],
},
},
};
const document = packageDocument({ version: { dist: {
signatures: [{ keyid: "key", sig: "sig" }],
attestations: {
url: "https://registry.npmjs.org/-/npm/v1/attestations/alpha@1.2.3",
provenance: { predicateType: "https://slsa.dev/provenance/v1" },
},
} } });
const result = await verifyDirectPackage(item, document, structuredClone(DEFAULT_POLICY), {
fetchImpl: async () => jsonResponse({ attestations: [{ predicateType: "https://slsa.dev/provenance/v1", bundle: { dsseEnvelope: { payload: Buffer.from(JSON.stringify(statement)).toString("base64") } } }] }),
remoteRefs: async () => [],
now,
timeoutMs: 100,
});
assert.ok(result.failures.some((message) => message.includes("no Git tag containing 1.2.3")));
});
test("fallback accepts an annotated version tag dereferenced to gitHead", async () => {
const document = packageDocument();
const gitHead = document.versions["1.2.3"].gitHead;
const result = await verifyDirectPackage(item, document, structuredClone(DEFAULT_POLICY), {
remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3^{}" }],
now,
timeoutMs: 100,
});
assert.deepEqual(result.failures, []);
assert.equal(result.provenance, "gitHead-tag");
});
test("rejects direct lifecycle scripts and untrusted maintainers", async () => {
const document = packageDocument({ version: { scripts: { postinstall: "node setup.js" }, maintainers: [{ name: "mallory" }] } });
const policy = structuredClone(DEFAULT_POLICY);
policy.trustedMaintainers.alpha = ["alice"];
const result = await verifyDirectPackage(item, document, policy, {
remoteRefs: async () => [{ sha: document.versions["1.2.3"].gitHead, ref: "refs/tags/alpha-1.2.3" }],
now,
timeoutMs: 100,
});
assert.ok(result.failures.some((message) => message.includes("lifecycle hook")));
assert.ok(result.failures.some((message) => message.includes("registry maintainer set changed")));
});
test("rejects a missing previously trusted maintainer", async () => {
const document = packageDocument({ version: { maintainers: [{ name: "alice" }] } });
const policy = structuredClone(DEFAULT_POLICY);
policy.trustedMaintainers.alpha = ["alice", "bob"];
const gitHead = document.versions["1.2.3"].gitHead;
const result = await verifyDirectPackage(item, document, policy, {
remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }],
now,
timeoutMs: 100,
});
assert.ok(result.failures.some((message) => message.includes("registry maintainer set changed")));
});
test("accepts only a version-pinned unexpired source evidence exception", async () => {
const document = packageDocument({ version: { gitHead: null } });
const policy = structuredClone(DEFAULT_POLICY);
policy.sourceEvidenceExceptions.alpha = {
version: "1.2.3",
reason: "Upstream does not publish version tags for this package.",
expiresAt: "2025-02-01T00:00:00Z",
};
const result = await verifyDirectPackage(item, document, policy, { now, timeoutMs: 100 });
assert.deepEqual(result.failures, []);
assert.equal(result.provenance, "policy-exception");
assert.ok(result.warnings.some((message) => message.includes("reviewed sourceEvidenceExceptions")));
});
test("rejects an expired source evidence exception", async () => {
const document = packageDocument({ version: { gitHead: null } });
const policy = structuredClone(DEFAULT_POLICY);
policy.sourceEvidenceExceptions.alpha = {
version: "1.2.3",
reason: "Upstream does not publish version tags for this package.",
expiresAt: "2024-12-31T00:00:00Z",
};
const result = await verifyDirectPackage(item, document, policy, { now, timeoutMs: 100 });
assert.ok(result.failures.some((message) => message.includes("gitHead is missing")));
});