mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Add tests
This commit is contained in:
parent
68352d9ca4
commit
8c38c0e35c
3 changed files with 436 additions and 0 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue