Add pre-install npm dependency guard
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
Reference in New Issue
Block a user