Allow to exclude packages from the minimum package age

This commit is contained in:
Sander Declerck 2026-01-14 17:41:23 +01:00
parent 4ef4218eb5
commit d7a9884ff6
No known key found for this signature in database
9 changed files with 387 additions and 1 deletions

View file

@ -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.

View file

@ -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",

View file

@ -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

View file

@ -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;
}

View file

@ -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)];
}

View file

@ -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"]);
});
});

View file

@ -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
);

View file

@ -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);

View file

@ -26,6 +26,7 @@ mock.module("../../../config/settings.js", {
setEcoSystem: () => {},
getMinimumPackageAgeHours: () => 24,
getNpmCustomRegistries: () => customRegistries,
getNpmMinimumPackageAgeExclusions: () => [],
skipMinimumPackageAge: () => false,
},
});