From 3bf7279195e9bbe68df415ef650c60c864226ca9 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 7 Nov 2025 16:16:37 +0100 Subject: [PATCH 01/26] Implement modification of request headerrs --- .../interceptors/npmInterceptor.js | 10 ++++ .../interceptors/requestInterceptorBuilder.js | 49 ++++++++++++++++++- .../src/registryProxy/mitmRequestHandler.js | 41 ++++++++++------ 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js index 6e33dd0..97ac15d 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js @@ -1,3 +1,4 @@ +import chalk from "chalk"; import { isMalwarePackage } from "../../scanning/audit/index.js"; import { createInterceptorBuilder } from "./interceptorBuilder.js"; @@ -32,6 +33,15 @@ function buildNpmInterceptor(registry) { if (await isMalwarePackage(packageName, version)) { req.blockMalware(packageName, version, req.targetUrl); } + + req.modifyRequestHeaders((headers) => { + if (headers["accept"]?.includes("application/vnd.npm.install-v1+json")) { + // The npm registry sometimes serves a more compact format that lacks + // the time metadata we need to filter out too new packages. + // Force the registry to return the full metadata by changing the Accept header. + headers["accept"] = "application/json"; + } + }); }); return builder.build(); diff --git a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js index a8b98c6..e492f57 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js @@ -3,10 +3,13 @@ * @property {string} targetUrl * @property {(statusCode: number, message: string) => void} blockRequest * @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware + * @property {(modificationFunc: (headers: NodeJS.Dict) => void) => void} modifyRequestHeaders * @property {() => RequestInterceptor} build * * @typedef {Object} RequestInterceptor * @property {{statusCode: number, message: string} | undefined} blockResponse + * @property {(headers: NodeJS.Dict | undefined) => void} modifyRequestHeaders + * @property {() => boolean} modifiesResponse */ /** @@ -18,6 +21,15 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { /** @type {{statusCode: number, message: string} | undefined} */ let blockResponse = undefined; + /** + * @type {{ + * requestHeaders: Array<(headers: NodeJS.Dict) => void> + * }} + */ + let modificationFuncs = { + requestHeaders: [], + }; + /** * @param {number} statusCode * @param {string} message @@ -47,10 +59,43 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { targetUrl, blockRequest, blockMalware, + modifyRequestHeaders(modificationFunc) { + modificationFuncs.requestHeaders.push(modificationFunc); + }, build() { - return { + return createRequestInterceptor( blockResponse, - }; + modificationFuncs.requestHeaders + ); }, }; } + +/** + * @param {{statusCode: number, message: string} | undefined} blockResponse + * @param {Array<(headers: NodeJS.Dict) => void>} requestHeadersModficationFuncs + * @returns {RequestInterceptor} + */ +function createRequestInterceptor( + blockResponse, + requestHeadersModficationFuncs +) { + /** + * @param {NodeJS.Dict | undefined} headers + */ + function modifyRequestHeaders(headers) { + if (!headers) { + return; + } + + for (const modificationFunc of requestHeadersModficationFuncs) { + modificationFunc(headers); + } + } + + function modifiesResponse() { + return false; + } + + return { blockResponse, modifyRequestHeaders, modifiesResponse }; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index c3ad934..a76efb4 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -5,6 +5,7 @@ import { ui } from "../environment/userInteraction.js"; /** * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor + * @typedef {import("./interceptors/requestInterceptorBuilder.js").RequestInterceptor} RequestInterceptor */ /** @@ -68,18 +69,20 @@ function createHttpsServer(hostname, interceptor) { const pathAndQuery = getRequestPathAndQuery(req.url); const targetUrl = `https://${hostname}${pathAndQuery}`; - const interceptorResult = await interceptor.handleRequest(targetUrl); - const blockResponse = interceptorResult.blockResponse; + const requestInterceptor = await interceptor.handleRequest(targetUrl); - if (blockResponse) { + if (requestInterceptor.blockResponse) { ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`); - res.writeHead(blockResponse.statusCode, blockResponse.message); - res.end(blockResponse.message); + res.writeHead( + requestInterceptor.blockResponse.statusCode, + requestInterceptor.blockResponse.message + ); + res.end(requestInterceptor.blockResponse.message); return; } // Collect request body - forwardRequest(req, hostname, res); + forwardRequest(req, hostname, res, requestInterceptor); } const server = https.createServer( @@ -109,9 +112,10 @@ function getRequestPathAndQuery(url) { * @param {import("http").IncomingMessage} req * @param {string} hostname * @param {import("http").ServerResponse} res + * @param {RequestInterceptor} requestInterceptor */ -function forwardRequest(req, hostname, res) { - const proxyReq = createProxyRequest(hostname, req, res); +function forwardRequest(req, hostname, res, requestInterceptor) { + const proxyReq = createProxyRequest(hostname, req, res, requestInterceptor); proxyReq.on("error", (err) => { ui.writeVerbose( @@ -142,10 +146,17 @@ function forwardRequest(req, hostname, res) { * @param {string} hostname * @param {import("http").IncomingMessage} req * @param {import("http").ServerResponse} res + * @param {RequestInterceptor} requestInterceptor * * @returns {import("http").ClientRequest} */ -function createProxyRequest(hostname, req, res) { +function createProxyRequest(hostname, req, res, requestInterceptor) { + const headers = { ...req.headers }; + if (headers.host) { + delete headers.host; + } + requestInterceptor.modifyRequestHeaders(headers); + /** @type {import("http").RequestOptions} */ const options = { hostname: hostname, @@ -155,10 +166,6 @@ function createProxyRequest(hostname, req, res) { headers: { ...req.headers }, }; - if (options.headers && "host" in options.headers) { - delete options.headers.host; - } - const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; if (httpsProxy) { options.agent = new HttpsProxyAgent(httpsProxy); @@ -183,7 +190,13 @@ function createProxyRequest(hostname, req, res) { } res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res); + + if (!requestInterceptor.modifiesResponse) { + // If the response is not being modified, we can + // just pipe without the need for + proxyRes.pipe(res); + } else { + } }); return proxyReq; From 8bd2ace3db27e8010d74505bfc5989410b9eeb97 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 12 Nov 2025 13:39:17 +0100 Subject: [PATCH 02/26] Remove too new packages from npm response --- packages/safe-chain/src/config/settings.js | 13 +- .../createInterceptorForEcoSystem.js | 2 +- .../interceptors/npm/npmInterceptor.js | 231 +++++++++++++++++ .../npm/npmInterceptor.minPackageAge.spec.js | 237 ++++++++++++++++++ .../npmInterceptor.packageDownload.spec.js} | 2 +- .../interceptors/npmInterceptor.js | 92 ------- .../interceptors/requestInterceptorBuilder.js | 48 ++-- .../responseInterceptorBuilder.js | 43 ++++ .../src/registryProxy/mitmRequestHandler.js | 33 ++- 9 files changed, 582 insertions(+), 119 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js rename packages/safe-chain/src/registryProxy/interceptors/{npmInterceptor.spec.js => npm/npmInterceptor.packageDownload.spec.js} (99%) delete mode 100644 packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 46cc30c..5b27118 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,5 +1,9 @@ import * as cliArguments from "./cliArguments.js"; +export const LOGGING_SILENT = "silent"; +export const LOGGING_NORMAL = "normal"; +export const LOGGING_VERBOSE = "verbose"; + export function getLoggingLevel() { const level = cliArguments.getLoggingLevel(); @@ -14,9 +18,6 @@ export function getLoggingLevel() { return LOGGING_NORMAL; } -export const MALWARE_ACTION_BLOCK = "block"; -export const MALWARE_ACTION_PROMPT = "prompt"; - export const ECOSYSTEM_JS = "js"; export const ECOSYSTEM_PY = "py"; @@ -36,6 +37,6 @@ export function setEcoSystem(setting) { ecosystemSettings.ecoSystem = setting; } -export const LOGGING_SILENT = "silent"; -export const LOGGING_NORMAL = "normal"; -export const LOGGING_VERBOSE = "verbose"; +export function getMinimumPackageAgeHours() { + return 24 * 6; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js index c97d867..79b5200 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +++ b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js @@ -3,7 +3,7 @@ import { ECOSYSTEM_PY, getEcoSystem, } from "../../config/settings.js"; -import { npmInterceptorForUrl } from "./npmInterceptor.js"; +import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; import { pipInterceptorForUrl } from "./pipInterceptor.js"; /** diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js new file mode 100644 index 0000000..e1fd16b --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -0,0 +1,231 @@ +import chalk from "chalk"; +import { getMinimumPackageAgeHours } from "../../../config/settings.js"; +import { isMalwarePackage } from "../../../scanning/audit/index.js"; +import { createInterceptorBuilder } from "../interceptorBuilder.js"; +import { ui } from "../../../environment/userInteraction.js"; +import { writeFileSync } from "node:fs"; + +const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; + +/** + * @param {string} url + * @returns {import("../interceptorBuilder.js").Interceptor | undefined} + */ +export function npmInterceptorForUrl(url) { + const registry = knownJsRegistries.find((reg) => url.includes(reg)); + + if (registry) { + return buildNpmInterceptor(registry); + } + + return undefined; +} + +/** + * @param {string} registry + * @returns {import("../interceptorBuilder.js").Interceptor | undefined} + */ +function buildNpmInterceptor(registry) { + const builder = createInterceptorBuilder(); + + builder.onRequest(async (req) => { + const { packageName, version } = parseNpmPackageUrl( + req.targetUrl, + registry + ); + if (await isMalwarePackage(packageName, version)) { + req.blockMalware(packageName, version, req.targetUrl); + } + + if (isPackageInfoUrl(req.targetUrl)) { + req.modifyRequestHeaders((headers) => { + if ( + headers["accept"]?.includes("application/vnd.npm.install-v1+json") + ) { + // The npm registry sometimes serves a more compact format that lacks + // the time metadata we need to filter out too new packages. + // Force the registry to return the full metadata by changing the Accept header. + headers["accept"] = "application/json"; + } + }); + + req.modifyResponse((res) => { + res.modifyBody(modifyNpmInfoRequestBody); + }); + } + }); + + return builder.build(); +} + +/** + * @param {string} url + * @param {string} registry + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +function parseNpmPackageUrl(url, registry) { + let packageName, version; + if (!registry || !url.endsWith(".tgz")) { + return { packageName, version }; + } + + const registryIndex = url.indexOf(registry); + const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + + const separatorIndex = afterRegistry.indexOf("/-/"); + if (separatorIndex === -1) { + return { packageName, version }; + } + + packageName = afterRegistry.substring(0, separatorIndex); + const filename = afterRegistry.substring( + separatorIndex + 3, + afterRegistry.length - 4 + ); // Remove /-/ and .tgz + + // Extract version from filename + // For scoped packages like @babel/core, the filename is core-7.21.4.tgz + // For regular packages like lodash, the filename is lodash-4.17.21.tgz + if (packageName.startsWith("@")) { + const scopedPackageName = packageName.substring( + packageName.lastIndexOf("/") + 1 + ); + if (filename.startsWith(scopedPackageName + "-")) { + version = filename.substring(scopedPackageName.length + 1); + } + } else { + if (filename.startsWith(packageName + "-")) { + version = filename.substring(packageName.length + 1); + } + } + + return { packageName, version }; +} + +/** + * @param {string} url + * @returns {boolean} + */ +function isPackageInfoUrl(url) { + // Remove query string and fragment to get the actual path + const urlWithoutParams = url.split("?")[0].split("#")[0]; + + // Tarball downloads end with .tgz + if (urlWithoutParams.endsWith(".tgz")) return false; + + // Special endpoints start with /-/ and should not be modified + // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access + if (urlWithoutParams.includes("/-/")) return false; + + // Everything else is package metadata that can be modified + return true; +} + +/** + * + * @param {Buffer} body + * @returns Buffer + */ +function modifyNpmInfoRequestBody(body) { + try { + const bodyContent = body.toString("utf8"); + const bodyJson = JSON.parse(bodyContent); + + if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) { + // Just return the body if the + return body; + } + + const cutOff = new Date( + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + ).toISOString(); + + const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; + + const versions = Object.entries(bodyJson.time) + .map(([version, timestamp]) => ({ + version, + timestamp, + })) + .filter((x) => x.version != "created" && x.version != "modified"); + + for (const { version, timestamp } of versions) { + if (version === "created" || version === "modified") { + continue; + } + + if (timestamp > cutOff) { + deleteVersionFromJson(bodyJson, version); + continue; + } + } + + if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) { + // The latest tag was removed because it contained a package younger than the treshold. + // A new latest tag needs to be calculated + bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson); + } + + return Buffer.from(JSON.stringify(bodyJson)); + } catch (err) { + // TODO: better error handling + return body; + } +} + +function deleteVersionFromJson(json, version) { + ui.writeVerbose( + `Safe-chain: Deleting ${version} from npm info request, it's newer than the minimumPackageAgeInHours` + ); + + delete json.time[version]; + delete json.versions[version]; + + for (const [tag, distVersion] of Object.entries(json["dist-tags"])) { + if (version == distVersion) { + delete json["dist-tags"][tag]; + } + } +} + +function calculateLatestTag(json) { + if (!json.time) { + return undefined; + } + + let latest, preview, latestDate, previewDate; + + for (const [version, timestamp] of Object.entries(json.time)) { + if (version == "created" || version == "modified") continue; + + if (version.includes("-")) { + // preview versions include "-" in the name + [preview, previewDate] = getLatest( + preview, + previewDate, + version, + timestamp + ); + } else { + [latest, latestDate] = getLatest(latest, latestDate, version, timestamp); + } + } + + if (latest) { + return latest; + } else { + return preview; + } + + function getLatest(currentLatest, currentLatestDate, version, timestamp) { + if (!currentLatest) { + return [version, timestamp]; + } + + if (timestamp > currentLatestDate) { + return [version, timestamp]; + } + + return [currentLatest, currentLatestDate]; + } +} 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 new file mode 100644 index 0000000..ea23f9e --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -0,0 +1,237 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("npmInterceptor minimum package age", async () => { + let minimumPackageAgeSettings = 48; + + mock.module("../../../config/settings.js", { + namedExports: { + getMinimumPackageAgeHours: () => minimumPackageAgeSettings, + }, + }); + + mock.module("../../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async () => { + return false; + }, + }, + }); + const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + + for (const packageInfoUrl of [ + // Basic package metadata + "https://registry.npmjs.org/lodash", + "https://registry.npmjs.org/express", + // Scoped packages + "https://registry.npmjs.org/@vercel/functions", + "https://registry.npmjs.org/@babel/core", + "https://registry.npmjs.org/@types/node", + // With query parameters + "https://registry.npmjs.org/lodash?write=true", + "https://registry.npmjs.org/@babel/core?param=value&other=test", + // With fragments + "https://registry.npmjs.org/lodash#readme", + "https://registry.npmjs.org/@babel/core#installation", + // Version-specific metadata + "https://registry.npmjs.org/lodash/4.17.21", + "https://registry.npmjs.org/lodash/latest", + "https://registry.npmjs.org/@babel/core/7.21.4", + // URL-encoded scoped packages + "https://registry.npmjs.org/@types%2Fnode", + "https://registry.npmjs.org/@babel%2Fcore", + // With trailing slashes + "https://registry.npmjs.org/lodash/", + "https://registry.npmjs.org/@babel/core/", + ]) { + it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => { + const interceptor = npmInterceptorForUrl(packageInfoUrl); + const requestInterceptor = await interceptor.handleRequest( + packageInfoUrl + ); + + assert.equal(requestInterceptor.modifiesResponse(), true); + }); + } + + for (const packageUrl of [ + // Regular package tarballs + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + // Scoped package tarballs + "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz", + "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", + // Tarballs with query parameters (integrity checks) + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123", + "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz?integrity=sha512-def456&cache=false", + // Tarballs with fragments + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#sha512-abc123", + "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz#hash", + // Prerelease versions + "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz", + "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz", + ]) { + it(`modifyResponse should be false for package downloads: ${packageUrl}`, async () => { + const interceptor = npmInterceptorForUrl(packageUrl); + const requestInterceptor = await interceptor.handleRequest(packageUrl); + + assert.equal(requestInterceptor.modifiesResponse(), false); + }); + } + + for (const specialEndpoint of [ + // Security advisory endpoints + "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", + "https://registry.npmjs.org/-/npm/v1/security/audits", + "https://registry.npmjs.org/-/npm/v1/security/audits/quick", + // Search endpoints + "https://registry.npmjs.org/-/v1/search?text=lodash&size=20", + "https://registry.npmjs.org/-/v1/search?text=react&from=0", + // Package access/collaboration endpoints + "https://registry.npmjs.org/-/package/lodash/access", + "https://registry.npmjs.org/-/package/@babel/core/collaborators", + "https://registry.npmjs.org/-/package/lodash/dist-tags", + "https://registry.npmjs.org/-/package/@babel/core/dist-tags/latest", + // User/organization endpoints + "https://registry.npmjs.org/-/user/org.couchdb.user:username", + "https://registry.npmjs.org/-/org/myorg/package", + // Anonymous metrics + "https://registry.npmjs.org/-/npm/anon-metrics/v1/", + // Ping/health check + "https://registry.npmjs.org/-/ping", + ]) { + it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => { + const interceptor = npmInterceptorForUrl(specialEndpoint); + const requestInterceptor = await interceptor.handleRequest( + specialEndpoint + ); + + assert.equal(requestInterceptor.modifiesResponse(), false); + }); + } + + it("Should remove packages older than the treshold", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + assert.equal(Object.keys(modifiedJson.time).length, 2); + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.time).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(!Object.keys(modifiedJson.time).includes("2.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("2.0.0")); + assert.ok(!Object.keys(modifiedJson.time).includes("3.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); + }); + + it("Should set the package to the new latest non-preview release", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + ["1.0.0"]: getDate(-7), + ["0.0.1"]: getDate(-8), // package order: this package is older than 1.0.0, it should not be considered latest + ["2.0.0-alpha"]: getDate(-6), //package is a pre-release, it should not be latest + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + assert.equal(modifiedJson["dist-tags"]["latest"], "1.0.0"); + }); + + it("Should remove dist-tags if version was removed", async () => { + minimumPackageAgeSettings = 5; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + alpha: "2.0.0-alpha", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0-alpha"]: getDate(-4), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + console.log(modifiedJson); + + assert.equal(modifiedJson["dist-tags"]["alpha"], undefined); + }); + + /** + * @param {import("../interceptorBuilder.js").Interceptor} interceptor + * @param {string} body + * @returns {Promise} + */ + async function runModifyNpmInfoRequest(url, body) { + const interceptor = npmInterceptorForUrl(url); + const requestInterceptor = await interceptor.handleRequest(url); + const responseInterceptor = requestInterceptor.handleResponse(); + + const modifiedBuffer = responseInterceptor.modifyBody(Buffer.from(body)); + + return modifiedBuffer.toString("utf8"); + } + + function getDate(plusHours) { + const date = new Date(); + date.setHours(date.getHours() + plusHours); + + return date; + } +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js similarity index 99% rename from packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js rename to packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index dd09527..a90432e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -5,7 +5,7 @@ describe("npmInterceptor", async () => { let lastPackage; let malwareResponse = false; - mock.module("../../scanning/audit/index.js", { + mock.module("../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { lastPackage = { packageName, version }; diff --git a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js deleted file mode 100644 index 97ac15d..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js +++ /dev/null @@ -1,92 +0,0 @@ -import chalk from "chalk"; -import { isMalwarePackage } from "../../scanning/audit/index.js"; -import { createInterceptorBuilder } from "./interceptorBuilder.js"; - -const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; - -/** - * @param {string} url - * @returns {import("./interceptorBuilder.js").Interceptor | undefined} - */ -export function npmInterceptorForUrl(url) { - const registry = knownJsRegistries.find((reg) => url.includes(reg)); - - if (registry) { - return buildNpmInterceptor(registry); - } - - return undefined; -} - -/** - * @param {string} registry - * @returns {import("./interceptorBuilder.js").Interceptor | undefined} - */ -function buildNpmInterceptor(registry) { - const builder = createInterceptorBuilder(); - - builder.onRequest(async (req) => { - const { packageName, version } = parseNpmPackageUrl( - req.targetUrl, - registry - ); - if (await isMalwarePackage(packageName, version)) { - req.blockMalware(packageName, version, req.targetUrl); - } - - req.modifyRequestHeaders((headers) => { - if (headers["accept"]?.includes("application/vnd.npm.install-v1+json")) { - // The npm registry sometimes serves a more compact format that lacks - // the time metadata we need to filter out too new packages. - // Force the registry to return the full metadata by changing the Accept header. - headers["accept"] = "application/json"; - } - }); - }); - - return builder.build(); -} - -/** - * @param {string} url - * @param {string} registry - * @returns {{packageName: string | undefined, version: string | undefined}} - */ -function parseNpmPackageUrl(url, registry) { - let packageName, version; - if (!registry || !url.endsWith(".tgz")) { - return { packageName, version }; - } - - const registryIndex = url.indexOf(registry); - const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash - - const separatorIndex = afterRegistry.indexOf("/-/"); - if (separatorIndex === -1) { - return { packageName, version }; - } - - packageName = afterRegistry.substring(0, separatorIndex); - const filename = afterRegistry.substring( - separatorIndex + 3, - afterRegistry.length - 4 - ); // Remove /-/ and .tgz - - // Extract version from filename - // For scoped packages like @babel/core, the filename is core-7.21.4.tgz - // For regular packages like lodash, the filename is lodash-4.17.21.tgz - if (packageName.startsWith("@")) { - const scopedPackageName = packageName.substring( - packageName.lastIndexOf("/") + 1 - ); - if (filename.startsWith(scopedPackageName + "-")) { - version = filename.substring(scopedPackageName.length + 1); - } - } else { - if (filename.startsWith(packageName + "-")) { - version = filename.substring(packageName.length + 1); - } - } - - return { packageName, version }; -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js index e492f57..2944968 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js @@ -4,14 +4,18 @@ * @property {(statusCode: number, message: string) => void} blockRequest * @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware * @property {(modificationFunc: (headers: NodeJS.Dict) => void) => void} modifyRequestHeaders + * @property {(requestFunc: (responseInterceptorBuilder: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void) => void} modifyResponse * @property {() => RequestInterceptor} build * * @typedef {Object} RequestInterceptor * @property {{statusCode: number, message: string} | undefined} blockResponse * @property {(headers: NodeJS.Dict | undefined) => void} modifyRequestHeaders + * @property {() => import("./responseInterceptorBuilder.js").ResponseInterceptor} handleResponse * @property {() => boolean} modifiesResponse */ +import { createResponseInterceptorBuilder } from "./responseInterceptorBuilder.js"; + /** * @param {string} targetUrl * @param {import('events').EventEmitter} eventEmitter @@ -20,15 +24,10 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { /** @type {{statusCode: number, message: string} | undefined} */ let blockResponse = undefined; - - /** - * @type {{ - * requestHeaders: Array<(headers: NodeJS.Dict) => void> - * }} - */ - let modificationFuncs = { - requestHeaders: [], - }; + /** @type {Array<(headers: NodeJS.Dict) => void>} */ + let requestHeaderFuncs = []; + /** @type {Array<(requestFunc: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void>} */ + let responseModifierFuncs = []; /** * @param {number} statusCode @@ -60,12 +59,16 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { blockRequest, blockMalware, modifyRequestHeaders(modificationFunc) { - modificationFuncs.requestHeaders.push(modificationFunc); + requestHeaderFuncs.push(modificationFunc); + }, + modifyResponse(modificationFunc) { + responseModifierFuncs.push(modificationFunc); }, build() { return createRequestInterceptor( blockResponse, - modificationFuncs.requestHeaders + requestHeaderFuncs, + responseModifierFuncs ); }, }; @@ -74,11 +77,13 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { /** * @param {{statusCode: number, message: string} | undefined} blockResponse * @param {Array<(headers: NodeJS.Dict) => void>} requestHeadersModficationFuncs + * @param {Array<(requestFunc: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void>} responseModifierFuncs * @returns {RequestInterceptor} */ function createRequestInterceptor( blockResponse, - requestHeadersModficationFuncs + requestHeadersModficationFuncs, + responseModifierFuncs ) { /** * @param {NodeJS.Dict | undefined} headers @@ -94,8 +99,23 @@ function createRequestInterceptor( } function modifiesResponse() { - return false; + return responseModifierFuncs.length > 0; } - return { blockResponse, modifyRequestHeaders, modifiesResponse }; + function handleResponse() { + const responseInterceptorBuilder = createResponseInterceptorBuilder(); + + for (const func of responseModifierFuncs) { + func(responseInterceptorBuilder); + } + + return responseInterceptorBuilder.build(); + } + + return { + blockResponse, + modifyRequestHeaders, + modifiesResponse, + handleResponse, + }; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js new file mode 100644 index 0000000..86d79e5 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js @@ -0,0 +1,43 @@ +/** + * @typedef {Object} ResponseInterceptorBuilder + * @property {() => ResponseInterceptor} build + * @property {(modificationFunc: (body: Buffer) => Buffer) => void} modifyBody + * + * @typedef {Object} ResponseInterceptor + * @property {(buffer: Buffer) => Buffer} modifyBody + */ + +/** + * @returns {ResponseInterceptorBuilder} + */ +export function createResponseInterceptorBuilder() { + /** @type {Array<(body: Buffer) => Buffer>} */ + let modifyBodyFuncs = []; + + return { + modifyBody: (func) => modifyBodyFuncs.push(func), + build: () => createResponseInterceptor(modifyBodyFuncs), + }; +} + +/** + * @returns {ResponseInterceptor} + * @param {Array<(body: Buffer) => Buffer>} modifyBodyFuncs + */ +function createResponseInterceptor(modifyBodyFuncs) { + /** + * @param {Buffer} body + * @returns {Buffer} + */ + function modifyBody(body) { + let modifiedBody = body; + + for (var func of modifyBodyFuncs) { + modifiedBody = func(body); + } + + return modifiedBody; + } + + return { modifyBody }; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index a76efb4..aa1391e 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -2,6 +2,7 @@ import https from "https"; import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; import { ui } from "../environment/userInteraction.js"; +import { gunzipSync, gzipSync } from "zlib"; /** * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor @@ -188,14 +189,36 @@ function createProxyRequest(hostname, req, res, requestInterceptor) { res.end("Internal Server Error"); return; } - res.writeHead(proxyRes.statusCode, proxyRes.headers); - if (!requestInterceptor.modifiesResponse) { - // If the response is not being modified, we can - // just pipe without the need for - proxyRes.pipe(res); + if (requestInterceptor.modifiesResponse()) { + const responseInterceptor = requestInterceptor.handleResponse(); + + /** @type {Array} */ + let chunks = []; + + proxyRes.on("data", (chunk) => chunks.push(chunk)); + + proxyRes.on("end", () => { + /** @type {Buffer} */ + let buffer = Buffer.concat(chunks); + + if (proxyRes.headers["content-encoding"] === "gzip") { + buffer = gunzipSync(buffer); + } + + buffer = responseInterceptor.modifyBody(buffer); + + if (proxyRes.headers["content-encoding"] === "gzip") { + buffer = gzipSync(buffer); + } + + res.end(buffer); + }); } else { + // If the response is not being modified, we can + // just pipe without the need for buffering the output + proxyRes.pipe(res); } }); From 6ae93686b7a06a622f505021cd44b114cb58b522 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 13 Nov 2025 14:51:57 +0100 Subject: [PATCH 03/26] Finish npm info modification. --- packages/safe-chain/src/config/settings.js | 3 +- .../interceptors/interceptorBuilder.js | 54 ++++- .../interceptors/npm/modifyNpmInfo.js | 148 +++++++++++++ .../interceptors/npm/npmInterceptor.js | 197 +----------------- .../npm/npmInterceptor.minPackageAge.spec.js | 29 ++- .../interceptors/npm/parseNpmPackageUrl.js | 43 ++++ .../src/registryProxy/mitmRequestHandler.js | 19 +- 7 files changed, 281 insertions(+), 212 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 5b27118..cef66c3 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -37,6 +37,7 @@ export function setEcoSystem(setting) { ecosystemSettings.ecoSystem = setting; } +const defaultMinimumPackageAge = 24; export function getMinimumPackageAgeHours() { - return 24 * 6; + return defaultMinimumPackageAge; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index 96c1e67..c8ef3fc 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -10,11 +10,16 @@ import { EventEmitter } from "events"; * @typedef {Object} RequestInterceptionContext * @property {string} targetUrl * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware + * @property {(modificationFunc: (headers: NodeJS.Dict) => void) => void} modifyRequestHeaders + * @property {(modificationFunc: (body: Buffer) => Buffer) => void} modifyBody * @property {() => RequestInterceptionHandler} build * * * @typedef {Object} RequestInterceptionHandler * @property {{statusCode: number, message: string} | undefined} blockResponse + * @property {(headers: NodeJS.Dict | undefined) => void} modifyRequestHeaders + * @property {() => boolean} modifiesResponse + * @property {(body: Buffer) => Buffer} modifyBody */ /** @@ -60,12 +65,16 @@ function buildInterceptor(requestHandlers) { function createRequestContext(targetUrl, eventEmitter) { /** @type {{statusCode: number, message: string} | undefined} */ let blockResponse = undefined; + /** @type {Array<(headers: NodeJS.Dict) => void>} */ + let reqheaderModificationFuncs = []; + /** @type {Array<(body: Buffer) => Buffer>} */ + let modifyBodyFuncs = []; /** * @param {string | undefined} packageName * @param {string | undefined} version */ - function blockMalware(packageName, version) { + function blockMalwareSetup(packageName, version) { blockResponse = { statusCode: 403, message: "Forbidden - blocked by safe-chain", @@ -80,13 +89,44 @@ function createRequestContext(targetUrl, eventEmitter) { }); } + /** @returns {RequestInterceptionHandler} */ + function build() { + /** @param {NodeJS.Dict | undefined} headers */ + function modifyRequestHeaders(headers) { + if (!headers) return; + + for (const func of reqheaderModificationFuncs) { + func(headers); + } + } + + /** + * @param {Buffer} body + * @returns {Buffer} + */ + function modifyBody(body) { + let modifiedBody = body; + + for (var func of modifyBodyFuncs) { + modifiedBody = func(body); + } + + return modifiedBody; + } + + return { + blockResponse, + modifyRequestHeaders: modifyRequestHeaders, + modifiesResponse: () => modifyBodyFuncs.length > 0, + modifyBody, + }; + } + return { targetUrl, - blockMalware, - build() { - return { - blockResponse, - }; - }, + blockMalware: blockMalwareSetup, + 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 new file mode 100644 index 0000000..b69159a --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -0,0 +1,148 @@ +import { getMinimumPackageAgeHours } from "../../../config/settings.js"; +import { ui } from "../../../environment/userInteraction.js"; + +/** + * @param {NodeJS.Dict} headers + */ +export function modifyNpmInfoRequestHeaders(headers) { + if (headers["accept"]?.includes("application/vnd.npm.install-v1+json")) { + // The npm registry sometimes serves a more compact format that lacks + // the time metadata we need to filter out too new packages. + // Force the registry to return the full metadata by changing the Accept header. + headers["accept"] = "application/json"; + } +} + +/** + * @param {string} url + * @returns {boolean} + */ +export function isPackageInfoUrl(url) { + // Remove query string and fragment to get the actual path + const urlWithoutParams = url.split("?")[0].split("#")[0]; + + // Tarball downloads end with .tgz + if (urlWithoutParams.endsWith(".tgz")) return false; + + // Special endpoints start with /-/ and should not be modified + // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access + if (urlWithoutParams.includes("/-/")) return false; + + // Everything else is package metadata that can be modified + return true; +} +/** + * + * @param {Buffer} body + * @returns Buffer + */ +export function modifyNpmInfoResponse(body) { + try { + if (body.byteLength === 0) { + return body; + } + + const bodyContent = body.toString("utf8"); + const bodyJson = JSON.parse(bodyContent); + + if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) { + // Just return the current body if the format is not + return body; + } + + const cutOff = new Date( + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + ).toISOString(); + + const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; + + const versions = Object.entries(bodyJson.time) + .map(([version, timestamp]) => ({ + version, + timestamp, + })) + .filter((x) => x.version !== "created" && x.version !== "modified"); + + for (const { version, timestamp } of versions) { + if (version === "created" || version === "modified") { + continue; + } + + if (timestamp > cutOff) { + deleteVersionFromJson(bodyJson, version); + continue; + } + } + + if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) { + // The latest tag was removed because it contained a package younger than the treshold. + // A new latest tag needs to be calculated + bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time); + } + + return Buffer.from(JSON.stringify(bodyJson)); + } catch (err) { + ui.writeVerbose( + `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}` + ); + return body; + } +} + +/** + * @param {any} json + * @param {string} version + */ +function deleteVersionFromJson(json, version) { + ui.writeVerbose( + `Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` + ); + + delete json.time[version]; + delete json.versions[version]; + + for (const [tag, distVersion] of Object.entries(json["dist-tags"])) { + if (version == distVersion) { + delete json["dist-tags"][tag]; + } + } +} + +/** + * @param {Record} tagList + * @returns {string | undefined} + */ +function calculateLatestTag(tagList) { + const entries = Object.entries(tagList).filter( + ([version, _]) => version !== "created" && version !== "modified" + ); + + const latestFullRelease = getMostRecentTag( + Object.fromEntries(entries.filter(([version, _]) => !version.includes("-"))) + ); + if (latestFullRelease) { + return latestFullRelease; + } + + const latestPrerelease = getMostRecentTag( + Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))) + ); + return latestPrerelease; +} + +/** + * @param {Record} tagList + * @returns {string | undefined} + */ +function getMostRecentTag(tagList) { + let current, currentDate; + + for (const [version, timestamp] of Object.entries(tagList)) { + if (!currentDate || currentDate < timestamp) { + current = version; + currentDate = timestamp; + } + } + + return current; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 0514636..467e5f0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -1,7 +1,11 @@ -import { getMinimumPackageAgeHours } from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; -import { ui } from "../../../environment/userInteraction.js"; +import { + isPackageInfoUrl, + modifyNpmInfoRequestHeaders, + modifyNpmInfoResponse, +} from "./modifyNpmInfo.js"; +import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; @@ -29,197 +33,14 @@ function buildNpmInterceptor(registry) { reqContext.targetUrl, registry ); + if (await isMalwarePackage(packageName, version)) { reqContext.blockMalware(packageName, version); } if (isPackageInfoUrl(reqContext.targetUrl)) { - reqContext.modifyRequestHeaders((headers) => { - if ( - headers["accept"]?.includes("application/vnd.npm.install-v1+json") - ) { - // The npm registry sometimes serves a more compact format that lacks - // the time metadata we need to filter out too new packages. - // Force the registry to return the full metadata by changing the Accept header. - headers["accept"] = "application/json"; - } - }); - - reqContext.modifyResponse((res) => { - res.modifyBody(modifyNpmInfoRequestBody); - }); + reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); + reqContext.modifyBody(modifyNpmInfoResponse); } }); } - -/** - * @param {string} url - * @param {string} registry - * @returns {{packageName: string | undefined, version: string | undefined}} - */ -function parseNpmPackageUrl(url, registry) { - let packageName, version; - if (!registry || !url.endsWith(".tgz")) { - return { packageName, version }; - } - - const registryIndex = url.indexOf(registry); - const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash - - const separatorIndex = afterRegistry.indexOf("/-/"); - if (separatorIndex === -1) { - return { packageName, version }; - } - - packageName = afterRegistry.substring(0, separatorIndex); - const filename = afterRegistry.substring( - separatorIndex + 3, - afterRegistry.length - 4 - ); // Remove /-/ and .tgz - - // Extract version from filename - // For scoped packages like @babel/core, the filename is core-7.21.4.tgz - // For regular packages like lodash, the filename is lodash-4.17.21.tgz - if (packageName.startsWith("@")) { - const scopedPackageName = packageName.substring( - packageName.lastIndexOf("/") + 1 - ); - if (filename.startsWith(scopedPackageName + "-")) { - version = filename.substring(scopedPackageName.length + 1); - } - } else { - if (filename.startsWith(packageName + "-")) { - version = filename.substring(packageName.length + 1); - } - } - - return { packageName, version }; -} - -/** - * @param {string} url - * @returns {boolean} - */ -function isPackageInfoUrl(url) { - // Remove query string and fragment to get the actual path - const urlWithoutParams = url.split("?")[0].split("#")[0]; - - // Tarball downloads end with .tgz - if (urlWithoutParams.endsWith(".tgz")) return false; - - // Special endpoints start with /-/ and should not be modified - // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access - if (urlWithoutParams.includes("/-/")) return false; - - // Everything else is package metadata that can be modified - return true; -} - -/** - * - * @param {Buffer} body - * @returns Buffer - */ -function modifyNpmInfoRequestBody(body) { - try { - const bodyContent = body.toString("utf8"); - const bodyJson = JSON.parse(bodyContent); - - if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) { - // Just return the body if the - return body; - } - - const cutOff = new Date( - new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 - ).toISOString(); - - const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; - - const versions = Object.entries(bodyJson.time) - .map(([version, timestamp]) => ({ - version, - timestamp, - })) - .filter((x) => x.version != "created" && x.version != "modified"); - - for (const { version, timestamp } of versions) { - if (version === "created" || version === "modified") { - continue; - } - - if (timestamp > cutOff) { - deleteVersionFromJson(bodyJson, version); - continue; - } - } - - if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) { - // The latest tag was removed because it contained a package younger than the treshold. - // A new latest tag needs to be calculated - bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson); - } - - return Buffer.from(JSON.stringify(bodyJson)); - } catch (err) { - // TODO: better error handling - return body; - } -} - -function deleteVersionFromJson(json, version) { - ui.writeVerbose( - `Safe-chain: Deleting ${version} from npm info request, it's newer than the minimumPackageAgeInHours` - ); - - delete json.time[version]; - delete json.versions[version]; - - for (const [tag, distVersion] of Object.entries(json["dist-tags"])) { - if (version == distVersion) { - delete json["dist-tags"][tag]; - } - } -} - -function calculateLatestTag(json) { - if (!json.time) { - return undefined; - } - - let latest, preview, latestDate, previewDate; - - for (const [version, timestamp] of Object.entries(json.time)) { - if (version == "created" || version == "modified") continue; - - if (version.includes("-")) { - // preview versions include "-" in the name - [preview, previewDate] = getLatest( - preview, - previewDate, - version, - timestamp - ); - } else { - [latest, latestDate] = getLatest(latest, latestDate, version, timestamp); - } - } - - if (latest) { - return latest; - } else { - return preview; - } - - function getLatest(currentLatest, currentLatestDate, version, timestamp) { - if (!currentLatest) { - return [version, timestamp]; - } - - if (timestamp > currentLatestDate) { - return [version, timestamp]; - } - - return [currentLatest, currentLatestDate]; - } -} 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 ea23f9e..269a241 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 @@ -1,5 +1,6 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; +import { buffer } from "node:stream/consumers"; describe("npmInterceptor minimum package age", async () => { let minimumPackageAgeSettings = 48; @@ -17,6 +18,19 @@ describe("npmInterceptor minimum package age", async () => { }, }, }); + mock.module("../../../environment/userInteraction.js", { + namedExports: { + ui: { + startProcess: () => {}, + writeError: () => {}, + writeInformation: () => {}, + writeWarning: () => {}, + writeVerbose: () => {}, + writeExitWithoutInstallingMaliciousPackages: () => {}, + emptyLine: () => {}, + }, + }, + }); const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); for (const packageInfoUrl of [ @@ -128,6 +142,7 @@ describe("npmInterceptor minimum package age", async () => { }, time: { created: getDate(-365 * 24), + modified: getDate(-3), ["1.0.0"]: getDate(-7), // cutoff-date here ["2.0.0"]: getDate(-4), @@ -138,7 +153,7 @@ describe("npmInterceptor minimum package age", async () => { const modifiedJson = JSON.parse(modifiedBody); - assert.equal(Object.keys(modifiedJson.time).length, 2); + assert.equal(Object.keys(modifiedJson.time).length, 3); assert.equal(Object.keys(modifiedJson.versions).length, 1); assert.ok(Object.keys(modifiedJson.time).includes("1.0.0")); assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); @@ -166,6 +181,7 @@ describe("npmInterceptor minimum package age", async () => { }, time: { created: getDate(-365 * 24), + modified: getDate(-3), ["1.0.0"]: getDate(-7), ["0.0.1"]: getDate(-8), // package order: this package is older than 1.0.0, it should not be considered latest ["2.0.0-alpha"]: getDate(-6), //package is a pre-release, it should not be latest @@ -200,6 +216,7 @@ describe("npmInterceptor minimum package age", async () => { }, time: { created: getDate(-365 * 24), + modified: getDate(-4), ["1.0.0"]: getDate(-7), // cutoff-date here ["2.0.0-alpha"]: getDate(-4), @@ -220,12 +237,14 @@ describe("npmInterceptor minimum package age", async () => { */ async function runModifyNpmInfoRequest(url, body) { const interceptor = npmInterceptorForUrl(url); - const requestInterceptor = await interceptor.handleRequest(url); - const responseInterceptor = requestInterceptor.handleResponse(); + const requestHandler = await interceptor.handleRequest(url); - const modifiedBuffer = responseInterceptor.modifyBody(Buffer.from(body)); + if (requestHandler.modifiesResponse()) { + const modifiedBuffer = requestHandler.modifyBody(Buffer.from(body)); + return modifiedBuffer.toString("utf8"); + } - return modifiedBuffer.toString("utf8"); + return buffer; } function getDate(plusHours) { diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js new file mode 100644 index 0000000..fa256d4 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -0,0 +1,43 @@ +/** + * @param {string} url + * @param {string} registry + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +export function parseNpmPackageUrl(url, registry) { + let packageName, version; + if (!registry || !url.endsWith(".tgz")) { + return { packageName, version }; + } + + const registryIndex = url.indexOf(registry); + const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + + const separatorIndex = afterRegistry.indexOf("/-/"); + if (separatorIndex === -1) { + return { packageName, version }; + } + + packageName = afterRegistry.substring(0, separatorIndex); + const filename = afterRegistry.substring( + separatorIndex + 3, + afterRegistry.length - 4 + ); // Remove /-/ and .tgz + + // Extract version from filename + // For scoped packages like @babel/core, the filename is core-7.21.4.tgz + // For regular packages like lodash, the filename is lodash-4.17.21.tgz + if (packageName.startsWith("@")) { + const scopedPackageName = packageName.substring( + packageName.lastIndexOf("/") + 1 + ); + if (filename.startsWith(scopedPackageName + "-")) { + version = filename.substring(scopedPackageName.length + 1); + } + } else { + if (filename.startsWith(packageName + "-")) { + version = filename.substring(packageName.length + 1); + } + } + + return { packageName, version }; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index aa1391e..70e0a51 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -6,7 +6,6 @@ import { gunzipSync, gzipSync } from "zlib"; /** * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor - * @typedef {import("./interceptors/requestInterceptorBuilder.js").RequestInterceptor} RequestInterceptor */ /** @@ -113,10 +112,10 @@ function getRequestPathAndQuery(url) { * @param {import("http").IncomingMessage} req * @param {string} hostname * @param {import("http").ServerResponse} res - * @param {RequestInterceptor} requestInterceptor + * @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler */ -function forwardRequest(req, hostname, res, requestInterceptor) { - const proxyReq = createProxyRequest(hostname, req, res, requestInterceptor); +function forwardRequest(req, hostname, res, requestHandler) { + const proxyReq = createProxyRequest(hostname, req, res, requestHandler); proxyReq.on("error", (err) => { ui.writeVerbose( @@ -147,16 +146,16 @@ function forwardRequest(req, hostname, res, requestInterceptor) { * @param {string} hostname * @param {import("http").IncomingMessage} req * @param {import("http").ServerResponse} res - * @param {RequestInterceptor} requestInterceptor + * @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler * * @returns {import("http").ClientRequest} */ -function createProxyRequest(hostname, req, res, requestInterceptor) { +function createProxyRequest(hostname, req, res, requestHandler) { const headers = { ...req.headers }; if (headers.host) { delete headers.host; } - requestInterceptor.modifyRequestHeaders(headers); + requestHandler.modifyRequestHeaders(headers); /** @type {import("http").RequestOptions} */ const options = { @@ -191,9 +190,7 @@ function createProxyRequest(hostname, req, res, requestInterceptor) { } res.writeHead(proxyRes.statusCode, proxyRes.headers); - if (requestInterceptor.modifiesResponse()) { - const responseInterceptor = requestInterceptor.handleResponse(); - + if (requestHandler.modifiesResponse()) { /** @type {Array} */ let chunks = []; @@ -207,7 +204,7 @@ function createProxyRequest(hostname, req, res, requestInterceptor) { buffer = gunzipSync(buffer); } - buffer = responseInterceptor.modifyBody(buffer); + buffer = requestHandler.modifyBody(buffer); if (proxyRes.headers["content-encoding"] === "gzip") { buffer = gzipSync(buffer); From a9a4d7670505ffed672c9aa2d2b99dfb31fd4f1c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 13 Nov 2025 15:08:36 +0100 Subject: [PATCH 04/26] Fix type error in modifyNpmInfo.js --- .../src/registryProxy/interceptors/npm/modifyNpmInfo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index b69159a..d334c27 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -81,7 +81,7 @@ export function modifyNpmInfoResponse(body) { } return Buffer.from(JSON.stringify(bodyJson)); - } catch (err) { + } catch (/** @type {any} */ err) { ui.writeVerbose( `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}` ); From f64ee3bccf088d5988558b17012743c2d7f26d42 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 13 Nov 2025 15:14:44 +0100 Subject: [PATCH 05/26] Add skipMinimumPackageAge. --- packages/safe-chain/src/config/settings.js | 4 ++ .../interceptors/npm/npmInterceptor.js | 3 +- .../npm/npmInterceptor.minPackageAge.spec.js | 47 ++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index cef66c3..ce5af2e 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -41,3 +41,7 @@ const defaultMinimumPackageAge = 24; export function getMinimumPackageAgeHours() { return defaultMinimumPackageAge; } + +export function skipMinimumPackageAge() { + return false; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 467e5f0..eaf50db 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -1,3 +1,4 @@ +import { skipMinimumPackageAge } from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { @@ -38,7 +39,7 @@ function buildNpmInterceptor(registry) { reqContext.blockMalware(packageName, version); } - if (isPackageInfoUrl(reqContext.targetUrl)) { + if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); reqContext.modifyBody(modifyNpmInfoResponse); } 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 269a241..ab3802b 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 @@ -1,13 +1,14 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; -import { buffer } from "node:stream/consumers"; describe("npmInterceptor minimum package age", async () => { let minimumPackageAgeSettings = 48; + let skipMinimumPackageAgeSetting = false; mock.module("../../../config/settings.js", { namedExports: { getMinimumPackageAgeHours: () => minimumPackageAgeSettings, + skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, }, }); @@ -244,9 +245,51 @@ describe("npmInterceptor minimum package age", async () => { return modifiedBuffer.toString("utf8"); } - return buffer; + return body; } + it("Should not filter packages when skipMinimumPackageAge is enabled", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = true; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const originalBody = JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-3), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + originalBody + ); + + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain unchanged + assert.equal(Object.keys(modifiedJson.versions).length, 3); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0")); + + // Latest should remain unchanged + assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0"); + }); + function getDate(plusHours) { const date = new Date(); date.setHours(date.getHours() + plusHours); From 752504dcc8a94f5b593df7422ab11683772c5b7f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 13 Nov 2025 16:04:24 +0100 Subject: [PATCH 06/26] Add --safe-chain-skip-minimum-package-age cli flag --- .../safe-chain/src/config/cliArguments.js | 35 ++++++++++- .../src/config/cliArguments.spec.js | 62 ++++++++++++++++++- packages/safe-chain/src/config/settings.js | 9 ++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 04645d8..794c97a 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -1,8 +1,9 @@ /** - * @type {{loggingLevel: string | undefined}} + * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined}} */ const state = { loggingLevel: undefined, + skipMinimumPackageAge: undefined, }; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; @@ -14,6 +15,7 @@ const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; export function initializeCliArguments(args) { // Reset state on each call state.loggingLevel = undefined; + state.skipMinimumPackageAge = undefined; const safeChainArgs = []; const remainingArgs = []; @@ -27,6 +29,7 @@ export function initializeCliArguments(args) { } setLoggingLevel(safeChainArgs); + setSkipMinimumPackageAge(safeChainArgs); return remainingArgs; } @@ -47,6 +50,20 @@ function getLastArgEqualsValue(args, prefix) { return undefined; } +/** + * @param {string[]} args + * @param {string} flagName + * @returns {boolean} + */ +function hasFlagArg(args, flagName) { + for (const arg of args) { + if (arg.toLowerCase() === flagName.toLowerCase()) { + return true; + } + } + return false; +} + /** * @param {string[]} args * @returns {void} @@ -64,3 +81,19 @@ function setLoggingLevel(args) { export function getLoggingLevel() { return state.loggingLevel; } + +/** + * @param {string[]} args + * @returns {void} + */ +function setSkipMinimumPackageAge(args) { + const flagName = SAFE_CHAIN_ARG_PREFIX + "skip-minimum-package-age"; + + if (hasFlagArg(args, flagName)) { + state.skipMinimumPackageAge = true; + } +} + +export function getSkipMinimumPackageAge() { + return state.skipMinimumPackageAge; +} diff --git a/packages/safe-chain/src/config/cliArguments.spec.js b/packages/safe-chain/src/config/cliArguments.spec.js index 415d34a..3c8b7da 100644 --- a/packages/safe-chain/src/config/cliArguments.spec.js +++ b/packages/safe-chain/src/config/cliArguments.spec.js @@ -1,6 +1,10 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { initializeCliArguments, getLoggingLevel } from "./cliArguments.js"; +import { + initializeCliArguments, + getLoggingLevel, + getSkipMinimumPackageAge, +} from "./cliArguments.js"; describe("initializeCliArguments", () => { it("should return all args when no safe-chain args are present", () => { @@ -118,4 +122,60 @@ describe("initializeCliArguments", () => { assert.deepEqual(result, ["install"]); assert.strictEqual(getLoggingLevel(), "silent"); }); + + it("should not set skipMinimumPackageAge when flag is absent", () => { + const args = ["install", "express", "--save"]; + initializeCliArguments(args); + + assert.strictEqual(getSkipMinimumPackageAge(), undefined); + }); + + it("should set skipMinimumPackageAge to true when flag is present", () => { + const args = ["--safe-chain-skip-minimum-package-age", "install", "lodash"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getSkipMinimumPackageAge(), true); + }); + + it("should handle skip-minimum-package-age flag case-insensitively", () => { + const args = ["--SAFE-CHAIN-SKIP-MINIMUM-PACKAGE-AGE", "install"]; + initializeCliArguments(args); + + assert.strictEqual(getSkipMinimumPackageAge(), true); + }); + + it("should filter out skip-minimum-package-age flag from returned args", () => { + const args = [ + "install", + "--safe-chain-skip-minimum-package-age", + "express", + "--save", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "express", "--save"]); + }); + + it("should handle skip-minimum-package-age with other safe-chain arguments", () => { + const args = [ + "--safe-chain-logging=verbose", + "--safe-chain-skip-minimum-package-age", + "install", + "lodash", + ]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getLoggingLevel(), "verbose"); + assert.strictEqual(getSkipMinimumPackageAge(), true); + }); + + it("should handle skip-minimum-package-age flag in different positions", () => { + const args = ["install", "lodash", "--safe-chain-skip-minimum-package-age"]; + const result = initializeCliArguments(args); + + assert.deepEqual(result, ["install", "lodash"]); + assert.strictEqual(getSkipMinimumPackageAge(), true); + }); }); diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index ce5af2e..ce7f35c 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -42,6 +42,13 @@ export function getMinimumPackageAgeHours() { return defaultMinimumPackageAge; } +const defaultSkipMinimumPackageAge = false; export function skipMinimumPackageAge() { - return false; + const cliValue = cliArguments.getSkipMinimumPackageAge(); + + if (cliValue === true) { + return true; + } + + return defaultSkipMinimumPackageAge; } From dc6f37b3ecdb6999180bc7275649754956a38db4 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 13 Nov 2025 16:27:42 +0100 Subject: [PATCH 07/26] Remove etag from response when modifying headers --- .../registryProxy/interceptors/interceptorBuilder.js | 11 ++++++----- .../registryProxy/interceptors/npm/modifyNpmInfo.js | 8 +++++++- .../src/registryProxy/mitmRequestHandler.js | 7 +++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index c8ef3fc..362f31a 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -11,7 +11,7 @@ import { EventEmitter } from "events"; * @property {string} targetUrl * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware * @property {(modificationFunc: (headers: NodeJS.Dict) => void) => void} modifyRequestHeaders - * @property {(modificationFunc: (body: Buffer) => Buffer) => void} modifyBody + * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict | undefined) => Buffer) => void} modifyBody * @property {() => RequestInterceptionHandler} build * * @@ -19,7 +19,7 @@ import { EventEmitter } from "events"; * @property {{statusCode: number, message: string} | undefined} blockResponse * @property {(headers: NodeJS.Dict | undefined) => void} modifyRequestHeaders * @property {() => boolean} modifiesResponse - * @property {(body: Buffer) => Buffer} modifyBody + * @property {(body: Buffer, headers: NodeJS.Dict | undefined) => Buffer} modifyBody */ /** @@ -67,7 +67,7 @@ function createRequestContext(targetUrl, eventEmitter) { let blockResponse = undefined; /** @type {Array<(headers: NodeJS.Dict) => void>} */ let reqheaderModificationFuncs = []; - /** @type {Array<(body: Buffer) => Buffer>} */ + /** @type {Array<(body: Buffer, headers: NodeJS.Dict | undefined) => Buffer>} */ let modifyBodyFuncs = []; /** @@ -102,13 +102,14 @@ function createRequestContext(targetUrl, eventEmitter) { /** * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers * @returns {Buffer} */ - function modifyBody(body) { + function modifyBody(body, headers) { let modifiedBody = body; for (var func of modifyBodyFuncs) { - modifiedBody = func(body); + modifiedBody = func(body, headers); } return modifiedBody; diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index d334c27..54269a8 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -34,9 +34,10 @@ export function isPackageInfoUrl(url) { /** * * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers * @returns Buffer */ -export function modifyNpmInfoResponse(body) { +export function modifyNpmInfoResponse(body, headers) { try { if (body.byteLength === 0) { return body; @@ -70,6 +71,11 @@ export function modifyNpmInfoResponse(body) { if (timestamp > cutOff) { deleteVersionFromJson(bodyJson, version); + if (headers) { + // When modifying the response, the etag no longer matches the content + // so the etag needs to be removed before sending the response. + delete headers["etag"]; + } continue; } } diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 70e0a51..edc114d 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -188,7 +188,8 @@ function createProxyRequest(hostname, req, res, requestHandler) { res.end("Internal Server Error"); return; } - res.writeHead(proxyRes.statusCode, proxyRes.headers); + + const { statusCode, headers } = proxyRes; if (requestHandler.modifiesResponse()) { /** @type {Array} */ @@ -204,17 +205,19 @@ function createProxyRequest(hostname, req, res, requestHandler) { buffer = gunzipSync(buffer); } - buffer = requestHandler.modifyBody(buffer); + buffer = requestHandler.modifyBody(buffer, headers); if (proxyRes.headers["content-encoding"] === "gzip") { buffer = gzipSync(buffer); } + res.writeHead(statusCode, headers); res.end(buffer); }); } else { // If the response is not being modified, we can // just pipe without the need for buffering the output + res.writeHead(statusCode, headers); proxyRes.pipe(res); } }); From 59fa76a42f1ea15076d121cb948314c17a638094 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 13 Nov 2025 17:10:22 +0100 Subject: [PATCH 08/26] Notify the user when we modified the package versions --- packages/safe-chain/src/main.js | 13 +++++++++++++ .../registryProxy/interceptors/npm/modifyNpmInfo.js | 11 +++++++++++ .../safe-chain/src/registryProxy/registryProxy.js | 2 ++ 3 files changed, 26 insertions(+) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index ea4fe0e..c46fc61 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -72,6 +72,19 @@ export async function main(args) { ); } + if (proxy.hasSuppressedVersions()) { + ui.writeInformation( + `${chalk.yellow( + "ℹ" + )} Safe-chain: Some package versions were suppressed due to minimum age requirement.` + ); + ui.writeInformation( + ` To disable this check, use: ${chalk.cyan( + "--safe-chain-skip-minimum-package-age" + )}` + ); + } + // Returning the exit code back to the caller allows the promise // to be awaited in the bin files and return the correct exit code return packageManagerResult.status; diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 54269a8..2ad8a68 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -1,6 +1,8 @@ import { getMinimumPackageAgeHours } from "../../../config/settings.js"; import { ui } from "../../../environment/userInteraction.js"; +let hasSuppressedVersions = false; + /** * @param {NodeJS.Dict} headers */ @@ -100,6 +102,8 @@ export function modifyNpmInfoResponse(body, headers) { * @param {string} version */ function deleteVersionFromJson(json, version) { + hasSuppressedVersions = true; + ui.writeVerbose( `Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` ); @@ -152,3 +156,10 @@ function getMostRecentTag(tagList) { return current; } + +/** + * @returns {boolean} + */ +export function getHasSuppressedVersions() { + return hasSuppressedVersions; +} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index beaa1ef..8169086 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -6,6 +6,7 @@ import { getCaCertPath } from "./certUtils.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; +import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; const SERVER_STOP_TIMEOUT_MS = 1000; /** @@ -23,6 +24,7 @@ export function createSafeChainProxy() { startServer: () => startServer(server), stopServer: () => stopServer(server), verifyNoMaliciousPackages, + hasSuppressedVersions: getHasSuppressedVersions, }; } From 06b287d4d422d3f55ac35329e0bff3194a48badb Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 14 Nov 2025 09:08:27 +0100 Subject: [PATCH 09/26] Use correct header collection for forwarding --- packages/safe-chain/src/registryProxy/mitmRequestHandler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index edc114d..9afadaa 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -163,7 +163,7 @@ function createProxyRequest(hostname, req, res, requestHandler) { port: 443, path: req.url, method: req.method, - headers: { ...req.headers }, + headers: { ...headers }, }; const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; From 86fb69a9311f4126d7de36128e08f155f838de12 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 12 Nov 2025 16:15:32 +0100 Subject: [PATCH 10/26] Clarify support for ecosystems and pip status Updated README to clarify that Aikido Safe Chain currently supports only JavaScript ecosystems and marks pip and pip3 as beta. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index acea710..f169747 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aikido Safe Chain -The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Python ecosystem (through pip or pip3, including `python -m pip[...]` and `python3 -m pip[...]` where available) or in the Javascript ecosystem (through npm, npx, yarn, pnpm, pnpx, bun and bunx). It's **free** to use and does not require any token. +The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Javascript ecosystem (through npm, npx, yarn, pnpm, pnpx, bun and bunx). It's **free** to use and does not require any token. The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip/pip3 from downloading or running the malware. @@ -15,8 +15,8 @@ Aikido Safe Chain works on Node.js version 18 and above and supports the followi - ✅ **pnpx** - ✅ **bun** - ✅ **bunx** -- ✅ **pip** -- ✅ **pip3** +- ✅ **pip** (beta) +- ✅ **pip3** (beta) # Usage @@ -41,7 +41,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: npm install safe-chain-test ``` - For Python: + For Python (beta): ```shell pip3 install safe-chain-pi-test ``` From 40523f29ddd91fffafd1f469081826ed5fe7ee0e Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 14 Nov 2025 09:30:55 +0100 Subject: [PATCH 11/26] Document minimum package age in README.md --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f169747..687bedf 100644 --- a/README.md +++ b/README.md @@ -33,15 +33,19 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: safe-chain setup ``` 3. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. + +- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. + 4. **Verify the installation** by running one of the following commands: For JavaScript/Node.js: + ```shell npm install safe-chain-test ``` For Python (beta): + ```shell pip3 install safe-chain-pi-test ``` @@ -58,7 +62,17 @@ safe-chain --version ## How it works -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +### Malware Blocking + +The Aikido Safe Chain runs a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. + +### Minimum package age (npm only) + +**⚠️ This feature only applies to npm-based package managers (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to PyPI/pip.** + +For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can bypass this protection for specific installs using the `--safe-chain-skip-minimum-package-age` flag. + +### Shell Integration The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: From 290a6305268aae77b89372c4d51007cb93a7ac5b Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 14 Nov 2025 10:23:06 +0100 Subject: [PATCH 12/26] Better header check + remove last-modified header --- .../interceptors/npm/modifyNpmInfo.js | 31 ++++++++++++++-- .../npm/npmInterceptor.minPackageAge.spec.js | 36 ++++++++++--------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 2ad8a68..acb7d07 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -7,7 +7,8 @@ let hasSuppressedVersions = false; * @param {NodeJS.Dict} headers */ export function modifyNpmInfoRequestHeaders(headers) { - if (headers["accept"]?.includes("application/vnd.npm.install-v1+json")) { + const accept = getHeaderValueAsString(headers, "accept"); + if (accept?.includes("application/vnd.npm.install-v1+json")) { // The npm registry sometimes serves a more compact format that lacks // the time metadata we need to filter out too new packages. // Force the registry to return the full metadata by changing the Accept header. @@ -41,6 +42,11 @@ export function isPackageInfoUrl(url) { */ export function modifyNpmInfoResponse(body, headers) { try { + const contentType = getHeaderValueAsString(headers, "content-type"); + if (!contentType?.toLowerCase().includes("application/json")) { + return body; + } + if (body.byteLength === 0) { return body; } @@ -74,9 +80,10 @@ export function modifyNpmInfoResponse(body, headers) { if (timestamp > cutOff) { deleteVersionFromJson(bodyJson, version); if (headers) { - // When modifying the response, the etag no longer matches the content - // so the etag needs to be removed before sending the response. + // When modifying the response, the etag and last-modified headers + // no longer match the content so they needs to be removed before sending the response. delete headers["etag"]; + delete headers["last-modified"]; } continue; } @@ -163,3 +170,21 @@ function getMostRecentTag(tagList) { export function getHasSuppressedVersions() { return hasSuppressedVersions; } + +/** + * @param {NodeJS.Dict | undefined} headers + * @param {string} headerName + */ +function getHeaderValueAsString(headers, headerName) { + if (!headers) { + return undefined; + } + + let header = headers[headerName]; + + if (Array.isArray(header)) { + return header.join(", "); + } + + return header; +} 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 ab3802b..2ff5a52 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 @@ -231,23 +231,6 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(modifiedJson["dist-tags"]["alpha"], undefined); }); - /** - * @param {import("../interceptorBuilder.js").Interceptor} interceptor - * @param {string} body - * @returns {Promise} - */ - async function runModifyNpmInfoRequest(url, body) { - const interceptor = npmInterceptorForUrl(url); - const requestHandler = await interceptor.handleRequest(url); - - if (requestHandler.modifiesResponse()) { - const modifiedBuffer = requestHandler.modifyBody(Buffer.from(body)); - return modifiedBuffer.toString("utf8"); - } - - return body; - } - it("Should not filter packages when skipMinimumPackageAge is enabled", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = true; @@ -296,4 +279,23 @@ describe("npmInterceptor minimum package age", async () => { return date; } + + /** + * @param {import("../interceptorBuilder.js").Interceptor} interceptor + * @param {string} body + * @returns {Promise} + */ + async function runModifyNpmInfoRequest(url, body) { + const interceptor = npmInterceptorForUrl(url); + const requestHandler = await interceptor.handleRequest(url); + + if (requestHandler.modifiesResponse()) { + const modifiedBuffer = requestHandler.modifyBody(Buffer.from(body), { + ["content-type"]: "application/json", + }); + return modifiedBuffer.toString("utf8"); + } + + return body; + } }); From 157725a25a5367257cc0cb716f4292283f104e28 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 14 Nov 2025 10:29:09 +0100 Subject: [PATCH 13/26] Cleanup --- .../interceptors/npm/modifyNpmInfo.js | 8 ++-- .../responseInterceptorBuilder.js | 43 ------------------- 2 files changed, 5 insertions(+), 46 deletions(-) delete mode 100644 packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index acb7d07..ea30d31 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -1,7 +1,9 @@ import { getMinimumPackageAgeHours } from "../../../config/settings.js"; import { ui } from "../../../environment/userInteraction.js"; -let hasSuppressedVersions = false; +const state = { + hasSuppressedVersions: false, +}; /** * @param {NodeJS.Dict} headers @@ -109,7 +111,7 @@ export function modifyNpmInfoResponse(body, headers) { * @param {string} version */ function deleteVersionFromJson(json, version) { - hasSuppressedVersions = true; + state.hasSuppressedVersions = true; ui.writeVerbose( `Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` @@ -168,7 +170,7 @@ function getMostRecentTag(tagList) { * @returns {boolean} */ export function getHasSuppressedVersions() { - return hasSuppressedVersions; + return state.hasSuppressedVersions; } /** diff --git a/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js deleted file mode 100644 index 86d79e5..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/responseInterceptorBuilder.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @typedef {Object} ResponseInterceptorBuilder - * @property {() => ResponseInterceptor} build - * @property {(modificationFunc: (body: Buffer) => Buffer) => void} modifyBody - * - * @typedef {Object} ResponseInterceptor - * @property {(buffer: Buffer) => Buffer} modifyBody - */ - -/** - * @returns {ResponseInterceptorBuilder} - */ -export function createResponseInterceptorBuilder() { - /** @type {Array<(body: Buffer) => Buffer>} */ - let modifyBodyFuncs = []; - - return { - modifyBody: (func) => modifyBodyFuncs.push(func), - build: () => createResponseInterceptor(modifyBodyFuncs), - }; -} - -/** - * @returns {ResponseInterceptor} - * @param {Array<(body: Buffer) => Buffer>} modifyBodyFuncs - */ -function createResponseInterceptor(modifyBodyFuncs) { - /** - * @param {Buffer} body - * @returns {Buffer} - */ - function modifyBody(body) { - let modifiedBody = body; - - for (var func of modifyBodyFuncs) { - modifiedBody = func(body); - } - - return modifiedBody; - } - - return { modifyBody }; -} From 4b5bef8d6a949ac2a9e948537e4730c8465b8bb9 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 12 Nov 2025 16:15:32 +0100 Subject: [PATCH 14/26] Clarify support for ecosystems and pip status Updated README to clarify that Aikido Safe Chain currently supports only JavaScript ecosystems and marks pip and pip3 as beta. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 687bedf..44ac933 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: ``` For Python (beta): - ```shell pip3 install safe-chain-pi-test ``` From ddf867bf535e7e397e787495406b05bd24d5e3d7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 14 Nov 2025 10:41:53 +0100 Subject: [PATCH 15/26] Fix readme indentation --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 44ac933..718db41 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: safe-chain setup ``` 3. **❗Restart your terminal** to start using the Aikido Safe Chain. - -- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. 4. **Verify the installation** by running one of the following commands: From 59963a6f3481ea11036c196d3a473f033a0cb41d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 14 Nov 2025 11:40:29 +0100 Subject: [PATCH 16/26] Make warning in readme less prominent --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 718db41..6c7ca9a 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,10 @@ The Aikido Safe Chain runs a lightweight proxy server that intercepts package do ### Minimum package age (npm only) -**⚠️ This feature only applies to npm-based package managers (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to PyPI/pip.** - For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can bypass this protection for specific installs using the `--safe-chain-skip-minimum-package-age` flag. +⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to PyPI/pip. + ### Shell Integration The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: From f7de81645c15293faf2651901f3d98b772b4558c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 14:17:47 +0100 Subject: [PATCH 17/26] Fix cliArgument.js merge issue --- packages/safe-chain/src/config/cliArguments.js | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 0b7876c..180c565 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -52,20 +52,6 @@ function getLastArgEqualsValue(args, prefix) { return undefined; } -/** - * @param {string[]} args - * @param {string} flagName - * @returns {boolean} - */ -function hasFlagArg(args, flagName) { - for (const arg of args) { - if (arg.toLowerCase() === flagName.toLowerCase()) { - return true; - } - } - return false; -} - /** * @param {string[]} args * @returns {void} From e02e36cfea206fbb60123b68c077848328a5d98c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 14:49:40 +0100 Subject: [PATCH 18/26] Apply suggestion from @bitterpanda63 Adds comment about "utf8" encoding of json response. Co-authored-by: bitterpanda --- .../src/registryProxy/interceptors/npm/modifyNpmInfo.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index ea30d31..9080d60 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -53,6 +53,7 @@ export function modifyNpmInfoResponse(body, headers) { return body; } + // utf-8 is default encoding for JSON, so we don't check if charset is defined in content-type header const bodyContent = body.toString("utf8"); const bodyJson = JSON.parse(bodyContent); From 78c8da6faef85902296a40db102cf786bd3b0d9b Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 14:44:01 +0100 Subject: [PATCH 19/26] Restore old "how it works" text in Readme.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 17657bb..17b4abc 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,19 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: npm install -g @aikidosec/safe-chain ``` 2. **Setup the shell integration** by running: + ```shell safe-chain setup ``` To enable Python (pip/pip3) support (beta), use the `--include-python` flag: + ```shell safe-chain setup --include-python ``` 3. **❗Restart your terminal** to start using the Aikido Safe Chain. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. 4. **Verify the installation** by running one of the following commands: @@ -50,6 +53,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: ``` For Python (beta): + ```shell pip3 install safe-chain-pi-test ``` @@ -68,7 +72,7 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain runs a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. ### Minimum package age (npm only) From 9a1092199dd6fec3c4ffd3710994573c04573de9 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 15:08:31 +0100 Subject: [PATCH 20/26] Move getHeaderValueAsString to separate utils file --- .../src/registryProxy/http-utils.js | 17 +++++++++++++++ .../interceptors/npm/modifyNpmInfo.js | 21 +++---------------- 2 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/http-utils.js diff --git a/packages/safe-chain/src/registryProxy/http-utils.js b/packages/safe-chain/src/registryProxy/http-utils.js new file mode 100644 index 0000000..e14a977 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/http-utils.js @@ -0,0 +1,17 @@ +/** + * @param {NodeJS.Dict | undefined} headers + * @param {string} headerName + */ +export function getHeaderValueAsString(headers, headerName) { + if (!headers) { + return undefined; + } + + let header = headers[headerName]; + + if (Array.isArray(header)) { + return header.join(", "); + } + + return header; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 9080d60..0e7e41d 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -1,5 +1,6 @@ import { getMinimumPackageAgeHours } from "../../../config/settings.js"; import { ui } from "../../../environment/userInteraction.js"; +import { getHeaderValueAsString } from "../../http-utils.js"; const state = { hasSuppressedVersions: false, @@ -80,6 +81,8 @@ export function modifyNpmInfoResponse(body, headers) { continue; } + // Timestamps are compared as strings. + // This can be done because they are formatted in ISO8601, which is sortable. if (timestamp > cutOff) { deleteVersionFromJson(bodyJson, version); if (headers) { @@ -173,21 +176,3 @@ function getMostRecentTag(tagList) { export function getHasSuppressedVersions() { return state.hasSuppressedVersions; } - -/** - * @param {NodeJS.Dict | undefined} headers - * @param {string} headerName - */ -function getHeaderValueAsString(headers, headerName) { - if (!headers) { - return undefined; - } - - let header = headers[headerName]; - - if (Array.isArray(header)) { - return header.join(", "); - } - - return header; -} From 5834229427d1173456432850375471e09b9c2cd8 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 15:13:25 +0100 Subject: [PATCH 21/26] Add comment in interceptorBuilder.js to clarify which api is for setup, and which api is used by the proxy. --- .../src/registryProxy/interceptors/interceptorBuilder.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index 362f31a..003aae7 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -115,6 +115,7 @@ function createRequestContext(targetUrl, eventEmitter) { return modifiedBody; } + // These functions are invoked in the proxy, allowing to apply the configured modifications return { blockResponse, modifyRequestHeaders: modifyRequestHeaders, @@ -123,6 +124,7 @@ function createRequestContext(targetUrl, eventEmitter) { }; } + // These functions are used to setup the modifications return { targetUrl, blockMalware: blockMalwareSetup, From 44ee58aa9be4797a76cb6a4b8edd1c3b06d24b43 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 15:22:41 +0100 Subject: [PATCH 22/26] Let modifyNpmInfoRequestHeaders return the header collection as well. --- .../interceptors/interceptorBuilder.js | 21 ++++++++++++------- .../interceptors/npm/modifyNpmInfo.js | 2 ++ .../src/registryProxy/mitmRequestHandler.js | 5 +++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index 003aae7..e25e641 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -10,14 +10,14 @@ import { EventEmitter } from "events"; * @typedef {Object} RequestInterceptionContext * @property {string} targetUrl * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware - * @property {(modificationFunc: (headers: NodeJS.Dict) => void) => void} modifyRequestHeaders + * @property {(modificationFunc: (headers: NodeJS.Dict) => NodeJS.Dict) => void} modifyRequestHeaders * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict | undefined) => Buffer) => void} modifyBody * @property {() => RequestInterceptionHandler} build * * * @typedef {Object} RequestInterceptionHandler * @property {{statusCode: number, message: string} | undefined} blockResponse - * @property {(headers: NodeJS.Dict | undefined) => void} modifyRequestHeaders + * @property {(headers: NodeJS.Dict | undefined) => NodeJS.Dict | undefined} modifyRequestHeaders * @property {() => boolean} modifiesResponse * @property {(body: Buffer, headers: NodeJS.Dict | undefined) => Buffer} modifyBody */ @@ -65,7 +65,7 @@ function buildInterceptor(requestHandlers) { function createRequestContext(targetUrl, eventEmitter) { /** @type {{statusCode: number, message: string} | undefined} */ let blockResponse = undefined; - /** @type {Array<(headers: NodeJS.Dict) => void>} */ + /** @type {Array<(headers: NodeJS.Dict) => NodeJS.Dict>} */ let reqheaderModificationFuncs = []; /** @type {Array<(body: Buffer, headers: NodeJS.Dict | undefined) => Buffer>} */ let modifyBodyFuncs = []; @@ -91,13 +91,18 @@ function createRequestContext(targetUrl, eventEmitter) { /** @returns {RequestInterceptionHandler} */ function build() { - /** @param {NodeJS.Dict | undefined} headers */ + /** + * @param {NodeJS.Dict | undefined} headers + * @returns {NodeJS.Dict | undefined} + */ function modifyRequestHeaders(headers) { - if (!headers) return; - - for (const func of reqheaderModificationFuncs) { - func(headers); + if (headers) { + for (const func of reqheaderModificationFuncs) { + func(headers); + } } + + return headers; } /** diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 0e7e41d..47c63d0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -8,6 +8,7 @@ const state = { /** * @param {NodeJS.Dict} headers + * @returns {NodeJS.Dict} */ export function modifyNpmInfoRequestHeaders(headers) { const accept = getHeaderValueAsString(headers, "accept"); @@ -17,6 +18,7 @@ export function modifyNpmInfoRequestHeaders(headers) { // Force the registry to return the full metadata by changing the Accept header. headers["accept"] = "application/json"; } + return headers; } /** diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 9afadaa..9845cd2 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -151,11 +151,12 @@ function forwardRequest(req, hostname, res, requestHandler) { * @returns {import("http").ClientRequest} */ function createProxyRequest(hostname, req, res, requestHandler) { - const headers = { ...req.headers }; + /** @type {NodeJS.Dict | undefined} */ + let headers = { ...req.headers }; if (headers.host) { delete headers.host; } - requestHandler.modifyRequestHeaders(headers); + headers = requestHandler.modifyRequestHeaders(headers); /** @type {import("http").RequestOptions} */ const options = { From faae0488c8c966c07be8d93381668f0b26e618d8 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 15:23:55 +0100 Subject: [PATCH 23/26] Undo small refactor --- .../safe-chain/src/registryProxy/mitmRequestHandler.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 9845cd2..c4e7bb7 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -70,14 +70,12 @@ function createHttpsServer(hostname, interceptor) { const targetUrl = `https://${hostname}${pathAndQuery}`; const requestInterceptor = await interceptor.handleRequest(targetUrl); + const blockResponse = requestInterceptor.blockResponse; - if (requestInterceptor.blockResponse) { + if (blockResponse) { ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`); - res.writeHead( - requestInterceptor.blockResponse.statusCode, - requestInterceptor.blockResponse.message - ); - res.end(requestInterceptor.blockResponse.message); + res.writeHead(blockResponse.statusCode, blockResponse.message); + res.end(blockResponse.message); return; } From 0a8dacda24216640e7318830bd8bd58a8d564c71 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 15:25:56 +0100 Subject: [PATCH 24/26] Add small comment on why we're removing the host header before forwarding. --- packages/safe-chain/src/registryProxy/mitmRequestHandler.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index c4e7bb7..bfc6c3e 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -151,6 +151,8 @@ function forwardRequest(req, hostname, res, requestHandler) { function createProxyRequest(hostname, req, res, requestHandler) { /** @type {NodeJS.Dict | undefined} */ let headers = { ...req.headers }; + // Remove the host header from the incoming request before forwarding. + // Node's http module sets the correct host header for the target hostname automatically. if (headers.host) { delete headers.host; } From ea751791437f64c8e6eacdb2cd07d5f4202d9329 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 15:31:26 +0100 Subject: [PATCH 25/26] Update readme to reflect our support for node 16+ and delete broken screenshot. --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 17b4abc..c2ac0ad 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,7 @@ The Aikido Safe Chain **prevents developers from installing malware** on their w The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip/pip3 from downloading or running the malware. -![demo](./docs/safe-package-manager-demo.png) - -Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers: +Aikido Safe Chain works on Node.js version 16 and above and supports the following package managers: - ✅ **npm** - ✅ **npx** From 900bf8e6ea80b776d22c8deb7c30cc2c4923dcba Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 24 Nov 2025 15:52:17 +0100 Subject: [PATCH 26/26] Parse npm registry's timestamps. --- .../registryProxy/interceptors/npm/modifyNpmInfo.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 47c63d0..ddcafea 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -67,7 +67,7 @@ export function modifyNpmInfoResponse(body, headers) { const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 - ).toISOString(); + ); const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; @@ -79,13 +79,8 @@ export function modifyNpmInfoResponse(body, headers) { .filter((x) => x.version !== "created" && x.version !== "modified"); for (const { version, timestamp } of versions) { - if (version === "created" || version === "modified") { - continue; - } - - // Timestamps are compared as strings. - // This can be done because they are formatted in ISO8601, which is sortable. - if (timestamp > cutOff) { + const timestampValue = new Date(timestamp); + if (timestampValue > cutOff) { deleteVersionFromJson(bodyJson, version); if (headers) { // When modifying the response, the etag and last-modified headers @@ -93,7 +88,6 @@ export function modifyNpmInfoResponse(body, headers) { delete headers["etag"]; delete headers["last-modified"]; } - continue; } }