diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 0b37eba..c319b37 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -20,8 +20,12 @@ export async function main(args) { process.on("SIGINT", handleProcessTermination); process.on("SIGTERM", handleProcessTermination); + /** @type {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} */ + let malwareBlockedEvents = []; + const proxy = createSafeChainProxy(); await proxy.startServer(); + proxy.addListener("malwareBlocked", (ev) => malwareBlockedEvents.push(ev)); // Global error handlers to log unhandled errors process.on("uncaughtException", (error) => { @@ -64,7 +68,8 @@ export async function main(args) { // Write all buffered logs ui.writeBufferedLogsAndStopBuffering(); - if (!proxy.verifyNoMaliciousPackages()) { + if (malwareBlockedEvents.length > 0) { + printBlockedMalware(malwareBlockedEvents); return 1; } @@ -117,3 +122,25 @@ function isSafeChainVerify(args) { return true; } } + +/** + * + * @param {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} 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(); +} diff --git a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js index 22cd394..c699f28 100644 --- a/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js +++ b/packages/safe-chain/src/registryProxy/builtInProxy/createBuiltInProxyServer.js @@ -3,23 +3,24 @@ import { tunnelRequest } from "./tunnelRequestHandler.js"; import { mitmConnect } from "./mitmRequestHandler.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { ui } from "../../environment/userInteraction.js"; -import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; import { getCaCertPath } from "./certUtils.js"; import { readFileSync } from "fs"; +import EventEmitter from "events"; /** * * @returns {import("../registryProxy.js").SafeChainProxy} */ export function createBuiltInProxyServer() { const SERVER_STOP_TIMEOUT_MS = 1000; /** - * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} + * @type {{port: number | null}} */ const state = { port: null, - blockedRequests: [], }; + /** @type {EventEmitter} */ + const emitter = new EventEmitter(); const server = http.createServer( // This handles direct HTTP requests (non-CONNECT requests) @@ -31,14 +32,13 @@ export function createBuiltInProxyServer() { // This handles HTTPS requests via the CONNECT method server.on("connect", handleConnect); - return { + return Object.assign(emitter, { startServer: () => startServer(server), stopServer: () => stopServer(server), - verifyNoMaliciousPackages, hasSuppressedVersions: getHasSuppressedVersions, getServerPort: () => state.port, getCaCert, - }; + }); /** * @param {import("http").Server} server @@ -102,7 +102,10 @@ export function createBuiltInProxyServer() { ( /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event, ) => { - onMalwareBlocked(event.packageName, event.version, event.targetUrl); + emitter.emit("malwareBlocked", { + packageName: event.packageName, + packageVersion: event.version + }); }, ); @@ -114,41 +117,6 @@ export function createBuiltInProxyServer() { } } - /** - * - * @param {string} packageName - * @param {string} version - * @param {string} url - */ - function onMalwareBlocked(packageName, version, url) { - state.blockedRequests.push({ packageName, version, url }); - } - - function verifyNoMaliciousPackages() { - if (state.blockedRequests.length === 0) { - // No malicious packages were blocked, so nothing to block - return true; - } - - 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 false; - } - function getCaCert() { try { const safeChainPath = getCaCertPath(); 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 0000000..26ced38 --- /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 mockGetHasSuppressedVersions = mock.fn(() => false); + +/** @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: { getHasSuppressedVersions: mockGetHasSuppressedVersions }, +}); +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/ramaProxy/createRamaProxy.js b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js index 43be0b6..6d98bce 100644 --- a/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js +++ b/packages/safe-chain/src/registryProxy/ramaProxy/createRamaProxy.js @@ -6,6 +6,8 @@ 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); @@ -37,23 +39,40 @@ export function getRamaPath() { * @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 { + return Object.assign(emitter, { startServer: async () => { - ramaInstance = await startRama(ramaPath, tempDir); + await reportingServer.start(); + reportingServer.addListener("blockReceived", (ev) => + emitter.emit("malwareBlocked", { + packageName: ev.artifact.identifier, + packageVersion: ev.artifact.version, + }), + ); + 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(); }, - verifyNoMaliciousPackages: () => true, hasSuppressedVersions: () => false, getServerPort: () => { if (!ramaInstance) return null; @@ -61,17 +80,25 @@ export function createRamaProxy(ramaPath) { 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) { - const startTime = Date.now(); - const args = ["--secrets", "memory", "--data", dataFolder]; +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 }); 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 0000000..e0c6eb8 --- /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 0000000..d8d6d15 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/ramaProxy/reportingServer.js @@ -0,0 +1,108 @@ +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 + */ + +/** + * @typedef {{ blockReceived: [BlockEvent] }} 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); + }); + } + 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} + */ +function parseBlockEventFromRequest(req) { + return new Promise((resolve, reject) => { + /** @type {Buffer[]} */ + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(JSON.parse(Buffer.concat(chunks).toString()))); + req.on("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 0000000..d16d35b --- /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 014c737..681dc91 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js @@ -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 () => { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 4b7ef82..42a0694 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -4,13 +4,20 @@ import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServe import { getCombinedCaBundlePath } from "./certBundle.js"; /** - * @typedef {Object} SafeChainProxy - * @prop {() => Promise} startServer - * @prop {() => Promise} stopServer - * @prop {() => boolean} verifyNoMaliciousPackages - * @prop {() => boolean} hasSuppressedVersions - * @prop {() => Number | null} getServerPort - * @prop {() => string | null} getCaCert + * @typedef {Object} MalwareBlockedEvent + * @prop {string} packageName + * @prop {string} packageVersion + * + * @typedef {{ malwareBlocked: [MalwareBlockedEvent] }} ProxyServerEvents + * + * @import { EventEmitter } from "node:stream" + * @typedef {EventEmitter & { + * startServer: () => Promise + * stopServer: () => Promise + * getServerPort: () => Number | null + * getCaCert: () => string | null + * hasSuppressedVersions: () => boolean + * }} SafeChainProxy * * @typedef {Object} ProxySettings * @prop {string | null} proxyUrl @@ -27,9 +34,10 @@ export function createSafeChainProxy() { let ramaPath = getRamaPath(); if (ramaPath) { - ui.writeInformation("Starting safe-chain rama proxy"); + ui.writeVerbose("Starting safe-chain rama proxy"); server = createRamaProxy(ramaPath); } else { + ui.writeVerbose("Starting built-in proxy"); server = createBuiltInProxyServer(); }