Pin dependency policy exceptions to versions

This commit is contained in:
2026-08-27 12:51:53 +01:00
parent f28b9ee87d
commit 8098495abf
10 changed files with 125 additions and 27 deletions
+18 -9
View File
@@ -95,8 +95,15 @@ runner without placing them in the policy or report.
```json ```json
{ {
"allowInstallScripts": ["fsevents"], "allowInstallScripts": {
"allowDirectLifecycleScripts": [], "fsevents": {
"versions": ["2.3.3"],
"reason": "Reviewed optional native filesystem watcher install script.",
"expiresAt": "2027-01-01T00:00:00Z"
}
},
"allowDirectLifecycleScripts": {},
"archiveSizeExceptions": {},
"sourceEvidenceExceptions": {}, "sourceEvidenceExceptions": {},
"trustedRepositories": { "trustedRepositories": {
"example-package": "https://github.com/example/example-package" "example-package": "https://github.com/example/example-package"
@@ -111,10 +118,11 @@ runner without placing them in the policy or report.
} }
``` ```
- `allowInstallScripts` permits `hasInstallScript` on any matching lock entry. The - `allowInstallScripts` permits `hasInstallScript` only for listed exact versions.
default contains only `fsevents`. - `allowDirectLifecycleScripts` separately permits exact direct package versions
- `allowDirectLifecycleScripts` separately permits direct packages whose registry whose registry manifest declares `preinstall`, `install`, or `postinstall`.
manifest declares `preinstall`, `install`, or `postinstall`. - `archiveSizeExceptions` permits exact package versions to use reviewed archive
limits above the project defaults.
- `sourceEvidenceExceptions` permits only an exact package version to proceed when - `sourceEvidenceExceptions` permits only an exact package version to proceed when
source provenance or tag evidence is unavailable. Each entry requires a detailed source provenance or tag evidence is unavailable. Each entry requires a detailed
`reason` and an `expiresAt` ISO date, and produces a warning. `reason` and an `expiresAt` ISO date, and produces a warning.
@@ -129,7 +137,8 @@ Unknown policy fields and malformed values fail closed.
## Exceptions ## Exceptions
Treat allowlists as reviewed security exceptions: Treat exception maps as reviewed security exceptions. Lifecycle and archive entries
require exact versions, a detailed reason, and an expiry:
1. Confirm the exact package and locked version. 1. Confirm the exact package and locked version.
2. Inspect the lifecycle script and every executable or downloaded artifact it uses. 2. Inspect the lifecycle script and every executable or downloaded artifact it uses.
@@ -138,10 +147,10 @@ Treat allowlists as reviewed security exceptions:
5. Record the rationale and reviewer beside the policy change in the pull request. 5. Record the rationale and reviewer beside the policy change in the pull request.
6. Remove the exception when the package or dependency is removed. 6. Remove the exception when the package or dependency is removed.
Do not add a package to both lifecycle allowlists automatically. A transitive Do not add a package to both lifecycle exception maps automatically. A transitive
`hasInstallScript` exception and a direct manifest-hook exception represent separate `hasInstallScript` exception and a direct manifest-hook exception represent separate
trust decisions. Scripts remain disabled during the guard's own `npm ci`; an trust decisions. Scripts remain disabled during the guard's own `npm ci`; an
allowlist permits review to pass but does not execute the script. exception permits review to pass but does not execute the script.
## Evidence report ## Evidence report
+2 -2
View File
@@ -32,8 +32,8 @@ Node.js 20 and newer are required.
- Do not write credentials, authorization headers, environment contents, metadata - Do not write credentials, authorization headers, environment contents, metadata
response bodies, or attestation bundles to reports. response bodies, or attestation bundles to reports.
- Do not weaken repository, commit, tag, signature, integrity, or lifecycle checks to - Do not weaken repository, commit, tag, signature, integrity, or lifecycle checks to
accommodate a package. Require an explicit, reviewed policy exception where one is accommodate a package. Require an explicit, version-pinned, expiring policy
supported. exception where one is supported.
## Disclosure ## Disclosure
+7 -3
View File
@@ -30,7 +30,7 @@ export async function checkProject(options, injected = {}) {
try { try {
const project = await readProject(projectPath); const project = await readProject(projectPath);
const lockInspection = inspectLock(project.lock, options.policy); const lockInspection = inspectLock(project.lock, options.policy, dependencies.now());
report.lockfile = { lockfileVersion: project.lock.lockfileVersion, registryEntries: lockInspection.registryEntries }; report.lockfile = { lockfileVersion: project.lock.lockfileVersion, registryEntries: lockInspection.registryEntries };
report.failures.push(...lockInspection.failures.map((message) => `lockfile: ${message}`)); report.failures.push(...lockInspection.failures.map((message) => `lockfile: ${message}`));
@@ -47,8 +47,12 @@ export async function checkProject(options, injected = {}) {
const packed = JSON.parse(pack.result.stdout)[0]; const packed = JSON.parse(pack.result.stdout)[0];
packageResult.tarballSizeBytes = packed.size; packageResult.tarballSizeBytes = packed.size;
packageResult.unpackedSizeBytes = packed.unpackedSize; packageResult.unpackedSizeBytes = packed.unpackedSize;
if (!Number.isFinite(packed.size) || packed.size > options.policy.maxTarballSizeBytes) packageResult.failures.push(`tarball size ${packed.size ?? "unknown"} exceeds ${options.policy.maxTarballSizeBytes}`); const archiveException = options.policy.archiveSizeExceptions[item.name];
if (!Number.isFinite(packed.unpackedSize) || packed.unpackedSize > options.policy.maxUnpackedSizeBytes) packageResult.failures.push(`unpacked size ${packed.unpackedSize ?? "unknown"} exceeds ${options.policy.maxUnpackedSizeBytes}`); const archiveExceptionValid = archiveException?.version === item.version && Date.parse(archiveException.expiresAt) > dependencies.now();
const tarballLimit = archiveExceptionValid ? archiveException.maxTarballSizeBytes ?? options.policy.maxTarballSizeBytes : options.policy.maxTarballSizeBytes;
const unpackedLimit = archiveExceptionValid ? archiveException.maxUnpackedSizeBytes ?? options.policy.maxUnpackedSizeBytes : options.policy.maxUnpackedSizeBytes;
if (!Number.isFinite(packed.size) || packed.size > tarballLimit) packageResult.failures.push(`tarball size ${packed.size ?? "unknown"} exceeds ${tarballLimit}`);
if (!Number.isFinite(packed.unpackedSize) || packed.unpackedSize > unpackedLimit) packageResult.failures.push(`unpacked size ${packed.unpackedSize ?? "unknown"} exceeds ${unpackedLimit}`);
} catch { } catch {
packageResult.failures.push("npm pack returned invalid JSON evidence"); packageResult.failures.push("npm pack returned invalid JSON evidence");
} }
+3 -2
View File
@@ -1,6 +1,7 @@
export const DEFAULT_POLICY = Object.freeze({ export const DEFAULT_POLICY = Object.freeze({
allowInstallScripts: ["fsevents"], allowInstallScripts: {},
allowDirectLifecycleScripts: [], allowDirectLifecycleScripts: {},
archiveSizeExceptions: {},
sourceEvidenceExceptions: {}, sourceEvidenceExceptions: {},
trustedRepositories: {}, trustedRepositories: {},
trustedMaintainers: {}, trustedMaintainers: {},
+37 -5
View File
@@ -19,8 +19,9 @@ export async function loadPolicy(file) {
if (!KEYS.has(key)) throw new Error(`unknown policy key: ${key}`); if (!KEYS.has(key)) throw new Error(`unknown policy key: ${key}`);
} }
const policy = { ...structuredClone(DEFAULT_POLICY), ...parsed }; const policy = { ...structuredClone(DEFAULT_POLICY), ...parsed };
validateStringArray(policy, "allowInstallScripts"); validateVersionExceptions(policy.allowInstallScripts, "allowInstallScripts");
validateStringArray(policy, "allowDirectLifecycleScripts"); validateVersionExceptions(policy.allowDirectLifecycleScripts, "allowDirectLifecycleScripts");
validateArchiveExceptions(policy.archiveSizeExceptions);
validateSourceExceptions(policy.sourceEvidenceExceptions); validateSourceExceptions(policy.sourceEvidenceExceptions);
validateMap(policy, "trustedRepositories", false); validateMap(policy, "trustedRepositories", false);
validateMap(policy, "trustedMaintainers", true); validateMap(policy, "trustedMaintainers", true);
@@ -60,9 +61,40 @@ function validateSourceExceptions(value) {
} }
} }
function validateStringArray(policy, key) { function validateVersionExceptions(value, key) {
if (!Array.isArray(policy[key]) || policy[key].some((value) => typeof value !== "string" || !value)) { if (!value || Array.isArray(value) || typeof value !== "object") throw new Error(`${key} must be an object`);
throw new Error(`${key} must be an array of non-empty strings`); for (const [name, exception] of Object.entries(value)) {
validateExceptionBase(name, exception, key, ["versions"]);
if (!Array.isArray(exception.versions) || exception.versions.length === 0 || exception.versions.some((version) => typeof version !== "string" || !version)) {
throw new Error(`${key}.${name}.versions must be an array of non-empty strings`);
}
}
}
function validateArchiveExceptions(value) {
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("archiveSizeExceptions must be an object");
for (const [name, exception] of Object.entries(value)) {
validateExceptionBase(name, exception, "archiveSizeExceptions", ["version", "maxTarballSizeBytes", "maxUnpackedSizeBytes"]);
if (typeof exception.version !== "string" || !exception.version) throw new Error(`archiveSizeExceptions.${name}.version must be a non-empty string`);
const limits = [exception.maxTarballSizeBytes, exception.maxUnpackedSizeBytes].filter((limit) => limit !== undefined);
if (limits.length === 0 || limits.some((limit) => !Number.isFinite(limit) || limit < 0)) {
throw new Error(`archiveSizeExceptions.${name} must contain at least one non-negative size limit`);
}
}
}
function validateExceptionBase(name, exception, key, allowedFields) {
if (!name || !exception || Array.isArray(exception) || typeof exception !== "object") {
throw new Error(`invalid ${key} entry for ${name || "<empty>"}`);
}
if (Object.keys(exception).some((field) => ![...allowedFields, "reason", "expiresAt"].includes(field))) {
throw new Error(`unknown ${key} field for ${name}`);
}
if (typeof exception.reason !== "string" || exception.reason.trim().length < 20) {
throw new Error(`${key}.${name}.reason must contain at least 20 characters`);
}
if (typeof exception.expiresAt !== "string" || !Number.isFinite(Date.parse(exception.expiresAt))) {
throw new Error(`${key}.${name}.expiresAt must be an ISO date`);
} }
} }
+5 -3
View File
@@ -20,14 +20,16 @@ export async function readProject(projectPath) {
return { manifest, lock, direct }; return { manifest, lock, direct };
} }
export function inspectLock(lock, policy) { export function inspectLock(lock, policy, now = Date.now()) {
const failures = []; const failures = [];
let registryEntries = 0; let registryEntries = 0;
for (const [lockPath, entry] of Object.entries(lock.packages)) { for (const [lockPath, entry] of Object.entries(lock.packages)) {
if (!lockPath || entry.link) continue; if (!lockPath || entry.link) continue;
const name = packageNameFromLockPath(lockPath, entry) ?? lockPath; const name = packageNameFromLockPath(lockPath, entry) ?? lockPath;
if (entry.hasInstallScript && !policy.allowInstallScripts.includes(name)) { const scriptException = policy.allowInstallScripts[name];
failures.push(`${name}: lock entry declares hasInstallScript; add a reviewed allowInstallScripts exception if intentional`); const scriptAllowed = scriptException?.versions.includes(entry.version) && Date.parse(scriptException.expiresAt) > now;
if (entry.hasInstallScript && !scriptAllowed) {
failures.push(`${name}@${entry.version ?? "unknown"}: lock entry declares hasInstallScript; add a reviewed, version-pinned allowInstallScripts exception if intentional`);
} }
if (!lockPath.includes("node_modules/")) continue; if (!lockPath.includes("node_modules/")) continue;
registryEntries += 1; registryEntries += 1;
+3 -1
View File
@@ -36,7 +36,9 @@ export async function verifyDirectPackage(item, document, policy, dependencies)
} }
const lifecycle = ["preinstall", "install", "postinstall"].filter((name) => versionData.scripts?.[name]); const lifecycle = ["preinstall", "install", "postinstall"].filter((name) => versionData.scripts?.[name]);
if (lifecycle.length && !policy.allowDirectLifecycleScripts.includes(item.name)) { const lifecycleException = policy.allowDirectLifecycleScripts[item.name];
const lifecycleAllowed = lifecycleException?.versions.includes(item.version) && Date.parse(lifecycleException.expiresAt) > dependencies.now();
if (lifecycle.length && !lifecycleAllowed) {
failures.push(`direct package declares lifecycle hook(s): ${lifecycle.join(", ")}`); failures.push(`direct package declares lifecycle hook(s): ${lifecycle.join(", ")}`);
} }
+20
View File
@@ -53,6 +53,26 @@ test("check records npm pack size violations without installing", async () => {
assert.equal(report.commands.length, 1); assert.equal(report.commands.length, 1);
}); });
test("check applies an archive override only to its reviewed package version", async () => {
const directory = await makeProject();
const document = packageDocument();
const gitHead = document.versions["1.2.3"].gitHead;
const policy = { ...structuredClone(DEFAULT_POLICY), maxTarballSizeBytes: 10 };
policy.archiveSizeExceptions.alpha = {
version: "1.2.3",
maxTarballSizeBytes: 100,
reason: "Reviewed archive is larger than the default project limit.",
expiresAt: "2025-02-01T00:00:00Z",
};
const report = await checkProject({ path: directory, report: join(directory, "report.json"), policy, skipInstall: true }, {
fetch: async () => jsonResponse(document),
remoteRefs: async () => [{ sha: gitHead, ref: "refs/tags/v1.2.3" }],
run: passingRun(),
now: () => Date.parse("2025-01-01T00:00:00Z"),
});
assert.equal(report.status, "pass");
});
test("check parses full npm pack JSON while truncating report evidence", async () => { test("check parses full npm pack JSON while truncating report evidence", async () => {
const directory = await makeProject(); const directory = await makeProject();
const reportPath = join(directory, "report.json"); const reportPath = join(directory, "report.json");
+10 -2
View File
@@ -19,7 +19,7 @@ test("lock inspection rejects unexpected install scripts and missing integrity",
assert.match(result.failures[1], /integrity/); assert.match(result.failures[1], /integrity/);
}); });
test("default lock allowlist permits fsevents install script", () => { test("version-pinned lock exception permits only the reviewed fsevents version", () => {
const lock = { const lock = {
packages: { packages: {
"": {}, "": {},
@@ -31,5 +31,13 @@ test("default lock allowlist permits fsevents install script", () => {
}, },
}, },
}; };
assert.deepEqual(inspectLock(lock, structuredClone(DEFAULT_POLICY)).failures, []); const policy = structuredClone(DEFAULT_POLICY);
policy.allowInstallScripts.fsevents = {
versions: ["2.3.3"],
reason: "Reviewed optional native filesystem watcher install script.",
expiresAt: "2027-01-01T00:00:00Z",
};
assert.deepEqual(inspectLock(lock, policy, Date.parse("2026-01-01T00:00:00Z")).failures, []);
lock.packages["node_modules/fsevents"].version = "2.3.4";
assert.match(inspectLock(lock, policy, Date.parse("2026-01-01T00:00:00Z")).failures[0], /2\.3\.4/);
}); });
+20
View File
@@ -115,6 +115,26 @@ test("rejects direct lifecycle scripts and untrusted maintainers", async () => {
assert.ok(result.failures.some((message) => message.includes("registry maintainer set changed"))); assert.ok(result.failures.some((message) => message.includes("registry maintainer set changed")));
}); });
test("permits a direct lifecycle script only for the reviewed version", async () => {
const document = packageDocument({ version: { scripts: { postinstall: "node setup.js" } } });
const policy = structuredClone(DEFAULT_POLICY);
policy.allowDirectLifecycleScripts.alpha = {
versions: ["1.2.3"],
reason: "Reviewed package setup script required by this exact release.",
expiresAt: "2025-02-01T00:00:00Z",
};
const dependencies = {
remoteRefs: async () => [{ sha: document.versions["1.2.3"].gitHead, ref: "refs/tags/v1.2.3" }],
now,
timeoutMs: 100,
};
const accepted = await verifyDirectPackage(item, document, policy, dependencies);
assert.ok(!accepted.failures.some((message) => message.includes("lifecycle hook")));
policy.allowDirectLifecycleScripts.alpha.versions = ["1.2.2"];
const rejected = await verifyDirectPackage(item, document, policy, dependencies);
assert.ok(rejected.failures.some((message) => message.includes("lifecycle hook")));
});
test("rejects a missing previously trusted maintainer", async () => { test("rejects a missing previously trusted maintainer", async () => {
const document = packageDocument({ version: { maintainers: [{ name: "alice" }] } }); const document = packageDocument({ version: { maintainers: [{ name: "alice" }] } });
const policy = structuredClone(DEFAULT_POLICY); const policy = structuredClone(DEFAULT_POLICY);