From 62a3c1330c6d8620018e359384f0c95bad6cba80 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 5 May 2026 13:45:55 +0200 Subject: [PATCH] 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]; } /**