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

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