Use proxy reporting endpoint to subscribe to blocked events

This commit is contained in:
Sander Declerck 2026-03-03 13:53:38 +01:00
parent b03c1f6817
commit 68352d9ca4
No known key found for this signature in database
6 changed files with 193 additions and 58 deletions

View file

@ -20,8 +20,12 @@ export async function main(args) {
process.on("SIGINT", handleProcessTermination); process.on("SIGINT", handleProcessTermination);
process.on("SIGTERM", handleProcessTermination); process.on("SIGTERM", handleProcessTermination);
/** @type {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} */
let malwareBlockedEvents = [];
const proxy = createSafeChainProxy(); const proxy = createSafeChainProxy();
await proxy.startServer(); await proxy.startServer();
proxy.addListener("malwareBlocked", (ev) => malwareBlockedEvents.push(ev));
// Global error handlers to log unhandled errors // Global error handlers to log unhandled errors
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
@ -64,7 +68,8 @@ export async function main(args) {
// Write all buffered logs // Write all buffered logs
ui.writeBufferedLogsAndStopBuffering(); ui.writeBufferedLogsAndStopBuffering();
if (!proxy.verifyNoMaliciousPackages()) { if (malwareBlockedEvents.length > 0) {
printBlockedMalware(malwareBlockedEvents);
return 1; return 1;
} }
@ -117,3 +122,25 @@ function isSafeChainVerify(args) {
return true; 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();
}

View file

@ -3,23 +3,24 @@ import { tunnelRequest } from "./tunnelRequestHandler.js";
import { mitmConnect } from "./mitmRequestHandler.js"; import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
import { getCaCertPath } from "./certUtils.js"; import { getCaCertPath } from "./certUtils.js";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import EventEmitter from "events";
/** * /** *
* @returns {import("../registryProxy.js").SafeChainProxy} */ * @returns {import("../registryProxy.js").SafeChainProxy} */
export function createBuiltInProxyServer() { export function createBuiltInProxyServer() {
const SERVER_STOP_TIMEOUT_MS = 1000; const SERVER_STOP_TIMEOUT_MS = 1000;
/** /**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} * @type {{port: number | null}}
*/ */
const state = { const state = {
port: null, port: null,
blockedRequests: [],
}; };
/** @type {EventEmitter<import("../registryProxy.js").ProxyServerEvents>} */
const emitter = new EventEmitter();
const server = http.createServer( const server = http.createServer(
// This handles direct HTTP requests (non-CONNECT requests) // This handles direct HTTP requests (non-CONNECT requests)
@ -31,14 +32,13 @@ export function createBuiltInProxyServer() {
// This handles HTTPS requests via the CONNECT method // This handles HTTPS requests via the CONNECT method
server.on("connect", handleConnect); server.on("connect", handleConnect);
return { return Object.assign(emitter, {
startServer: () => startServer(server), startServer: () => startServer(server),
stopServer: () => stopServer(server), stopServer: () => stopServer(server),
verifyNoMaliciousPackages,
hasSuppressedVersions: getHasSuppressedVersions, hasSuppressedVersions: getHasSuppressedVersions,
getServerPort: () => state.port, getServerPort: () => state.port,
getCaCert, getCaCert,
}; });
/** /**
* @param {import("http").Server} server * @param {import("http").Server} server
@ -102,7 +102,10 @@ export function createBuiltInProxyServer() {
( (
/** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event, /** @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() { function getCaCert() {
try { try {
const safeChainPath = getCaCertPath(); const safeChainPath = getCaCertPath();

View file

@ -6,6 +6,8 @@ import { dirname, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js"; import { getLoggingLevel, LOGGING_VERBOSE } from "../../config/settings.js";
import { getReportingServer } from "./reportingServer.js";
import EventEmitter from "node:events";
const readFilePromise = promisify(readFile); const readFilePromise = promisify(readFile);
@ -37,23 +39,40 @@ export function getRamaPath() {
* @returns {import("../registryProxy.js").SafeChainProxy} */ * @returns {import("../registryProxy.js").SafeChainProxy} */
export function createRamaProxy(ramaPath) { export function createRamaProxy(ramaPath) {
const tempDir = mkdtempSync(join(tmpdir(), "safe-chain-proxy-")); const tempDir = mkdtempSync(join(tmpdir(), "safe-chain-proxy-"));
const reportingServer = getReportingServer();
/** @type {EventEmitter<import("../registryProxy.js").ProxyServerEvents>} */
const emitter = new EventEmitter();
/** @type {RamaProxyInstance | null} */ /** @type {RamaProxyInstance | null} */
let ramaInstance = null; let ramaInstance = null;
return { return Object.assign(emitter, {
startServer: async () => { 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( ui.writeVerbose(
`Proxy started at address "${ramaInstance.proxyAddress}"`, `Proxy started at address "${ramaInstance.proxyAddress}"`,
); );
}, },
stopServer: async () => { stopServer: async () => {
await reportingServer.stop();
if (ramaInstance) { if (ramaInstance) {
ramaInstance.process.kill(); ramaInstance.process.kill();
} }
return Promise.resolve(); return Promise.resolve();
}, },
verifyNoMaliciousPackages: () => true,
hasSuppressedVersions: () => false, hasSuppressedVersions: () => false,
getServerPort: () => { getServerPort: () => {
if (!ramaInstance) return null; if (!ramaInstance) return null;
@ -61,17 +80,25 @@ export function createRamaProxy(ramaPath) {
return url.port ? parseInt(url.port, 10) : null; return url.port ? parseInt(url.port, 10) : null;
}, },
getCaCert: () => ramaInstance?.caCert ?? null, getCaCert: () => ramaInstance?.caCert ?? null,
}; });
} }
/** /**
* @param {string} ramaPath * @param {string} ramaPath
* @param {string} dataFolder * @param {string} dataFolder
* @param {string} reportingUrl
* @returns {Promise<RamaProxyInstance>} * @returns {Promise<RamaProxyInstance>}
*/ */
async function startRama(ramaPath, dataFolder) { async function startRama(ramaPath, dataFolder, reportingUrl) {
const startTime = Date.now(); const startTime = Date.now();
const args = ["--secrets", "memory", "--data", dataFolder]; const args = [
"--secrets",
"memory",
"--data",
dataFolder,
"--reporting-endpoint",
reportingUrl,
];
const process = const process =
getLoggingLevel() === LOGGING_VERBOSE getLoggingLevel() === LOGGING_VERBOSE
? spawn(ramaPath, args, { ? spawn(ramaPath, args, {

View file

@ -0,0 +1,99 @@
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<ReportingServerEvents> & {
* start: () => Promise<void>,
* stop: () => Promise<void>,
* getAddress: () => string,
* }} ReportingServer
*/
/**
* @returns {ReportingServer}
*/
export function getReportingServer() {
/** @type {EventEmitter<ReportingServerEvents>} */
const emitter = new EventEmitter();
/** @type {{server: http.Server | null, address: string }} */
let state = {server: null, address: ""};
return Object.assign(emitter, {
start: async () => {
state = await startServer(async (req, res) => {
if (req.method == "POST" && req.url?.startsWith("/events/block")) {
const blockEvent = await parseBlockEventFromRequest(req);
emitter.emit("blockReceived", blockEvent);
}
res.writeHead(200);
res.end();
});
},
stop: () => {
return /** @type {Promise<void>} */ (new Promise((resolve) => {
try {
if (!state.server) {
resolve();
return;
}
state.server.close(() => {
resolve();
});
} catch {
resolve();
}
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
}));
},
getAddress: () => state.address,
});
}
/**
* @param {http.IncomingMessage} req
* @returns {Promise<BlockEvent>}
*/
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}>}
*/
async function startServer(requestListener) {
let server = http.createServer(requestListener);
return await 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: server,
});
} else {
reject(new Error("Failed to start proxy server"));
}
});
});
}

View file

@ -111,6 +111,9 @@ describe("registryProxy.connectTunnel", () => {
describe("Error Handling", () => { describe("Error Handling", () => {
it("should return 502 Bad Gateway for invalid hostname", async () => { 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 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`; 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); 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(); socket.destroy();
if (https_proxy) {
process.env.HTTPS_PROXY = https_proxy;
}
}); });
it("should handle client disconnect during tunnel establishment", async () => { it("should handle client disconnect during tunnel establishment", async () => {

View file

@ -4,13 +4,20 @@ import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServe
import { getCombinedCaBundlePath } from "./certBundle.js"; import { getCombinedCaBundlePath } from "./certBundle.js";
/** /**
* @typedef {Object} SafeChainProxy * @typedef {Object} MalwareBlockedEvent
* @prop {() => Promise<void>} startServer * @prop {string} packageName
* @prop {() => Promise<void>} stopServer * @prop {string} packageVersion
* @prop {() => boolean} verifyNoMaliciousPackages *
* @prop {() => boolean} hasSuppressedVersions * @typedef {{ malwareBlocked: [MalwareBlockedEvent] }} ProxyServerEvents
* @prop {() => Number | null} getServerPort *
* @prop {() => string | null} getCaCert * @import { EventEmitter } from "node:stream"
* @typedef {EventEmitter<ProxyServerEvents> & {
* startServer: () => Promise<void>
* stopServer: () => Promise<void>
* getServerPort: () => Number | null
* getCaCert: () => string | null
* hasSuppressedVersions: () => boolean
* }} SafeChainProxy
* *
* @typedef {Object} ProxySettings * @typedef {Object} ProxySettings
* @prop {string | null} proxyUrl * @prop {string | null} proxyUrl
@ -27,9 +34,10 @@ export function createSafeChainProxy() {
let ramaPath = getRamaPath(); let ramaPath = getRamaPath();
if (ramaPath) { if (ramaPath) {
ui.writeInformation("Starting safe-chain rama proxy"); ui.writeVerbose("Starting safe-chain rama proxy");
server = createRamaProxy(ramaPath); server = createRamaProxy(ramaPath);
} else { } else {
ui.writeVerbose("Starting built-in proxy");
server = createBuiltInProxyServer(); server = createBuiltInProxyServer();
} }