Pin dependency policy exceptions to versions
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user