From 6815b620199d4ef8ce48202bcb7a0ebfe5f66f55 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 17:41:23 +0100 Subject: [PATCH] Allow to exclude packages from the minimum package age --- README.md | 16 ++ packages/safe-chain/src/api/aikido.spec.js | 8 + packages/safe-chain/src/config/configFile.js | 22 +++ .../src/config/environmentVariables.js | 10 ++ packages/safe-chain/src/config/settings.js | 31 ++++ .../safe-chain/src/config/settings.spec.js | 135 ++++++++++++++++ .../interceptors/npm/modifyNpmInfo.js | 12 +- .../npm/npmInterceptor.minPackageAge.spec.js | 153 ++++++++++++++++++ .../npmInterceptor.packageDownload.spec.js | 1 + 9 files changed, 387 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 17d2515..bc61787 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,22 @@ You can set the minimum package age through multiple sources (in order of priori } ``` +### Excluding Packages + +Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged): + +```shell +export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="react,@aikidosec/safe-chain" +``` + +```json +{ + "npm": { + "minimumPackageAgeExclusions": ["react", "@aikidosec/safe-chain"] + } +} +``` + ## Custom Registries Configure Safe Chain to scan packages from custom or private registries. diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index 2191d42..2e7cecb 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -8,6 +8,14 @@ describe("aikido API", async () => { defaultExport: mockFetch, }); + mock.module("../environment/userInteraction.js", { + namedExports: { + ui: { + writeVerbose: () => {}, + }, + }, + }); + mock.module("../config/settings.js", { namedExports: { getEcoSystem: () => "js", diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index a98304e..fd6ac26 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -16,6 +16,7 @@ import { getEcoSystem } from "./settings.js"; * @typedef {Object} SafeChainRegistryConfiguration * We cannot trust the input and should add the necessary validations. * @property {unknown | string[]} customRegistries + * @property {unknown | string[]} minimumPackageAgeExclusions */ /** @@ -127,6 +128,27 @@ export function getPipCustomRegistries() { return customRegistries.filter((item) => typeof item === "string"); } +/** + * Gets the minimum package age exclusions from the config file + * @returns {string[]} + */ +export function getNpmMinimumPackageAgeExclusions() { + const config = readConfigFile(); + + if (!config || !config.npm) { + return []; + } + + const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); + const exclusions = npmConfig.minimumPackageAgeExclusions; + + if (!Array.isArray(exclusions)) { + return []; + } + + return exclusions.filter((item) => typeof item === "string"); +} + /** * @param {import("../api/aikido.js").MalwarePackage[]} data * @param {string | number} version diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 1b85ed7..8a44841 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -34,3 +34,13 @@ export function getPipCustomRegistries() { export function getLoggingLevel() { return process.env.SAFE_CHAIN_LOGGING; } + +/** + * Gets the minimum package age exclusions from environment variable + * Expected format: comma-separated list of package names + * Example: "react,@aikidosec/safe-chain,lodash" + * @returns {string | undefined} + */ +export function getNpmMinimumPackageAgeExclusions() { + return process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 6910fe3..b9243b0 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -167,3 +167,34 @@ export function getPipCustomRegistries() { // Normalize each registry (remove protocol if any) return uniqueRegistries.map(normalizeRegistry); } + +/** + * Parses comma-separated exclusions from environment variable + * @param {string | undefined} envValue + * @returns {string[]} + */ +function parseExclusionsFromEnv(envValue) { + if (!envValue || typeof envValue !== "string") { + return []; + } + + return envValue + .split(",") + .map((exclusion) => exclusion.trim()) + .filter((exclusion) => exclusion.length > 0); +} + +/** + * Gets the minimum package age exclusions from both environment variable and config file (merged) + * @returns {string[]} + */ +export function getNpmMinimumPackageAgeExclusions() { + const envExclusions = parseExclusionsFromEnv( + environmentVariables.getNpmMinimumPackageAgeExclusions() + ); + const configExclusions = configFile.getNpmMinimumPackageAgeExclusions(); + + // Merge both sources and remove duplicates + const allExclusions = [...envExclusions, ...configExclusions]; + return [...new Set(allExclusions)]; +} diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 314fac0..8db5b83 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -14,6 +14,7 @@ mock.module("fs", { const { getNpmCustomRegistries, getPipCustomRegistries, + getNpmMinimumPackageAgeExclusions, getLoggingLevel, LOGGING_SILENT, LOGGING_NORMAL, @@ -365,3 +366,137 @@ describe("getLoggingLevel", () => { assert.strictEqual(level, LOGGING_NORMAL); }); }); + +describe("getNpmMinimumPackageAgeExclusions", () => { + let originalEnv; + const envVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS"; + + beforeEach(() => { + originalEnv = process.env[envVarName]; + delete process.env[envVarName]; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env[envVarName] = originalEnv; + } else { + delete process.env[envVarName]; + } + configFileContent = undefined; + }); + + it("should return empty array when no exclusions configured", () => { + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, []); + }); + + it("should return exclusions from config file", () => { + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["react", "@aikidosec/safe-chain"], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]); + }); + + it("should parse comma-separated exclusions from environment variable", () => { + process.env[envVarName] = "lodash,express,@types/node"; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "express", "@types/node"]); + }); + + it("should merge environment variable and config file exclusions", () => { + process.env[envVarName] = "lodash"; + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["react"], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react"]); + }); + + it("should remove duplicate exclusions when merging", () => { + process.env[envVarName] = "lodash,react"; + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["react", "express"], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]); + }); + + it("should trim whitespace from environment variable exclusions", () => { + process.env[envVarName] = " lodash , react "; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react"]); + }); + + it("should handle scoped packages", () => { + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["@babel/core", "@types/react"], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]); + }); + + it("should handle empty strings in comma-separated list", () => { + process.env[envVarName] = "lodash,,react,"; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react"]); + }); + + it("should return empty array for empty environment variable", () => { + process.env[envVarName] = ""; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, []); + }); + + it("should return empty array for whitespace-only environment variable", () => { + process.env[envVarName] = " , , "; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, []); + }); + + it("should filter non-string values from config file", () => { + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["react", 123, null, "lodash", undefined], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["react", "lodash"]); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 421666a..3407397 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -1,4 +1,4 @@ -import { getMinimumPackageAgeHours } from "../../../config/settings.js"; +import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js"; import { ui } from "../../../environment/userInteraction.js"; import { getHeaderValueAsString } from "../../http-utils.js"; @@ -65,6 +65,16 @@ export function modifyNpmInfoResponse(body, headers) { return body; } + // Check if this package is excluded from minimum age filtering + const packageName = bodyJson.name; + const exclusions = getNpmMinimumPackageAgeExclusions(); + if (packageName && exclusions.includes(packageName)) { + ui.writeVerbose( + `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` + ); + return body; + } + const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 ); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index fb7ae56..ed00909 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -4,12 +4,14 @@ import assert from "node:assert"; describe("npmInterceptor minimum package age", async () => { let minimumPackageAgeSettings = 48; let skipMinimumPackageAgeSetting = false; + let minimumPackageAgeExclusionsSetting = []; mock.module("../../../config/settings.js", { namedExports: { getMinimumPackageAgeHours: () => minimumPackageAgeSettings, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, getNpmCustomRegistries: () => [], + getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, }, }); @@ -357,6 +359,157 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); }); + it("Should not filter packages when package is in exclusion list", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["lodash"]; + + const packageUrl = "https://registry.npmjs.org/lodash"; + + const originalBody = JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-3), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), // Would normally be filtered + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain unchanged since lodash is excluded + assert.equal(Object.keys(modifiedJson.versions).length, 3); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0")); + assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0"); + }); + + it("Should filter packages when package is NOT in exclusion list", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["react"]; // Different package + + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { latest: "3.0.0" }, + versions: { ["1.0.0"]: {}, ["3.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-3), + ["1.0.0"]: getDate(-7), + ["3.0.0"]: getDate(-3), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + // lodash should still be filtered since it's not in exclusions + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); + }); + + it("Should handle scoped packages in exclusion list", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["@babel/core"]; + + const packageUrl = "https://registry.npmjs.org/@babel/core"; + + const originalBody = JSON.stringify({ + name: "@babel/core", + ["dist-tags"]: { latest: "7.0.0" }, + versions: { ["6.0.0"]: {}, ["7.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["6.0.0"]: getDate(-100), + ["7.0.0"]: getDate(-1), // Would normally be filtered + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain for excluded scoped package + assert.equal(Object.keys(modifiedJson.versions).length, 2); + assert.ok(Object.keys(modifiedJson.versions).includes("6.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("7.0.0")); + }); + + it("Should handle multiple packages in exclusion list", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["react", "lodash", "@types/node"]; + + const packageUrl = "https://registry.npmjs.org/lodash"; + + const originalBody = JSON.stringify({ + name: "lodash", + ["dist-tags"]: { latest: "2.0.0" }, + versions: { ["1.0.0"]: {}, ["2.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["1.0.0"]: getDate(-100), + ["2.0.0"]: getDate(-1), + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain since lodash is in the exclusion list + assert.equal(Object.keys(modifiedJson.versions).length, 2); + }); + + it("Should reset exclusions between tests", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = []; // Reset to empty + + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { latest: "2.0.0" }, + versions: { ["1.0.0"]: {}, ["2.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["1.0.0"]: getDate(-100), + ["2.0.0"]: getDate(-1), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + // Version 2.0.0 should be filtered since exclusions are empty + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + }); + function getDate(plusHours) { const date = new Date(); date.setHours(date.getHours() + plusHours); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index 88fcbd0..e1b7c79 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -26,6 +26,7 @@ mock.module("../../../config/settings.js", { setEcoSystem: () => {}, getMinimumPackageAgeHours: () => 24, getNpmCustomRegistries: () => customRegistries, + getNpmMinimumPackageAgeExclusions: () => [], skipMinimumPackageAge: () => false, }, });