mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #328 from AikidoSec/rama-blocked-events
Use proxy reporting endpoint to subscribe to blocked events
This commit is contained in:
commit
c5d6413795
9 changed files with 639 additions and 59 deletions
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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<typeof createBuiltInProxyServer>} */
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 stdio = getLoggingLevel() === LOGGING_VERBOSE ? "inherit" : "pipe";
|
const stdio = getLoggingLevel() === LOGGING_VERBOSE ? "inherit" : "pipe";
|
||||||
const process = spawn(ramaPath, args, { stdio: stdio });
|
const process = spawn(ramaPath, args, { stdio: stdio });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<typeof createRamaProxy>} */
|
||||||
|
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<typeof createRamaProxy>} */
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<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: ""};
|
||||||
|
|
||||||
|
/** @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<void>}
|
||||||
|
*/
|
||||||
|
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<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}>}
|
||||||
|
*/
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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<number>} 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<typeof getReportingServer>} */
|
||||||
|
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:<port> address after starting", () => {
|
||||||
|
const address = server.getAddress();
|
||||||
|
assert.match(
|
||||||
|
address,
|
||||||
|
/^http:\/\/127\.0\.0\.1:\d+$/,
|
||||||
|
"Address should be http://127.0.0.1:<port>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue