diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 5f73c533..8662fe2f 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -429,6 +429,9 @@ parse_arguments() { --include-python) warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." ;; + --experimental-rama-proxy) + USE_RAMA_PROXY=true + ;; *) error "Unknown argument: $1" ;; @@ -444,6 +447,7 @@ parse_arguments() { main() { # Initialize argument flags USE_CI_SETUP=false + USE_RAMA_PROXY=false # Parse command-line arguments parse_arguments "$@" @@ -503,6 +507,33 @@ main() { info "Binary installed to: $FINAL_FILE" + # Download safechain-proxy + if [ "$USE_RAMA_PROXY" = "true" ] && { [ "$OS" = "macos" ] || [ "$OS" = "linux" ] || [ "$OS" = "linuxstatic" ]; }; then + info "Downloading safechain-proxy..." + + if [ "$OS" = "macos" ]; then + if [ "$ARCH" = "arm64" ]; then + PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/safechain-proxy-darwin-arm64" + else + PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/safechain-proxy-darwin-amd64" + fi + else + # Linux (both linux and linuxstatic) + if [ "$ARCH" = "x64" ]; then + PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/safechain-proxy-linux-amd64" + else + PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/safechain-proxy-linux-arm64" + fi + fi + + if [ -n "$PROXY_URL" ]; then + PROXY_FILE="${INSTALL_DIR}/safechain-proxy" + download "$PROXY_URL" "$PROXY_FILE" + chmod +x "$PROXY_FILE" || error "Failed to make proxy executable" + info "Proxy installed to: $PROXY_FILE" + fi + fi + run_setup_command "$FINAL_FILE" } diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 74f8a257..645fb3a3 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -20,8 +20,22 @@ export async function main(args) { process.on("SIGINT", handleProcessTermination); process.on("SIGTERM", handleProcessTermination); + /** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */ + let malwareBlockedEvents = []; + /** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */ + let minPackageAgeBlocks = []; + /** @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), + ); + proxy.addListener("minimumAgeRequestBlocked", (ev) => + minPackageAgeBlocks.push(ev), + ); // Global error handlers to log unhandled errors process.on("uncaughtException", (error) => { @@ -64,11 +78,13 @@ export async function main(args) { // Write all buffered logs ui.writeBufferedLogsAndStopBuffering(); - if (proxy.hasBlockedMaliciousPackages()) { + if (malwareBlockedEvents.length > 0) { + printBlockedMalware(malwareBlockedEvents); return 1; } - if (proxy.hasBlockedMinimumAgeRequests()) { + if (minPackageAgeBlocks.length > 0) { + printMinPackageAgeBlocks(minPackageAgeBlocks); return 1; } @@ -81,17 +97,8 @@ export async function main(args) { ); } - if (proxy.hasSuppressedVersions()) { - ui.writeInformation( - `${chalk.yellow( - "ℹ", - )} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`, - ); - ui.writeInformation( - ` To disable this check, use: ${chalk.cyan( - "--safe-chain-skip-minimum-package-age", - )}`, - ); + if (suppressedVersionEvents.length > 0) { + printSuppressedVersions(suppressedVersionEvents); } // Returning the exit code back to the caller allows the promise @@ -121,3 +128,77 @@ function isSafeChainVerify(args) { return true; } } + +/** + * + * @param {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} malwareBlockedEvents + */ +function printBlockedMalware(malwareBlockedEvents) { + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${malwareBlockedEvents.length} malicious package downloads`, + )}:`, + ); + + for (const ev of malwareBlockedEvents) { + ui.writeInformation(` - ${ev.packageName}@${ev.packageVersion}`); + } + + ui.emptyLine(); + ui.writeExitWithoutInstallingMaliciousPackages(); + ui.emptyLine(); +} + +/** + * + * @param {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} minPackageAgeBlocks + */ +function printMinPackageAgeBlocks(minPackageAgeBlocks) { + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${minPackageAgeBlocks.length} direct package download request(s) due to minimum package age`, + )}:`, + ); + + for (const req of minPackageAgeBlocks) { + ui.writeInformation(` - ${req.packageName}@${req.packageVersion}`); + } + + ui.writeInformation( + ` To disable this check, use: ${chalk.cyan( + "--safe-chain-skip-minimum-package-age", + )}`, + ); + + ui.emptyLine(); + ui.writeError( + "Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check.", + ); + ui.emptyLine(); +} + +/** + * + * @param {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} minPackageAgeSuppressionEvents + */ +function printSuppressedVersions(minPackageAgeSuppressionEvents) { + ui.writeVerbose( + `${chalk.yellow( + "ℹ", + )} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age:`, + ); + + 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/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index 4f4e401c..77268cc9 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -1,7 +1,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { + getProxySettings, + mergeSafeChainProxyEnvironmentVariables, +} from "../../registryProxy/registryProxy.js"; import { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js"; import fs from "node:fs/promises"; import fsSync from "node:fs"; @@ -100,7 +102,7 @@ export async function runPip(command, args) { // Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs) // so that any network request made by pip, including those outside explicit CLI args, // validates correctly under both MITM'd and tunneled HTTPS. - const combinedCaPath = getCombinedCaBundlePath(); + const combinedCaPath = getProxySettings().caCertBundlePath; // Commands that need access to persistent config/cache/state files // These should not have PIP_CONFIG_FILE overridden as it would prevent them from diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js index 07073331..c4699780 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js @@ -45,6 +45,12 @@ describe("runPipCommand environment variable handling", () => { HTTPS_PROXY: "http://localhost:8080", HTTP_PROXY: "", }), + getProxySettings: () => { + return { + proxyUrl: "http://localhost:8080", + caCertBundlePath: "/tmp/test-combined-ca.pem", + }; + }, }, }); diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js index c374e2a7..fd99a78c 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -1,7 +1,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { + getProxySettings, + mergeSafeChainProxyEnvironmentVariables, +} from "../../registryProxy/registryProxy.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** @@ -42,7 +44,7 @@ export async function runPipX(command, args) { try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); - const combinedCaPath = getCombinedCaBundlePath(); + const combinedCaPath = getProxySettings().caCertBundlePath; const modifiedEnv = getPipXCaBundleEnvironmentVariables(env, combinedCaPath); // Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js index dd04dc2d..a6c328d8 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js @@ -38,6 +38,12 @@ describe("runPipXCommand", () => { mergeCalls.push(env); return { ...env, ...mergedEnvReturn }; }, + getProxySettings: () => { + return { + proxyUrl: "", + caCertBundlePath: "/tmp/test-combined-ca.pem", + }; + }, }, }); diff --git a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js index 567fb439..21ff946a 100644 --- a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js +++ b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js @@ -1,7 +1,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { + getProxySettings, + mergeSafeChainProxyEnvironmentVariables, +} from "../../registryProxy/registryProxy.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** @@ -57,7 +59,7 @@ async function runPoetryCommand(args) { try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); - const combinedCaPath = getCombinedCaBundlePath(); + const combinedCaPath = getProxySettings().caCertBundlePath; setPoetryCaBundleEnvironmentVariables(env, combinedCaPath); const result = await safeSpawn("poetry", args, { diff --git a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js index 7c225183..163b1b8e 100644 --- a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js +++ b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js @@ -1,7 +1,9 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; -import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; -import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { + getProxySettings, + mergeSafeChainProxyEnvironmentVariables, +} from "../../registryProxy/registryProxy.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** @@ -48,7 +50,7 @@ export async function runUv(command, args) { try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); - const combinedCaPath = getCombinedCaBundlePath(); + const combinedCaPath = getProxySettings().caCertBundlePath; setUvCaBundleEnvironmentVariables(env, combinedCaPath); // Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/builtInProxy/certUtils.js similarity index 98% rename from packages/safe-chain/src/registryProxy/certUtils.js rename to packages/safe-chain/src/registryProxy/builtInProxy/certUtils.js index 3918177b..03019a7c 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/certUtils.js @@ -1,7 +1,7 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; -import { getCertsDir } from "../config/safeChainDir.js"; +import { getCertsDir } from "../../config/safeChainDir.js"; const ca = loadCa(); diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/certUtils.spec.js similarity index 97% rename from packages/safe-chain/src/registryProxy/certUtils.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/certUtils.spec.js index 4bf8c95c..f9484b32 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/certUtils.spec.js @@ -6,7 +6,7 @@ describe("certUtils", () => { beforeEach(() => { installedSafeChainDir = undefined; - mock.module("../config/safeChainDir.js", { + mock.module("../../config/safeChainDir.js", { namedExports: { getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain", getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`, diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js new file mode 100644 index 00000000..d4dbf98e --- /dev/null +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js @@ -0,0 +1,156 @@ +import * as http from "http"; +import { tunnelRequest } from "./tunnelRequestHandler.js"; +import { mitmConnect } from "./mitmRequestHandler.js"; +import { handleHttpProxyRequest } from "./plainHttpProxy.js"; +import { ui } from "../../environment/userInteraction.js"; +import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; +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"; + +/** * + * @returns {import("../registryProxy.js").SafeChainProxy} */ +export function createBuiltInProxyServer() { + const SERVER_STOP_TIMEOUT_MS = 1000; + /** + * @type {{port: number | null}} + */ + const state = { + port: null, + }; + /** @type {EventEmitter} */ + const emitter = new EventEmitter(); + + modifyResponseEventEmitter.addListener("versionsRemoved", (ev) => { + 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 + // https for clients that don't properly use CONNECT + handleHttpProxyRequest, + ); + + // This handles HTTPS requests via the CONNECT method + server.on("connect", handleConnect); + + return Object.assign(emitter, { + startServer: () => startServer(server), + stopServer: () => stopServer(server), + getServerPort: () => state.port, + getCaCert, + }); + + /** + * @param {import("http").Server} server + * + * @returns {Promise} + */ + function startServer(server) { + return new Promise((resolve, reject) => { + // Bind to loopback only. Without an explicit host, Node listens on every + // interface, turning the proxy into an unauthenticated forward proxy that + // anyone reachable on the network can use to hit the victim's localhost, + // intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port. + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + state.port = address.port; + resolve(); + } else { + reject(new Error("Failed to start proxy server")); + } + }); + + server.on("error", (err) => { + reject(err); + }); + }); + } + + /** + * @param {import("http").Server} server + * + * @returns {Promise} + */ + function stopServer(server) { + return new Promise((resolve) => { + try { + server.close(() => { + cleanupCertBundle(); + resolve(); + }); + } catch { + resolve(); + } + setTimeout(() => { + cleanupCertBundle(); + resolve(); + }, SERVER_STOP_TIMEOUT_MS); + }); + } + + /** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} clientSocket + * @param {Buffer} head + * + * @returns {void} + */ + function handleConnect(req, clientSocket, head) { + // CONNECT method is used for HTTPS requests + // It establishes a tunnel to the server identified by the request URL + + const interceptor = createInterceptorForUrl(req.url || ""); + + if (interceptor) { + // Subscribe to malware blocked events + interceptor.on( + "malwareBlocked", + ( + /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event, + ) => { + emitter.emit("malwareBlocked", { + packageName: event.packageName, + packageVersion: event.version, + }); + }, + ); + + interceptor.on( + "minimumAgeRequestBlocked", + ( + /** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event, + ) => { + emitter.emit("minimumAgeRequestBlocked", { + packageName: event.packageName, + packageVersion: event.version, + }); + }, + ); + + mitmConnect(req, clientSocket, interceptor); + } else { + // For other hosts, just tunnel the request to the destination tcp socket + ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); + tunnelRequest(req, clientSocket, head); + } + } + + function getCaCert() { + try { + const safeChainPath = getCaCertPath(); + return readFileSync(safeChainPath, "utf8"); + } catch { + return null; + } + } +} diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js new file mode 100644 index 00000000..601ec8f4 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.spec.js @@ -0,0 +1,125 @@ +import { describe, it, before, after, mock } from "node:test"; +import assert from "node:assert"; +import EventEmitter from "events"; + +// Mock dependencies before importing the module under test +const mockMitmConnect = mock.fn(); +const mockTunnelRequest = mock.fn(); +const mockUi = { writeVerbose: mock.fn() }; +const mockGetCaCertPath = mock.fn(() => "/fake/cert/path"); +const mockModifyResponseEventEmitter = new EventEmitter(); + +/** @type {import("./interceptors/interceptorBuilder.js").Interceptor | undefined} */ +let mockInterceptor; + +mock.module("./mitmRequestHandler.js", { + namedExports: { mitmConnect: mockMitmConnect }, +}); +mock.module("./tunnelRequestHandler.js", { + namedExports: { tunnelRequest: mockTunnelRequest }, +}); +mock.module("./plainHttpProxy.js", { + namedExports: { handleHttpProxyRequest: mock.fn() }, +}); +mock.module("../../environment/userInteraction.js", { + namedExports: { ui: mockUi }, +}); +mock.module("./interceptors/createInterceptorForEcoSystem.js", { + namedExports: { + createInterceptorForUrl: mock.fn(() => mockInterceptor), + }, +}); +mock.module("./interceptors/npm/modifyNpmInfo.js", { + namedExports: { modifyResponseEventEmitter: mockModifyResponseEventEmitter }, +}); +mock.module("./certUtils.js", { + namedExports: { getCaCertPath: mockGetCaCertPath }, +}); + +const { createBuiltInProxyServer } = await import( + "./createBuiltInProxyServer.js" +); + +describe("createBuiltInProxyServer event emission", () => { + /** @type {ReturnType} */ + let proxy; + + before(async () => { + proxy = createBuiltInProxyServer(); + await proxy.startServer(); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("emits malwareBlocked when the interceptor fires a malwareBlocked event", async () => { + // Create a real EventEmitter-based interceptor that we can trigger + const interceptorEmitter = new EventEmitter(); + mockInterceptor = Object.assign(interceptorEmitter, { + handleRequest: mock.fn(async () => ({ + blockResponse: { statusCode: 403, message: "blocked" }, + modifyRequestHeaders: (/** @type {any} */ h) => h, + modifiesResponse: () => false, + modifyBody: (/** @type {any} */ b) => b, + })), + }); + + const eventPromise = new Promise((resolve) => { + proxy.once("malwareBlocked", resolve); + }); + + // Trigger a CONNECT request to the proxy to wire up the interceptor + const port = proxy.getServerPort(); + assert.ok(port, "Server should have a port"); + + const net = await import("net"); + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "CONNECT registry.npmjs.org:443 HTTP/1.1\r\nHost: registry.npmjs.org:443\r\n\r\n", + ); + }); + + // Wait for the CONNECT handler to run and subscribe to the interceptor + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Now fire the malwareBlocked event on the interceptor + interceptorEmitter.emit("malwareBlocked", { + packageName: "evil-package", + version: "1.0.0", + targetUrl: "https://registry.npmjs.org/evil-package/-/evil-package-1.0.0.tgz", + timestamp: Date.now(), + }); + + const received = await eventPromise; + assert.deepStrictEqual(received, { + packageName: "evil-package", + packageVersion: "1.0.0", + }); + + socket.destroy(); + }); + + it("does not emit malwareBlocked for non-intercepted hosts", async () => { + // No interceptor for this URL + mockInterceptor = undefined; + + let emitted = false; + proxy.on("malwareBlocked", () => { + emitted = true; + }); + + const port = proxy.getServerPort(); + const net = await import("net"); + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", + ); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.strictEqual(emitted, false, "Should not emit for non-intercepted hosts"); + + socket.destroy(); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/getConnectTimeout.js b/packages/safe-chain/src/registryProxy/builtInProxy/getConnectTimeout.js similarity index 100% rename from packages/safe-chain/src/registryProxy/getConnectTimeout.js rename to packages/safe-chain/src/registryProxy/builtInProxy/getConnectTimeout.js diff --git a/packages/safe-chain/src/registryProxy/http-utils.js b/packages/safe-chain/src/registryProxy/builtInProxy/http-utils.js similarity index 100% rename from packages/safe-chain/src/registryProxy/http-utils.js rename to packages/safe-chain/src/registryProxy/builtInProxy/http-utils.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/createInterceptorForEcoSystem.js similarity index 93% rename from packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/createInterceptorForEcoSystem.js index 869af810..ffe59b71 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/createInterceptorForEcoSystem.js @@ -2,7 +2,7 @@ import { ECOSYSTEM_JS, ECOSYSTEM_PY, getEcoSystem, -} from "../../config/settings.js"; +} from "../../../config/settings.js"; import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; import { pipInterceptorForUrl } from "./pip/pipInterceptor.js"; diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/interceptorBuilder.js similarity index 100% rename from packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/interceptorBuilder.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/minimumPackageAgeExclusions.js similarity index 88% rename from packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/minimumPackageAgeExclusions.js index 05a86eaf..9e94899f 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/minimumPackageAgeExclusions.js @@ -1,5 +1,5 @@ -import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../config/settings.js"; -import { getEquivalentPackageNames } from "../../scanning/packageNameVariants.js"; +import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../../config/settings.js"; +import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants.js"; /** * Checks if a package name matches an exclusion pattern. diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js similarity index 84% rename from packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js index 26b3b706..80c7ff62 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/modifyNpmInfo.js @@ -1,7 +1,13 @@ -import { getMinimumPackageAgeHours } from "../../../config/settings.js"; -import { ui } from "../../../environment/userInteraction.js"; -import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js"; -import { recordSuppressedVersion } from "../suppressedVersionsState.js"; +import { EventEmitter } from "events"; +import { getMinimumPackageAgeHours } from "../../../../config/settings.js"; +import { ui } from "../../../../environment/userInteraction.js"; +import { + clearCachingHeaders, + getHeaderValueAsString, +} from "../../http-utils.js"; + +/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */ +export const modifyResponseEventEmitter = new EventEmitter(); /** * @param {NodeJS.Dict} headers @@ -62,8 +68,10 @@ export function modifyNpmInfoResponse(body, headers) { return body; } + const packageName = bodyJson.name; + const cutOff = new Date( - new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000, ); const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; @@ -75,9 +83,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); clearCachingHeaders(headers); } @@ -89,10 +101,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; } @@ -103,12 +122,10 @@ export function modifyNpmInfoResponse(body, headers) { * @param {string} version */ function deleteVersionFromJson(json, version) { - recordSuppressedVersion(); - 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]; @@ -127,18 +144,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; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.js similarity index 93% rename from packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.js index 8caae84a..ed40d0a9 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.js @@ -1,8 +1,8 @@ import { getNpmCustomRegistries, skipMinimumPackageAge, -} from "../../../config/settings.js"; -import { isMalwarePackage } from "../../../scanning/audit/index.js"; +} from "../../../../config/settings.js"; +import { isMalwarePackage } from "../../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { getPackageNameFromMetadataResponse, @@ -11,7 +11,7 @@ import { modifyNpmInfoResponse, } from "./modifyNpmInfo.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; -import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js"; +import { openNewPackagesDatabase } from "../../../../scanning/newPackagesListCache.js"; import { isExcludedFromMinimumPackageAge, } from "../minimumPackageAgeExclusions.js"; diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js similarity index 99% rename from packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index cdd38ef3..1f9de641 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -7,7 +7,7 @@ describe("npmInterceptor minimum package age", async () => { let minimumPackageAgeExclusionsSetting = []; let newlyReleasedPackages = new Set(); - mock.module("../../../config/settings.js", { + mock.module("../../../../config/settings.js", { namedExports: { ECOSYSTEM_JS: "js", ECOSYSTEM_PY: "py", @@ -18,7 +18,7 @@ describe("npmInterceptor minimum package age", async () => { getEcoSystem: () => "js", }, }); - mock.module("../../../scanning/newPackagesListCache.js", { + mock.module("../../../../scanning/newPackagesListCache.js", { namedExports: { openNewPackagesDatabase: async () => ({ isNewlyReleasedPackage: (name, version) => @@ -27,14 +27,14 @@ describe("npmInterceptor minimum package age", async () => { }, }); - mock.module("../../../scanning/audit/index.js", { + mock.module("../../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async () => { return false; }, }, }); - mock.module("../../../environment/userInteraction.js", { + mock.module("../../../../environment/userInteraction.js", { namedExports: { ui: { startProcess: () => {}, diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js similarity index 98% rename from packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index 769b6e15..9975c6c5 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -7,7 +7,7 @@ let customRegistries = []; let newlyReleasedPackages = new Set(); let skipMinimumPackageAgeSetting = false; -mock.module("../../../scanning/audit/index.js", { +mock.module("../../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { lastPackage = { packageName, version }; @@ -16,7 +16,7 @@ mock.module("../../../scanning/audit/index.js", { }, }); -mock.module("../../../config/settings.js", { +mock.module("../../../../config/settings.js", { namedExports: { LOGGING_SILENT: "silent", LOGGING_NORMAL: "normal", @@ -32,7 +32,7 @@ mock.module("../../../config/settings.js", { skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, }, }); -mock.module("../../../scanning/newPackagesListCache.js", { +mock.module("../../../../scanning/newPackagesListCache.js", { namedExports: { openNewPackagesDatabase: async () => ({ isNewlyReleasedPackage: (name, version) => diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/parseNpmPackageUrl.js similarity index 100% rename from packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/npm/parseNpmPackageUrl.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.js similarity index 75% rename from packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.js index ef0ab182..fca444f0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.js @@ -1,11 +1,15 @@ -import { ui } from "../../../environment/userInteraction.js"; +import { EventEmitter } from "events"; +import { ui } from "../../../../environment/userInteraction.js"; import { clearCachingHeaders } from "../../http-utils.js"; -import { normalizePipPackageName } from "../../../scanning/packageNameVariants.js"; +import { normalizePipPackageName } from "../../../../scanning/packageNameVariants.js"; import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js"; export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.js"; 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/interceptors/pip/modifyPipInfo.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.spec.js similarity index 98% rename from packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.spec.js index 900941d1..fc584e7d 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipInfo.spec.js @@ -2,14 +2,14 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; describe("modifyPipInfo", async () => { - mock.module("../../../config/settings.js", { + mock.module("../../../../config/settings.js", { namedExports: { getMinimumPackageAgeHours: () => 48, ECOSYSTEM_PY: "py", }, }); - mock.module("../../../environment/userInteraction.js", { + mock.module("../../../../environment/userInteraction.js", { namedExports: { ui: { writeVerbose: () => {}, diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipJsonResponse.js similarity index 79% rename from packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/modifyPipJsonResponse.js index e0052377..92544f18 100644 --- a/packages/safe-chain/src/registryProxy/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]; } /** diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/parsePipPackageUrl.js similarity index 100% rename from packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/parsePipPackageUrl.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/parsePipPackageUrl.spec.js similarity index 100% rename from packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/parsePipPackageUrl.spec.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js similarity index 97% rename from packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js index 5904f05a..c36ea34e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js @@ -6,7 +6,7 @@ describe("pipInterceptor custom registries", async () => { let malwareResponse = false; let customRegistries = []; - mock.module("../../../config/settings.js", { + mock.module("../../../../config/settings.js", { namedExports: { ECOSYSTEM_PY: "py", getEcoSystem: () => "py", @@ -20,7 +20,7 @@ describe("pipInterceptor custom registries", async () => { }, }); - mock.module("../../../scanning/newPackagesListCache.js", { + mock.module("../../../../scanning/newPackagesListCache.js", { namedExports: { openNewPackagesDatabase: async () => ({ isNewlyReleasedPackage: () => false, @@ -28,7 +28,7 @@ describe("pipInterceptor custom registries", async () => { }, }); - mock.module("../../../scanning/audit/index.js", { + mock.module("../../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { scannedPackages.push({ packageName, version }); @@ -202,4 +202,4 @@ describe("pipInterceptor custom registries", async () => { ) ); }); -}); +}); \ No newline at end of file diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.js similarity index 91% rename from packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.js index 86d84eb1..22b55083 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.js @@ -2,10 +2,10 @@ import { ECOSYSTEM_PY, getPipCustomRegistries, skipMinimumPackageAge, -} from "../../../config/settings.js"; -import { isMalwarePackage } from "../../../scanning/audit/index.js"; -import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants.js"; -import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js"; +} from "../../../../config/settings.js"; +import { isMalwarePackage } from "../../../../scanning/audit/index.js"; +import { getEquivalentPackageNames } from "../../../../scanning/packageNameVariants.js"; +import { openNewPackagesDatabase } from "../../../../scanning/newPackagesListCache.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js"; import { diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js similarity index 97% rename from packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js index f311df74..4d5762b0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js @@ -6,13 +6,13 @@ describe("pipInterceptor minimum package age", async () => { let newlyReleasedPackageResponse = false; let minimumPackageAgeExclusionsSetting = []; - mock.module("../../../scanning/audit/index.js", { + mock.module("../../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async () => false, }, }); - mock.module("../../../scanning/newPackagesListCache.js", { + mock.module("../../../../scanning/newPackagesListCache.js", { namedExports: { openNewPackagesDatabase: async () => ({ isNewlyReleasedPackage: (packageName, version) => { @@ -26,7 +26,7 @@ describe("pipInterceptor minimum package age", async () => { }, }); - mock.module("../../../config/settings.js", { + mock.module("../../../../config/settings.js", { namedExports: { ECOSYSTEM_PY: "py", getEcoSystem: () => "py", diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js similarity index 96% rename from packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js index f4a54a42..05e802b6 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js @@ -5,7 +5,7 @@ describe("pipInterceptor", async () => { let scannedPackages; let malwareResponse = false; - mock.module("../../../scanning/audit/index.js", { + mock.module("../../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { scannedPackages.push({ packageName, version }); @@ -14,7 +14,7 @@ describe("pipInterceptor", async () => { }, }); - mock.module("../../../scanning/newPackagesListCache.js", { + mock.module("../../../../scanning/newPackagesListCache.js", { namedExports: { openNewPackagesDatabase: async () => ({ isNewlyReleasedPackage: () => false, @@ -22,7 +22,7 @@ describe("pipInterceptor", async () => { }, }); - mock.module("../../../config/settings.js", { + mock.module("../../../../config/settings.js", { namedExports: { ECOSYSTEM_PY: "py", getEcoSystem: () => "py", diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.spec.js new file mode 100644 index 00000000..05e802b6 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipInterceptor.spec.js @@ -0,0 +1,163 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("pipInterceptor", async () => { + let scannedPackages; + let malwareResponse = false; + + mock.module("../../../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + scannedPackages.push({ packageName, version }); + return malwareResponse; + }, + }, + }); + + mock.module("../../../../scanning/newPackagesListCache.js", { + namedExports: { + openNewPackagesDatabase: async () => ({ + isNewlyReleasedPackage: () => false, + }), + }, + }); + + mock.module("../../../../config/settings.js", { + namedExports: { + ECOSYSTEM_PY: "py", + getEcoSystem: () => "py", + getLoggingLevel: () => "silent", + getMinimumPackageAgeHours: () => 48, + getMinimumPackageAgeExclusions: () => [], + getPipCustomRegistries: () => [], + LOGGING_SILENT: "silent", + LOGGING_VERBOSE: "verbose", + skipMinimumPackageAge: () => false, + }, + }); + + const { pipInterceptorForUrl } = await import("./pipInterceptor.js"); + + const parserCases = [ + { + url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz", + expected: { packageName: "foobar", version: "1.2.3" }, + }, + { + url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz", + expected: { packageName: "foobar", version: "1.2.3" }, + }, + { + url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz", + expected: { packageName: "foo-bar", version: "0.9.0" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl", + expected: { packageName: "foo-bar", version: "2.0.0" }, + }, + { + url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata", + expected: { packageName: "foo-bar", version: "2.0.0" }, + }, + { + url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl", + expected: { packageName: "foo-bar", version: "2.0.0" }, + }, + { + url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz", + expected: { packageName: "foo.bar", version: "1.0.0" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz", + expected: { packageName: "foo-bar", version: "2.0.0b1" }, + }, + { + url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata", + expected: { packageName: "foo-bar", version: "2.0.0" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz", + expected: { packageName: "foo-bar", version: "2.0.0rc1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz", + expected: { packageName: "foo-bar", version: "2.0.0.post1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz", + expected: { packageName: "foo-bar", version: "2.0.0.dev1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz", + expected: { packageName: "foo-bar", version: "2.0.0a1" }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl", + expected: { packageName: "foo-bar", version: "2.0.0" }, + }, + { + url: "https://pypi.org/simple/", + expected: { packageName: undefined, version: undefined }, + }, + { + url: "https://pypi.org/project/foobar/", + expected: { packageName: undefined, version: undefined }, + }, + { + url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz", + expected: { packageName: undefined, version: undefined }, + }, + { + url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz", + expected: { packageName: undefined, version: undefined }, + }, + ]; + + parserCases.forEach(({ url, expected }, index) => { + it(`should parse URL ${index + 1}: ${url}`, async () => { + scannedPackages = []; + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created for known pip registry"); + + await interceptor.handleRequest(url); + + if (expected.packageName === undefined) { + assert.deepEqual(scannedPackages, []); + return; + } + + assert.ok( + scannedPackages.some( + ({ packageName, version }) => + packageName === expected.packageName && + version === expected.version + ) + ); + }); + }); + + it("should not create interceptor for unknown registry", () => { + const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; + const interceptor = pipInterceptorForUrl(url); + assert.equal(interceptor, undefined); + }); + + it("should block malicious package", async () => { + scannedPackages = []; + const url = + "https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz"; + malwareResponse = true; + + const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.ok(result.blockResponse); + assert.equal(result.blockResponse.statusCode, 403); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain" + ); + + malwareResponse = false; + }); +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipMetadataResponseUtils.js similarity index 73% rename from packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipMetadataResponseUtils.js index e3948109..11dfec3f 100644 --- a/packages/safe-chain/src/registryProxy/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 { 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/interceptors/pip/pipMetadataVersionUtils.js b/packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipMetadataVersionUtils.js similarity index 100% rename from packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js rename to packages/safe-chain/src/registryProxy/builtInProxy/interceptors/pip/pipMetadataVersionUtils.js diff --git a/packages/safe-chain/src/registryProxy/isImdsEndpoint.js b/packages/safe-chain/src/registryProxy/builtInProxy/isImdsEndpoint.js similarity index 100% rename from packages/safe-chain/src/registryProxy/isImdsEndpoint.js rename to packages/safe-chain/src/registryProxy/builtInProxy/isImdsEndpoint.js diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.js similarity index 99% rename from packages/safe-chain/src/registryProxy/mitmRequestHandler.js rename to packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.js index 4c4e9ecb..2be82a36 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.js @@ -1,7 +1,7 @@ import https from "https"; import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; -import { ui } from "../environment/userInteraction.js"; +import { ui } from "../../environment/userInteraction.js"; import { gunzipSync } from "zlib"; import { omitHeaders } from "./http-utils.js"; diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js b/packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.spec.js similarity index 98% rename from packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js rename to packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.spec.js index de01e2cb..f80533f0 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/mitmRequestHandler.spec.js @@ -62,7 +62,7 @@ describe("mitmRequestHandler", async () => { }, }); - mock.module("../environment/userInteraction.js", { + mock.module("../../environment/userInteraction.js", { namedExports: { ui: { writeVerbose: () => {}, diff --git a/packages/safe-chain/src/registryProxy/plainHttpProxy.js b/packages/safe-chain/src/registryProxy/builtInProxy/plainHttpProxy.js similarity index 97% rename from packages/safe-chain/src/registryProxy/plainHttpProxy.js rename to packages/safe-chain/src/registryProxy/builtInProxy/plainHttpProxy.js index 75b9d77f..98547740 100644 --- a/packages/safe-chain/src/registryProxy/plainHttpProxy.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/plainHttpProxy.js @@ -1,6 +1,6 @@ import * as http from "http"; import * as https from "https"; -import { ui } from "../environment/userInteraction.js"; +import { ui } from "../../environment/userInteraction.js"; /** * @param {import("http").IncomingMessage} req diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/builtInProxy/tunnelRequestHandler.js similarity index 99% rename from packages/safe-chain/src/registryProxy/tunnelRequestHandler.js rename to packages/safe-chain/src/registryProxy/builtInProxy/tunnelRequestHandler.js index 5eac3816..861be8a9 100644 --- a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/tunnelRequestHandler.js @@ -1,5 +1,5 @@ import * as net from "net"; -import { ui } from "../environment/userInteraction.js"; +import { ui } from "../../environment/userInteraction.js"; import { isImdsEndpoint } from "./isImdsEndpoint.js"; import { getConnectTimeout } from "./getConnectTimeout.js"; @@ -210,4 +210,3 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { } }); } - diff --git a/packages/safe-chain/src/registryProxy/certBundle.js b/packages/safe-chain/src/registryProxy/certBundle.js index 19dc8000..aada1b93 100644 --- a/packages/safe-chain/src/registryProxy/certBundle.js +++ b/packages/safe-chain/src/registryProxy/certBundle.js @@ -5,7 +5,6 @@ import path from "node:path"; import certifi from "certifi"; import tls from "node:tls"; import { X509Certificate } from "node:crypto"; -import { getCaCertPath } from "./certUtils.js"; import { ui } from "../environment/userInteraction.js"; /** @type {string | null} */ @@ -53,25 +52,19 @@ function isParsable(pem) { * - Mozilla roots via certifi (for public HTTPS) * - Node's built-in root certificates (fallback) * - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set) - * + * @param {string | null} proxyCaCert + * * @returns {string} Path to the combined CA bundle PEM file */ -export function getCombinedCaBundlePath() { +export function getCombinedCaBundlePath(proxyCaCert) { if (bundlePath) { return bundlePath; } - const parts = []; - // 1) Safe Chain CA (for MITM'd registries) - const safeChainPath = getCaCertPath(); - try { - const safeChainPem = fs.readFileSync(safeChainPath, "utf8"); - if (isParsable(safeChainPem)) parts.push(safeChainPem.trim()); - } catch { - // Ignore if Safe Chain CA is not available - } + const parts = []; + if (proxyCaCert && isParsable(proxyCaCert)) parts.push(proxyCaCert.trim()); // 2) certifi (Mozilla CA bundle for all public HTTPS) try { @@ -200,4 +193,3 @@ function readUserCertificateFile(certPath) { } } - diff --git a/packages/safe-chain/src/registryProxy/certBundle.spec.js b/packages/safe-chain/src/registryProxy/certBundle.spec.js index e3b58fb4..3287554b 100644 --- a/packages/safe-chain/src/registryProxy/certBundle.spec.js +++ b/packages/safe-chain/src/registryProxy/certBundle.spec.js @@ -17,8 +17,14 @@ function removeBundleIfExists() { // Utility to get a valid PEM certificate for testing function getValidCert() { - const cert = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : ""; - assert.ok(cert.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test"); + const cert = + typeof tls.rootCertificates?.[0] === "string" + ? tls.rootCertificates[0] + : ""; + assert.ok( + cert.includes("BEGIN CERTIFICATE"), + "Environment lacks Node root certificates for test", + ); return cert; } @@ -30,26 +36,25 @@ describe("certBundle.getCombinedCaBundlePath", () => { it("includes Safe Chain CA when parsable and produces a PEM bundle", async () => { // Prepare a temporary Safe Chain CA file with a recognizable marker and a valid cert block - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-")); - const safeChainPath = path.join(tmpDir, "safechain-ca.pem"); const marker = "# SAFE_CHAIN_TEST_MARKER"; - const rootPem = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : ""; - assert.ok(rootPem.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test"); - fs.writeFileSync(safeChainPath, `${marker}\n${rootPem}`, "utf8"); - - // Mock the certUtils.getCaCertPath to return our temp file - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); + const rootPem = + typeof tls.rootCertificates?.[0] === "string" + ? tls.rootCertificates[0] + : ""; + assert.ok( + rootPem.includes("BEGIN CERTIFICATE"), + "Environment lacks Node root certificates for test", + ); const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); + const bundlePath = getCombinedCaBundlePath(`${marker}\n${rootPem}`); assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); const contents = fs.readFileSync(bundlePath, "utf8"); assert.match(contents, /-----BEGIN CERTIFICATE-----/); - assert.ok(contents.includes(marker), "Bundle should include Safe Chain CA content when parsable"); + assert.ok( + contents.includes(marker), + "Bundle should include Safe Chain CA content when parsable", + ); }); it("ignores invalid Safe Chain CA but still builds from other sources", async () => { @@ -59,21 +64,21 @@ describe("certBundle.getCombinedCaBundlePath", () => { const invalidMarker = "INVALID_SAFE_CHAIN_CONTENT"; fs.writeFileSync(safeChainPath, invalidMarker, "utf8"); - // Mock the certUtils.getCaCertPath to return our invalid file - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - // Ensure fresh build removeBundleIfExists(); const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); + const bundlePath = getCombinedCaBundlePath(invalidMarker); assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Bundle should contain certificate blocks from certifi/Node roots"); - assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content"); + assert.match( + contents, + /-----BEGIN CERTIFICATE-----/, + "Bundle should contain certificate blocks from certifi/Node roots", + ); + assert.ok( + !contents.includes(invalidMarker), + "Bundle should not include invalid Safe Chain content", + ); }); }); @@ -84,34 +89,28 @@ describe("certBundle.getCombinedCaBundlePath with user certs", () => { }); it("returns a path with full CA bundle (Safe Chain + Mozilla + Node roots) when no user cert in env", async () => { - // Mock getCaCertPath to return valid cert - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); + const bundlePath = getCombinedCaBundlePath(getValidCert()); assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain certificate blocks"); + assert.match( + contents, + /-----BEGIN CERTIFICATE-----/, + "Should contain certificate blocks", + ); // Should include base bundle (Safe Chain + Mozilla/Node roots) - assert.ok(contents.length > 1000, "Bundle should be substantial with Mozilla/Node roots included"); + assert.ok( + contents.length > 1000, + "Bundle should be substantial with Mozilla/Node roots included", + ); }); it("merges user cert with full base bundle (Safe Chain CA + Mozilla + Node roots)", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - + // Create Safe Chain CA - const safeChainPath = path.join(tmpDir, "safechain.pem"); const safeChainCert = getValidCert(); - fs.writeFileSync(safeChainPath, safeChainCert, "utf8"); // Create user cert file const userCertPath = path.join(tmpDir, "user-cert.pem"); @@ -119,261 +118,63 @@ describe("certBundle.getCombinedCaBundlePath with user certs", () => { fs.writeFileSync(userCertPath, userCert, "utf8"); process.env.NODE_EXTRA_CA_CERTS = userCertPath; - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); + const bundlePath = getCombinedCaBundlePath(safeChainCert); assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); const contents = fs.readFileSync(bundlePath, "utf8"); - - // Both certs should be in the bundle - const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length; - assert.ok(certCount >= 2, "Bundle should contain both Safe Chain and user certificates"); - }); - it("ignores non-existent user cert path", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - process.env.NODE_EXTRA_CA_CERTS = "/nonexistent/path.pem"; - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should still have Safe Chain CA - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); + // Both certs should be in the bundle + const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []) + .length; + assert.ok( + certCount >= 2, + "Bundle should contain both Safe Chain and user certificates", + ); }); it("ignores invalid PEM user cert", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); const userCertPath = path.join(tmpDir, "invalid.pem"); fs.writeFileSync(userCertPath, "NOT A VALID PEM", "utf8"); process.env.NODE_EXTRA_CA_CERTS = userCertPath; - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); + const bundlePath = getCombinedCaBundlePath(getValidCert()); assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); const contents = fs.readFileSync(bundlePath, "utf8"); // Should still have Safe Chain CA only - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - assert.ok(!contents.includes("NOT A VALID"), "Should not include invalid cert"); - }); - - it("rejects user cert with path traversal attempts", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - process.env.NODE_EXTRA_CA_CERTS = "../../../etc/passwd"; - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should only have Safe Chain CA, rejected the traversal path - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - }); - - it("rejects user cert with symlink", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - // Create a target file and a symlink to it - const targetCert = path.join(tmpDir, "target.pem"); - fs.writeFileSync(targetCert, getValidCert(), "utf8"); - - const symlinkPath = path.join(tmpDir, "symlink.pem"); - try { - fs.symlinkSync(targetCert, symlinkPath); - } catch { - // Skip test if symlinks are not supported (e.g., on Windows without admin) - return; - } - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - process.env.NODE_EXTRA_CA_CERTS = symlinkPath; - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should only have Safe Chain CA, symlinks are rejected - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - }); - - it("rejects user cert that is a directory", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - const certDir = path.join(tmpDir, "certs"); - fs.mkdirSync(certDir); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - process.env.NODE_EXTRA_CA_CERTS = certDir; - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should only have Safe Chain CA - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - }); - - it("handles empty string user cert path", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - process.env.NODE_EXTRA_CA_CERTS = " "; - const bundlePath = getCombinedCaBundlePath(); - - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); + assert.match( + contents, + /-----BEGIN CERTIFICATE-----/, + "Should contain Safe Chain CA", + ); + assert.ok( + !contents.includes("NOT A VALID"), + "Should not include invalid cert", + ); }); it("accepts files with CRLF line endings (Windows-style)", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - // Create a real file with CRLF content to test Windows line ending support + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); const userCertPath = path.join(tmpDir, "user-cert-crlf.pem"); const userCert = getValidCert(); const certWithCRLF = userCert.replace(/\n/g, "\r\n"); fs.writeFileSync(userCertPath, certWithCRLF, "utf8"); process.env.NODE_EXTRA_CA_CERTS = userCertPath; - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - const bundlePath = getCombinedCaBundlePath(); + const bundlePath = getCombinedCaBundlePath(getValidCert()); assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); const contents = fs.readFileSync(bundlePath, "utf8"); - const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length; - assert.ok(certCount >= 2, "Bundle should contain Safe Chain and user certificates with CRLF"); - }); - - it("detects and handles Windows-style path syntax (drive letters and UNC)", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - - // Test that Windows path syntax is recognized (even if files don't exist on macOS/Linux) - // These should gracefully fail (return Safe Chain CA only) rather than crash - const winPaths = [ - "C:\\temp\\cert.pem", - "D:\\Users\\name\\certs\\ca.pem", - "\\\\server\\share\\cert.pem" - ]; - - for (const winPath of winPaths) { - process.env.NODE_EXTRA_CA_CERTS = winPath; - const bundlePath = getCombinedCaBundlePath(); - assert.ok(fs.existsSync(bundlePath), `Bundle should exist for ${winPath}`); - const contents = fs.readFileSync(bundlePath, "utf8"); - assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA"); - } - }); - - it("rejects path traversal with Windows-style paths (C:\\temp\\..\\etc\\passwd)", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-")); - const safeChainPath = path.join(tmpDir, "safechain.pem"); - fs.writeFileSync(safeChainPath, getValidCert(), "utf8"); - - mock.module("./certUtils.js", { - namedExports: { - getCaCertPath: () => safeChainPath, - }, - }); - - const { getCombinedCaBundlePath } = await import("./certBundle.js"); - - // Test various Windows-style traversal attempts - const traversalPaths = [ - "C:\\temp\\..\\etc\\passwd", - "D:\\Users\\..\\..\\Windows\\System32", - "\\\\server\\share\\..\\admin", - "../../../etc/passwd", // Unix-style for comparison - ]; - - // First, get baseline bundle without user certs to know expected cert count - delete process.env.NODE_EXTRA_CA_CERTS; - const baselineBundlePath = getCombinedCaBundlePath(); - const baselineContents = fs.readFileSync(baselineBundlePath, "utf8"); - const baselineCertCount = (baselineContents.match(/-----BEGIN CERTIFICATE-----/g) || []).length; - - for (const badPath of traversalPaths) { - process.env.NODE_EXTRA_CA_CERTS = badPath; - const bundlePath = getCombinedCaBundlePath(); - assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); - const contents = fs.readFileSync(bundlePath, "utf8"); - // Should contain base bundle (Safe Chain + Mozilla + Node roots) but NOT user cert - const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length; - assert.strictEqual(certCount, baselineCertCount, `Traversal path ${badPath} should be rejected; base bundle only (no user cert added)`); - } + const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []) + .length; + assert.ok( + certCount >= 2, + "Bundle should contain Safe Chain and user certificates with CRLF", + ); }); }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js b/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js deleted file mode 100644 index 26c05596..00000000 --- a/packages/safe-chain/src/registryProxy/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; -} diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js new file mode 100644 index 00000000..e749061a --- /dev/null +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js @@ -0,0 +1,149 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtempSync, readFile } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { ui } from "../../environment/userInteraction.js"; +import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js"; +import { getReportingServer } from "./reportingServer.js"; +import EventEmitter from "node:events"; + +const readFilePromise = promisify(readFile); + +/** + * @typedef {Object} RamaProxyInstance + * @property {import("node:child_process").ChildProcess} process + * @property {string} proxyAddress + * @property {string} metaAddress + * @property {string} caCert + */ + +/** + * @returns {String | null} + */ +export function getRamaPath() { + const executableDir = dirname(process.execPath); + const ramaPath = join(executableDir, "safechain-proxy"); + + if (existsSync(ramaPath)) { + return ramaPath; + } + + return null; +} + +/** + * @param {string} ramaPath + * + * @returns {import("../registryProxy.js").SafeChainProxy} */ +export function createRamaProxy(ramaPath) { + const tempDir = mkdtempSync(join(tmpdir(), "safe-chain-proxy-")); + const reportingServer = getReportingServer(); + /** @type {EventEmitter} */ + const emitter = new EventEmitter(); + /** @type {RamaProxyInstance | null} */ + let ramaInstance = null; + + return Object.assign(emitter, { + startServer: async () => { + await reportingServer.start(); + 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()}`, + ); + ramaInstance = await startRama( + ramaPath, + tempDir, + reportingServer.getAddress(), + ); + ui.writeVerbose( + `Proxy started at address "${ramaInstance.proxyAddress}"`, + ); + }, + stopServer: async () => { + await reportingServer.stop(); + if (ramaInstance) { + ramaInstance.process.kill(); + } + return Promise.resolve(); + }, + hasSuppressedVersions: () => false, + getServerPort: () => { + if (!ramaInstance) return null; + const url = new URL(`http://${ramaInstance.proxyAddress}`); + return url.port ? parseInt(url.port, 10) : null; + }, + getCaCert: () => ramaInstance?.caCert ?? null, + }); +} + +/** + * @param {string} ramaPath + * @param {string} dataFolder + * @param {string} reportingUrl + * @returns {Promise} + */ +async function startRama(ramaPath, dataFolder, reportingUrl) { + const startTime = Date.now(); + const args = [ + "--secrets", + "memory", + "--data", + dataFolder, + "--reporting-endpoint", + reportingUrl, + ]; + const stdio = getLoggingLevel() === LOGGING_VERBOSE ? "inherit" : "pipe"; + const process = spawn(ramaPath, args, { stdio: stdio }); + + // wait for the proxy process to start (poll for proxy.addr.txt file) + const proxyAddrPath = join(dataFolder, "proxy.addr.txt"); + const maxWaitTime = 60000; // 60 seconds + const pollInterval = 500; // 500 ms + + while (!existsSync(proxyAddrPath)) { + if (Date.now() - startTime > maxWaitTime) { + throw new Error("Timeout waiting for proxy to start"); + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + const elapsedTime = Date.now() - startTime; + ui.writeVerbose(`Proxy started in ${elapsedTime}ms`); + + const proxyAddress = await readFilePromise(proxyAddrPath, "utf-8"); + const metaAddress = await readFilePromise( + join(dataFolder, "meta.addr.txt"), + "utf-8", + ); + + const certResponse = await fetch(`http://${metaAddress}/ca`); + const caCert = await certResponse.text(); + + return { + process, + proxyAddress, + metaAddress, + caCert, + }; +} diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.spec.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.spec.js new file mode 100644 index 00000000..e0c6eb8f --- /dev/null +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.spec.js @@ -0,0 +1,177 @@ +import { describe, it, before, after, mock } from "node:test"; +import assert from "node:assert"; +import EventEmitter from "node:events"; + +// --- Mock setup --- + +const mockReportingServer = Object.assign(new EventEmitter(), { + start: mock.fn(async () => {}), + stop: mock.fn(async () => {}), + getAddress: mock.fn(() => "http://127.0.0.1:9999"), +}); + +mock.module("./reportingServer.js", { + namedExports: { + getReportingServer: () => mockReportingServer, + }, +}); + +const mockKill = mock.fn(); +mock.module("node:child_process", { + namedExports: { + spawn: mock.fn(() => ({ kill: mockKill })), + }, +}); + +const mockExistsSync = mock.fn(() => true); +const mockMkdtempSync = mock.fn(() => "/tmp/safe-chain-proxy-abc"); +const mockReadFile = mock.fn( + (/** @type {string} */ path, /** @type {string} */ _encoding, /** @type {Function} */ cb) => { + if (path.endsWith("proxy.addr.txt")) { + cb(null, "127.0.0.1:8080"); + } else if (path.endsWith("meta.addr.txt")) { + cb(null, "127.0.0.1:8081"); + } else { + cb(new Error("unknown file")); + } + }, +); + +mock.module("node:fs", { + namedExports: { + existsSync: mockExistsSync, + mkdtempSync: mockMkdtempSync, + readFile: mockReadFile, + }, +}); + +mock.module("../../environment/userInteraction.js", { + namedExports: { ui: { writeVerbose: mock.fn() } }, +}); + +mock.module("../../config/settings.js", { + namedExports: { + getLoggingLevel: mock.fn(() => "default"), + LOGGING_VERBOSE: "verbose", + }, +}); + +const mockFetch = mock.method(globalThis, "fetch", async () => ({ + text: async () => "MOCK_CA_CERT_PEM", +})); + +const { getRamaPath, createRamaProxy } = await import( + "./createRamaProxy.js" +); + +describe("getRamaPath", () => { + it("returns path ending in safechain-proxy when existsSync returns true", () => { + mockExistsSync.mock.resetCalls(); + mockExistsSync.mock.mockImplementation(() => true); + + const result = getRamaPath(); + assert.ok(result?.endsWith("safechain-proxy"), `Expected path ending in safechain-proxy, got ${result}`); + }); + + it("returns null when existsSync returns false", () => { + mockExistsSync.mock.mockImplementation(() => false); + + const result = getRamaPath(); + assert.strictEqual(result, null); + + // Restore for other tests + mockExistsSync.mock.mockImplementation(() => true); + }); +}); + +describe("createRamaProxy — before startServer", () => { + /** @type {ReturnType} */ + let proxy; + + before(() => { + proxy = createRamaProxy("/fake/path/safechain-proxy"); + }); + + it("getServerPort() returns null", () => { + assert.strictEqual(proxy.getServerPort(), null); + }); + + it("getCaCert() returns null", () => { + assert.strictEqual(proxy.getCaCert(), null); + }); + + it("hasSuppressedVersions() returns false", () => { + assert.strictEqual(proxy.hasSuppressedVersions(), false); + }); +}); + +describe("createRamaProxy — after startServer", () => { + /** @type {ReturnType} */ + let proxy; + + before(async () => { + mockReportingServer.start.mock.resetCalls(); + mockReportingServer.stop.mock.resetCalls(); + mockKill.mock.resetCalls(); + mockFetch.mock.resetCalls(); + + proxy = createRamaProxy("/fake/path/safechain-proxy"); + await proxy.startServer(); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("transforms blockReceived into malwareBlocked event", async () => { + const eventPromise = new Promise((resolve) => { + proxy.once("malwareBlocked", resolve); + }); + + mockReportingServer.emit("blockReceived", { + ts_ms: Date.now(), + artifact: { + product: "npm", + identifier: "evil-pkg", + version: "2.0.0", + }, + }); + + const received = await eventPromise; + assert.deepStrictEqual(received, { + packageName: "evil-pkg", + packageVersion: "2.0.0", + }); + }); + + it("getServerPort() returns the correct port", () => { + assert.strictEqual(proxy.getServerPort(), 8080); + }); + + it("getCaCert() returns the mocked certificate", () => { + assert.strictEqual(proxy.getCaCert(), "MOCK_CA_CERT_PEM"); + }); +}); + +describe("createRamaProxy — stopServer", () => { + it("calls kill on spawned process and stop on reporting server", async () => { + mockReportingServer.start.mock.resetCalls(); + mockReportingServer.stop.mock.resetCalls(); + mockKill.mock.resetCalls(); + + const proxy = createRamaProxy("/fake/path/safechain-proxy"); + await proxy.startServer(); + await proxy.stopServer(); + + assert.strictEqual(mockKill.mock.callCount(), 1); + assert.strictEqual(mockReportingServer.stop.mock.callCount(), 1); + }); + + it("is safe to call when server was never started", async () => { + mockReportingServer.stop.mock.resetCalls(); + + const proxy = createRamaProxy("/fake/path/safechain-proxy"); + // Should not throw + await proxy.stopServer(); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js new file mode 100644 index 00000000..24149636 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js @@ -0,0 +1,139 @@ +import * as http from "node:http"; +import { EventEmitter } from "node:events"; + +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 + */ + +/** + * @typedef {Object} MinPackageAgeEvent + * @property {number} ts_ms + * @property {{ product: string, identifier: string }} artifact + * @property {string[]} suppressed_versions + */ + +/** + * @typedef {{ blockReceived: [BlockEvent], minPackageAgeSuppressionReceived: [MinPackageAgeEvent] }} ReportingServerEvents + */ + +/** + * @typedef {EventEmitter & { + * start: () => Promise, + * stop: () => Promise, + * getAddress: () => string, + * }} ReportingServer + */ + +/** + * @returns {ReportingServer} + */ +export function getReportingServer() { + /** @type {EventEmitter} */ + const emitter = new EventEmitter(); + + /** @type {{server: http.Server | null, address: string }} */ + let state = {server: null, address: ""}; + + /** @param {http.IncomingMessage} req @param {http.ServerResponse} res */ + async function handleRequest(req, res) { + if (req.method === "POST" && req.url?.startsWith("/events/block")) { + await parseBlockEventFromRequest(req).then((blockEvent) => { + 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(); + } + + async function start() { + state = await startReportingServer(handleRequest); + } + + /** + * + * @returns {Promise} + */ + function stop() { + return new Promise((resolve) => { + if (!state.server) { + resolve(); + return; + } + const timeout = setTimeout(resolve, SERVER_STOP_TIMEOUT_MS); + state.server.close(() => { + clearTimeout(timeout); + resolve(); + }); + }); + } + + function getAddress() { + return state.address; + } + + return Object.assign(emitter, { start, stop, getAddress }); +} + +/** + * @param {http.IncomingMessage} req + * @returns {Promise} + */ +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(Buffer.concat(chunks).toString())); + req.on("error", reject); + }); +} + +/** + * @param {http.RequestListener} requestListener + * @returns {Promise<{server: http.Server, address: string}>} + */ +function startReportingServer(requestListener) { + const server = http.createServer(requestListener); + + return new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + resolve({ + address: `http://${address.address}:${address.port}`, + server, + }); + } else { + reject(new Error("Failed to start proxy server")); + } + }); + }); +} diff --git a/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.spec.js b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.spec.js new file mode 100644 index 00000000..d16d35b8 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.spec.js @@ -0,0 +1,134 @@ +import { describe, it, after, before } from "node:test"; +import assert from "node:assert"; +import { getReportingServer } from "./reportingServer.js"; + +/** + * Helper: POST JSON to a URL and return the response status code. + * @param {string} url + * @param {string} body + * @returns {Promise} HTTP status code + */ +async function postJson(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + return res.status; +} + +describe("reportingServer", () => { + /** @type {ReturnType} */ + let server; + + before(async () => { + server = getReportingServer(); + await server.start(); + }); + + after(async () => { + await server.stop(); + }); + + describe("start / getAddress", () => { + it("returns a valid http://127.0.0.1: address after starting", () => { + const address = server.getAddress(); + assert.match( + address, + /^http:\/\/127\.0\.0\.1:\d+$/, + "Address should be http://127.0.0.1:", + ); + }); + }); + + describe("POST /events/block", () => { + it("emits a blockReceived event with the parsed JSON body", async () => { + const blockEvent = { + ts_ms: Date.now(), + artifact: { + product: "npm", + identifier: "malicious-pkg", + version: "1.0.0", + }, + }; + + const eventPromise = new Promise((resolve) => { + server.once("blockReceived", resolve); + }); + + const status = await postJson( + `${server.getAddress()}/events/block`, + JSON.stringify(blockEvent), + ); + + assert.strictEqual(status, 200); + + const received = await eventPromise; + assert.deepStrictEqual(received, blockEvent); + }); + }); + + describe("non-matching routes", () => { + it("returns 200 for GET requests but does not emit blockReceived", async () => { + let emitted = false; + const listener = () => { + emitted = true; + }; + server.on("blockReceived", listener); + + const res = await fetch(`${server.getAddress()}/other-route`); + assert.strictEqual(res.status, 200); + + // Give a tick for any event to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(emitted, false, "Should not emit blockReceived for non-matching routes"); + + server.off("blockReceived", listener); + }); + + it("returns 200 for POST to a different path but does not emit blockReceived", async () => { + let emitted = false; + const listener = () => { + emitted = true; + }; + server.on("blockReceived", listener); + + const status = await postJson( + `${server.getAddress()}/other-path`, + JSON.stringify({ foo: "bar" }), + ); + assert.strictEqual(status, 200); + + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.strictEqual(emitted, false, "Should not emit blockReceived for non-block paths"); + + server.off("blockReceived", listener); + }); + }); +}); + +describe("reportingServer stop", () => { + it("stops cleanly and frees the port", async () => { + const server = getReportingServer(); + await server.start(); + const address = server.getAddress(); + assert.ok(address, "Server should have an address"); + + await server.stop(); + + // After stopping, the server should no longer accept connections + try { + await fetch(`${address}/events/block`); + assert.fail("Should not be able to connect to stopped server"); + } catch (err) { + // Expected: connection refused or similar + assert.ok(err, "Fetch should throw after server stops"); + } + }); + + it("stop is safe to call when server was never started", async () => { + const server = getReportingServer(); + // Should not throw + await server.stop(); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js index ace84ee1..681dc91f 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js @@ -14,14 +14,14 @@ const mockIsImdsEndpoint = (host) => { ].includes(host); }; -mock.module("./isImdsEndpoint.js", { +mock.module("./builtInProxy/isImdsEndpoint.js", { namedExports: { isImdsEndpoint: mockIsImdsEndpoint, }, }); // Mock getConnectTimeout to speed up tests -mock.module("./getConnectTimeout.js", { +mock.module("./builtInProxy/getConnectTimeout.js", { namedExports: { getConnectTimeout: (host) => { // IMDS endpoints: 100ms (real: 3s) @@ -111,6 +111,9 @@ describe("registryProxy.connectTunnel", () => { describe("Error Handling", () => { it("should return 502 Bad Gateway for invalid hostname", async () => { + // We need to make sure we're not running behind an existing safe-chain installation to allow this test to work + const https_proxy = process.env.HTTPS_PROXY; + delete process.env.HTTPS_PROXY; const socket = await connectToProxy(proxyHost, proxyPort); const connectRequest = `CONNECT invalid.hostname.that.does.not.exist:443 HTTP/1.1\r\nHost: invalid.hostname.that.does.not.exist:443\r\n\r\n`; socket.write(connectRequest); @@ -123,8 +126,11 @@ describe("registryProxy.connectTunnel", () => { }); }); - assert.ok(responseData.includes("HTTP/1.1 502 Bad Gateway")); + assert.ok(responseData.includes("HTTP/1.1 502 Bad Gateway"), responseData); socket.destroy(); + if (https_proxy) { + process.env.HTTPS_PROXY = https_proxy; + } }); it("should handle client disconnect during tunnel establishment", async () => { @@ -185,13 +191,13 @@ describe("registryProxy.connectTunnel", () => { // Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts) assert.ok( responseData.includes("HTTP/1.1 504 Gateway Timeout"), - "Should return 504 for timeout" + "Should return 504 for timeout", ); // Should timeout around 100ms for IMDS endpoints (allow some margin) assert.ok( duration >= 80 && duration < 200, - `IMDS timeout should be ~80-200ms, got ${duration}ms` + `IMDS timeout should be ~80-200ms, got ${duration}ms`, ); socket.destroy(); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 694c72c1..76878065 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -1,36 +1,74 @@ -import * as http from "http"; -import { tunnelRequest } from "./tunnelRequestHandler.js"; -import { mitmConnect } from "./mitmRequestHandler.js"; -import { handleHttpProxyRequest } from "./plainHttpProxy.js"; -import { getCombinedCaBundlePath, cleanupCertBundle } from "./certBundle.js"; import { ui } from "../environment/userInteraction.js"; -import chalk from "chalk"; -import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; -import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js"; +import { createRamaProxy, getRamaPath } from "./ramaProxy/createRamaProxy.js"; +import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServer.js"; +import { getCombinedCaBundlePath } from "./certBundle.js"; -const SERVER_STOP_TIMEOUT_MS = 1000; /** - * @type {{ - * port: number | null, - * blockedRequests: {packageName: string, version: string, url: string}[], - * blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[] - * }} + * @typedef {Object} PackageBlockedEvent + * @prop {string} packageName + * @prop {string} packageVersion + * + * @typedef {Object} MinPackageAgeSuppressionEvent + * @prop {string} packageName + * @prop {string[]} packageVersions + * + * @typedef {{ + * malwareBlocked: [PackageBlockedEvent], + * minimumAgeRequestBlocked: [PackageBlockedEvent] + * minPackageAgeVersionsSuppressed: [MinPackageAgeSuppressionEvent] + * }} ProxyServerEvents + * + * @import { EventEmitter } from "node:stream" + * @typedef {EventEmitter & { + * startServer: () => Promise + * stopServer: () => Promise + * getServerPort: () => Number | null + * getCaCert: () => string | null + * }} SafeChainProxy + * + * @typedef {Object} ProxySettings + * @prop {string | null} proxyUrl + * @prop {string} caCertBundlePath */ -const state = { - port: null, - blockedRequests: [], - blockedMinimumAgeRequests: [], -}; + +/** @type {SafeChainProxy} */ +let server; export function createSafeChainProxy() { - const server = createProxyServer(); + if (server) { + return server; + } + + let ramaPath = getRamaPath(); + if (ramaPath) { + ui.writeVerbose("Starting safe-chain rama proxy"); + server = createRamaProxy(ramaPath); + } else { + ui.writeVerbose("Starting built-in proxy"); + server = createBuiltInProxyServer(); + } + + return server; +} + +/** + * @returns {ProxySettings} + */ +export function getProxySettings() { + if (!server || !server.getServerPort()) { + return { + proxyUrl: null, + caCertBundlePath: getCombinedCaBundlePath(null), + }; + } + + const proxyUrl = `http://127.0.0.1:${server.getServerPort()}`; + const caCert = server.getCaCert(); + const caCertBundlePath = getCombinedCaBundlePath(caCert); return { - startServer: () => startServer(server), - stopServer: () => stopServer(server), - hasBlockedMaliciousPackages, - hasBlockedMinimumAgeRequests, - hasSuppressedVersions: getHasSuppressedVersions, + proxyUrl, + caCertBundlePath, }; } @@ -38,17 +76,16 @@ export function createSafeChainProxy() { * @returns {Record} */ function getSafeChainProxyEnvironmentVariables() { - if (!state.port) { + if (!server || !server.getServerPort()) { return {}; } - const proxyUrl = `http://127.0.0.1:${state.port}`; - const caCertPath = getCombinedCaBundlePath(); + const proxySettings = getProxySettings(); return { - HTTPS_PROXY: proxyUrl, - GLOBAL_AGENT_HTTP_PROXY: proxyUrl, - NODE_EXTRA_CA_CERTS: caCertPath, + HTTPS_PROXY: proxySettings.proxyUrl ?? "", + GLOBAL_AGENT_HTTP_PROXY: proxySettings.proxyUrl ?? "", + NODE_EXTRA_CA_CERTS: proxySettings.caCertBundlePath, }; } @@ -73,186 +110,3 @@ export function mergeSafeChainProxyEnvironmentVariables(env) { return proxyEnv; } - -function createProxyServer() { - const server = http.createServer( - // This handles direct HTTP requests (non-CONNECT requests) - // This is normally http-only traffic, but we also handle - // https for clients that don't properly use CONNECT - handleHttpProxyRequest - ); - - // This handles HTTPS requests via the CONNECT method - server.on("connect", handleConnect); - - return server; -} - -/** - * @param {import("http").Server} server - * - * @returns {Promise} - */ -function startServer(server) { - return new Promise((resolve, reject) => { - // Bind to loopback only. Without an explicit host, Node listens on every - // interface, turning the proxy into an unauthenticated forward proxy that - // anyone reachable on the network can use to hit the victim's localhost, - // intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port. - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address && typeof address === "object") { - state.port = address.port; - resolve(); - } else { - reject(new Error("Failed to start proxy server")); - } - }); - - server.on("error", (err) => { - reject(err); - }); - }); -} - -/** - * @param {import("http").Server} server - * - * @returns {Promise} - */ -function stopServer(server) { - return new Promise((resolve) => { - try { - server.close(() => { - cleanupCertBundle(); - resolve(); - }); - } catch { - resolve(); - } - setTimeout(() => { - cleanupCertBundle(); - resolve(); - }, SERVER_STOP_TIMEOUT_MS); - }); -} - -/** - * @param {import("http").IncomingMessage} req - * @param {import("http").ServerResponse} clientSocket - * @param {Buffer} head - * - * @returns {void} - */ -function handleConnect(req, clientSocket, head) { - // CONNECT method is used for HTTPS requests - // It establishes a tunnel to the server identified by the request URL - - const interceptor = createInterceptorForUrl(req.url || ""); - - if (interceptor) { - // Subscribe to malware blocked events - interceptor.on( - "malwareBlocked", - ( - /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event - ) => { - onMalwareBlocked(event.packageName, event.version, event.targetUrl); - } - ); - interceptor.on( - "minimumAgeRequestBlocked", - ( - /** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event - ) => { - onMinimumAgeRequestBlocked( - event.packageName, - event.version, - event.targetUrl - ); - } - ); - - mitmConnect(req, clientSocket, interceptor); - } else { - // For other hosts, just tunnel the request to the destination tcp socket - ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); - tunnelRequest(req, clientSocket, head); - } -} - -/** - * - * @param {string} packageName - * @param {string} version - * @param {string} url - */ -function onMalwareBlocked(packageName, version, url) { - state.blockedRequests.push({ packageName, version, url }); -} - -/** - * - * @param {string} packageName - * @param {string} version - * @param {string} url - */ -function onMinimumAgeRequestBlocked(packageName, version, url) { - state.blockedMinimumAgeRequests.push({ packageName, version, url }); -} - -function hasBlockedMaliciousPackages() { - if (state.blockedRequests.length === 0) { - return false; - } - - ui.emptyLine(); - - ui.writeInformation( - `Safe-chain: ${chalk.bold( - `blocked ${state.blockedRequests.length} malicious package downloads` - )}:` - ); - - for (const req of state.blockedRequests) { - ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); - } - - ui.emptyLine(); - ui.writeExitWithoutInstallingMaliciousPackages(); - ui.emptyLine(); - - return true; -} - -function hasBlockedMinimumAgeRequests() { - if (state.blockedMinimumAgeRequests.length === 0) { - return false; - } - - ui.emptyLine(); - - ui.writeInformation( - `Safe-chain: ${chalk.bold( - `blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age` - )}:` - ); - - for (const req of state.blockedMinimumAgeRequests) { - ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); - } - - ui.writeInformation( - ` To disable this check, use: ${chalk.cyan( - "--safe-chain-skip-minimum-package-age" - )}` - ); - - ui.emptyLine(); - ui.writeError( - "Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check." - ); - ui.emptyLine(); - - return true; -} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js index 407aa3c0..4ac9feda 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js @@ -7,7 +7,7 @@ import { createSafeChainProxy, mergeSafeChainProxyEnvironmentVariables, } from "./registryProxy.js"; -import { getCaCertPath } from "./certUtils.js"; +import { getCaCertPath } from "./builtInProxy/certUtils.js"; import { setEcoSystem, ECOSYSTEM_JS,