Pin dependency policy exceptions to versions
This commit is contained in:
+7
-3
@@ -30,7 +30,7 @@ export async function checkProject(options, injected = {}) {
|
||||
|
||||
try {
|
||||
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.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];
|
||||
packageResult.tarballSizeBytes = packed.size;
|
||||
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}`);
|
||||
if (!Number.isFinite(packed.unpackedSize) || packed.unpackedSize > options.policy.maxUnpackedSizeBytes) packageResult.failures.push(`unpacked size ${packed.unpackedSize ?? "unknown"} exceeds ${options.policy.maxUnpackedSizeBytes}`);
|
||||
const archiveException = options.policy.archiveSizeExceptions[item.name];
|
||||
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 {
|
||||
packageResult.failures.push("npm pack returned invalid JSON evidence");
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
export const DEFAULT_POLICY = Object.freeze({
|
||||
allowInstallScripts: ["fsevents"],
|
||||
allowDirectLifecycleScripts: [],
|
||||
allowInstallScripts: {},
|
||||
allowDirectLifecycleScripts: {},
|
||||
archiveSizeExceptions: {},
|
||||
sourceEvidenceExceptions: {},
|
||||
trustedRepositories: {},
|
||||
trustedMaintainers: {},
|
||||
|
||||
+37
-5
@@ -19,8 +19,9 @@ export async function loadPolicy(file) {
|
||||
if (!KEYS.has(key)) throw new Error(`unknown policy key: ${key}`);
|
||||
}
|
||||
const policy = { ...structuredClone(DEFAULT_POLICY), ...parsed };
|
||||
validateStringArray(policy, "allowInstallScripts");
|
||||
validateStringArray(policy, "allowDirectLifecycleScripts");
|
||||
validateVersionExceptions(policy.allowInstallScripts, "allowInstallScripts");
|
||||
validateVersionExceptions(policy.allowDirectLifecycleScripts, "allowDirectLifecycleScripts");
|
||||
validateArchiveExceptions(policy.archiveSizeExceptions);
|
||||
validateSourceExceptions(policy.sourceEvidenceExceptions);
|
||||
validateMap(policy, "trustedRepositories", false);
|
||||
validateMap(policy, "trustedMaintainers", true);
|
||||
@@ -60,9 +61,40 @@ function validateSourceExceptions(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateStringArray(policy, key) {
|
||||
if (!Array.isArray(policy[key]) || policy[key].some((value) => typeof value !== "string" || !value)) {
|
||||
throw new Error(`${key} must be an array of non-empty strings`);
|
||||
function validateVersionExceptions(value, key) {
|
||||
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error(`${key} must be an object`);
|
||||
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 };
|
||||
}
|
||||
|
||||
export function inspectLock(lock, policy) {
|
||||
export function inspectLock(lock, policy, now = Date.now()) {
|
||||
const failures = [];
|
||||
let registryEntries = 0;
|
||||
for (const [lockPath, entry] of Object.entries(lock.packages)) {
|
||||
if (!lockPath || entry.link) continue;
|
||||
const name = packageNameFromLockPath(lockPath, entry) ?? lockPath;
|
||||
if (entry.hasInstallScript && !policy.allowInstallScripts.includes(name)) {
|
||||
failures.push(`${name}: lock entry declares hasInstallScript; add a reviewed allowInstallScripts exception if intentional`);
|
||||
const scriptException = policy.allowInstallScripts[name];
|
||||
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;
|
||||
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]);
|
||||
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(", ")}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user