From ceefaabe579b0038f896cb15124db661d8f87551 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 10 Mar 2026 11:46:27 +0100 Subject: [PATCH 01/10] Consume the safe chain proxy min package age reporting webhook --- packages/safe-chain/src/main.js | 45 ++++++++++++----- .../builtInProxy/createBuiltInProxyServer.js | 9 ++-- .../interceptors/npm/modifyNpmInfo.js | 49 +++++++++++-------- .../ramaProxy/createRamaProxy.js | 6 +++ .../ramaProxy/reportingServer.js | 35 +++++++++++-- .../src/registryProxy/registryProxy.js | 14 ++++-- 6 files changed, 115 insertions(+), 43 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index c319b37..1f42d06 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -23,9 +23,15 @@ export async function main(args) { /** @type {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} */ let malwareBlockedEvents = []; + /** @type {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} */ + let suppressedVersionEvents = []; + const proxy = createSafeChainProxy(); await proxy.startServer(); proxy.addListener("malwareBlocked", (ev) => malwareBlockedEvents.push(ev)); + proxy.addListener("minPackageAgeVersionsSuppressed", (ev) => + suppressedVersionEvents.push(ev), + ); // Global error handlers to log unhandled errors process.on("uncaughtException", (error) => { @@ -82,17 +88,8 @@ 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", - )}`, - ); + if (suppressedVersionEvents.length > 0) { + printSuppressedVersions(suppressedVersionEvents); } // Returning the exit code back to the caller allows the promise @@ -124,8 +121,8 @@ function isSafeChainVerify(args) { } /** - * - * @param {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} malwareBlockedEvents + * + * @param {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} malwareBlockedEvents */ function printBlockedMalware(malwareBlockedEvents) { ui.emptyLine(); @@ -144,3 +141,25 @@ function printBlockedMalware(malwareBlockedEvents) { ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); } + +/** + * + * @param {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} minPackageAgeSuppressionEvents + */ +function printSuppressedVersions(minPackageAgeSuppressionEvents) { + ui.writeVerbose( + `${chalk.yellow( + "ℹ", + )} Safe-chain: Suppressed package versions due to minimum age requirement:`, + ); + + for (const ev of minPackageAgeSuppressionEvents) { + ui.writeVerbose(` - ${ev.packageName} (${ev.packageVersions.join(", ")})`); + } + + ui.writeVerbose( + ` To disable this check, use: ${chalk.cyan( + "--safe-chain-skip-minimum-package-age", + )}`, + ); +} diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js index c699f28..87a43eb 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js @@ -4,10 +4,10 @@ import { mitmConnect } from "./mitmRequestHandler.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { ui } from "../../environment/userInteraction.js"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; -import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; import { getCaCertPath } from "./certUtils.js"; import { readFileSync } from "fs"; import EventEmitter from "events"; +import { modifyResponseEventEmitter } from "./interceptors/npm/modifyNpmInfo.js"; /** * * @returns {import("../registryProxy.js").SafeChainProxy} */ @@ -22,6 +22,10 @@ export function createBuiltInProxyServer() { /** @type {EventEmitter} */ const emitter = new EventEmitter(); + modifyResponseEventEmitter.addListener("versionsRemoved", (ev) => { + emitter.emit("minPackageAgeVersionsSuppressed", ev); + }); + const server = http.createServer( // This handles direct HTTP requests (non-CONNECT requests) // This is normally http-only traffic, but we also handle @@ -35,7 +39,6 @@ export function createBuiltInProxyServer() { return Object.assign(emitter, { startServer: () => startServer(server), stopServer: () => stopServer(server), - hasSuppressedVersions: getHasSuppressedVersions, getServerPort: () => state.port, getCaCert, }); @@ -104,7 +107,7 @@ export function createBuiltInProxyServer() { ) => { emitter.emit("malwareBlocked", { packageName: event.packageName, - packageVersion: event.version + packageVersion: event.version, }); }, ); diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js index ae9a72c..09bdad2 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js @@ -1,3 +1,4 @@ +import { EventEmitter } from "stream"; import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions, @@ -5,9 +6,8 @@ import { import { ui } from "../../../../environment/userInteraction.js"; import { getHeaderValueAsString } from "../../http-utils.js"; -const state = { - hasSuppressedVersions: false, -}; +/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */ +export const modifyResponseEventEmitter = new EventEmitter(); /** * @param {NodeJS.Dict} headers @@ -71,15 +71,20 @@ export function modifyNpmInfoResponse(body, headers) { // Check if this package is excluded from minimum age filtering const packageName = bodyJson.name; const exclusions = getNpmMinimumPackageAgeExclusions(); - if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) { + if ( + packageName && + exclusions.some((pattern) => + matchesExclusionPattern(packageName, pattern), + ) + ) { ui.writeVerbose( - `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` + `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`, ); return body; } const cutOff = new Date( - new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000, ); const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; @@ -91,9 +96,13 @@ export function modifyNpmInfoResponse(body, headers) { })) .filter((x) => x.version !== "created" && x.version !== "modified"); + const removedVersions = []; + for (const { version, timestamp } of versions) { const timestampValue = new Date(timestamp); if (timestampValue > cutOff) { + removedVersions.push(version); + deleteVersionFromJson(bodyJson, version); if (headers) { // When modifying the response, the etag and last-modified headers @@ -113,10 +122,17 @@ export function modifyNpmInfoResponse(body, headers) { bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time); } + if (removedVersions.length > 0) { + modifyResponseEventEmitter.emit("versionsRemoved", { + packageName: packageName, + packageVersions: removedVersions, + }); + } + return Buffer.from(JSON.stringify(bodyJson)); } catch (/** @type {any} */ err) { ui.writeVerbose( - `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}` + `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`, ); return body; } @@ -127,12 +143,10 @@ export function modifyNpmInfoResponse(body, headers) { * @param {string} version */ function deleteVersionFromJson(json, version) { - state.hasSuppressedVersions = true; - const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; ui.writeVerbose( - `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` + `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`, ); delete json.time[version]; @@ -151,18 +165,20 @@ function deleteVersionFromJson(json, version) { */ function calculateLatestTag(tagList) { const entries = Object.entries(tagList).filter( - ([version, _]) => version !== "created" && version !== "modified" + ([version, _]) => version !== "created" && version !== "modified", ); const latestFullRelease = getMostRecentTag( - Object.fromEntries(entries.filter(([version, _]) => !version.includes("-"))) + Object.fromEntries( + entries.filter(([version, _]) => !version.includes("-")), + ), ); if (latestFullRelease) { return latestFullRelease; } const latestPrerelease = getMostRecentTag( - Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))) + Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))), ); return latestPrerelease; } @@ -184,13 +200,6 @@ function getMostRecentTag(tagList) { return current; } -/** - * @returns {boolean} - */ -export function getHasSuppressedVersions() { - return state.hasSuppressedVersions; -} - /** * Checks if a package name matches an exclusion pattern. * Supports trailing wildcard (*) for prefix matching. diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js index 6d98bce..91f9dcf 100644 --- a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js @@ -54,6 +54,12 @@ export function createRamaProxy(ramaPath) { packageVersion: ev.artifact.version, }), ); + reportingServer.addListener("minPackageAgeSuppressionReceived", (ev) => + emitter.emit("minPackageAgeVersionsSuppressed", { + packageName: ev.artifact.identifier, + packageVersions: ev.artifact.suppressed_versions, + }) + ) ui.writeVerbose( `Started reporting server at ${reportingServer.getAddress()}`, ); diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js index d8d6d15..5884ef3 100644 --- a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js @@ -10,7 +10,13 @@ const SERVER_STOP_TIMEOUT_MS = 1000; */ /** - * @typedef {{ blockReceived: [BlockEvent] }} ReportingServerEvents + * @typedef {Object} MinPackageAgeEvent + * @property {number} ts_ms + * @property {{ product: string, identifier: string, suppressed_versions: string[] }} artifact + */ + +/** + * @typedef {{ blockReceived: [BlockEvent], minPackageAgeSuppressionReceived: [MinPackageAgeEvent] }} ReportingServerEvents */ /** @@ -38,6 +44,11 @@ export function getReportingServer() { emitter.emit("blockReceived", blockEvent); }); } + else if (req.method === "POST" && req.url?.startsWith("/events/min-package-age")) { + await parseMinPackageAgeEventFromRequest(req).then((minPackageAgeEvent) => { + emitter.emit("minPackageAgeSuppressionReceived", minPackageAgeEvent); + }); + } res.writeHead(200); res.end(); } @@ -75,12 +86,30 @@ export function getReportingServer() { * @param {http.IncomingMessage} req * @returns {Promise} */ -function parseBlockEventFromRequest(req) { +async function parseBlockEventFromRequest(req) { + const requestData = await getRequestDataAsString(req); + return JSON.parse(requestData); +} + +/** + * @param {http.IncomingMessage} req + * @returns {Promise} + */ +async function parseMinPackageAgeEventFromRequest(req) { + const requestData = await getRequestDataAsString(req); + return JSON.parse(requestData); +} + +/** + * @param {http.IncomingMessage} req + * @returns {Promise} + */ +function getRequestDataAsString(req) { return new Promise((resolve, reject) => { /** @type {Buffer[]} */ const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", () => resolve(JSON.parse(Buffer.concat(chunks).toString()))); + req.on("end", () => resolve(Buffer.concat(chunks).toString())); req.on("error", reject); }); } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 42a0694..3f9c5f2 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -7,16 +7,22 @@ import { getCombinedCaBundlePath } from "./certBundle.js"; * @typedef {Object} MalwareBlockedEvent * @prop {string} packageName * @prop {string} packageVersion - * - * @typedef {{ malwareBlocked: [MalwareBlockedEvent] }} ProxyServerEvents - * + * + * @typedef {Object} MinPackageAgeSuppressionEvent + * @prop {string} packageName + * @prop {string[]} packageVersions + * + * @typedef {{ + * malwareBlocked: [MalwareBlockedEvent], + * minPackageAgeVersionsSuppressed: [MinPackageAgeSuppressionEvent] + * }} ProxyServerEvents + * * @import { EventEmitter } from "node:stream" * @typedef {EventEmitter & { * startServer: () => Promise * stopServer: () => Promise * getServerPort: () => Number | null * getCaCert: () => string | null - * hasSuppressedVersions: () => boolean * }} SafeChainProxy * * @typedef {Object} ProxySettings From 1b32be6c5837dc4d4c15b373f7281d582589b854 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 10 Mar 2026 12:58:22 +0100 Subject: [PATCH 02/10] Fix tests --- .../builtInProxy/createBuiltInProxyServer.spec.js | 4 ++-- .../builtInProxy/interceptors/npm/modifyNpmInfo.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js index 26ced38..601ec8f 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js @@ -7,7 +7,7 @@ const mockMitmConnect = mock.fn(); const mockTunnelRequest = mock.fn(); const mockUi = { writeVerbose: mock.fn() }; const mockGetCaCertPath = mock.fn(() => "/fake/cert/path"); -const mockGetHasSuppressedVersions = mock.fn(() => false); +const mockModifyResponseEventEmitter = new EventEmitter(); /** @type {import("./interceptors/interceptorBuilder.js").Interceptor | undefined} */ let mockInterceptor; @@ -30,7 +30,7 @@ mock.module("./interceptors/createInterceptorForEcoSystem.js", { }, }); mock.module("./interceptors/npm/modifyNpmInfo.js", { - namedExports: { getHasSuppressedVersions: mockGetHasSuppressedVersions }, + namedExports: { modifyResponseEventEmitter: mockModifyResponseEventEmitter }, }); mock.module("./certUtils.js", { namedExports: { getCaCertPath: mockGetCaCertPath }, diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js index 09bdad2..29487bc 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js @@ -1,4 +1,4 @@ -import { EventEmitter } from "stream"; +import { EventEmitter } from "events"; import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions, From 983f26ea2027fed13e9f28f6f17320aabb5ecbc2 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 11 Mar 2026 16:24:06 +0100 Subject: [PATCH 03/10] Adapt to modified contract with rama proxy --- .../safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js | 2 +- .../safe-chain/src/registryProxy/ramaProxy/reportingServer.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js index 91f9dcf..f266cfe 100644 --- a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js @@ -57,7 +57,7 @@ export function createRamaProxy(ramaPath) { reportingServer.addListener("minPackageAgeSuppressionReceived", (ev) => emitter.emit("minPackageAgeVersionsSuppressed", { packageName: ev.artifact.identifier, - packageVersions: ev.artifact.suppressed_versions, + packageVersions: ev.suppressed_versions, }) ) ui.writeVerbose( diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js index 5884ef3..42443e4 100644 --- a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js @@ -12,7 +12,8 @@ const SERVER_STOP_TIMEOUT_MS = 1000; /** * @typedef {Object} MinPackageAgeEvent * @property {number} ts_ms - * @property {{ product: string, identifier: string, suppressed_versions: string[] }} artifact + * @property {{ product: string, identifier: string }} artifact + * @property {string[]} suppressed_versions */ /** From c7ec7fcf37c4cd301ccafe2c3c20d06c1611fab6 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 4 May 2026 16:07:50 +0200 Subject: [PATCH 04/10] Fix linting and type errors --- .../registryProxy/builtInProxy/createBuiltInProxyServer.js | 1 - .../builtInProxy/interceptors/npm/modifyNpmInfo.js | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js index 9d0d520..a3c708b 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js @@ -9,7 +9,6 @@ import { readFileSync } from "fs"; import EventEmitter from "events"; import { modifyResponseEventEmitter } from "./interceptors/npm/modifyNpmInfo.js"; import { cleanupCertBundle } from "../certBundle.js"; -import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js"; /** * * @returns {import("../registryProxy.js").SafeChainProxy} */ diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js index cfd4edd..9fc64e4 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js @@ -1,7 +1,7 @@ import { EventEmitter } from "events"; import { + getMinimumPackageAgeExclusions, getMinimumPackageAgeHours, - getNpmMinimumPackageAgeExclusions, } from "../../../../config/settings.js"; import { ui } from "../../../../environment/userInteraction.js"; import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js"; @@ -70,7 +70,7 @@ export function modifyNpmInfoResponse(body, headers) { // Check if this package is excluded from minimum age filtering const packageName = bodyJson.name; - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); if ( packageName && exclusions.some((pattern) => From dc1bbea56becfd5faa199cec59b34afab49101fd Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 5 May 2026 09:46:51 +0200 Subject: [PATCH 05/10] Undo merge issue --- .../interceptors/npm/modifyNpmInfo.js | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js index 9fc64e4..37c57e9 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js @@ -1,10 +1,10 @@ import { EventEmitter } from "events"; -import { - getMinimumPackageAgeExclusions, - getMinimumPackageAgeHours, -} from "../../../../config/settings.js"; +import { getMinimumPackageAgeHours } from "../../../../config/settings.js"; import { ui } from "../../../../environment/userInteraction.js"; -import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js"; +import { + clearCachingHeaders, + getHeaderValueAsString, +} from "../../http-utils.js"; /** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */ export const modifyResponseEventEmitter = new EventEmitter(); @@ -68,21 +68,6 @@ export function modifyNpmInfoResponse(body, headers) { return body; } - // Check if this package is excluded from minimum age filtering - const packageName = bodyJson.name; - const exclusions = getMinimumPackageAgeExclusions(); - if ( - packageName && - exclusions.some((pattern) => - matchesExclusionPattern(packageName, pattern), - ) - ) { - ui.writeVerbose( - `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`, - ); - return body; - } - const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000, ); From 24127792d41e7bc722174ad9f5857feba5ee1bfd Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 5 May 2026 10:55:22 +0200 Subject: [PATCH 06/10] Add package name again --- .../builtInProxy/interceptors/npm/modifyNpmInfo.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js index 37c57e9..9021bcb 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js @@ -68,6 +68,8 @@ export function modifyNpmInfoResponse(body, headers) { return body; } + const packageName = bodyJson.name; + const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000, ); From 6442c4cf53053ad4e43bd3e21a3dd57439aee479 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 5 May 2026 11:01:49 +0200 Subject: [PATCH 07/10] Fix linting --- .../builtInProxy/interceptors/npm/modifyNpmInfo.js | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js index 9021bcb..80c7ff6 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js @@ -179,20 +179,6 @@ function getMostRecentTag(tagList) { return current; } -/** - * Checks if a package name matches an exclusion pattern. - * Supports trailing wildcard (*) for prefix matching. - * @param {string} packageName - * @param {string} pattern - * @returns {boolean} - */ -function matchesExclusionPattern(packageName, pattern) { - if (pattern.endsWith("/*")) { - return packageName.startsWith(pattern.slice(0, -1)); - } - return packageName === pattern; -} - /** * @param {Buffer} body * @param {NodeJS.Dict | undefined} headers From f2479ad8663e18a9a618eb7f5ddf907e5c4df80e Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 5 May 2026 13:10:42 +0200 Subject: [PATCH 08/10] Listen to blocks with reason new_package --- .../ramaProxy/createRamaProxy.js | 24 ++++++++++++------- .../ramaProxy/reportingServer.js | 1 + 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js index f266cfe..e749061 100644 --- a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js @@ -48,18 +48,26 @@ export function createRamaProxy(ramaPath) { return Object.assign(emitter, { startServer: async () => { await reportingServer.start(); - reportingServer.addListener("blockReceived", (ev) => - emitter.emit("malwareBlocked", { - packageName: ev.artifact.identifier, - packageVersion: ev.artifact.version, - }), - ); + reportingServer.addListener("blockReceived", (ev) => { + if (ev.block_reason === "new_package") { + emitter.emit("minimumAgeRequestBlocked", { + packageName: ev.artifact.identifier, + packageVersion: ev.artifact.version, + }); + } + else { + emitter.emit("malwareBlocked", { + packageName: ev.artifact.identifier, + packageVersion: ev.artifact.version, + }); + } + }); reportingServer.addListener("minPackageAgeSuppressionReceived", (ev) => emitter.emit("minPackageAgeVersionsSuppressed", { packageName: ev.artifact.identifier, packageVersions: ev.suppressed_versions, - }) - ) + }), + ); ui.writeVerbose( `Started reporting server at ${reportingServer.getAddress()}`, ); diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js index 42443e4..2414963 100644 --- a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js @@ -7,6 +7,7 @@ const SERVER_STOP_TIMEOUT_MS = 1000; * @typedef {Object} BlockEvent * @property {number} ts_ms * @property {{ product: string, identifier: string, version: string }} artifact + * @property {string} block_reason */ /** From 62a3c1330c6d8620018e359384f0c95bad6cba80 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 5 May 2026 13:45:55 +0200 Subject: [PATCH 09/10] Expose minPackageAgeVersionsSuppressed for pypi as well. --- packages/safe-chain/src/main.js | 1 - .../builtInProxy/createBuiltInProxyServer.js | 5 ++ .../interceptors/pip/modifyPipInfo.js | 51 +++++++++++------- .../interceptors/pip/modifyPipJsonResponse.js | 52 +++++++++---------- 4 files changed, 63 insertions(+), 46 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index b03b6dd..645fb3a 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -24,7 +24,6 @@ export async function main(args) { let malwareBlockedEvents = []; /** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */ let minPackageAgeBlocks = []; - /** @type {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} */ let suppressedVersionEvents = []; diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js index a3c708b..d4dbf98 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js @@ -8,6 +8,7 @@ import { getCaCertPath } from "./certUtils.js"; import { readFileSync } from "fs"; import EventEmitter from "events"; import { modifyResponseEventEmitter } from "./interceptors/npm/modifyNpmInfo.js"; +import { modifyPipResponseEventEmitter } from "./interceptors/pip/modifyPipInfo.js"; import { cleanupCertBundle } from "../certBundle.js"; /** * @@ -27,6 +28,10 @@ export function createBuiltInProxyServer() { emitter.emit("minPackageAgeVersionsSuppressed", ev); }); + modifyPipResponseEventEmitter.addListener("versionsRemoved", (ev) => { + emitter.emit("minPackageAgeVersionsSuppressed", ev); + }); + const server = http.createServer( // This handles direct HTTP requests (non-CONNECT requests) // This is normally http-only traffic, but we also handle diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.js index a272e03..fca444f 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.js @@ -1,3 +1,4 @@ +import { EventEmitter } from "events"; import { ui } from "../../../../environment/userInteraction.js"; import { clearCachingHeaders } from "../../http-utils.js"; import { normalizePipPackageName } from "../../../../scanning/packageNameVariants.js"; @@ -6,6 +7,9 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js"; import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js"; +/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */ +export const modifyPipResponseEventEmitter = new EventEmitter(); + /** * Strip conditional GET headers so PyPI always returns a full 200 response * with a body we can rewrite. Without this, pip sends If-None-Match / @@ -50,33 +54,42 @@ export function modifyPipInfoResponse( return body; } + /** @type {{ buffer: Buffer, suppressedVersions: string[] } | undefined} */ + let result; if ( contentType.includes("html") || contentType.includes("application/vnd.pypi.simple.v1+html") ) { - return modifyHtmlSimpleResponse( + result = modifyHtmlSimpleResponse( body, headers, metadataUrl, isNewlyReleasedPackage, packageName ); - } - - if ( + } else if ( contentType.includes("json") || contentType.includes("application/vnd.pypi.simple.v1+json") ) { - return modifyJsonResponse( + result = modifyJsonResponse( body, headers, metadataUrl, isNewlyReleasedPackage, packageName ); + } else { + return body; } - return body; + if (result.suppressedVersions.length > 0) { + modifyPipResponseEventEmitter.emit("versionsRemoved", { + packageName, + packageVersions: result.suppressedVersions, + }); + } + + return result.buffer; } catch (/** @type {any} */ err) { ui.writeVerbose( `Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}` @@ -91,7 +104,7 @@ export function modifyPipInfoResponse( * @param {string} metadataUrl * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage * @param {string} packageName - * @returns {Buffer} + * @returns {{ buffer: Buffer, suppressedVersions: string[] }} */ function modifyHtmlSimpleResponse( body, @@ -101,35 +114,35 @@ function modifyHtmlSimpleResponse( packageName ) { const html = body.toString("utf8"); - let modified = false; + const suppressedVersions = /** @type {string[]} */ ([]); const rewriteHtmlAnchor = createHtmlAnchorRewriter( metadataUrl, isNewlyReleasedPackage, packageName, - () => { - modified = true; + (version) => { + suppressedVersions.push(version); } ); const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor); - if (!modified) return body; + if (suppressedVersions.length === 0) return { buffer: body, suppressedVersions: [] }; const modifiedBuffer = Buffer.from(updatedHtml); clearCachingHeaders(headers); - return modifiedBuffer; + return { buffer: modifiedBuffer, suppressedVersions: [...new Set(suppressedVersions)] }; } /** * @param {string} metadataUrl * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage * @param {string} packageName - * @param {() => void} onModified + * @param {(version: string) => void} onVersionSuppressed * @returns {(anchor: string, quote: string, href: string) => string} */ function createHtmlAnchorRewriter( metadataUrl, isNewlyReleasedPackage, packageName, - onModified + onVersionSuppressed ) { return (anchor, _quote, href) => { const resolvedHref = new URL(href, metadataUrl).toString(); @@ -145,8 +158,8 @@ function createHtmlAnchorRewriter( version && isNewlyReleasedPackage(packageName, version) ) { - onModified(); logSuppressedVersion(packageName, version); + onVersionSuppressed(version); return ""; } @@ -160,7 +173,7 @@ function createHtmlAnchorRewriter( * @param {string} metadataUrl * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage * @param {string} packageName - * @returns {Buffer} + * @returns {{ buffer: Buffer, suppressedVersions: string[] }} */ function modifyJsonResponse( body, @@ -170,15 +183,15 @@ function modifyJsonResponse( packageName ) { const json = JSON.parse(body.toString("utf8")); - const modified = modifyPipJsonResponse( + const { suppressedVersions, wasModified } = modifyPipJsonResponse( json, metadataUrl, isNewlyReleasedPackage, packageName ); - if (!modified) return body; + if (!wasModified) return { buffer: body, suppressedVersions: [] }; const modifiedBuffer = Buffer.from(JSON.stringify(json)); clearCachingHeaders(headers); - return modifiedBuffer; + return { buffer: modifiedBuffer, suppressedVersions }; } diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipJsonResponse.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipJsonResponse.js index e005237..92544f1 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipJsonResponse.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipJsonResponse.js @@ -10,7 +10,7 @@ import { logSuppressedVersion } from "./pipMetadataResponseUtils.js"; * @param {string} metadataUrl * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage * @param {string} packageName - * @returns {boolean} + * @returns {{ suppressedVersions: string[], wasModified: boolean }} */ export function modifyPipJsonResponse( json, @@ -18,18 +18,18 @@ export function modifyPipJsonResponse( isNewlyReleasedPackage, packageName ) { - const filesModified = filterJsonMetadataFiles( + const filesSuppressed = filterJsonMetadataFiles( json, metadataUrl, isNewlyReleasedPackage, packageName ); - const releasesModified = removeJsonMetadataReleases( + const releasesSuppressed = removeJsonMetadataReleases( json, isNewlyReleasedPackage, packageName ); - const urlsModified = filterJsonMetadataUrls( + const urlsSuppressed = filterJsonMetadataUrls( json, metadataUrl, isNewlyReleasedPackage, @@ -37,7 +37,11 @@ export function modifyPipJsonResponse( ); const versionModified = updateJsonInfoVersion(json, metadataUrl); - return filesModified || releasesModified || urlsModified || versionModified; + const suppressedVersions = [ + ...new Set([...filesSuppressed, ...releasesSuppressed, ...urlsSuppressed]), + ]; + + return { suppressedVersions, wasModified: suppressedVersions.length > 0 || versionModified }; } /** @@ -45,7 +49,7 @@ export function modifyPipJsonResponse( * @param {string} metadataUrl * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage * @param {string} packageName - * @returns {boolean} + * @returns {string[]} */ function filterJsonMetadataFiles( json, @@ -54,19 +58,17 @@ function filterJsonMetadataFiles( packageName ) { if (!Array.isArray(json.files)) { - return false; + return []; } - let modified = false; - const loggedVersions = new Set(); + const suppressed = new Set(); json.files = json.files.filter((/** @type {any} */ file) => { const version = getPackageVersionFromMetadataFile(file, metadataUrl); if (version && isNewlyReleasedPackage(packageName, version)) { - modified = true; - if (!loggedVersions.has(version)) { + if (!suppressed.has(version)) { logSuppressedVersion(packageName, version); - loggedVersions.add(version); + suppressed.add(version); } return false; } @@ -74,21 +76,21 @@ function filterJsonMetadataFiles( return true; }); - return modified; + return [...suppressed]; } /** * @param {any} json * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage * @param {string} packageName - * @returns {boolean} + * @returns {string[]} */ function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) { if (!json.releases || typeof json.releases !== "object") { - return false; + return []; } - let modified = false; + const suppressed = []; for (const [version, files] of Object.entries(json.releases)) { if ( @@ -96,12 +98,12 @@ function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) { isNewlyReleasedPackage(packageName, version) ) { delete json.releases[version]; - modified = true; logSuppressedVersion(packageName, version); + suppressed.push(version); } } - return modified; + return suppressed; } /** @@ -109,7 +111,7 @@ function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) { * @param {string} metadataUrl * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage * @param {string} packageName - * @returns {boolean} + * @returns {string[]} */ function filterJsonMetadataUrls( json, @@ -118,19 +120,17 @@ function filterJsonMetadataUrls( packageName ) { if (!Array.isArray(json.urls)) { - return false; + return []; } - let modified = false; - const loggedVersions = new Set(); + const suppressed = new Set(); json.urls = json.urls.filter((/** @type {any} */ file) => { const version = getPackageVersionFromMetadataFile(file, metadataUrl); if (version && isNewlyReleasedPackage(packageName, version)) { - modified = true; - if (!loggedVersions.has(version)) { + if (!suppressed.has(version)) { logSuppressedVersion(packageName, version); - loggedVersions.add(version); + suppressed.add(version); } return false; } @@ -138,7 +138,7 @@ function filterJsonMetadataUrls( return true; }); - return modified; + return [...suppressed]; } /** From fbc94f77ca8089d6f9e8518d8f3c58475111c07a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 5 May 2026 14:39:44 +0200 Subject: [PATCH 10/10] Remove suppressedVersionState.js as it's no longer used --- .../pip/pipMetadataResponseUtils.js | 2 -- .../interceptors/suppressedVersionsState.js | 21 ------------------- 2 files changed, 23 deletions(-) delete mode 100644 packages/safe-chain/src/registryProxy/builtInProxy/interceptors/suppressedVersionsState.js diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipMetadataResponseUtils.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipMetadataResponseUtils.js index 8757bee..11dfec3 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipMetadataResponseUtils.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipMetadataResponseUtils.js @@ -1,7 +1,6 @@ import { getMinimumPackageAgeHours } from "../../../../config/settings.js"; import { ui } from "../../../../environment/userInteraction.js"; import { getHeaderValueAsString } from "../../http-utils.js"; -import { recordSuppressedVersion } from "../suppressedVersionsState.js"; /** * @param {NodeJS.Dict | undefined} headers @@ -20,7 +19,6 @@ export function getPipMetadataContentType(headers) { * @returns {void} */ export function logSuppressedVersion(packageName, version) { - recordSuppressedVersion(); ui.writeVerbose( `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` ); diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/suppressedVersionsState.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/suppressedVersionsState.js deleted file mode 100644 index 26c0559..0000000 --- a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/suppressedVersionsState.js +++ /dev/null @@ -1,21 +0,0 @@ -const state = { - hasSuppressedVersions: false, -}; - -/** - * Tracks whether any rewritten metadata response suppressed versions during the - * current process lifetime. This is intentional shared state used only for the - * end-of-run summary message exposed through the proxy API. - * - * @returns {void} - */ -export function recordSuppressedVersion() { - state.hasSuppressedVersions = true; -} - -/** - * @returns {boolean} - */ -export function getHasSuppressedVersions() { - return state.hasSuppressedVersions; -}