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