mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #176 from AikidoSec/min-package-age-configuration
This commit is contained in:
commit
a57c37b58d
8 changed files with 429 additions and 3 deletions
31
README.md
31
README.md
|
|
@ -76,7 +76,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).
|
||||
|
||||
|
|
@ -128,6 +128,35 @@ 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 SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS=48
|
||||
npm install express
|
||||
```
|
||||
|
||||
3. **Config File** (`~/.aikido/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"minimumPackageAgeHours": 48
|
||||
}
|
||||
```
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
7
packages/safe-chain/src/config/environmentVariables.js
Normal file
7
packages/safe-chain/src/config/environmentVariables.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Gets the minimum package age in hours from environment variable
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function getMinimumPackageAgeHours() {
|
||||
return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue