From 13892efa700547682b17597ec407cb70c96cff63 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 26 Nov 2025 16:42:51 +0100 Subject: [PATCH] Allow to configure the minimum package age --- README.md | 51 +++++++- .../safe-chain/src/config/cliArguments.js | 25 +++- .../src/config/cliArguments.spec.js | 93 ++++++++++++++ packages/safe-chain/src/config/configFile.js | 34 +++++- .../safe-chain/src/config/configFile.spec.js | 113 ++++++++++++++++++ .../src/config/environmentVariables.js | 7 ++ packages/safe-chain/src/config/settings.js | 46 +++++++ .../npm/npmInterceptor.minPackageAge.spec.js | 83 +++++++++++++ 8 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/src/config/environmentVariables.js diff --git a/README.md b/README.md index 437b76f..b9d0357 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ The Aikido Safe Chain works by running a lightweight proxy server that intercept ### Minimum package age (npm only) -For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can bypass this protection for specific installs using the `--safe-chain-skip-minimum-package-age` flag. +For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. ⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3). @@ -127,6 +127,55 @@ You can control the output from Aikido Safe Chain using the `--safe-chain-loggin npm install express --safe-chain-logging=verbose ``` +## Minimum Package Age + +You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 24 hours old before they can be installed through npm-based package managers. + +### Configuration Options + +You can set the minimum package age through multiple sources (in order of priority): + +1. **CLI Argument** (highest priority): + + ```shell + npm install express --safe-chain-minimum-package-age-hours=48 + ``` + +2. **Environment Variable**: + + ```shell + export AIKIDO_MINIMUM_PACKAGE_AGE_HOURS=48 + npm install express + ``` + +3. **Config File** (`~/.aikido/config.json`): + + ```json + { + "minimumPackageAgeHours": 48 + } + ``` + +### Examples + +- **Set to 48 hours for extra caution:** + + ```shell + npm install express --safe-chain-minimum-package-age-hours=48 + ``` + +- **Set to 1 hour for faster access to new packages:** + + ```shell + npm install express --safe-chain-minimum-package-age-hours=1 + ``` + +- **Completely bypass the age check for a specific install:** + + ```shell + npm install express --safe-chain-skip-minimum-package-age + ``` + # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 180c565..ddcd8b9 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -1,9 +1,10 @@ /** - * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, includePython: boolean}} + * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, includePython: boolean}} */ const state = { loggingLevel: undefined, skipMinimumPackageAge: undefined, + minimumPackageAgeHours: undefined, includePython: false, }; @@ -17,6 +18,7 @@ export function initializeCliArguments(args) { // Reset state on each call state.loggingLevel = undefined; state.skipMinimumPackageAge = undefined; + state.minimumPackageAgeHours = undefined; const safeChainArgs = []; const remainingArgs = []; @@ -31,6 +33,7 @@ export function initializeCliArguments(args) { setLoggingLevel(safeChainArgs); setSkipMinimumPackageAge(safeChainArgs); + setMinimumPackageAgeHours(safeChainArgs); setIncludePython(args); return remainingArgs; @@ -86,6 +89,26 @@ export function getSkipMinimumPackageAge() { return state.skipMinimumPackageAge; } +/** + * @param {string[]} args + * @returns {void} + */ +function setMinimumPackageAgeHours(args) { + const argName = SAFE_CHAIN_ARG_PREFIX + "minimum-package-age-hours="; + + const value = getLastArgEqualsValue(args, argName); + if (value) { + state.minimumPackageAgeHours = value; + } +} + +/** + * @returns {string | undefined} + */ +export function getMinimumPackageAgeHours() { + return state.minimumPackageAgeHours; +} + /** * @param {string[]} args */ diff --git a/packages/safe-chain/src/config/cliArguments.spec.js b/packages/safe-chain/src/config/cliArguments.spec.js index 3c8b7da..bbd5121 100644 --- a/packages/safe-chain/src/config/cliArguments.spec.js +++ b/packages/safe-chain/src/config/cliArguments.spec.js @@ -4,6 +4,7 @@ import { initializeCliArguments, getLoggingLevel, getSkipMinimumPackageAge, + getMinimumPackageAgeHours, } from "./cliArguments.js"; describe("initializeCliArguments", () => { @@ -178,4 +179,96 @@ describe("initializeCliArguments", () => { assert.deepEqual(result, ["install", "lodash"]); assert.strictEqual(getSkipMinimumPackageAge(), true); }); + + it("should return undefined when no minimum-package-age-hours argument is passed", () => { + const args = ["install", "express", "--save"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), undefined); + }); + + it("should parse minimum-package-age-hours value and set state", () => { + const args = [ + "--safe-chain-minimum-package-age-hours=48", + "install", + "lodash", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getMinimumPackageAgeHours(), "48"); + }); + + it("should handle minimum-package-age-hours with zero value", () => { + const args = ["--safe-chain-minimum-package-age-hours=0", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "0"); + }); + + it("should handle minimum-package-age-hours with decimal values", () => { + const args = ["--safe-chain-minimum-package-age-hours=1.5", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "1.5"); + }); + + it("should handle minimum-package-age-hours case-insensitively", () => { + const args = ["--SAFE-CHAIN-MINIMUM-PACKAGE-AGE-HOURS=72", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "72"); + }); + + it("should use the last minimum-package-age-hours argument when multiple are provided", () => { + const args = [ + "--safe-chain-minimum-package-age-hours=12", + "--safe-chain-minimum-package-age-hours=36", + "install", + ]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "36"); + }); + + it("should filter out minimum-package-age-hours argument from returned args", () => { + const args = [ + "install", + "--safe-chain-minimum-package-age-hours=48", + "express", + "--save", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "express", "--save"]); + }); + + it("should handle minimum-package-age-hours with other safe-chain arguments", () => { + const args = [ + "--safe-chain-logging=verbose", + "--safe-chain-minimum-package-age-hours=96", + "install", + "lodash", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getLoggingLevel(), "verbose"); + assert.strictEqual(getMinimumPackageAgeHours(), "96"); + }); + + it("should handle non-numeric values without validation (validation in settings.js)", () => { + const args = ["--safe-chain-minimum-package-age-hours=invalid", "install"]; + initializeCliArguments(args); + + // cliArguments.js just captures the value; validation is in settings.js + assert.strictEqual(getMinimumPackageAgeHours(), "invalid"); + }); + + it("should handle negative values as strings (validation in settings.js)", () => { + const args = ["--safe-chain-minimum-package-age-hours=-24", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getMinimumPackageAgeHours(), "-24"); + }); }); diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index cb56705..ae25a1d 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -9,7 +9,8 @@ import { getEcoSystem } from "./settings.js"; * * This should be a number, but can be anything because it is user-input. * We cannot trust the input and should add the necessary validations. - * @property {any} scanTimeout + * @property {unknown} scanTimeout + * @property {unknown} minimumPackageAgeHours */ /** @@ -48,6 +49,35 @@ function validateTimeout(value) { return null; } +/** + * @param {any} value + * @returns {number | undefined} + */ +function validateMinimumPackageAgeHours(value) { + const hours = Number(value); + if (!Number.isNaN(hours)) { + return hours; + } + return undefined; +} + +/** + * Gets the minimum package age in hours from config file only + * @returns {number | undefined} + */ +export function getMinimumPackageAgeHours() { + const config = readConfigFile(); + if (config.minimumPackageAgeHours) { + const validated = validateMinimumPackageAgeHours( + config.minimumPackageAgeHours + ); + if (validated !== undefined) { + return validated; + } + } + return undefined; +} + /** * @param {import("../api/aikido.js").MalwarePackage[]} data * @param {string | number} version @@ -111,6 +141,7 @@ function readConfigFile() { if (!fs.existsSync(configFilePath)) { return { scanTimeout: undefined, + minimumPackageAgeHours: undefined, }; } @@ -120,6 +151,7 @@ function readConfigFile() { } catch { return { scanTimeout: undefined, + minimumPackageAgeHours: undefined, }; } } diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js index 8ec980c..18415bc 100644 --- a/packages/safe-chain/src/config/configFile.spec.js +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -170,3 +170,116 @@ describe("getScanTimeout", () => { assert.strictEqual(timeout, 10000); }); }); + +describe("getMinimumPackageAgeHours", () => { + let fsMock; + let getMinimumPackageAgeHours; + + beforeEach(async () => { + // Mock fs module + fsMock = { + existsSync: mock.fn(() => false), + readFileSync: mock.fn(() => "{}"), + writeFileSync: mock.fn(), + mkdirSync: mock.fn(), + }; + + mock.module("fs", { + namedExports: fsMock, + }); + + // Re-import the module to get the mocked version + const configFileModule = await import( + `./configFile.js?update=${Date.now()}` + ); + getMinimumPackageAgeHours = configFileModule.getMinimumPackageAgeHours; + }); + + afterEach(() => { + // Reset all mocks + mock.restoreAll(); + }); + + it("should return null when config file doesn't exist", () => { + fsMock.existsSync.mock.mockImplementation(() => false); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); + + it("should return null when config file exists but minimumPackageAgeHours is not set", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ scanTimeout: 5000 }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); + + it("should return value from config file when set to valid number", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: 48 }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, 48); + }); + + it("should handle string numbers in config file", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: "72" }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, 72); + }); + + it("should handle decimal values", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: 1.5 }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, 1.5); + }); + + it("should return null for non-numeric strings", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: "invalid" }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); + + it("should return null for values with units suffix", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => + JSON.stringify({ minimumPackageAgeHours: "48h" }) + ); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); + + it("should handle malformed JSON and return null", () => { + fsMock.existsSync.mock.mockImplementation(() => true); + fsMock.readFileSync.mock.mockImplementation(() => "{ invalid json"); + + const hours = getMinimumPackageAgeHours(); + + assert.strictEqual(hours, undefined); + }); +}); diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js new file mode 100644 index 0000000..95616c5 --- /dev/null +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -0,0 +1,7 @@ +/** + * Gets the minimum package age in hours from environment variable + * @returns {string | undefined} + */ +export function getMinimumPackageAgeHours() { + return process.env.AIKIDO_MINIMUM_PACKAGE_AGE_HOURS; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index ce7f35c..7c20358 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,4 +1,6 @@ import * as cliArguments from "./cliArguments.js"; +import * as configFile from "./configFile.js"; +import * as environmentVariables from "./environmentVariables.js"; export const LOGGING_SILENT = "silent"; export const LOGGING_NORMAL = "normal"; @@ -38,10 +40,54 @@ export function setEcoSystem(setting) { } const defaultMinimumPackageAge = 24; +/** @returns {number} */ export function getMinimumPackageAgeHours() { + // Priority 1: CLI argument + const cliValue = validateMinimumPackageAgeHours( + cliArguments.getMinimumPackageAgeHours() + ); + if (cliValue !== undefined) { + return cliValue; + } + + // Priority 2: Environment variable + const envValue = validateMinimumPackageAgeHours( + environmentVariables.getMinimumPackageAgeHours() + ); + if (envValue !== undefined) { + return envValue; + } + + // Priority 3: Config file + const configValue = configFile.getMinimumPackageAgeHours(); + if (configValue !== undefined) { + return configValue; + } + return defaultMinimumPackageAge; } +/** + * @param {string | undefined} value + * @returns {number | undefined} + */ +function validateMinimumPackageAgeHours(value) { + if (!value) { + return undefined; + } + + const numericValue = Number(value); + if (Number.isNaN(numericValue)) { + return undefined; + } + + if (numericValue > 0) { + return numericValue; + } + + return undefined; +} + const defaultSkipMinimumPackageAge = false; export function skipMinimumPackageAge() { const cliValue = cliArguments.getSkipMinimumPackageAge(); 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 2ff5a52..999e64a 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 @@ -273,6 +273,89 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0"); }); + it("Should use custom minimum package age of 48 hours", async () => { + minimumPackageAgeSettings = 48; + skipMinimumPackageAgeSetting = false; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "4.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + ["4.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-24), + ["1.0.0"]: getDate(-72), // 3 days old - should remain + ["2.0.0"]: getDate(-50), // ~2 days old - should remain + // 48-hour cutoff here + ["3.0.0"]: getDate(-40), // ~1.7 days old - should be removed + ["4.0.0"]: getDate(-24), // 1 day old - should be removed + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + // Versions older than 48 hours should remain + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + + // Versions newer than 48 hours should be removed + assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("4.0.0")); + + // Latest should be recalculated to 2.0.0 + assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); + + assert.equal(Object.keys(modifiedJson.versions).length, 2); + }); + + it("Should use very small minimum package age of 1 hour", async () => { + minimumPackageAgeSettings = 1; + skipMinimumPackageAgeSetting = false; + 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"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-48), + modified: getDate(0), + ["1.0.0"]: getDate(-3), // 3 hours old - should remain + ["2.0.0"]: getDate(-2), // 2 hours old - should remain + // 1-hour cutoff here + ["3.0.0"]: getDate(0), // just published - should be removed + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + assert.equal(Object.keys(modifiedJson.versions).length, 2); + 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"], "2.0.0"); + }); + function getDate(plusHours) { const date = new Date(); date.setHours(date.getHours() + plusHours);