diff --git a/README.md b/README.md index a391cdd..9b1b04e 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 48 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. +For npm packages, Safe Chain applies minimum package age checks in two ways: + +- During normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry. +- For direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages. + +By default, the minimum package age is 48 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. ⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx). @@ -185,6 +190,11 @@ You can set the logging level through multiple sources (in order of priority): You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed through npm-based package managers. +For npm-based package managers, this check currently has two enforcement modes: + +- Safe Chain suppresses too-young versions from package metadata during normal dependency resolution. +- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list. + ### Configuration Options You can set the minimum package age through multiple sources (in order of priority): diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index abb2135..0ceec21 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", }; +const newPackagesListUrls = { + [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases/npm.json", + [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases/pypi.json", +}; + +const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; + /** * @typedef {Object} MalwarePackage * @property {string} package_name @@ -18,12 +25,19 @@ const malwareDatabaseUrls = { * @property {string} reason */ +/** + * @typedef {Object} NewPackageEntry + * @property {string} [source] + * @property {string} package_name + * @property {string} version + * @property {number} released_on - Unix timestamp (seconds) + * @property {number} scraped_on - Unix timestamp (seconds) + */ + /** * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} */ export async function fetchMalwareDatabase() { - const numberOfAttempts = 4; - return retry(async () => { const ecosystem = getEcoSystem(); const malwareDatabaseUrl = @@ -46,15 +60,13 @@ export async function fetchMalwareDatabase() { } catch (/** @type {any} */ error) { throw new Error(`Error parsing malware database: ${error.message}`); } - }, numberOfAttempts); + }, DEFAULT_FETCH_RETRY_ATTEMPTS); } /** * @returns {Promise} */ 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..0d3a964 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,87 @@ describe("aikido API", async () => { assert.strictEqual(result, '"final-etag"'); }); }); + + describe("fetchNewPackagesList", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + const releases = [ + { + package_name: "fresh-pkg", + version: "1.0.0", + released_on: 123, + scraped_on: 456, + }, + ]; + mockFetch.mock.mockImplementationOnce(() => ({ + ok: true, + json: async () => releases, + headers: { get: () => '"etag-new-packages"' }, + })); + + const result = await fetchNewPackagesList(); + + assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.strictEqual( + mockFetch.mock.calls[0].arguments[0], + "https://malware-list.aikido.dev/releases/npm.json" + ); + assert.deepStrictEqual(result.newPackagesList, releases); + assert.strictEqual(result.version, '"etag-new-packages"'); + }); + + it("should throw error after exhausting all retries", async () => { + mockFetch.mock.mockImplementation(() => { + throw new Error("Network error"); + }); + + await assert.rejects(() => fetchNewPackagesList(), { + message: "Network error", + }); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + }); + + it("should return an empty list without fetching for unsupported ecosystems", async () => { + ecosystem = "ruby"; + + const result = await fetchNewPackagesList(); + + assert.strictEqual(mockFetch.mock.calls.length, 0); + assert.deepStrictEqual(result.newPackagesList, []); + assert.strictEqual(result.version, undefined); + }); + }); + + describe("fetchNewPackagesListVersion", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + mockFetch.mock.mockImplementationOnce(() => ({ + ok: true, + headers: { get: () => '"new-packages-etag"' }, + })); + + const result = await fetchNewPackagesListVersion(); + + assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.strictEqual( + mockFetch.mock.calls[0].arguments[0], + "https://malware-list.aikido.dev/releases/npm.json" + ); + assert.deepStrictEqual(mockFetch.mock.calls[0].arguments[1], { + method: "HEAD", + }); + assert.strictEqual(result, '"new-packages-etag"'); + }); + + it("should throw error after exhausting all retries", async () => { + mockFetch.mock.mockImplementation(() => { + throw new Error("Connection refused"); + }); + + await assert.rejects(() => fetchNewPackagesListVersion(), { + message: "Connection refused", + }); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + }); + }); }); diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index bc4dc94..b421fde 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -248,6 +248,24 @@ function getDatabaseVersionPath() { return path.join(aikidoDir, `version_${ecosystem}.txt`); } +/** + * @returns {string} + */ +export function getNewPackagesListPath() { + const safeChainDir = getSafeChainDirectory(); + const ecosystem = getEcoSystem(); + return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`); +} + +/** + * @returns {string} + */ +export function getNewPackagesListVersionPath() { + const safeChainDir = getSafeChainDirectory(); + const ecosystem = getEcoSystem(); + return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`); +} + /** * @returns {string} */ @@ -268,7 +286,7 @@ function getConfigFilePath() { /** * @returns {string} */ -function getSafeChainDirectory() { +export function getSafeChainDirectory() { const homeDir = os.homedir(); const safeChainDir = path.join(homeDir, ".safe-chain"); diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 0b37eba..74f8a25 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -64,7 +64,11 @@ export async function main(args) { // Write all buffered logs ui.writeBufferedLogsAndStopBuffering(); - if (!proxy.verifyNoMaliciousPackages()) { + if (proxy.hasBlockedMaliciousPackages()) { + return 1; + } + + if (proxy.hasBlockedMinimumAgeRequests()) { return 1; } @@ -81,7 +85,7 @@ export async function main(args) { ui.writeInformation( `${chalk.yellow( "ℹ", - )} Safe-chain: Some package versions were suppressed due to minimum age requirement.`, + )} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`, ); ui.writeInformation( ` To disable this check, use: ${chalk.cyan( 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..a9a8c41 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 ); @@ -188,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. @@ -195,7 +204,7 @@ export function getHasSuppressedVersions() { * @param {string} pattern * @returns {boolean} */ -function matchesExclusionPattern(packageName, pattern) { +export function matchesExclusionPattern(packageName, pattern) { if (pattern.endsWith("/*")) { return packageName.startsWith(pattern.slice(0, -1)); } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 3d3b8b4..2a41524 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -1,15 +1,19 @@ import { getNpmCustomRegistries, + getNpmMinimumPackageAgeExclusions, skipMinimumPackageAge, } from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { + getPackageNameFromMetadataResponse, isPackageInfoUrl, + matchesExclusionPattern, modifyNpmInfoRequestHeaders, modifyNpmInfoResponse, } from "./modifyNpmInfo.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; +import { openNewPackagesDatabase } from "../../../scanning/newPackagesDatabase.js"; const knownJsRegistries = [ "registry.npmjs.org", @@ -43,14 +47,65 @@ function buildNpmInterceptor(registry) { reqContext.targetUrl, registry ); + const minimumAgeChecksEnabled = !skipMinimumPackageAge(); if (await isMalwarePackage(packageName, version)) { reqContext.blockMalware(packageName, version); + return; } - if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { + if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) { reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); - reqContext.modifyBody(modifyNpmInfoResponse); + reqContext.modifyBody(modifyNpmInfoResponseUnlessExcluded); + return; + } + + // For tarball requests the metadata check above is skipped, so we check the + // new packages list as a fallback (covers e.g. frozen-lockfile installs). + if ( + minimumAgeChecksEnabled && + packageName && + version && + !isExcludedFromMinimumPackageAge(packageName) + ) { + const newPackagesDatabase = await openNewPackagesDatabase(); + + if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) { + reqContext.blockMinimumAgeRequest( + packageName, + version, + `Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})` + ); + } } }); } + +/** + * @param {string} packageName + * @returns {boolean} + */ +function isExcludedFromMinimumPackageAge(packageName) { + const exclusions = getNpmMinimumPackageAgeExclusions(); + return exclusions.some((pattern) => + matchesExclusionPattern(packageName, pattern) + ); +} + +/** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @returns {Buffer} + */ +function modifyNpmInfoResponseUnlessExcluded(body, headers) { + const metadataPackageName = getPackageNameFromMetadataResponse(body, headers); + + if ( + metadataPackageName && + isExcludedFromMinimumPackageAge(metadataPackageName) + ) { + return body; + } + + return modifyNpmInfoResponse(body, headers); +} 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..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 @@ -5,13 +5,25 @@ describe("npmInterceptor minimum package age", async () => { let minimumPackageAgeSettings = 48; let skipMinimumPackageAgeSetting = false; let minimumPackageAgeExclusionsSetting = []; + let newlyReleasedPackages = new Set(); mock.module("../../../config/settings.js", { namedExports: { + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", getMinimumPackageAgeHours: () => minimumPackageAgeSettings, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, getNpmCustomRegistries: () => [], getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, + getEcoSystem: () => "js", + }, + }); + mock.module("../../../scanning/newPackagesDatabase.js", { + namedExports: { + openNewPackagesDatabase: async () => ({ + isNewlyReleasedPackage: (name, version) => + newlyReleasedPackages.has(`${name}@${version}`), + }), }, }); @@ -359,6 +371,67 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); }); + it("Should suppress too-young versions on metadata requests without directly blocking the request", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const interceptor = npmInterceptorForUrl(packageUrl); + const requestHandler = await interceptor.handleRequest(packageUrl); + + assert.equal(requestHandler.blockResponse, undefined); + assert.equal(requestHandler.modifiesResponse(), true); + }); + + it("Should directly block tarball requests when the new packages list marks them as too young", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + const packageUrl = + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123"; + + const interceptor = npmInterceptorForUrl(packageUrl); + const requestHandler = await interceptor.handleRequest(packageUrl); + + assert.ok(requestHandler.blockResponse); + assert.equal(requestHandler.modifiesResponse(), false); + assert.equal(requestHandler.blockResponse.statusCode, 403); + assert.equal( + requestHandler.blockResponse.message, + "Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)" + ); + }); + + it("Should not block tarball requests when skipMinimumPackageAge is enabled", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = true; + minimumPackageAgeExclusionsSetting = []; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + const packageUrl = + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; + + const interceptor = npmInterceptorForUrl(packageUrl); + const requestHandler = await interceptor.handleRequest(packageUrl); + + assert.equal(requestHandler.blockResponse, undefined); + assert.equal(requestHandler.modifiesResponse(), false); + }); + + it("Should not block tarball requests when the package is excluded from minimum age", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["lodash"]; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + const packageUrl = + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; + + const interceptor = npmInterceptorForUrl(packageUrl); + const requestHandler = await interceptor.handleRequest(packageUrl); + + assert.equal(requestHandler.blockResponse, undefined); + assert.equal(requestHandler.modifiesResponse(), false); + }); + it("Should not filter packages when package is in exclusion list", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false; @@ -540,6 +613,7 @@ describe("npmInterceptor minimum package age", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false; minimumPackageAgeExclusionsSetting = []; // Reset to empty + newlyReleasedPackages = new Set(); const packageUrl = "https://registry.npmjs.org/lodash"; 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..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 @@ -1,9 +1,11 @@ -import { describe, it, mock } from "node:test"; +import { describe, it, mock, beforeEach } from "node:test"; import assert from "node:assert"; let lastPackage; let malwareResponse = false; let customRegistries = []; +let newlyReleasedPackages = new Set(); +let skipMinimumPackageAgeSetting = false; mock.module("../../../scanning/audit/index.js", { namedExports: { @@ -27,13 +29,29 @@ mock.module("../../../config/settings.js", { getMinimumPackageAgeHours: () => 24, getNpmCustomRegistries: () => customRegistries, getNpmMinimumPackageAgeExclusions: () => [], - skipMinimumPackageAge: () => false, + skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, + }, +}); +mock.module("../../../scanning/newPackagesDatabase.js", { + namedExports: { + openNewPackagesDatabase: async () => ({ + isNewlyReleasedPackage: (name, version) => + newlyReleasedPackages.has(`${name}@${version}`), + }), }, }); describe("npmInterceptor", async () => { const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + beforeEach(() => { + lastPackage = undefined; + malwareResponse = false; + customRegistries = []; + newlyReleasedPackages = new Set(); + skipMinimumPackageAgeSetting = false; + }); + const parserCases = [ // Regular packages { @@ -109,6 +127,10 @@ describe("npmInterceptor", async () => { url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", expected: { packageName: "@babel/core", version: "7.21.4" }, }, + { + url: "https://registry.yarnpkg.com/@music-i18n%2fverovio/-/verovio-1.4.1.tgz", + expected: { packageName: "@music-i18n/verovio", version: "1.4.1" }, + }, // URL to get package info, not tarball { url: "https://registry.npmjs.org/lodash", @@ -178,6 +200,36 @@ describe("npmInterceptor", async () => { "Block response should have correct status message" ); }); + + it("should block direct tarball downloads for newly released packages", async () => { + const url = + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123"; + malwareResponse = false; + skipMinimumPackageAgeSetting = false; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + + const interceptor = npmInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.ok(result.blockResponse); + assert.equal(result.blockResponse.statusCode, 403); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)" + ); + }); + + it("should not block direct tarball downloads when minimum age checks are skipped", async () => { + const url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; + malwareResponse = false; + skipMinimumPackageAgeSetting = true; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + + const interceptor = npmInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.equal(result.blockResponse, undefined); + }); }); describe("npmInterceptor with custom registries", async () => { diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index fa256d4..13cb99a 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -5,12 +5,29 @@ */ export function parseNpmPackageUrl(url, registry) { let packageName, version; - if (!registry || !url.endsWith(".tgz")) { + let parsedUrl; + + try { + parsedUrl = new URL(url); + } catch { return { packageName, version }; } - const registryIndex = url.indexOf(registry); - const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + const pathname = parsedUrl.pathname; + + if (!registry || !pathname.endsWith(".tgz")) { + return { packageName, version }; + } + + const registryPrefix = `${registry}/`; + const urlAfterProtocol = `${parsedUrl.host}${pathname}`; + if (!urlAfterProtocol.startsWith(registryPrefix)) { + return { packageName, version }; + } + + const afterRegistry = decodeURIComponent( + urlAfterProtocol.substring(registryPrefix.length) + ); const separatorIndex = afterRegistry.indexOf("/-/"); if (separatorIndex === -1) { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 2de776e..81b265d 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() { @@ -23,7 +28,8 @@ export function createSafeChainProxy() { return { startServer: () => startServer(server), stopServer: () => stopServer(server), - verifyNoMaliciousPackages, + hasBlockedMaliciousPackages, + hasBlockedMinimumAgeRequests, hasSuppressedVersions: getHasSuppressedVersions, }; } @@ -151,6 +157,18 @@ function handleConnect(req, clientSocket, head) { onMalwareBlocked(event.packageName, event.version, event.targetUrl); } ); + interceptor.on( + "minimumAgeRequestBlocked", + ( + /** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event + ) => { + onMinimumAgeRequestBlocked( + event.packageName, + event.version, + event.targetUrl + ); + } + ); mitmConnect(req, clientSocket, interceptor); } else { @@ -170,10 +188,19 @@ function onMalwareBlocked(packageName, version, url) { state.blockedRequests.push({ packageName, version, url }); } -function verifyNoMaliciousPackages() { +/** + * + * @param {string} packageName + * @param {string} version + * @param {string} url + */ +function onMinimumAgeRequestBlocked(packageName, version, url) { + state.blockedMinimumAgeRequests.push({ packageName, version, url }); +} + +function hasBlockedMaliciousPackages() { if (state.blockedRequests.length === 0) { - // No malicious packages were blocked, so nothing to block - return true; + return false; } ui.emptyLine(); @@ -192,5 +219,37 @@ function verifyNoMaliciousPackages() { ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); - return false; + return true; +} + +function hasBlockedMinimumAgeRequests() { + if (state.blockedMinimumAgeRequests.length === 0) { + return false; + } + + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age` + )}:` + ); + + for (const req of state.blockedMinimumAgeRequests) { + ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); + } + + ui.writeInformation( + ` To disable this check, use: ${chalk.cyan( + "--safe-chain-skip-minimum-package-age" + )}` + ); + + ui.emptyLine(); + ui.writeError( + "Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check." + ); + ui.emptyLine(); + + return true; } diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js new file mode 100644 index 0000000..6a74656 --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -0,0 +1,187 @@ +import fs from "fs"; +import { + fetchNewPackagesList, + fetchNewPackagesListVersion, +} from "../api/aikido.js"; +import { + getNewPackagesListPath, + getNewPackagesListVersionPath, +} from "../config/configFile.js"; +import { ui } from "../environment/userInteraction.js"; +import { + getMinimumPackageAgeHours, + getEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, +} from "../config/settings.js"; + +/** + * @typedef {Object} NewPackagesDatabase + * @property {function(string, string): boolean} isNewlyReleasedPackage + */ + +// Shared per-process cache to avoid rebuilding the same feed-backed database on each request. +/** @type {NewPackagesDatabase | null} */ +let cachedNewPackagesDatabase = null; +let hasWarnedAboutUnavailableNewPackagesDatabase = false; + +/** + * Returns the ecosystem identifier expected in upstream/core release feeds. + * @returns {string} + */ +function getCurrentFeedSource() { + const ecosystem = getEcoSystem(); + + if (ecosystem === ECOSYSTEM_JS) { + return "npm"; + } + + if (ecosystem === ECOSYSTEM_PY) { + return "pypi"; + } + + return ecosystem; +} + +/** + * @returns {Promise} + */ +export async function openNewPackagesDatabase() { + if (cachedNewPackagesDatabase) { + return cachedNewPackagesDatabase; + } + + if (getEcoSystem() !== ECOSYSTEM_JS) { + cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; + return cachedNewPackagesDatabase; + } + + /** @type {import("../api/aikido.js").NewPackageEntry[]} */ + let newPackagesList; + + try { + newPackagesList = await getNewPackagesList(); + } catch (/** @type {any} */ error) { + if (!hasWarnedAboutUnavailableNewPackagesDatabase) { + ui.writeWarning( + `Failed to load the new packages list used for direct package download request blocking. Continuing with metadata-based minimum age checks only. ${error.message}` + ); + hasWarnedAboutUnavailableNewPackagesDatabase = true; + } + + cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; + return cachedNewPackagesDatabase; + } + + /** + * @param {string} name + * @param {string} version + * @returns {boolean} + */ + function isNewlyReleasedPackage(name, version) { + const cutOff = new Date( + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + ); + const expectedSource = getCurrentFeedSource(); + + const entry = newPackagesList.find( + (pkg) => + (!pkg.source || pkg.source.toLowerCase() === expectedSource) && + pkg.package_name === name && + pkg.version === version + ); + + if (!entry) { + return false; + } + + const releasedOn = new Date(entry.released_on * 1000); + return releasedOn > cutOff; + } + + cachedNewPackagesDatabase = { isNewlyReleasedPackage }; + return cachedNewPackagesDatabase; +} + +/** + * @returns {Promise} + */ +async function getNewPackagesList() { + const { newPackagesList: cachedList, version: cachedVersion } = + readNewPackagesListFromLocalCache(); + + try { + if (cachedList) { + const currentVersion = await fetchNewPackagesListVersion(); + if (cachedVersion === currentVersion) { + return cachedList; + } + } + + const { newPackagesList, version } = await fetchNewPackagesList(); + + if (version) { + writeNewPackagesListToLocalCache(newPackagesList, version); + return newPackagesList; + } else { + ui.writeWarning( + "The new packages list for direct package download request blocking was downloaded, but could not be cached due to a missing version." + ); + return newPackagesList; + } + } catch (/** @type {any} */ error) { + if (cachedList) { + ui.writeWarning( + "Failed to fetch the latest new packages list for direct package download request blocking. Using cached version." + ); + return cachedList; + } + throw error; + } +} + +/** + * @param {import("../api/aikido.js").NewPackageEntry[]} data + * @param {string | number} version + * + * @returns {void} + */ +export function writeNewPackagesListToLocalCache(data, version) { + try { + const listPath = getNewPackagesListPath(); + const versionPath = getNewPackagesListVersionPath(); + + fs.writeFileSync(listPath, JSON.stringify(data)); + fs.writeFileSync(versionPath, version.toString()); + } catch { + ui.writeWarning( + "Failed to write new packages list to local cache, next time the list will be fetched from the server again." + ); + } +} + +/** + * @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}} + */ +export function readNewPackagesListFromLocalCache() { + try { + const listPath = getNewPackagesListPath(); + if (!fs.existsSync(listPath)) { + return { newPackagesList: null, version: null }; + } + + const data = fs.readFileSync(listPath, "utf8"); + const newPackagesList = JSON.parse(data); + const versionPath = getNewPackagesListVersionPath(); + let version = null; + if (fs.existsSync(versionPath)) { + version = fs.readFileSync(versionPath, "utf8").trim(); + } + return { newPackagesList, version }; + } catch { + ui.writeWarning( + "Failed to read new packages list from local cache. Continuing without local cache." + ); + return { newPackagesList: null, version: null }; + } +} 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..29f04d5 --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -0,0 +1,257 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +// --- shared mutable state for mocks --- +let fetchedList = []; +let fetchedVersion = "etag-1"; +let fetchVersionResult = "etag-1"; +let minimumPackageAgeHours = 24; +let ecosystem = "js"; +let writeWarningCalls = []; +let fetchListError = null; +let fetchVersionError = null; +let importCounter = 0; +let testHomeDir = ""; + +mock.module("../api/aikido.js", { + namedExports: { + fetchNewPackagesList: async () => { + if (fetchListError) { + throw fetchListError; + } + + return { + newPackagesList: fetchedList, + version: fetchedVersion, + }; + }, + fetchNewPackagesListVersion: async () => { + if (fetchVersionError) { + throw fetchVersionError; + } + + return fetchVersionResult; + }, + }, +}); + +mock.module("../environment/userInteraction.js", { + namedExports: { + ui: { + writeWarning: (msg) => writeWarningCalls.push(msg), + writeVerbose: () => {}, + }, + }, +}); + +mock.module("../config/settings.js", { + namedExports: { + getMinimumPackageAgeHours: () => minimumPackageAgeHours, + getEcoSystem: () => ecosystem, + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", + }, +}); + +describe("newPackagesDatabase", async () => { + beforeEach(() => { + fetchedList = []; + fetchedVersion = "etag-1"; + fetchVersionResult = "etag-1"; + minimumPackageAgeHours = 24; + ecosystem = "js"; + writeWarningCalls = []; + fetchListError = null; + fetchVersionError = null; + testHomeDir = path.join( + os.tmpdir(), + `safe-chain-new-packages-db-${process.pid}-${importCounter}` + ); + fs.rmSync(testHomeDir, { recursive: true, force: true }); + fs.mkdirSync(testHomeDir, { recursive: true }); + process.env.HOME = testHomeDir; + }); + + async function openNewPackagesDatabase() { + const module = await import( + `./newPackagesDatabase.js?test_case=${importCounter++}` + ); + return module.openNewPackagesDatabase(); + } + + async function loadNewPackagesDatabaseModule() { + return import(`./newPackagesDatabase.js?test_case=${importCounter++}`); + } + + function hoursAgo(hours) { + return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); + } + + function writeCachedList(list, version) { + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + fs.writeFileSync( + path.join(safeChainDir, `newPackagesList_${ecosystem}.json`), + JSON.stringify(list) + ); + fs.writeFileSync( + path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`), + version + ); + } + + describe("isNewlyReleasedPackage", () => { + it("returns true for a package released within the age threshold", async () => { + fetchedList = [ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + }); + + it("returns false for a package released outside the age threshold", async () => { + fetchedList = [ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(48), scraped_on: hoursAgo(48) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + }); + + it("returns false for a package not in the list", async () => { + fetchedList = []; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false); + }); + + it("returns false for a known package but different version", async () => { + fetchedList = [ + { package_name: "foo", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + }); + + it("matches the current feed ecosystem when source metadata is present", async () => { + fetchedList = [ + { + source: "pypi", + package_name: "foo", + version: "1.0.0", + released_on: hoursAgo(1), + scraped_on: hoursAgo(1), + }, + { + source: "npm", + package_name: "bar", + version: "1.0.0", + released_on: hoursAgo(1), + scraped_on: hoursAgo(1), + }, + ]; + + const db = await openNewPackagesDatabase(); + + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + assert.strictEqual(db.isNewlyReleasedPackage("bar", "1.0.0"), true); + }); + + it("respects a custom minimumPackageAgeHours threshold", async () => { + minimumPackageAgeHours = 168; // 7 days + fetchedList = [ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(100), scraped_on: hoursAgo(100) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + }); + + it("returns false for all packages when ecosystem is not JS", async () => { + ecosystem = "py"; + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + }); + }); + + describe("caching behaviour", () => { + it("uses local cache when etag matches", async () => { + writeCachedList([ + { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ], "etag-1"); + fetchVersionResult = "etag-1"; + // fetchedList is empty — if we used the remote list, the lookup would return false + fetchedList = []; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true); + }); + + it("fetches fresh list when etag does not match", async () => { + writeCachedList([ + { package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ], "etag-old"); + fetchVersionResult = "etag-new"; + fetchedList = [ + { package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("stale-pkg", "1.0.0"), false); + assert.strictEqual(db.isNewlyReleasedPackage("fresh-pkg", "2.0.0"), true); + }); + + it("falls back to local cache when fetch fails", async () => { + writeCachedList([ + { + package_name: "cached-pkg", + version: "1.0.0", + released_on: hoursAgo(1), + scraped_on: hoursAgo(1), + }, + ], "etag-old"); + fetchVersionResult = "etag-new"; + fetchListError = new Error("Network error"); + + const db = await openNewPackagesDatabase(); + + assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true); + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok(writeWarningCalls[0].includes("Using cached version")); + }); + + it("emits a warning when list has no version (cannot be cached)", async () => { + fetchedList = [ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + fetchedVersion = undefined; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok(writeWarningCalls[0].includes("could not be cached")); + }); + + it("fails open and only warns once when the new packages list cannot be loaded", async () => { + fetchListError = new Error("feed unavailable"); + + const module = await loadNewPackagesDatabaseModule(); + const db1 = await module.openNewPackagesDatabase(); + const db2 = await module.openNewPackagesDatabase(); + + assert.strictEqual(db1.isNewlyReleasedPackage("foo", "1.0.0"), false); + assert.strictEqual(db2.isNewlyReleasedPackage("foo", "1.0.0"), false); + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok( + writeWarningCalls[0].includes( + "Continuing with metadata-based minimum age checks only" + ) + ); + }); + }); +}); 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); });