From cddcec9ba52df44c2064fb777ce5aa7accc5d7a8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 19 Mar 2026 14:14:13 -0700 Subject: [PATCH 01/13] Fetch new package list --- packages/safe-chain/src/api/aikido.js | 82 ++++++- packages/safe-chain/src/api/aikido.spec.js | 85 ++++++- packages/safe-chain/src/config/configFile.js | 64 +++++ .../src/scanning/newPackagesDatabase.js | 112 +++++++++ .../src/scanning/newPackagesDatabase.spec.js | 230 ++++++++++++++++++ .../src/ultimate/ultimateTroubleshooting.js | 2 +- 6 files changed, 564 insertions(+), 11 deletions(-) create mode 100644 packages/safe-chain/src/scanning/newPackagesDatabase.js create mode 100644 packages/safe-chain/src/scanning/newPackagesDatabase.spec.js diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index abb2135..fb01f42 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -11,6 +11,13 @@ const malwareDatabaseUrls = { [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", }; +// TODO: replace with the real CDN URL once core publishes the S3 endpoint +const newPackagesListUrls = { + [ECOSYSTEM_JS]: "https://new-packages.aikido.dev/js_packages.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} 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} */ 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} + */ +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; diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index 2e7cecb..b2d25c2 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -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,77 @@ describe("aikido API", async () => { assert.strictEqual(result, '"final-etag"'); }); }); + + describe("fetchNewPackagesList", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + const releases = [ + { + source: "NPM", + 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.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 = "py"; + + 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(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); + }); + }); }); diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index bc4dc94..0246fa9 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -203,6 +203,70 @@ export function readDatabaseFromLocalCache() { } } +/** + * @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 }; + } +} + +/** + * @returns {string} + */ +function getNewPackagesListPath() { + const safeChainDir = getSafeChainDirectory(); + const ecosystem = getEcoSystem(); + return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`); +} + +/** + * @returns {string} + */ +function getNewPackagesListVersionPath() { + const safeChainDir = getSafeChainDirectory(); + const ecosystem = getEcoSystem(); + return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`); +} + /** * @returns {SafeChainConfig} */ diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js new file mode 100644 index 0000000..31afb7d --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -0,0 +1,112 @@ +import { + fetchNewPackagesList, + fetchNewPackagesListVersion, +} from "../api/aikido.js"; +import { + readNewPackagesListFromLocalCache, + writeNewPackagesListToLocalCache, +} from "../config/configFile.js"; +import { ui } from "../environment/userInteraction.js"; +import { + getMinimumPackageAgeHours, + getEcoSystem, + ECOSYSTEM_JS, +} from "../config/settings.js"; + +/** + * @typedef {Object} NewPackagesDatabase + * @property {function(string, string): boolean} isNewlyReleasedPackage + */ + +/** @type {NewPackagesDatabase | null} */ +let cachedNewPackagesDatabase = null; + +/** + * Returns the source identifier used in the feed for the current ecosystem. + * @returns {string} + */ +function getCurrentFeedSource() { + return getEcoSystem(); +} + +/** + * @returns {Promise} + */ +export async function openNewPackagesDatabase() { + if (cachedNewPackagesDatabase) { + return cachedNewPackagesDatabase; + } + + if (getEcoSystem() !== ECOSYSTEM_JS) { + cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; + return cachedNewPackagesDatabase; + } + + const newPackagesList = await getNewPackagesList(); + + /** + * @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?.toLowerCase() === expectedSource && + pkg.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} + */ +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 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. Using cached version." + ); + return cachedList; + } + throw error; + } +} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js new file mode 100644 index 0000000..60a806f --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -0,0 +1,230 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; + +// --- shared mutable state for mocks --- +let cachedList = null; +let cachedVersion = null; +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; + +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("../config/configFile.js", { + namedExports: { + readNewPackagesListFromLocalCache: () => ({ + newPackagesList: cachedList, + version: cachedVersion, + }), + writeNewPackagesListToLocalCache: () => {}, + }, +}); + +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(() => { + cachedList = null; + cachedVersion = null; + fetchedList = []; + fetchedVersion = "etag-1"; + fetchVersionResult = "etag-1"; + minimumPackageAgeHours = 24; + ecosystem = "js"; + writeWarningCalls = []; + fetchListError = null; + fetchVersionError = null; + }); + + async function openNewPackagesDatabase() { + const module = await import( + `./newPackagesDatabase.js?test_case=${importCounter++}` + ); + return module.openNewPackagesDatabase(); + } + + function hoursAgo(hours) { + return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); + } + + describe("isNewlyReleasedPackage", () => { + it("returns true for a package released within the age threshold", async () => { + fetchedList = [ + { source: "js", 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 = [ + { source: "js", 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 = [ + { source: "js", 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("ignores entries from a different source in a mixed feed", async () => { + fetchedList = [ + { + source: "npm", + name: "foo", + version: "1.0.0", + released_on: hoursAgo(1), + scraped_on: hoursAgo(1), + }, + { + source: "js", + 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 = [ + { source: "js", 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 () => { + cachedList = [ + { source: "js", name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + cachedVersion = "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 () => { + cachedList = [ + { source: "js", name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + cachedVersion = "etag-old"; + fetchVersionResult = "etag-new"; + fetchedList = [ + { source: "js", 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 () => { + cachedList = [ + { + source: "js", + name: "cached-pkg", + version: "1.0.0", + released_on: hoursAgo(1), + scraped_on: hoursAgo(1), + }, + ]; + cachedVersion = "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 = [ + { source: "js", 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")); + }); + }); +}); diff --git a/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js index e333615..114bd5e 100644 --- a/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js +++ b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js @@ -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); }); From 2f4268f1af8c51f95c2d93d7f627a69232ace965 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 19 Mar 2026 15:58:42 -0700 Subject: [PATCH 02/13] 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" + ) + ); + }); }); }); From 07e315a382611eab263dd3514799e4a68b3bba7f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 19 Mar 2026 16:07:31 -0700 Subject: [PATCH 03/13] Adapt doc --- README.md | 12 +++++++++++- packages/safe-chain/src/main.js | 2 +- .../safe-chain/src/registryProxy/registryProxy.js | 4 ++-- .../safe-chain/src/scanning/newPackagesDatabase.js | 6 +++--- .../src/scanning/newPackagesDatabase.spec.js | 2 +- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4daf1d2..6d0e875 100644 --- a/README.md +++ b/README.md @@ -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 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. +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 24 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 24 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): diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 9d5c031..d9e5417 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -85,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( diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index e67bab0..4adba61 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -232,7 +232,7 @@ function verifyNoMinimumAgeBlockedRequests() { ui.writeInformation( `Safe-chain: ${chalk.bold( - `blocked ${state.blockedMinimumAgeRequests.length} package downloads due to minimum age` + `blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age` )}:` ); @@ -248,7 +248,7 @@ function verifyNoMinimumAgeBlockedRequests() { ui.emptyLine(); ui.writeError( - "Safe-chain: Exiting without installing packages blocked by minimum age." + "Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check." ); ui.emptyLine(); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index b587cdd..fb99164 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -51,7 +51,7 @@ export async function openNewPackagesDatabase() { } catch (/** @type {any} */ error) { if (!hasWarnedAboutUnavailableNewPackagesDatabase) { ui.writeWarning( - `Failed to load the new packages list. Continuing without tarball minimum age fallback. ${error.message}` + `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; } @@ -112,14 +112,14 @@ async function getNewPackagesList() { return newPackagesList; } else { ui.writeWarning( - "The new packages list was downloaded, but could not be cached due to a missing version." + "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. Using cached version." + "Failed to fetch the latest new packages list for direct package download request blocking. Using cached version." ); return cachedList; } diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 3b2a20f..e2c88f7 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -243,7 +243,7 @@ describe("newPackagesDatabase", async () => { assert.strictEqual(writeWarningCalls.length, 1); assert.ok( writeWarningCalls[0].includes( - "Continuing without tarball minimum age fallback" + "Continuing with metadata-based minimum age checks only" ) ); }); From ac09534070efb2e34b76fd4650b1675044198c53 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 20 Mar 2026 09:11:02 -0700 Subject: [PATCH 04/13] Adapt per latest core --- packages/safe-chain/src/api/aikido.js | 8 ++--- packages/safe-chain/src/api/aikido.spec.js | 5 ++-- .../src/scanning/newPackagesDatabase.js | 19 +++++++++--- .../src/scanning/newPackagesDatabase.spec.js | 29 +++++++++---------- 4 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index fb01f42..5248e0f 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -11,9 +11,9 @@ const malwareDatabaseUrls = { [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", }; -// TODO: replace with the real CDN URL once core publishes the S3 endpoint const newPackagesListUrls = { - [ECOSYSTEM_JS]: "https://new-packages.aikido.dev/js_packages.json", + [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; @@ -27,8 +27,8 @@ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; /** * @typedef {Object} NewPackageEntry - * @property {string} source - * @property {string} name + * @property {string} [source] + * @property {string} package_name * @property {string} version * @property {number} released_on - Unix timestamp (seconds) * @property {number} scraped_on - Unix timestamp (seconds) diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index b2d25c2..d70f7e2 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -141,8 +141,7 @@ describe("aikido API", async () => { it("should succeed immediately when fetch succeeds on first try", async () => { const releases = [ { - source: "NPM", - name: "fresh-pkg", + package_name: "fresh-pkg", version: "1.0.0", released_on: 123, scraped_on: 456, @@ -174,7 +173,7 @@ describe("aikido API", async () => { }); it("should return an empty list without fetching for unsupported ecosystems", async () => { - ecosystem = "py"; + ecosystem = "ruby"; const result = await fetchNewPackagesList(); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index fb99164..b480dab 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -11,6 +11,7 @@ import { getMinimumPackageAgeHours, getEcoSystem, ECOSYSTEM_JS, + ECOSYSTEM_PY, } from "../config/settings.js"; /** @@ -23,11 +24,21 @@ let cachedNewPackagesDatabase = null; let hasWarnedAboutUnavailableNewPackagesDatabase = false; /** - * Returns the source identifier used in the feed for the current ecosystem. + * Returns the ecosystem identifier expected in upstream/core release feeds. * @returns {string} */ function getCurrentFeedSource() { - return getEcoSystem(); + const ecosystem = getEcoSystem(); + + if (ecosystem === ECOSYSTEM_JS) { + return "npm"; + } + + if (ecosystem === ECOSYSTEM_PY) { + return "pypi"; + } + + return ecosystem; } /** @@ -73,8 +84,8 @@ export async function openNewPackagesDatabase() { const entry = newPackagesList.find( (pkg) => - pkg.source?.toLowerCase() === expectedSource && - pkg.name === name && + (!pkg.source || pkg.source.toLowerCase() === expectedSource) && + pkg.package_name === name && pkg.version === version ); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index e2c88f7..58c9a74 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -96,7 +96,7 @@ describe("newPackagesDatabase", async () => { describe("isNewlyReleasedPackage", () => { it("returns true for a package released within the age threshold", async () => { fetchedList = [ - { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; const db = await openNewPackagesDatabase(); @@ -105,7 +105,7 @@ describe("newPackagesDatabase", async () => { it("returns false for a package released outside the age threshold", async () => { fetchedList = [ - { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(48), scraped_on: hoursAgo(48) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(48), scraped_on: hoursAgo(48) }, ]; const db = await openNewPackagesDatabase(); @@ -121,25 +121,25 @@ describe("newPackagesDatabase", async () => { it("returns false for a known package but different version", async () => { fetchedList = [ - { source: "js", name: "foo", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { 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("ignores entries from a different source in a mixed feed", async () => { + it("matches the current feed ecosystem when source metadata is present", async () => { fetchedList = [ { - source: "npm", - name: "foo", + source: "pypi", + package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1), }, { - source: "js", - name: "bar", + source: "npm", + package_name: "bar", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1), @@ -155,7 +155,7 @@ describe("newPackagesDatabase", async () => { it("respects a custom minimumPackageAgeHours threshold", async () => { minimumPackageAgeHours = 168; // 7 days fetchedList = [ - { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(100), scraped_on: hoursAgo(100) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(100), scraped_on: hoursAgo(100) }, ]; const db = await openNewPackagesDatabase(); @@ -172,7 +172,7 @@ describe("newPackagesDatabase", async () => { describe("caching behaviour", () => { it("uses local cache when etag matches", async () => { cachedList = [ - { source: "js", name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; cachedVersion = "etag-1"; fetchVersionResult = "etag-1"; @@ -185,12 +185,12 @@ describe("newPackagesDatabase", async () => { it("fetches fresh list when etag does not match", async () => { cachedList = [ - { source: "js", name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; cachedVersion = "etag-old"; fetchVersionResult = "etag-new"; fetchedList = [ - { source: "js", name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; const db = await openNewPackagesDatabase(); @@ -201,8 +201,7 @@ describe("newPackagesDatabase", async () => { it("falls back to local cache when fetch fails", async () => { cachedList = [ { - source: "js", - name: "cached-pkg", + package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1), @@ -221,7 +220,7 @@ describe("newPackagesDatabase", async () => { it("emits a warning when list has no version (cannot be cached)", async () => { fetchedList = [ - { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; fetchedVersion = undefined; From 16c51c2720497179e9dc1d2ea4663455a289f41b Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 20 Mar 2026 10:28:46 -0700 Subject: [PATCH 05/13] Add e2e test skeleton --- ...imum-package-age-request-block.e2e.spec.js | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 test/e2e/minimum-package-age-request-block.e2e.spec.js diff --git a/test/e2e/minimum-package-age-request-block.e2e.spec.js b/test/e2e/minimum-package-age-request-block.e2e.spec.js new file mode 100644 index 0000000..5dd147c --- /dev/null +++ b/test/e2e/minimum-package-age-request-block.e2e.spec.js @@ -0,0 +1,161 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe.skip( + "E2E: minimum package age direct request fallback", + () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup-ci"); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("blocks npm ci when a lockfile resolves to a recently released package", async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand( + "npm init -y && npm pkg set dependencies.axios=1.8.4" + ); + await shell.runCommand("npm install --package-lock-only"); + await shell.runCommand("rm -rf node_modules"); + await seedNewPackagesListCache(shell, [ + { + package_name: "axios", + version: "1.8.4", + released_on: unixHoursAgo(1), + scraped_on: unixHoursAgo(1), + }, + ]); + + const result = await shell.runCommand( + "npm ci --safe-chain-minimum-package-age-hours=168 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes( + "blocked 1 direct package download request(s) due to minimum package age" + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- axios@1.8.4"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes( + "Exiting without installing packages blocked by the direct download minimum package age check." + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it("blocks yarn frozen-lockfile installs when the cached recent releases list marks the tarball as too young", async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand( + "npm init -y && npm pkg set dependencies.axios=1.8.4" + ); + await shell.runCommand("yarn install"); + await shell.runCommand("rm -rf node_modules"); + await seedNewPackagesListCache(shell, [ + { + package_name: "axios", + version: "1.8.4", + released_on: unixHoursAgo(1), + scraped_on: unixHoursAgo(1), + }, + ]); + + const result = await shell.runCommand( + "yarn install --frozen-lockfile --safe-chain-minimum-package-age-hours=168 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes( + "blocked 1 direct package download request(s) due to minimum package age" + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- axios@1.8.4"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it("allows the same lockfile-driven install when minimum age checks are skipped", async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand( + "npm init -y && npm pkg set dependencies.axios=1.8.4" + ); + await shell.runCommand("npm install --package-lock-only"); + await shell.runCommand("rm -rf node_modules"); + await seedNewPackagesListCache(shell, [ + { + package_name: "axios", + version: "1.8.4", + released_on: unixHoursAgo(1), + scraped_on: unixHoursAgo(1), + }, + ]); + + const result = await shell.runCommand( + "npm ci --safe-chain-minimum-package-age-hours=168 --safe-chain-skip-minimum-package-age --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + !result.output.includes( + "direct package download request(s) due to minimum package age" + ), + `Output unexpectedly contained a direct request block. Output was:\n${result.output}` + ); + }); + } +); + +/** + * @param {{ runCommand: (command: string) => Promise<{output: string}> }} shell + * @param {Array<{package_name: string, version: string, released_on: number, scraped_on: number}>} entries + */ +async function seedNewPackagesListCache(shell, entries) { + const payload = JSON.stringify(entries).replace(/"/g, '\\"'); + + await shell.runCommand("mkdir -p ~/.safe-chain"); + await shell.runCommand( + `printf "%s" "${payload}" > ~/.safe-chain/newPackagesList_js.json` + ); + await shell.runCommand( + 'printf "%s" "test-etag" > ~/.safe-chain/newPackagesList_version_js.txt' + ); +} + +/** + * @param {number} hours + * @returns {number} + */ +function unixHoursAgo(hours) { + return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); +} From edf6a1694f93503b000b359f3e6b7d8aac662c83 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 10:35:41 -0700 Subject: [PATCH 06/13] Some cleanups --- packages/safe-chain/src/api/aikido.js | 4 +- packages/safe-chain/src/api/aikido.spec.js | 11 ++ .../interceptors/npm/npmInterceptor.js | 2 +- ...imum-package-age-request-block.e2e.spec.js | 161 ------------------ 4 files changed, 14 insertions(+), 164 deletions(-) delete mode 100644 test/e2e/minimum-package-age-request-block.e2e.spec.js diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 5248e0f..0ceec21 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -12,8 +12,8 @@ const malwareDatabaseUrls = { }; const newPackagesListUrls = { - [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases_npm.json", - [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases_pypi.json", + [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; diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index d70f7e2..0d3a964 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -156,6 +156,10 @@ describe("aikido API", async () => { 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"'); }); @@ -193,6 +197,13 @@ describe("aikido API", async () => { 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"'); }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index b912977..c1310bd 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -73,7 +73,7 @@ function buildNpmInterceptor(registry) { reqContext.blockMinimumAgeRequest( packageName, version, - `Forbidden - blocked by safe-chain minimum package age (${packageName}@${version})` + `Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})` ); } } diff --git a/test/e2e/minimum-package-age-request-block.e2e.spec.js b/test/e2e/minimum-package-age-request-block.e2e.spec.js deleted file mode 100644 index 5dd147c..0000000 --- a/test/e2e/minimum-package-age-request-block.e2e.spec.js +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe.skip( - "E2E: minimum package age direct request fallback", - () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup-ci"); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" - ); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("blocks npm ci when a lockfile resolves to a recently released package", async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand( - "npm init -y && npm pkg set dependencies.axios=1.8.4" - ); - await shell.runCommand("npm install --package-lock-only"); - await shell.runCommand("rm -rf node_modules"); - await seedNewPackagesListCache(shell, [ - { - package_name: "axios", - version: "1.8.4", - released_on: unixHoursAgo(1), - scraped_on: unixHoursAgo(1), - }, - ]); - - const result = await shell.runCommand( - "npm ci --safe-chain-minimum-package-age-hours=168 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes( - "blocked 1 direct package download request(s) due to minimum package age" - ), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- axios@1.8.4"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes( - "Exiting without installing packages blocked by the direct download minimum package age check." - ), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it("blocks yarn frozen-lockfile installs when the cached recent releases list marks the tarball as too young", async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand( - "npm init -y && npm pkg set dependencies.axios=1.8.4" - ); - await shell.runCommand("yarn install"); - await shell.runCommand("rm -rf node_modules"); - await seedNewPackagesListCache(shell, [ - { - package_name: "axios", - version: "1.8.4", - released_on: unixHoursAgo(1), - scraped_on: unixHoursAgo(1), - }, - ]); - - const result = await shell.runCommand( - "yarn install --frozen-lockfile --safe-chain-minimum-package-age-hours=168 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes( - "blocked 1 direct package download request(s) due to minimum package age" - ), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- axios@1.8.4"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it("allows the same lockfile-driven install when minimum age checks are skipped", async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand( - "npm init -y && npm pkg set dependencies.axios=1.8.4" - ); - await shell.runCommand("npm install --package-lock-only"); - await shell.runCommand("rm -rf node_modules"); - await seedNewPackagesListCache(shell, [ - { - package_name: "axios", - version: "1.8.4", - released_on: unixHoursAgo(1), - scraped_on: unixHoursAgo(1), - }, - ]); - - const result = await shell.runCommand( - "npm ci --safe-chain-minimum-package-age-hours=168 --safe-chain-skip-minimum-package-age --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - !result.output.includes( - "direct package download request(s) due to minimum package age" - ), - `Output unexpectedly contained a direct request block. Output was:\n${result.output}` - ); - }); - } -); - -/** - * @param {{ runCommand: (command: string) => Promise<{output: string}> }} shell - * @param {Array<{package_name: string, version: string, released_on: number, scraped_on: number}>} entries - */ -async function seedNewPackagesListCache(shell, entries) { - const payload = JSON.stringify(entries).replace(/"/g, '\\"'); - - await shell.runCommand("mkdir -p ~/.safe-chain"); - await shell.runCommand( - `printf "%s" "${payload}" > ~/.safe-chain/newPackagesList_js.json` - ); - await shell.runCommand( - 'printf "%s" "test-etag" > ~/.safe-chain/newPackagesList_version_js.txt' - ); -} - -/** - * @param {number} hours - * @returns {number} - */ -function unixHoursAgo(hours) { - return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); -} From db31fa9f416f2a43849289b9cc0326ae645e7c57 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 10:37:47 -0700 Subject: [PATCH 07/13] Fix unit test --- .../interceptors/npm/npmInterceptor.minPackageAge.spec.js | 2 +- .../interceptors/npm/npmInterceptor.packageDownload.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 2e43119..45d3ceb 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 @@ -398,7 +398,7 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(requestHandler.blockResponse.statusCode, 403); assert.equal( requestHandler.blockResponse.message, - "Forbidden - blocked by safe-chain minimum package age (lodash@4.17.21)" + "Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)" ); }); 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 839605b..f376e1b 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 @@ -211,7 +211,7 @@ describe("npmInterceptor", async () => { assert.equal(result.blockResponse.statusCode, 403); assert.equal( result.blockResponse.message, - "Forbidden - blocked by safe-chain minimum package age (lodash@4.17.21)" + "Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)" ); }); From a53fc736e9e63b87c23ce3a3658bed94d70af3f9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 11:45:26 -0700 Subject: [PATCH 08/13] Fix yarn URL issue --- .../interceptors/npm/npmInterceptor.packageDownload.spec.js | 4 ++++ .../src/registryProxy/interceptors/npm/parseNpmPackageUrl.js | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) 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 f376e1b..0c4b377 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 @@ -127,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", diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index 5e5248e..5d12c0e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -12,9 +12,9 @@ export function parseNpmPackageUrl(url, registry) { } const registryIndex = urlWithoutParams.indexOf(registry); - const afterRegistry = urlWithoutParams.substring( + const afterRegistry = decodeURIComponent(urlWithoutParams.substring( registryIndex + registry.length + 1 - ); // +1 to skip the slash + )); // +1 to skip the slash const separatorIndex = afterRegistry.indexOf("/-/"); if (separatorIndex === -1) { From 8353f353ae9e3858b8ec15195b39c7b32e9ec554 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 11:52:55 -0700 Subject: [PATCH 09/13] Fix per review comment --- packages/safe-chain/src/scanning/newPackagesDatabase.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index b480dab..acda1e9 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -19,6 +19,7 @@ import { * @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; From 2df8ce463c19c50cd5996ad897590c1ae2f4ad65 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 13:17:58 -0700 Subject: [PATCH 10/13] Adapt per review --- packages/safe-chain/src/config/configFile.js | 84 +++++-------------- packages/safe-chain/src/main.js | 4 +- .../interceptors/npm/parseNpmPackageUrl.js | 25 ++++-- .../src/registryProxy/registryProxy.js | 17 ++-- .../src/scanning/newPackagesDatabase.js | 51 ++++++++++- .../src/scanning/newPackagesDatabase.spec.js | 53 +++++++----- 6 files changed, 127 insertions(+), 107 deletions(-) diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 0246fa9..b421fde 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -203,70 +203,6 @@ export function readDatabaseFromLocalCache() { } } -/** - * @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 }; - } -} - -/** - * @returns {string} - */ -function getNewPackagesListPath() { - const safeChainDir = getSafeChainDirectory(); - const ecosystem = getEcoSystem(); - return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`); -} - -/** - * @returns {string} - */ -function getNewPackagesListVersionPath() { - const safeChainDir = getSafeChainDirectory(); - const ecosystem = getEcoSystem(); - return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`); -} - /** * @returns {SafeChainConfig} */ @@ -312,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} */ @@ -332,7 +286,7 @@ function getConfigFilePath() { /** * @returns {string} */ -function getSafeChainDirectory() { +export function getSafeChainDirectory() { const homeDir = os.homedir(); const safeChainDir = path.join(homeDir, ".safe-chain"); diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index d9e5417..74f8a25 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -64,11 +64,11 @@ export async function main(args) { // Write all buffered logs ui.writeBufferedLogsAndStopBuffering(); - if (!proxy.verifyNoMaliciousPackages()) { + if (proxy.hasBlockedMaliciousPackages()) { return 1; } - if (!proxy.verifyNoMinimumAgeBlockedRequests()) { + if (proxy.hasBlockedMinimumAgeRequests()) { return 1; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index 5d12c0e..13cb99a 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -5,16 +5,29 @@ */ export function parseNpmPackageUrl(url, registry) { let packageName, version; - const urlWithoutParams = url.split("?")[0].split("#")[0]; + let parsedUrl; - if (!registry || !urlWithoutParams.endsWith(".tgz")) { + try { + parsedUrl = new URL(url); + } catch { return { packageName, version }; } - const registryIndex = urlWithoutParams.indexOf(registry); - const afterRegistry = decodeURIComponent(urlWithoutParams.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) { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 4adba61..81b265d 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -28,8 +28,8 @@ export function createSafeChainProxy() { return { startServer: () => startServer(server), stopServer: () => stopServer(server), - verifyNoMaliciousPackages, - verifyNoMinimumAgeBlockedRequests, + hasBlockedMaliciousPackages, + hasBlockedMinimumAgeRequests, hasSuppressedVersions: getHasSuppressedVersions, }; } @@ -198,10 +198,9 @@ function onMinimumAgeRequestBlocked(packageName, version, url) { state.blockedMinimumAgeRequests.push({ packageName, version, url }); } -function verifyNoMaliciousPackages() { +function hasBlockedMaliciousPackages() { if (state.blockedRequests.length === 0) { - // No malicious packages were blocked, so nothing to block - return true; + return false; } ui.emptyLine(); @@ -220,12 +219,12 @@ function verifyNoMaliciousPackages() { ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); - return false; + return true; } -function verifyNoMinimumAgeBlockedRequests() { +function hasBlockedMinimumAgeRequests() { if (state.blockedMinimumAgeRequests.length === 0) { - return true; + return false; } ui.emptyLine(); @@ -252,5 +251,5 @@ function verifyNoMinimumAgeBlockedRequests() { ); ui.emptyLine(); - return false; + return true; } diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index acda1e9..6a74656 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -1,10 +1,11 @@ +import fs from "fs"; import { fetchNewPackagesList, fetchNewPackagesListVersion, } from "../api/aikido.js"; import { - readNewPackagesListFromLocalCache, - writeNewPackagesListToLocalCache, + getNewPackagesListPath, + getNewPackagesListVersionPath, } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; import { @@ -138,3 +139,49 @@ async function getNewPackagesList() { 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 }; + } +} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 58c9a74..29f04d5 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -1,9 +1,10 @@ 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 cachedList = null; -let cachedVersion = null; let fetchedList = []; let fetchedVersion = "etag-1"; let fetchVersionResult = "etag-1"; @@ -13,6 +14,7 @@ let writeWarningCalls = []; let fetchListError = null; let fetchVersionError = null; let importCounter = 0; +let testHomeDir = ""; mock.module("../api/aikido.js", { namedExports: { @@ -36,16 +38,6 @@ mock.module("../api/aikido.js", { }, }); -mock.module("../config/configFile.js", { - namedExports: { - readNewPackagesListFromLocalCache: () => ({ - newPackagesList: cachedList, - version: cachedVersion, - }), - writeNewPackagesListToLocalCache: () => {}, - }, -}); - mock.module("../environment/userInteraction.js", { namedExports: { ui: { @@ -66,8 +58,6 @@ mock.module("../config/settings.js", { describe("newPackagesDatabase", async () => { beforeEach(() => { - cachedList = null; - cachedVersion = null; fetchedList = []; fetchedVersion = "etag-1"; fetchVersionResult = "etag-1"; @@ -76,6 +66,13 @@ describe("newPackagesDatabase", async () => { 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() { @@ -93,6 +90,19 @@ describe("newPackagesDatabase", async () => { 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 = [ @@ -171,10 +181,9 @@ describe("newPackagesDatabase", async () => { describe("caching behaviour", () => { it("uses local cache when etag matches", async () => { - cachedList = [ + writeCachedList([ { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, - ]; - cachedVersion = "etag-1"; + ], "etag-1"); fetchVersionResult = "etag-1"; // fetchedList is empty — if we used the remote list, the lookup would return false fetchedList = []; @@ -184,10 +193,9 @@ describe("newPackagesDatabase", async () => { }); it("fetches fresh list when etag does not match", async () => { - cachedList = [ + writeCachedList([ { package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, - ]; - cachedVersion = "etag-old"; + ], "etag-old"); fetchVersionResult = "etag-new"; fetchedList = [ { package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, @@ -199,15 +207,14 @@ describe("newPackagesDatabase", async () => { }); it("falls back to local cache when fetch fails", async () => { - cachedList = [ + writeCachedList([ { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1), }, - ]; - cachedVersion = "etag-old"; + ], "etag-old"); fetchVersionResult = "etag-new"; fetchListError = new Error("Network error"); From 8a4f759a78c8d608e9d61f4c8a55eab6e7124f28 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 14:25:58 -0700 Subject: [PATCH 11/13] Some cleanup --- .../interceptors/npm/modifyNpmInfo.js | 12 +-- .../interceptors/npm/npmInterceptor.js | 83 +++++++++++++++---- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index dfab97b..d8468d6 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -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 ); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index c1310bd..8a6d7eb 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -46,37 +46,86 @@ function buildNpmInterceptor(registry) { reqContext.targetUrl, registry ); + const minimumAgeChecksEnabled = !skipMinimumPackageAge(); + const packageIsExcludedFromMinimumAgeChecks = + packageName && isExcludedFromMinimumPackageAge(packageName); 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((body, headers) => { + const metadataPackageName = getPackageNameFromMetadataResponse( + body, + headers + ); + + if ( + metadataPackageName && + isExcludedFromMinimumPackageAge(metadataPackageName) + ) { + return body; + } + + return modifyNpmInfoResponse(body, headers); + }); 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 ( + minimumAgeChecksEnabled && + packageName && + version && + !packageIsExcludedFromMinimumAgeChecks + ) { + const newPackagesDatabase = await openNewPackagesDatabase(); - if (!isExcluded) { - 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})` - ); - } + 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 | undefined} headers + * @returns {string | undefined} + */ +function getPackageNameFromMetadataResponse(body, headers) { + try { + const contentType = headers?.["content-type"]; + const normalizedContentType = Array.isArray(contentType) + ? contentType.join(",") + : contentType; + + if (!normalizedContentType?.toLowerCase().includes("application/json")) { + return undefined; + } + + const bodyJson = JSON.parse(body.toString("utf8")); + return typeof bodyJson.name === "string" ? bodyJson.name : undefined; + } catch { + return undefined; + } +} From 8133f0c97016fd00ae5544c08ae6d1bd6047ad68 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 14:38:41 -0700 Subject: [PATCH 12/13] Some more cleanup --- .../interceptors/npm/modifyNpmInfo.js | 19 +++++++++++++ .../interceptors/npm/npmInterceptor.js | 28 ++----------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index d8468d6..a9a8c41 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -178,6 +178,25 @@ export function getHasSuppressedVersions() { return state.hasSuppressedVersions; } +/** + * @param {Buffer} body + * @param {NodeJS.Dict | 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. diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 8a6d7eb..57e5b93 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -6,6 +6,7 @@ import { import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { + getPackageNameFromMetadataResponse, isPackageInfoUrl, matchesExclusionPattern, modifyNpmInfoRequestHeaders, @@ -47,8 +48,6 @@ function buildNpmInterceptor(registry) { registry ); const minimumAgeChecksEnabled = !skipMinimumPackageAge(); - const packageIsExcludedFromMinimumAgeChecks = - packageName && isExcludedFromMinimumPackageAge(packageName); if (await isMalwarePackage(packageName, version)) { reqContext.blockMalware(packageName, version); @@ -81,7 +80,7 @@ function buildNpmInterceptor(registry) { minimumAgeChecksEnabled && packageName && version && - !packageIsExcludedFromMinimumAgeChecks + !isExcludedFromMinimumPackageAge(packageName) ) { const newPackagesDatabase = await openNewPackagesDatabase(); @@ -106,26 +105,3 @@ function isExcludedFromMinimumPackageAge(packageName) { matchesExclusionPattern(packageName, pattern) ); } - -/** - * @param {Buffer} body - * @param {NodeJS.Dict | undefined} headers - * @returns {string | undefined} - */ -function getPackageNameFromMetadataResponse(body, headers) { - try { - const contentType = headers?.["content-type"]; - const normalizedContentType = Array.isArray(contentType) - ? contentType.join(",") - : contentType; - - if (!normalizedContentType?.toLowerCase().includes("application/json")) { - return undefined; - } - - const bodyJson = JSON.parse(body.toString("utf8")); - return typeof bodyJson.name === "string" ? bodyJson.name : undefined; - } catch { - return undefined; - } -} From 3a01a92f03605ee55e2aa0fe173c8e22ce587476 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 15:14:13 -0700 Subject: [PATCH 13/13] Code Quality --- .../interceptors/npm/npmInterceptor.js | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 57e5b93..2a41524 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -56,21 +56,7 @@ function buildNpmInterceptor(registry) { if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) { reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); - reqContext.modifyBody((body, headers) => { - const metadataPackageName = getPackageNameFromMetadataResponse( - body, - headers - ); - - if ( - metadataPackageName && - isExcludedFromMinimumPackageAge(metadataPackageName) - ) { - return body; - } - - return modifyNpmInfoResponse(body, headers); - }); + reqContext.modifyBody(modifyNpmInfoResponseUnlessExcluded); return; } @@ -105,3 +91,21 @@ function isExcludedFromMinimumPackageAge(packageName) { matchesExclusionPattern(packageName, pattern) ); } + +/** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @returns {Buffer} + */ +function modifyNpmInfoResponseUnlessExcluded(body, headers) { + const metadataPackageName = getPackageNameFromMetadataResponse(body, headers); + + if ( + metadataPackageName && + isExcludedFromMinimumPackageAge(metadataPackageName) + ) { + return body; + } + + return modifyNpmInfoResponse(body, headers); +}