Merge pull request #354 from AikidoSec/feature/minimum-package-age-from-list

Use new package feed to enforce minimum package age for direct npm downloads
This commit is contained in:
bitterpanda 2026-03-27 15:38:19 -07:00 committed by GitHub
commit f920fc61ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 976 additions and 44 deletions

View file

@ -113,7 +113,12 @@ 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 48 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.
For npm packages, Safe Chain applies minimum package age checks in two ways:
- During normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry.
- For direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages.
By default, the minimum package age is 48 hours. 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, poetry, pipx).
@ -185,6 +190,11 @@ You can set the logging level through multiple sources (in order of priority):
You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed through npm-based package managers.
For npm-based package managers, this check currently has two enforcement modes:
- Safe Chain suppresses too-young versions from package metadata during normal dependency resolution.
- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
### Configuration Options
You can set the minimum package age through multiple sources (in order of priority):

View file

@ -11,6 +11,13 @@ const malwareDatabaseUrls = {
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json",
};
const newPackagesListUrls = {
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases/npm.json",
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases/pypi.json",
};
const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
/**
* @typedef {Object} MalwarePackage
* @property {string} package_name
@ -18,12 +25,19 @@ const malwareDatabaseUrls = {
* @property {string} reason
*/
/**
* @typedef {Object} NewPackageEntry
* @property {string} [source]
* @property {string} package_name
* @property {string} version
* @property {number} released_on - Unix timestamp (seconds)
* @property {number} scraped_on - Unix timestamp (seconds)
*/
/**
* @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>}
*/
export async function fetchMalwareDatabase() {
const numberOfAttempts = 4;
return retry(async () => {
const ecosystem = getEcoSystem();
const malwareDatabaseUrl =
@ -46,15 +60,13 @@ export async function fetchMalwareDatabase() {
} catch (/** @type {any} */ error) {
throw new Error(`Error parsing malware database: ${error.message}`);
}
}, numberOfAttempts);
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<string | undefined>}
*/
export async function fetchMalwareDatabaseVersion() {
const numberOfAttempts = 4;
return retry(async () => {
const ecosystem = getEcoSystem();
const malwareDatabaseUrl =
@ -71,7 +83,63 @@ export async function fetchMalwareDatabaseVersion() {
);
}
return response.headers.get("etag") || undefined;
}, numberOfAttempts);
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<{newPackagesList: NewPackageEntry[], version: string | undefined}>}
*/
export async function fetchNewPackagesList() {
return retry(async () => {
const ecosystem = getEcoSystem();
const url =
newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)];
if (!url) {
return { newPackagesList: [], version: undefined };
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} new packages list: ${response.statusText}`
);
}
try {
const newPackagesList = await response.json();
return {
newPackagesList,
version: response.headers.get("etag") || undefined,
};
} catch (/** @type {any} */ error) {
throw new Error(`Error parsing new packages list: ${error.message}`);
}
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<string | undefined>}
*/
export async function fetchNewPackagesListVersion() {
return retry(async () => {
const ecosystem = getEcoSystem();
const url =
newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)];
if (!url) {
return undefined;
}
const response = await fetch(url, { method: "HEAD" });
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} new packages list version: ${response.statusText}`
);
}
return response.headers.get("etag") || undefined;
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
@ -91,7 +159,7 @@ async function retry(func, attempts) {
return await func();
} catch (error) {
ui.writeVerbose(
"An error occurred while trying to download the Aikido Malware database",
"An error occurred while trying to download Aikido data",
error
);
lastError = error;

View file

@ -3,6 +3,7 @@ import assert from "node:assert";
describe("aikido API", async () => {
const mockFetch = mock.fn();
let ecosystem = "js";
mock.module("make-fetch-happen", {
defaultExport: mockFetch,
@ -18,17 +19,22 @@ describe("aikido API", async () => {
mock.module("../config/settings.js", {
namedExports: {
getEcoSystem: () => "js",
getEcoSystem: () => ecosystem,
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
},
});
const { fetchMalwareDatabase, fetchMalwareDatabaseVersion } =
await import("./aikido.js");
const {
fetchMalwareDatabase,
fetchMalwareDatabaseVersion,
fetchNewPackagesList,
fetchNewPackagesListVersion,
} = await import("./aikido.js");
beforeEach(() => {
mockFetch.mock.resetCalls();
ecosystem = "js";
});
describe("fetchMalwareDatabase", () => {
@ -130,4 +136,87 @@ describe("aikido API", async () => {
assert.strictEqual(result, '"final-etag"');
});
});
describe("fetchNewPackagesList", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
const releases = [
{
package_name: "fresh-pkg",
version: "1.0.0",
released_on: 123,
scraped_on: 456,
},
];
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
json: async () => releases,
headers: { get: () => '"etag-new-packages"' },
}));
const result = await fetchNewPackagesList();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.strictEqual(
mockFetch.mock.calls[0].arguments[0],
"https://malware-list.aikido.dev/releases/npm.json"
);
assert.deepStrictEqual(result.newPackagesList, releases);
assert.strictEqual(result.version, '"etag-new-packages"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Network error");
});
await assert.rejects(() => fetchNewPackagesList(), {
message: "Network error",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
it("should return an empty list without fetching for unsupported ecosystems", async () => {
ecosystem = "ruby";
const result = await fetchNewPackagesList();
assert.strictEqual(mockFetch.mock.calls.length, 0);
assert.deepStrictEqual(result.newPackagesList, []);
assert.strictEqual(result.version, undefined);
});
});
describe("fetchNewPackagesListVersion", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
headers: { get: () => '"new-packages-etag"' },
}));
const result = await fetchNewPackagesListVersion();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.strictEqual(
mockFetch.mock.calls[0].arguments[0],
"https://malware-list.aikido.dev/releases/npm.json"
);
assert.deepStrictEqual(mockFetch.mock.calls[0].arguments[1], {
method: "HEAD",
});
assert.strictEqual(result, '"new-packages-etag"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Connection refused");
});
await assert.rejects(() => fetchNewPackagesListVersion(), {
message: "Connection refused",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
});
});

View file

@ -248,6 +248,24 @@ function getDatabaseVersionPath() {
return path.join(aikidoDir, `version_${ecosystem}.txt`);
}
/**
* @returns {string}
*/
export function getNewPackagesListPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`);
}
/**
* @returns {string}
*/
export function getNewPackagesListVersionPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`);
}
/**
* @returns {string}
*/
@ -268,7 +286,7 @@ function getConfigFilePath() {
/**
* @returns {string}
*/
function getSafeChainDirectory() {
export function getSafeChainDirectory() {
const homeDir = os.homedir();
const safeChainDir = path.join(homeDir, ".safe-chain");

View file

@ -64,7 +64,11 @@ export async function main(args) {
// Write all buffered logs
ui.writeBufferedLogsAndStopBuffering();
if (!proxy.verifyNoMaliciousPackages()) {
if (proxy.hasBlockedMaliciousPackages()) {
return 1;
}
if (proxy.hasBlockedMinimumAgeRequests()) {
return 1;
}
@ -81,7 +85,7 @@ export async function main(args) {
ui.writeInformation(
`${chalk.yellow(
"",
)} Safe-chain: Some package versions were suppressed due to minimum age requirement.`,
)} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`,
);
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(

View file

@ -10,6 +10,7 @@ import { EventEmitter } from "events";
* @typedef {Object} RequestInterceptionContext
* @property {string} targetUrl
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
* @property {(packageName: string, version: string, message: string) => void} blockMinimumAgeRequest
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
* @property {() => RequestInterceptionHandler} build
@ -26,6 +27,12 @@ import { EventEmitter } from "events";
* @property {string} version
* @property {string} targetUrl
* @property {number} timestamp
*
* @typedef {Object} MinimumAgeRequestBlockedEvent
* @property {string} packageName
* @property {string} version
* @property {string} targetUrl
* @property {number} timestamp
*/
/**
@ -81,10 +88,7 @@ function createRequestContext(targetUrl, eventEmitter) {
* @param {string | undefined} version
*/
function blockMalwareSetup(packageName, version) {
blockResponse = {
statusCode: 403,
message: "Forbidden - blocked by safe-chain",
};
blockResponse = createBlockResponse("Forbidden - blocked by safe-chain");
// Emit the malwareBlocked event
eventEmitter.emit("malwareBlocked", {
@ -95,6 +99,34 @@ function createRequestContext(targetUrl, eventEmitter) {
});
}
/**
* @param {string} message
*/
function blockMinimumAgeRequestSetup(
/** @type {string} */ packageName,
/** @type {string} */ version,
/** @type {string} */ message
) {
blockResponse = createBlockResponse(message);
eventEmitter.emit("minimumAgeRequestBlocked", {
packageName,
version,
targetUrl,
timestamp: Date.now(),
});
}
/**
* @param {string} message
* @returns {{statusCode: number, message: string}}
*/
function createBlockResponse(message) {
return {
statusCode: 403,
message,
};
}
/** @returns {RequestInterceptionHandler} */
function build() {
/**
@ -139,6 +171,7 @@ function createRequestContext(targetUrl, eventEmitter) {
return {
targetUrl,
blockMalware: blockMalwareSetup,
blockMinimumAgeRequest: blockMinimumAgeRequestSetup,
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
modifyBody: (func) => modifyBodyFuncs.push(func),
build,

View file

@ -1,4 +1,4 @@
import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js";
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
import { ui } from "../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js";
@ -65,16 +65,6 @@ 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.some((pattern) => matchesExclusionPattern(packageName, pattern))) {
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
);
@ -188,6 +178,25 @@ export function getHasSuppressedVersions() {
return state.hasSuppressedVersions;
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {string | undefined}
*/
export function getPackageNameFromMetadataResponse(body, headers) {
try {
const contentType = getHeaderValueAsString(headers, "content-type");
if (!contentType?.toLowerCase().includes("application/json")) {
return undefined;
}
const bodyJson = JSON.parse(body.toString("utf8"));
return typeof bodyJson.name === "string" ? bodyJson.name : undefined;
} catch {
return undefined;
}
}
/**
* Checks if a package name matches an exclusion pattern.
* Supports trailing wildcard (*) for prefix matching.
@ -195,7 +204,7 @@ export function getHasSuppressedVersions() {
* @param {string} pattern
* @returns {boolean}
*/
function matchesExclusionPattern(packageName, pattern) {
export function matchesExclusionPattern(packageName, pattern) {
if (pattern.endsWith("/*")) {
return packageName.startsWith(pattern.slice(0, -1));
}

View file

@ -1,15 +1,19 @@
import {
getNpmCustomRegistries,
getNpmMinimumPackageAgeExclusions,
skipMinimumPackageAge,
} from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js";
import {
getPackageNameFromMetadataResponse,
isPackageInfoUrl,
matchesExclusionPattern,
modifyNpmInfoRequestHeaders,
modifyNpmInfoResponse,
} from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesDatabase.js";
const knownJsRegistries = [
"registry.npmjs.org",
@ -43,14 +47,65 @@ function buildNpmInterceptor(registry) {
reqContext.targetUrl,
registry
);
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version);
return;
}
if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) {
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
reqContext.modifyBody(modifyNpmInfoResponse);
reqContext.modifyBody(modifyNpmInfoResponseUnlessExcluded);
return;
}
// For tarball requests the metadata check above is skipped, so we check the
// new packages list as a fallback (covers e.g. frozen-lockfile installs).
if (
minimumAgeChecksEnabled &&
packageName &&
version &&
!isExcludedFromMinimumPackageAge(packageName)
) {
const newPackagesDatabase = await openNewPackagesDatabase();
if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) {
reqContext.blockMinimumAgeRequest(
packageName,
version,
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
);
}
}
});
}
/**
* @param {string} packageName
* @returns {boolean}
*/
function isExcludedFromMinimumPackageAge(packageName) {
const exclusions = getNpmMinimumPackageAgeExclusions();
return exclusions.some((pattern) =>
matchesExclusionPattern(packageName, pattern)
);
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {Buffer}
*/
function modifyNpmInfoResponseUnlessExcluded(body, headers) {
const metadataPackageName = getPackageNameFromMetadataResponse(body, headers);
if (
metadataPackageName &&
isExcludedFromMinimumPackageAge(metadataPackageName)
) {
return body;
}
return modifyNpmInfoResponse(body, headers);
}

View file

@ -5,13 +5,25 @@ describe("npmInterceptor minimum package age", async () => {
let minimumPackageAgeSettings = 48;
let skipMinimumPackageAgeSetting = false;
let minimumPackageAgeExclusionsSetting = [];
let newlyReleasedPackages = new Set();
mock.module("../../../config/settings.js", {
namedExports: {
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
getNpmCustomRegistries: () => [],
getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getEcoSystem: () => "js",
},
});
mock.module("../../../scanning/newPackagesDatabase.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (name, version) =>
newlyReleasedPackages.has(`${name}@${version}`),
}),
},
});
@ -359,6 +371,67 @@ describe("npmInterceptor minimum package age", async () => {
assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0");
});
it("Should suppress too-young versions on metadata requests without directly blocking the request", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
const packageUrl = "https://registry.npmjs.org/lodash";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), true);
});
it("Should directly block tarball requests when the new packages list marks them as too young", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.ok(requestHandler.blockResponse);
assert.equal(requestHandler.modifiesResponse(), false);
assert.equal(requestHandler.blockResponse.statusCode, 403);
assert.equal(
requestHandler.blockResponse.message,
"Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)"
);
});
it("Should not block tarball requests when skipMinimumPackageAge is enabled", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = true;
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), false);
});
it("Should not block tarball requests when the package is excluded from minimum age", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["lodash"];
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), false);
});
it("Should not filter packages when package is in exclusion list", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
@ -540,6 +613,7 @@ describe("npmInterceptor minimum package age", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = []; // Reset to empty
newlyReleasedPackages = new Set();
const packageUrl = "https://registry.npmjs.org/lodash";

View file

@ -1,9 +1,11 @@
import { describe, it, mock } from "node:test";
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert";
let lastPackage;
let malwareResponse = false;
let customRegistries = [];
let newlyReleasedPackages = new Set();
let skipMinimumPackageAgeSetting = false;
mock.module("../../../scanning/audit/index.js", {
namedExports: {
@ -27,13 +29,29 @@ mock.module("../../../config/settings.js", {
getMinimumPackageAgeHours: () => 24,
getNpmCustomRegistries: () => customRegistries,
getNpmMinimumPackageAgeExclusions: () => [],
skipMinimumPackageAge: () => false,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});
mock.module("../../../scanning/newPackagesDatabase.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (name, version) =>
newlyReleasedPackages.has(`${name}@${version}`),
}),
},
});
describe("npmInterceptor", async () => {
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
beforeEach(() => {
lastPackage = undefined;
malwareResponse = false;
customRegistries = [];
newlyReleasedPackages = new Set();
skipMinimumPackageAgeSetting = false;
});
const parserCases = [
// Regular packages
{
@ -109,6 +127,10 @@ describe("npmInterceptor", async () => {
url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz",
expected: { packageName: "@babel/core", version: "7.21.4" },
},
{
url: "https://registry.yarnpkg.com/@music-i18n%2fverovio/-/verovio-1.4.1.tgz",
expected: { packageName: "@music-i18n/verovio", version: "1.4.1" },
},
// URL to get package info, not tarball
{
url: "https://registry.npmjs.org/lodash",
@ -178,6 +200,36 @@ describe("npmInterceptor", async () => {
"Block response should have correct status message"
);
});
it("should block direct tarball downloads for newly released packages", async () => {
const url =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123";
malwareResponse = false;
skipMinimumPackageAgeSetting = false;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const interceptor = npmInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse);
assert.equal(result.blockResponse.statusCode, 403);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)"
);
});
it("should not block direct tarball downloads when minimum age checks are skipped", async () => {
const url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
malwareResponse = false;
skipMinimumPackageAgeSetting = true;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const interceptor = npmInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
});
});
describe("npmInterceptor with custom registries", async () => {

View file

@ -5,12 +5,29 @@
*/
export function parseNpmPackageUrl(url, registry) {
let packageName, version;
if (!registry || !url.endsWith(".tgz")) {
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
return { packageName, version };
}
const registryIndex = url.indexOf(registry);
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
const pathname = parsedUrl.pathname;
if (!registry || !pathname.endsWith(".tgz")) {
return { packageName, version };
}
const registryPrefix = `${registry}/`;
const urlAfterProtocol = `${parsedUrl.host}${pathname}`;
if (!urlAfterProtocol.startsWith(registryPrefix)) {
return { packageName, version };
}
const afterRegistry = decodeURIComponent(
urlAfterProtocol.substring(registryPrefix.length)
);
const separatorIndex = afterRegistry.indexOf("/-/");
if (separatorIndex === -1) {

View file

@ -10,11 +10,16 @@ import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
const SERVER_STOP_TIMEOUT_MS = 1000;
/**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
* @type {{
* port: number | null,
* blockedRequests: {packageName: string, version: string, url: string}[],
* blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[]
* }}
*/
const state = {
port: null,
blockedRequests: [],
blockedMinimumAgeRequests: [],
};
export function createSafeChainProxy() {
@ -23,7 +28,8 @@ export function createSafeChainProxy() {
return {
startServer: () => startServer(server),
stopServer: () => stopServer(server),
verifyNoMaliciousPackages,
hasBlockedMaliciousPackages,
hasBlockedMinimumAgeRequests,
hasSuppressedVersions: getHasSuppressedVersions,
};
}
@ -151,6 +157,18 @@ function handleConnect(req, clientSocket, head) {
onMalwareBlocked(event.packageName, event.version, event.targetUrl);
}
);
interceptor.on(
"minimumAgeRequestBlocked",
(
/** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event
) => {
onMinimumAgeRequestBlocked(
event.packageName,
event.version,
event.targetUrl
);
}
);
mitmConnect(req, clientSocket, interceptor);
} else {
@ -170,10 +188,19 @@ function onMalwareBlocked(packageName, version, url) {
state.blockedRequests.push({ packageName, version, url });
}
function verifyNoMaliciousPackages() {
/**
*
* @param {string} packageName
* @param {string} version
* @param {string} url
*/
function onMinimumAgeRequestBlocked(packageName, version, url) {
state.blockedMinimumAgeRequests.push({ packageName, version, url });
}
function hasBlockedMaliciousPackages() {
if (state.blockedRequests.length === 0) {
// No malicious packages were blocked, so nothing to block
return true;
return false;
}
ui.emptyLine();
@ -192,5 +219,37 @@ function verifyNoMaliciousPackages() {
ui.writeExitWithoutInstallingMaliciousPackages();
ui.emptyLine();
return true;
}
function hasBlockedMinimumAgeRequests() {
if (state.blockedMinimumAgeRequests.length === 0) {
return false;
}
ui.emptyLine();
ui.writeInformation(
`Safe-chain: ${chalk.bold(
`blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age`
)}:`
);
for (const req of state.blockedMinimumAgeRequests) {
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
}
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age"
)}`
);
ui.emptyLine();
ui.writeError(
"Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check."
);
ui.emptyLine();
return true;
}

View file

@ -0,0 +1,187 @@
import fs from "fs";
import {
fetchNewPackagesList,
fetchNewPackagesListVersion,
} from "../api/aikido.js";
import {
getNewPackagesListPath,
getNewPackagesListVersionPath,
} from "../config/configFile.js";
import { ui } from "../environment/userInteraction.js";
import {
getMinimumPackageAgeHours,
getEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
} from "../config/settings.js";
/**
* @typedef {Object} NewPackagesDatabase
* @property {function(string, string): boolean} isNewlyReleasedPackage
*/
// Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
/** @type {NewPackagesDatabase | null} */
let cachedNewPackagesDatabase = null;
let hasWarnedAboutUnavailableNewPackagesDatabase = false;
/**
* Returns the ecosystem identifier expected in upstream/core release feeds.
* @returns {string}
*/
function getCurrentFeedSource() {
const ecosystem = getEcoSystem();
if (ecosystem === ECOSYSTEM_JS) {
return "npm";
}
if (ecosystem === ECOSYSTEM_PY) {
return "pypi";
}
return ecosystem;
}
/**
* @returns {Promise<NewPackagesDatabase>}
*/
export async function openNewPackagesDatabase() {
if (cachedNewPackagesDatabase) {
return cachedNewPackagesDatabase;
}
if (getEcoSystem() !== ECOSYSTEM_JS) {
cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false };
return cachedNewPackagesDatabase;
}
/** @type {import("../api/aikido.js").NewPackageEntry[]} */
let newPackagesList;
try {
newPackagesList = await getNewPackagesList();
} catch (/** @type {any} */ error) {
if (!hasWarnedAboutUnavailableNewPackagesDatabase) {
ui.writeWarning(
`Failed to load the new packages list used for direct package download request blocking. Continuing with metadata-based minimum age checks only. ${error.message}`
);
hasWarnedAboutUnavailableNewPackagesDatabase = true;
}
cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false };
return cachedNewPackagesDatabase;
}
/**
* @param {string} name
* @param {string} version
* @returns {boolean}
*/
function isNewlyReleasedPackage(name, version) {
const cutOff = new Date(
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
);
const expectedSource = getCurrentFeedSource();
const entry = newPackagesList.find(
(pkg) =>
(!pkg.source || pkg.source.toLowerCase() === expectedSource) &&
pkg.package_name === name &&
pkg.version === version
);
if (!entry) {
return false;
}
const releasedOn = new Date(entry.released_on * 1000);
return releasedOn > cutOff;
}
cachedNewPackagesDatabase = { isNewlyReleasedPackage };
return cachedNewPackagesDatabase;
}
/**
* @returns {Promise<import("../api/aikido.js").NewPackageEntry[]>}
*/
async function getNewPackagesList() {
const { newPackagesList: cachedList, version: cachedVersion } =
readNewPackagesListFromLocalCache();
try {
if (cachedList) {
const currentVersion = await fetchNewPackagesListVersion();
if (cachedVersion === currentVersion) {
return cachedList;
}
}
const { newPackagesList, version } = await fetchNewPackagesList();
if (version) {
writeNewPackagesListToLocalCache(newPackagesList, version);
return newPackagesList;
} else {
ui.writeWarning(
"The new packages list for direct package download request blocking was downloaded, but could not be cached due to a missing version."
);
return newPackagesList;
}
} catch (/** @type {any} */ error) {
if (cachedList) {
ui.writeWarning(
"Failed to fetch the latest new packages list for direct package download request blocking. Using cached version."
);
return cachedList;
}
throw error;
}
}
/**
* @param {import("../api/aikido.js").NewPackageEntry[]} data
* @param {string | number} version
*
* @returns {void}
*/
export function writeNewPackagesListToLocalCache(data, version) {
try {
const listPath = getNewPackagesListPath();
const versionPath = getNewPackagesListVersionPath();
fs.writeFileSync(listPath, JSON.stringify(data));
fs.writeFileSync(versionPath, version.toString());
} catch {
ui.writeWarning(
"Failed to write new packages list to local cache, next time the list will be fetched from the server again."
);
}
}
/**
* @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}}
*/
export function readNewPackagesListFromLocalCache() {
try {
const listPath = getNewPackagesListPath();
if (!fs.existsSync(listPath)) {
return { newPackagesList: null, version: null };
}
const data = fs.readFileSync(listPath, "utf8");
const newPackagesList = JSON.parse(data);
const versionPath = getNewPackagesListVersionPath();
let version = null;
if (fs.existsSync(versionPath)) {
version = fs.readFileSync(versionPath, "utf8").trim();
}
return { newPackagesList, version };
} catch {
ui.writeWarning(
"Failed to read new packages list from local cache. Continuing without local cache."
);
return { newPackagesList: null, version: null };
}
}

View file

@ -0,0 +1,257 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert";
import fs from "fs";
import path from "path";
import os from "os";
// --- shared mutable state for mocks ---
let fetchedList = [];
let fetchedVersion = "etag-1";
let fetchVersionResult = "etag-1";
let minimumPackageAgeHours = 24;
let ecosystem = "js";
let writeWarningCalls = [];
let fetchListError = null;
let fetchVersionError = null;
let importCounter = 0;
let testHomeDir = "";
mock.module("../api/aikido.js", {
namedExports: {
fetchNewPackagesList: async () => {
if (fetchListError) {
throw fetchListError;
}
return {
newPackagesList: fetchedList,
version: fetchedVersion,
};
},
fetchNewPackagesListVersion: async () => {
if (fetchVersionError) {
throw fetchVersionError;
}
return fetchVersionResult;
},
},
});
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
writeWarning: (msg) => writeWarningCalls.push(msg),
writeVerbose: () => {},
},
},
});
mock.module("../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
getEcoSystem: () => ecosystem,
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
},
});
describe("newPackagesDatabase", async () => {
beforeEach(() => {
fetchedList = [];
fetchedVersion = "etag-1";
fetchVersionResult = "etag-1";
minimumPackageAgeHours = 24;
ecosystem = "js";
writeWarningCalls = [];
fetchListError = null;
fetchVersionError = null;
testHomeDir = path.join(
os.tmpdir(),
`safe-chain-new-packages-db-${process.pid}-${importCounter}`
);
fs.rmSync(testHomeDir, { recursive: true, force: true });
fs.mkdirSync(testHomeDir, { recursive: true });
process.env.HOME = testHomeDir;
});
async function openNewPackagesDatabase() {
const module = await import(
`./newPackagesDatabase.js?test_case=${importCounter++}`
);
return module.openNewPackagesDatabase();
}
async function loadNewPackagesDatabaseModule() {
return import(`./newPackagesDatabase.js?test_case=${importCounter++}`);
}
function hoursAgo(hours) {
return Math.floor((Date.now() - hours * 3600 * 1000) / 1000);
}
function writeCachedList(list, version) {
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
fs.writeFileSync(
path.join(safeChainDir, `newPackagesList_${ecosystem}.json`),
JSON.stringify(list)
);
fs.writeFileSync(
path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`),
version
);
}
describe("isNewlyReleasedPackage", () => {
it("returns true for a package released within the age threshold", async () => {
fetchedList = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
it("returns false for a package released outside the age threshold", async () => {
fetchedList = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(48), scraped_on: hoursAgo(48) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
});
it("returns false for a package not in the list", async () => {
fetchedList = [];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false);
});
it("returns false for a known package but different version", async () => {
fetchedList = [
{ package_name: "foo", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
});
it("matches the current feed ecosystem when source metadata is present", async () => {
fetchedList = [
{
source: "pypi",
package_name: "foo",
version: "1.0.0",
released_on: hoursAgo(1),
scraped_on: hoursAgo(1),
},
{
source: "npm",
package_name: "bar",
version: "1.0.0",
released_on: hoursAgo(1),
scraped_on: hoursAgo(1),
},
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(db.isNewlyReleasedPackage("bar", "1.0.0"), true);
});
it("respects a custom minimumPackageAgeHours threshold", async () => {
minimumPackageAgeHours = 168; // 7 days
fetchedList = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(100), scraped_on: hoursAgo(100) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
it("returns false for all packages when ecosystem is not JS", async () => {
ecosystem = "py";
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
});
});
describe("caching behaviour", () => {
it("uses local cache when etag matches", async () => {
writeCachedList([
{ package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
], "etag-1");
fetchVersionResult = "etag-1";
// fetchedList is empty — if we used the remote list, the lookup would return false
fetchedList = [];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true);
});
it("fetches fresh list when etag does not match", async () => {
writeCachedList([
{ package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
], "etag-old");
fetchVersionResult = "etag-new";
fetchedList = [
{ package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("stale-pkg", "1.0.0"), false);
assert.strictEqual(db.isNewlyReleasedPackage("fresh-pkg", "2.0.0"), true);
});
it("falls back to local cache when fetch fails", async () => {
writeCachedList([
{
package_name: "cached-pkg",
version: "1.0.0",
released_on: hoursAgo(1),
scraped_on: hoursAgo(1),
},
], "etag-old");
fetchVersionResult = "etag-new";
fetchListError = new Error("Network error");
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true);
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("Using cached version"));
});
it("emits a warning when list has no version (cannot be cached)", async () => {
fetchedList = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
];
fetchedVersion = undefined;
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("could not be cached"));
});
it("fails open and only warns once when the new packages list cannot be loaded", async () => {
fetchListError = new Error("feed unavailable");
const module = await loadNewPackagesDatabaseModule();
const db1 = await module.openNewPackagesDatabase();
const db2 = await module.openNewPackagesDatabase();
assert.strictEqual(db1.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(db2.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(
writeWarningCalls[0].includes(
"Continuing with metadata-based minimum age checks only"
)
);
});
});
});

View file

@ -42,7 +42,7 @@ export async function troubleshootingExport() {
resolve(zipFileName);
});
archive.on('error', (err) => {
archive.on('error', (/** @type {Error} */ err) => {
ui.writeError(`Failed to zip logs: ${err.message}`);
reject(err);
});