From 2f4268f1af8c51f95c2d93d7f627a69232ace965 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 19 Mar 2026 15:58:42 -0700 Subject: [PATCH] Add extra check --- packages/safe-chain/src/main.js | 4 + .../interceptors/interceptorBuilder.js | 41 +++++++++- .../interceptors/npm/modifyNpmInfo.js | 2 +- .../interceptors/npm/npmInterceptor.js | 26 +++++++ .../npm/npmInterceptor.minPackageAge.spec.js | 74 +++++++++++++++++++ .../npmInterceptor.packageDownload.spec.js | 52 ++++++++++++- .../interceptors/npm/parseNpmPackageUrl.js | 10 ++- .../src/registryProxy/registryProxy.js | 62 +++++++++++++++- .../src/scanning/newPackagesDatabase.js | 18 ++++- .../src/scanning/newPackagesDatabase.spec.js | 21 ++++++ 10 files changed, 298 insertions(+), 12 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 0b37eba..9d5c031 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -68,6 +68,10 @@ export async function main(args) { return 1; } + if (!proxy.verifyNoMinimumAgeBlockedRequests()) { + return 1; + } + const auditStats = getAuditStats(); if (auditStats.totalPackages > 0) { ui.writeVerbose( diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index 7a844e9..fbfc131 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -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) => NodeJS.Dict) => void} modifyRequestHeaders * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict | 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, diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 14e3ba7..dfab97b 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -195,7 +195,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)); } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 3d3b8b4..b912977 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -1,15 +1,18 @@ import { getNpmCustomRegistries, + getNpmMinimumPackageAgeExclusions, skipMinimumPackageAge, } from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { isPackageInfoUrl, + matchesExclusionPattern, modifyNpmInfoRequestHeaders, modifyNpmInfoResponse, } from "./modifyNpmInfo.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; +import { openNewPackagesDatabase } from "../../../scanning/newPackagesDatabase.js"; const knownJsRegistries = [ "registry.npmjs.org", @@ -46,11 +49,34 @@ function buildNpmInterceptor(registry) { if (await isMalwarePackage(packageName, version)) { reqContext.blockMalware(packageName, version); + return; } if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); reqContext.modifyBody(modifyNpmInfoResponse); + 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 (!skipMinimumPackageAge() && packageName && version) { + const exclusions = getNpmMinimumPackageAgeExclusions(); + const isExcluded = exclusions.some((pattern) => + matchesExclusionPattern(packageName, pattern) + ); + + if (!isExcluded) { + const newPackagesDatabase = await openNewPackagesDatabase(); + + if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) { + reqContext.blockMinimumAgeRequest( + packageName, + version, + `Forbidden - blocked by safe-chain minimum package age (${packageName}@${version})` + ); + } + } } }); } 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 834a2ad..2e43119 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 @@ -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 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"; diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index e1b7c79..839605b 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -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 { @@ -178,6 +196,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 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 () => { diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index fa256d4..5e5248e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -5,12 +5,16 @@ */ export function parseNpmPackageUrl(url, registry) { let packageName, version; - if (!registry || !url.endsWith(".tgz")) { + const urlWithoutParams = url.split("?")[0].split("#")[0]; + + if (!registry || !urlWithoutParams.endsWith(".tgz")) { return { packageName, version }; } - const registryIndex = url.indexOf(registry); - const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + const registryIndex = urlWithoutParams.indexOf(registry); + const afterRegistry = urlWithoutParams.substring( + registryIndex + registry.length + 1 + ); // +1 to skip the slash const separatorIndex = afterRegistry.indexOf("/-/"); if (separatorIndex === -1) { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 2de776e..e67bab0 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -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() { @@ -24,6 +29,7 @@ export function createSafeChainProxy() { startServer: () => startServer(server), stopServer: () => stopServer(server), verifyNoMaliciousPackages, + verifyNoMinimumAgeBlockedRequests, 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,6 +188,16 @@ function onMalwareBlocked(packageName, version, url) { state.blockedRequests.push({ packageName, version, url }); } +/** + * + * @param {string} packageName + * @param {string} version + * @param {string} url + */ +function onMinimumAgeRequestBlocked(packageName, version, url) { + state.blockedMinimumAgeRequests.push({ packageName, version, url }); +} + function verifyNoMaliciousPackages() { if (state.blockedRequests.length === 0) { // No malicious packages were blocked, so nothing to block @@ -194,3 +222,35 @@ function verifyNoMaliciousPackages() { return false; } + +function verifyNoMinimumAgeBlockedRequests() { + if (state.blockedMinimumAgeRequests.length === 0) { + return true; + } + + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${state.blockedMinimumAgeRequests.length} package downloads due to minimum 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 minimum age." + ); + ui.emptyLine(); + + return false; +} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index 31afb7d..b587cdd 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -20,6 +20,7 @@ import { /** @type {NewPackagesDatabase | null} */ let cachedNewPackagesDatabase = null; +let hasWarnedAboutUnavailableNewPackagesDatabase = false; /** * Returns the source identifier used in the feed for the current ecosystem. @@ -42,7 +43,22 @@ export async function openNewPackagesDatabase() { return cachedNewPackagesDatabase; } - const newPackagesList = await getNewPackagesList(); + /** @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. Continuing without tarball minimum age fallback. ${error.message}` + ); + hasWarnedAboutUnavailableNewPackagesDatabase = true; + } + + cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; + return cachedNewPackagesDatabase; + } /** * @param {string} name diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 60a806f..3b2a20f 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -85,6 +85,10 @@ describe("newPackagesDatabase", async () => { 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); } @@ -226,5 +230,22 @@ describe("newPackagesDatabase", async () => { 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 without tarball minimum age fallback" + ) + ); + }); }); });