From e251908cb306ae5b97f27177afa04bd6f0bbb5ec Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 6 Nov 2025 18:00:11 +0100 Subject: [PATCH 1/9] Add interceptors for MITM --- .../interceptors/interceptorBuilder.js | 50 +++++++++++++++++++ .../interceptors/requestInterceptorBuilder.js | 30 +++++++++++ .../src/registryProxy/mitmRequestHandler.js | 23 ++++++--- .../src/registryProxy/registryProxy.js | 31 ++++++++++-- 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js new file mode 100644 index 0000000..574abb9 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -0,0 +1,50 @@ +/** + * @typedef {import('./requestInterceptorBuilder.js').RequestInterceptorBuilder} RequestInterceptorBuilder + * @typedef {import('./requestInterceptorBuilder.js').RequestInterceptor} RequestInterceptor + * + * @typedef {Object} InterceptorBuilder + * @property {(requestFunc: (requestHandlerBuilder: RequestInterceptorBuilder) => Promise) => void} onRequest + * @property {() => Interceptor} build + * + * @typedef {Object} Interceptor + * @property {(targetUrl: string) => Promise} handleRequest + */ + +import { createRequestInterceptorBuilder } from "./requestInterceptorBuilder.js"; + +/** + * @returns {InterceptorBuilder} + */ +export function createInterceptorBuilder() { + /** + * @type {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise>} + */ + const requestHandlers = []; + + return { + onRequest(requestFunc) { + requestHandlers.push(requestFunc); + }, + build() { + return buildInterceptor(requestHandlers); + }, + }; +} + +/** + * @param {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise>} requestHandlers + * @returns {Interceptor} + */ +function buildInterceptor(requestHandlers) { + return { + async handleRequest(targetUrl) { + const reqInterceptorBuilder = createRequestInterceptorBuilder(targetUrl); + + for (const handler of requestHandlers) { + await handler(reqInterceptorBuilder); + } + + return reqInterceptorBuilder.build(); + }, + }; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js new file mode 100644 index 0000000..e0d560a --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js @@ -0,0 +1,30 @@ +/** + * @typedef {Object} RequestInterceptorBuilder + * @property {string} targetUrl + * @property {(statusCode: number, message: string) => void} blockRequest + * @property {() => RequestInterceptor} build + * + * @typedef {Object} RequestInterceptor + * @property {{statusCode: number, message: string} | undefined} blockResponse + */ + +/** + * @param {string} targetUrl + * @returns {RequestInterceptorBuilder} + */ +export function createRequestInterceptorBuilder(targetUrl) { + /** @type {{statusCode: number, message: string} | undefined} */ + let blockResponse = undefined; + + return { + targetUrl, + blockRequest(statusCode, message) { + blockResponse = { statusCode, message }; + }, + build() { + return { + blockResponse, + }; + }, + }; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 6f7b20e..58f220c 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -3,12 +3,16 @@ import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; import { ui } from "../environment/userInteraction.js"; +/** + * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor + */ + /** * @param {import("http").IncomingMessage} req * @param {import("http").ServerResponse} clientSocket - * @param {(target: string) => Promise} isAllowed + * @param {Interceptor} interceptor */ -export function mitmConnect(req, clientSocket, isAllowed) { +export function mitmConnect(req, clientSocket, interceptor) { ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`); const { hostname } = new URL(`http://${req.url}`); @@ -21,7 +25,7 @@ export function mitmConnect(req, clientSocket, isAllowed) { // Not subscribing to 'close' event will cause node to throw and crash. }); - const server = createHttpsServer(hostname, isAllowed); + const server = createHttpsServer(hostname, interceptor); server.on("error", (err) => { ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`); @@ -41,10 +45,10 @@ export function mitmConnect(req, clientSocket, isAllowed) { /** * @param {string} hostname - * @param {(target: string) => Promise} isAllowed + * @param {Interceptor} interceptor * @returns {import("https").Server} */ -function createHttpsServer(hostname, isAllowed) { +function createHttpsServer(hostname, interceptor) { const cert = generateCertForHost(hostname); /** @@ -64,10 +68,13 @@ function createHttpsServer(hostname, isAllowed) { const pathAndQuery = getRequestPathAndQuery(req.url); const targetUrl = `https://${hostname}${pathAndQuery}`; - if (!(await isAllowed(targetUrl))) { + const interceptorResult = await interceptor.handleRequest(targetUrl); + const blockResponse = interceptorResult?.blockResponse; + + if (blockResponse) { ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`); - res.writeHead(403, "Forbidden - blocked by safe-chain"); - res.end("Blocked by safe-chain"); + res.writeHead(blockResponse.statusCode, blockResponse.message); + res.end(blockResponse.message); return; } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index c5e272b..d66f397 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -4,10 +4,19 @@ import { mitmConnect } from "./mitmRequestHandler.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { getCaCertPath } from "./certUtils.js"; import { auditChanges } from "../scanning/audit/index.js"; -import { knownJsRegistries, knownPipRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js"; -import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { + knownJsRegistries, + knownPipRegistries, + parsePackageFromUrl, +} from "./parsePackageFromUrl.js"; +import { + getEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, +} from "../config/settings.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; +import { createInterceptorBuilder } from "./interceptors/interceptorBuilder.js"; const SERVER_STOP_TIMEOUT_MS = 1000; /** @@ -143,7 +152,7 @@ function handleConnect(req, clientSocket, head) { } if (isKnownRegistry) { - mitmConnect(req, clientSocket, isAllowedUrl); + mitmConnect(req, clientSocket, createMitmInterceptor()); } else { // For other hosts, just tunnel the request to the destination tcp socket ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); @@ -151,6 +160,22 @@ function handleConnect(req, clientSocket, head) { } } +/** + * + * @returns {import("./interceptors/interceptorBuilder.js").Interceptor} + */ +function createMitmInterceptor() { + const builder = createInterceptorBuilder(); + + builder.onRequest(async (req) => { + if (!(await isAllowedUrl(req.targetUrl))) { + req.blockRequest(403, "Forbidden - blocked by safe-chain"); + } + }); + + return builder.build(); +} + /** * @param {string} url * @returns {Promise} From f4694ba11954a7b7a257924c24a4d8a688fc6c04 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 7 Nov 2025 10:10:27 +0100 Subject: [PATCH 2/9] Move npm and pip mitm interception to separate files --- .../createInterceptorForEcoSystem.js | 25 +++ .../interceptors/npmInterceptor.js | 82 ++++++++++ .../npmInterceptor.spec.js} | 148 +++++++----------- .../pipInterceptor.js} | 98 ++++-------- .../interceptors/pipInterceptor.spec.js | 135 ++++++++++++++++ .../src/registryProxy/mitmRequestHandler.js | 2 +- .../src/registryProxy/registryProxy.js | 68 +------- .../safe-chain/src/scanning/audit/index.js | 16 ++ 8 files changed, 350 insertions(+), 224 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js rename packages/safe-chain/src/registryProxy/{parsePackageFromUrl.spec.js => interceptors/npmInterceptor.spec.js} (50%) rename packages/safe-chain/src/registryProxy/{parsePackageFromUrl.js => interceptors/pipInterceptor.js} (50%) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js new file mode 100644 index 0000000..c97d867 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js @@ -0,0 +1,25 @@ +import { + ECOSYSTEM_JS, + ECOSYSTEM_PY, + getEcoSystem, +} from "../../config/settings.js"; +import { npmInterceptorForUrl } from "./npmInterceptor.js"; +import { pipInterceptorForUrl } from "./pipInterceptor.js"; + +/** + * @param {string} url + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} + */ +export function createInterceptorForUrl(url) { + const ecosystem = getEcoSystem(); + + if (ecosystem === ECOSYSTEM_JS) { + return npmInterceptorForUrl(url); + } + + if (ecosystem === ECOSYSTEM_PY) { + return pipInterceptorForUrl(url); + } + + return undefined; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js new file mode 100644 index 0000000..557e9cb --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js @@ -0,0 +1,82 @@ +import { isMalwarePackage } from "../../scanning/audit/index.js"; +import { createInterceptorBuilder } from "./interceptorBuilder.js"; + +const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; + +/** + * @param {string} url + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} + */ +export function npmInterceptorForUrl(url) { + const registry = knownJsRegistries.find((reg) => url.includes(reg)); + + if (registry) { + return buildNpmInterceptor(registry); + } + + return undefined; +} + +/** + * @param {string} registry + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} + */ +function buildNpmInterceptor(registry) { + const builder = createInterceptorBuilder(); + + builder.onRequest(async (req) => { + const { packageName, version } = parseNpmPackageUrl( + req.targetUrl, + registry + ); + if (await isMalwarePackage(packageName, version)) { + req.blockRequest(403, "Forbidden - blocked by safe-chain"); + } + }); + + return builder.build(); +} + +/** + * @param {string} url + * @param {string} registry + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +export function parseNpmPackageUrl(url, registry) { + let packageName, version; + if (!registry || !url.endsWith(".tgz")) { + return { packageName, version }; + } + + const registryIndex = url.indexOf(registry); + const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + + const separatorIndex = afterRegistry.indexOf("/-/"); + if (separatorIndex === -1) { + return { packageName, version }; + } + + packageName = afterRegistry.substring(0, separatorIndex); + const filename = afterRegistry.substring( + separatorIndex + 3, + afterRegistry.length - 4 + ); // Remove /-/ and .tgz + + // Extract version from filename + // For scoped packages like @babel/core, the filename is core-7.21.4.tgz + // For regular packages like lodash, the filename is lodash-4.17.21.tgz + if (packageName.startsWith("@")) { + const scopedPackageName = packageName.substring( + packageName.lastIndexOf("/") + 1 + ); + if (filename.startsWith(scopedPackageName + "-")) { + version = filename.substring(scopedPackageName.length + 1); + } + } else { + if (filename.startsWith(packageName + "-")) { + version = filename.substring(packageName.length + 1); + } + } + + return { packageName, version }; +} diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js similarity index 50% rename from packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js rename to packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js index d052e9d..dd09527 100644 --- a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.spec.js @@ -1,14 +1,22 @@ -import { describe, it, beforeEach } from "node:test"; +import { describe, it, mock } from "node:test"; import assert from "node:assert"; -import { parsePackageFromUrl } from "./parsePackageFromUrl.js"; -import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; -describe("parsePackageFromUrl", () => { - beforeEach(() => { - setEcoSystem(ECOSYSTEM_JS); +describe("npmInterceptor", async () => { + let lastPackage; + let malwareResponse = false; + + mock.module("../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + lastPackage = { packageName, version }; + return malwareResponse; + }, + }, }); - const testCases = [ + const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + + const parserCases = [ // Regular packages { url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -83,11 +91,6 @@ describe("parsePackageFromUrl", () => { url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", expected: { packageName: "@babel/core", version: "7.21.4" }, }, - // Invalid URLs should return undefined values - { - url: "https://example.com/package.tgz", - expected: { packageName: undefined, version: undefined }, - }, // URL to get package info, not tarball { url: "https://registry.npmjs.org/lodash", @@ -110,92 +113,51 @@ describe("parsePackageFromUrl", () => { }, ]; - testCases.forEach(({ url, expected }, index) => { - it(`should parse URL ${index + 1}: ${url}`, () => { - const result = parsePackageFromUrl(url); - assert.deepEqual(result, expected); + parserCases.forEach(({ url, expected }, index) => { + it(`should parse URL ${index + 1}: ${url}`, async () => { + const interceptor = npmInterceptorForUrl(url); + assert.ok( + interceptor, + "Interceptor should be created for known npm registry" + ); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, expected); }); }); -}); -describe("parsePackageFromUrl - pip URLs", () => { - beforeEach(() => { - setEcoSystem(ECOSYSTEM_PY); + it("should not create interceptor for unknown registry", () => { + const url = "https://example.com/some-package/-/some-package-1.0.0.tgz"; + + const interceptor = npmInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined for unknown registry" + ); }); - const pipTestCases = [ - // Valid pip URLs - { - 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", - 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://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" }, - }, - // Invalid pip URLs - { - 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 }, - }, - ]; + it("should block malicious package", async () => { + const url = + "https://registry.npmjs.org/malicious-package/-/malicious-package-1.0.0.tgz"; + malwareResponse = true; - pipTestCases.forEach(({ url, expected }, index) => { - it(`should parse pip URL ${index + 1}: ${url}`, () => { - const result = parsePackageFromUrl(url); - assert.deepEqual(result, expected); - }); + const interceptor = npmInterceptorForUrl(url); + + const result = await interceptor.handleRequest(url); + + assert.ok(result.blockResponse, "Should contain a blockResponse"); + assert.equal( + result.blockResponse.statusCode, + 403, + "Block response should have status code 403" + ); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain", + "Block response should have correct status message" + ); }); }); diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js similarity index 50% rename from packages/safe-chain/src/registryProxy/parsePackageFromUrl.js rename to packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js index 1fda121..90099b1 100644 --- a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js @@ -1,79 +1,45 @@ -import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { isMalwarePackage } from "../../scanning/audit/index.js"; +import { createInterceptorBuilder } from "./interceptorBuilder.js"; -export const knownJsRegistries = ["registry.npmjs.org","registry.yarnpkg.com"]; -export const knownPipRegistries = ["files.pythonhosted.org", "pypi.org", "pypi.python.org", "pythonhosted.org"]; +const knownPipRegistries = [ + "files.pythonhosted.org", + "pypi.org", + "pypi.python.org", + "pythonhosted.org", +]; /** * @param {string} url - * @returns {{packageName: string | undefined, version: string | undefined}} + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} */ -export function parsePackageFromUrl(url) { - const ecosystem = getEcoSystem(); - let registry; +export function pipInterceptorForUrl(url) { + const registry = knownPipRegistries.find((reg) => url.includes(reg)); - // Only check registries that match the current ecosystem - if (ecosystem === ECOSYSTEM_JS) { - for (const knownRegistry of knownJsRegistries) { - if (url.includes(knownRegistry)) { - registry = knownRegistry; - return parseJsPackageFromUrl(url, registry); - } - } - } else if (ecosystem === ECOSYSTEM_PY) { - for (const knownRegistry of knownPipRegistries) { - if (url.includes(knownRegistry)) { - registry = knownRegistry; - return parsePipPackageFromUrl(url, registry); - } - } + if (registry) { + return buildPipInterceptor(registry); } - // If no known registry matched, return { packageName: undefined, version: undefined } - return { packageName: undefined, version: undefined }; + return undefined; } /** - * @param {string} url * @param {string} registry - * @returns {{packageName: string | undefined, version: string | undefined}} + * @returns {import("./interceptorBuilder.js").Interceptor | undefined} */ -function parseJsPackageFromUrl(url, registry) { - let packageName, version; - if (!registry || !url.endsWith(".tgz")) { - return { packageName, version }; - } +function buildPipInterceptor(registry) { + const builder = createInterceptorBuilder(); - const registryIndex = url.indexOf(registry); - const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash - - const separatorIndex = afterRegistry.indexOf("/-/"); - if (separatorIndex === -1) { - return { packageName, version }; - } - - packageName = afterRegistry.substring(0, separatorIndex); - const filename = afterRegistry.substring( - separatorIndex + 3, - afterRegistry.length - 4 - ); // Remove /-/ and .tgz - - // Extract version from filename - // For scoped packages like @babel/core, the filename is core-7.21.4.tgz - // For regular packages like lodash, the filename is lodash-4.17.21.tgz - if (packageName.startsWith("@")) { - const scopedPackageName = packageName.substring( - packageName.lastIndexOf("/") + 1 + builder.onRequest(async (req) => { + const { packageName, version } = parsePipPackageFromUrl( + req.targetUrl, + registry ); - if (filename.startsWith(scopedPackageName + "-")) { - version = filename.substring(scopedPackageName.length + 1); + if (await isMalwarePackage(packageName, version)) { + req.blockRequest(403, "Forbidden - blocked by safe-chain"); } - } else { - if (filename.startsWith(packageName + "-")) { - version = filename.substring(packageName.length + 1); - } - } + }); - return { packageName, version }; + return builder.build(); } /** @@ -82,11 +48,11 @@ function parseJsPackageFromUrl(url, registry) { * @returns {{packageName: string | undefined, version: string | undefined}} */ function parsePipPackageFromUrl(url, registry) { - let packageName, version + let packageName, version; // Basic validation if (!registry || typeof url !== "string") { - return { packageName, version}; + return { packageName, version }; } // Quick sanity check on the URL + parse @@ -94,13 +60,13 @@ function parsePipPackageFromUrl(url, registry) { try { urlObj = new URL(url); } catch { - return { packageName, version}; + return { packageName, version }; } // Get the last path segment (filename) and decode it (strip query & fragment automatically) const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop(); - if (!lastSegment){ - return { packageName, version}; + if (!lastSegment) { + return { packageName, version }; } const filename = decodeURIComponent(lastSegment); @@ -114,8 +80,8 @@ function parsePipPackageFromUrl(url, registry) { const base = filename.slice(0, -4); // remove ".whl" const firstDash = base.indexOf("-"); if (firstDash > 0) { - const dist = base.slice(0, firstDash); // may contain underscores - const rest = base.slice(firstDash + 1); // version + the rest of tags + const dist = base.slice(0, firstDash); // may contain underscores + const rest = base.slice(firstDash + 1); // version + the rest of tags const secondDash = rest.indexOf("-"); const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest; packageName = dist; // preserve underscores diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js new file mode 100644 index 0000000..8b60b9b --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js @@ -0,0 +1,135 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("pipInterceptor", async () => { + let lastPackage; + let malwareResponse = false; + + mock.module("../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + lastPackage = { packageName, version }; + return malwareResponse; + }, + }, + }); + + const { pipInterceptorForUrl } = await import("./pipInterceptor.js"); + + const parserCases = [ + // Valid pip URLs + { + 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", + 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://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" }, + }, + // Invalid pip URLs + { + 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 () => { + const interceptor = pipInterceptorForUrl(url); + assert.ok( + interceptor, + "Interceptor should be created for known npm registry" + ); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, expected); + }); + }); + + 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, + "Interceptor should be undefined for unknown registry" + ); + }); + + it("should block malicious package", async () => { + 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, "Should contain a blockResponse"); + assert.equal( + result.blockResponse.statusCode, + 403, + "Block response should have status code 403" + ); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain", + "Block response should have correct status message" + ); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 58f220c..c3ad934 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -69,7 +69,7 @@ function createHttpsServer(hostname, interceptor) { const targetUrl = `https://${hostname}${pathAndQuery}`; const interceptorResult = await interceptor.handleRequest(targetUrl); - const blockResponse = interceptorResult?.blockResponse; + const blockResponse = interceptorResult.blockResponse; if (blockResponse) { ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index d66f397..d41a8bb 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -3,20 +3,9 @@ import { tunnelRequest } from "./tunnelRequestHandler.js"; import { mitmConnect } from "./mitmRequestHandler.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { getCaCertPath } from "./certUtils.js"; -import { auditChanges } from "../scanning/audit/index.js"; -import { - knownJsRegistries, - knownPipRegistries, - parsePackageFromUrl, -} from "./parsePackageFromUrl.js"; -import { - getEcoSystem, - ECOSYSTEM_JS, - ECOSYSTEM_PY, -} from "../config/settings.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; -import { createInterceptorBuilder } from "./interceptors/interceptorBuilder.js"; +import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; const SERVER_STOP_TIMEOUT_MS = 1000; /** @@ -141,18 +130,10 @@ 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 ecosystem = getEcoSystem(); - const url = req.url || ""; + const interceptor = createInterceptorForUrl(req.url || ""); - let isKnownRegistry = false; - if (ecosystem === ECOSYSTEM_JS) { - isKnownRegistry = knownJsRegistries.some((reg) => url.includes(reg)); - } else if (ecosystem === ECOSYSTEM_PY) { - isKnownRegistry = knownPipRegistries.some((reg) => url.includes(reg)); - } - - if (isKnownRegistry) { - mitmConnect(req, clientSocket, createMitmInterceptor()); + if (interceptor) { + 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}`); @@ -160,47 +141,6 @@ function handleConnect(req, clientSocket, head) { } } -/** - * - * @returns {import("./interceptors/interceptorBuilder.js").Interceptor} - */ -function createMitmInterceptor() { - const builder = createInterceptorBuilder(); - - builder.onRequest(async (req) => { - if (!(await isAllowedUrl(req.targetUrl))) { - req.blockRequest(403, "Forbidden - blocked by safe-chain"); - } - }); - - return builder.build(); -} - -/** - * @param {string} url - * @returns {Promise} - */ -async function isAllowedUrl(url) { - const { packageName, version } = parsePackageFromUrl(url); - - // packageName and version are undefined when the URL is not a package download - // In that case, we can allow the request to proceed - if (!packageName || !version) { - return true; - } - - const auditResult = await auditChanges([ - { name: packageName, version, type: "add" }, - ]); - - if (!auditResult.isAllowed) { - state.blockedRequests.push({ packageName, version, url }); - return false; - } - - return true; -} - function verifyNoMaliciousPackages() { if (state.blockedRequests.length === 0) { // No malicious packages were blocked, so nothing to block diff --git a/packages/safe-chain/src/scanning/audit/index.js b/packages/safe-chain/src/scanning/audit/index.js index 803051a..09fcfd8 100644 --- a/packages/safe-chain/src/scanning/audit/index.js +++ b/packages/safe-chain/src/scanning/audit/index.js @@ -41,6 +41,22 @@ export function getAuditStats() { return auditStats; } +/** + * + * @param {string | undefined} name + * @param {string | undefined} version + * @returns {Promise} + */ +export async function isMalwarePackage(name, version) { + if (!name || !version) { + return false; + } + + const auditResult = await auditChanges([{ name, version, type: "add" }]); + + return !auditResult.isAllowed; +} + /** * @param {PackageChange[]} changes * From 1f570a9f393909d58bdd6577abd8ab9918561e39 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 7 Nov 2025 11:39:41 +0100 Subject: [PATCH 3/9] Keep track of amount of malware packages blocked --- .../interceptors/interceptorBuilder.js | 17 +++++++++- .../interceptors/npmInterceptor.js | 4 +-- .../interceptors/pipInterceptor.js | 2 +- .../interceptors/requestInterceptorBuilder.js | 34 ++++++++++++++++--- .../src/registryProxy/registryProxy.js | 16 +++++++++ 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index 574abb9..73bde02 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -8,8 +8,11 @@ * * @typedef {Object} Interceptor * @property {(targetUrl: string) => Promise} handleRequest + * @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on + * @property {(event: string, ...args: any[]) => boolean} emit */ +import { EventEmitter } from "events"; import { createRequestInterceptorBuilder } from "./requestInterceptorBuilder.js"; /** @@ -36,9 +39,14 @@ export function createInterceptorBuilder() { * @returns {Interceptor} */ function buildInterceptor(requestHandlers) { + const eventEmitter = new EventEmitter(); + return { async handleRequest(targetUrl) { - const reqInterceptorBuilder = createRequestInterceptorBuilder(targetUrl); + const reqInterceptorBuilder = createRequestInterceptorBuilder( + targetUrl, + eventEmitter + ); for (const handler of requestHandlers) { await handler(reqInterceptorBuilder); @@ -46,5 +54,12 @@ function buildInterceptor(requestHandlers) { return reqInterceptorBuilder.build(); }, + on(event, listener) { + eventEmitter.on(event, listener); + return this; + }, + emit(event, ...args) { + return eventEmitter.emit(event, ...args); + }, }; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js index 557e9cb..6e33dd0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js @@ -30,7 +30,7 @@ function buildNpmInterceptor(registry) { registry ); if (await isMalwarePackage(packageName, version)) { - req.blockRequest(403, "Forbidden - blocked by safe-chain"); + req.blockMalware(packageName, version, req.targetUrl); } }); @@ -42,7 +42,7 @@ function buildNpmInterceptor(registry) { * @param {string} registry * @returns {{packageName: string | undefined, version: string | undefined}} */ -export function parseNpmPackageUrl(url, registry) { +function parseNpmPackageUrl(url, registry) { let packageName, version; if (!registry || !url.endsWith(".tgz")) { return { packageName, version }; diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js index 90099b1..7d793d3 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js @@ -35,7 +35,7 @@ function buildPipInterceptor(registry) { registry ); if (await isMalwarePackage(packageName, version)) { - req.blockRequest(403, "Forbidden - blocked by safe-chain"); + req.blockMalware(packageName, version, req.targetUrl); } }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js index e0d560a..a8b98c6 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js @@ -2,6 +2,7 @@ * @typedef {Object} RequestInterceptorBuilder * @property {string} targetUrl * @property {(statusCode: number, message: string) => void} blockRequest + * @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware * @property {() => RequestInterceptor} build * * @typedef {Object} RequestInterceptor @@ -10,17 +11,42 @@ /** * @param {string} targetUrl + * @param {import('events').EventEmitter} eventEmitter * @returns {RequestInterceptorBuilder} */ -export function createRequestInterceptorBuilder(targetUrl) { +export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { /** @type {{statusCode: number, message: string} | undefined} */ let blockResponse = undefined; + /** + * @param {number} statusCode + * @param {string} message + */ + function blockRequest(statusCode, message) { + blockResponse = { statusCode, message }; + } + + /** + * @param {string | undefined} packageName + * @param {string | undefined} version + * @param {string} url + */ + function blockMalware(packageName, version, url) { + blockRequest(403, "Forbidden - blocked by safe-chain"); + + // Emit the malwareBlocked event + eventEmitter.emit("malwareBlocked", { + packageName, + version, + url, + timestamp: Date.now(), + }); + } + return { targetUrl, - blockRequest(statusCode, message) { - blockResponse = { statusCode, message }; - }, + blockRequest, + blockMalware, build() { return { blockResponse, diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index d41a8bb..f366a93 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -6,6 +6,7 @@ import { getCaCertPath } from "./certUtils.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; +import { on } from "events"; const SERVER_STOP_TIMEOUT_MS = 1000; /** @@ -133,6 +134,11 @@ function handleConnect(req, clientSocket, head) { const interceptor = createInterceptorForUrl(req.url || ""); if (interceptor) { + // Subscribe to malware blocked events + interceptor.on("malwareBlocked", (event) => { + onMalwareBlocked(event.packageName, event.version, event.url); + }); + mitmConnect(req, clientSocket, interceptor); } else { // For other hosts, just tunnel the request to the destination tcp socket @@ -141,6 +147,16 @@ function handleConnect(req, clientSocket, head) { } } +/** + * + * @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 From 76a1100b8cb11d1aeee434b7530c983764338786 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 7 Nov 2025 11:42:53 +0100 Subject: [PATCH 4/9] Fix linter issues --- packages/safe-chain/src/registryProxy/registryProxy.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index f366a93..beaa1ef 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -6,7 +6,6 @@ import { getCaCertPath } from "./certUtils.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; -import { on } from "events"; const SERVER_STOP_TIMEOUT_MS = 1000; /** From 2cf23d5109e9cd1a0f0e1a4e0835b27cc7dc65f7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 12 Nov 2025 13:43:47 +0100 Subject: [PATCH 5/9] Don't expose blockRequest --- .../src/registryProxy/interceptors/requestInterceptorBuilder.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js index a8b98c6..ad1f145 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js @@ -1,7 +1,6 @@ /** * @typedef {Object} RequestInterceptorBuilder * @property {string} targetUrl - * @property {(statusCode: number, message: string) => void} blockRequest * @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware * @property {() => RequestInterceptor} build * @@ -45,7 +44,6 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { return { targetUrl, - blockRequest, blockMalware, build() { return { From ad6d9bcdd5f3d2b599969c9c2dd261f84c9b3fbe Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 12 Nov 2025 14:03:33 +0100 Subject: [PATCH 6/9] Simplify interceptor code and rename variables for clarity. --- .../interceptors/interceptorBuilder.js | 87 +++++++++++++------ .../interceptors/npmInterceptor.js | 12 +-- .../interceptors/pipInterceptor.js | 12 +-- .../interceptors/requestInterceptorBuilder.js | 54 ------------ 4 files changed, 69 insertions(+), 96 deletions(-) delete mode 100644 packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index 73bde02..beed1f9 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -1,41 +1,32 @@ /** - * @typedef {import('./requestInterceptorBuilder.js').RequestInterceptorBuilder} RequestInterceptorBuilder - * @typedef {import('./requestInterceptorBuilder.js').RequestInterceptor} RequestInterceptor - * - * @typedef {Object} InterceptorBuilder - * @property {(requestFunc: (requestHandlerBuilder: RequestInterceptorBuilder) => Promise) => void} onRequest - * @property {() => Interceptor} build - * * @typedef {Object} Interceptor - * @property {(targetUrl: string) => Promise} handleRequest + * @property {(targetUrl: string) => Promise} handleRequest * @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on * @property {(event: string, ...args: any[]) => boolean} emit + * + * + * @typedef {Object} RequestInterceptionContext + * @property {string} targetUrl + * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware + * @property {() => RequestInterceptionHandler} build + * + * + * @typedef {Object} RequestInterceptionHandler + * @property {{statusCode: number, message: string} | undefined} blockResponse */ import { EventEmitter } from "events"; -import { createRequestInterceptorBuilder } from "./requestInterceptorBuilder.js"; /** - * @returns {InterceptorBuilder} + * @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise} requestInterceptionFunc + * @returns {Interceptor} */ -export function createInterceptorBuilder() { - /** - * @type {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise>} - */ - const requestHandlers = []; - - return { - onRequest(requestFunc) { - requestHandlers.push(requestFunc); - }, - build() { - return buildInterceptor(requestHandlers); - }, - }; +export function interceptRequests(requestInterceptionFunc) { + return buildInterceptor([requestInterceptionFunc]); } /** - * @param {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise>} requestHandlers + * @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise>} requestHandlers * @returns {Interceptor} */ function buildInterceptor(requestHandlers) { @@ -43,7 +34,7 @@ function buildInterceptor(requestHandlers) { return { async handleRequest(targetUrl) { - const reqInterceptorBuilder = createRequestInterceptorBuilder( + const reqInterceptorBuilder = createRequestContext( targetUrl, eventEmitter ); @@ -63,3 +54,47 @@ function buildInterceptor(requestHandlers) { }, }; } + +/** + * @param {string} targetUrl + * @param {import('events').EventEmitter} eventEmitter + * @returns {RequestInterceptionContext} + */ +function createRequestContext(targetUrl, eventEmitter) { + /** @type {{statusCode: number, message: string} | undefined} */ + let blockResponse = undefined; + + /** + * @param {number} statusCode + * @param {string} message + */ + function blockRequest(statusCode, message) { + blockResponse = { statusCode, message }; + } + + /** + * @param {string | undefined} packageName + * @param {string | undefined} version + */ + function blockMalware(packageName, version) { + blockRequest(403, "Forbidden - blocked by safe-chain"); + + // Emit the malwareBlocked event + eventEmitter.emit("malwareBlocked", { + packageName, + version, + targetUrl, + timestamp: Date.now(), + }); + } + + return { + targetUrl, + blockMalware, + build() { + return { + blockResponse, + }; + }, + }; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js index 6e33dd0..9a80890 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js @@ -1,5 +1,5 @@ import { isMalwarePackage } from "../../scanning/audit/index.js"; -import { createInterceptorBuilder } from "./interceptorBuilder.js"; +import { interceptRequests } from "./interceptorBuilder.js"; const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; @@ -22,19 +22,15 @@ export function npmInterceptorForUrl(url) { * @returns {import("./interceptorBuilder.js").Interceptor | undefined} */ function buildNpmInterceptor(registry) { - const builder = createInterceptorBuilder(); - - builder.onRequest(async (req) => { + return interceptRequests(async (reqContext) => { const { packageName, version } = parseNpmPackageUrl( - req.targetUrl, + reqContext.targetUrl, registry ); if (await isMalwarePackage(packageName, version)) { - req.blockMalware(packageName, version, req.targetUrl); + reqContext.blockMalware(packageName, version); } }); - - return builder.build(); } /** diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js index 7d793d3..212c830 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js @@ -1,5 +1,5 @@ import { isMalwarePackage } from "../../scanning/audit/index.js"; -import { createInterceptorBuilder } from "./interceptorBuilder.js"; +import { interceptRequests } from "./interceptorBuilder.js"; const knownPipRegistries = [ "files.pythonhosted.org", @@ -27,19 +27,15 @@ export function pipInterceptorForUrl(url) { * @returns {import("./interceptorBuilder.js").Interceptor | undefined} */ function buildPipInterceptor(registry) { - const builder = createInterceptorBuilder(); - - builder.onRequest(async (req) => { + return interceptRequests(async (reqContext) => { const { packageName, version } = parsePipPackageFromUrl( - req.targetUrl, + reqContext.targetUrl, registry ); if (await isMalwarePackage(packageName, version)) { - req.blockMalware(packageName, version, req.targetUrl); + reqContext.blockMalware(packageName, version); } }); - - return builder.build(); } /** diff --git a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js deleted file mode 100644 index ad1f145..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/requestInterceptorBuilder.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @typedef {Object} RequestInterceptorBuilder - * @property {string} targetUrl - * @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware - * @property {() => RequestInterceptor} build - * - * @typedef {Object} RequestInterceptor - * @property {{statusCode: number, message: string} | undefined} blockResponse - */ - -/** - * @param {string} targetUrl - * @param {import('events').EventEmitter} eventEmitter - * @returns {RequestInterceptorBuilder} - */ -export function createRequestInterceptorBuilder(targetUrl, eventEmitter) { - /** @type {{statusCode: number, message: string} | undefined} */ - let blockResponse = undefined; - - /** - * @param {number} statusCode - * @param {string} message - */ - function blockRequest(statusCode, message) { - blockResponse = { statusCode, message }; - } - - /** - * @param {string | undefined} packageName - * @param {string | undefined} version - * @param {string} url - */ - function blockMalware(packageName, version, url) { - blockRequest(403, "Forbidden - blocked by safe-chain"); - - // Emit the malwareBlocked event - eventEmitter.emit("malwareBlocked", { - packageName, - version, - url, - timestamp: Date.now(), - }); - } - - return { - targetUrl, - blockMalware, - build() { - return { - blockResponse, - }; - }, - }; -} From d8007f62362718a9c432bfbbc76b4eba435dfbdd Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 12 Nov 2025 14:07:35 +0100 Subject: [PATCH 7/9] Cleanup interceptorBuilder.js --- .../interceptors/interceptorBuilder.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index beed1f9..e6017d9 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -1,3 +1,5 @@ +import { EventEmitter } from "events"; + /** * @typedef {Object} Interceptor * @property {(targetUrl: string) => Promise} handleRequest @@ -15,8 +17,6 @@ * @property {{statusCode: number, message: string} | undefined} blockResponse */ -import { EventEmitter } from "events"; - /** * @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise} requestInterceptionFunc * @returns {Interceptor} @@ -34,16 +34,13 @@ function buildInterceptor(requestHandlers) { return { async handleRequest(targetUrl) { - const reqInterceptorBuilder = createRequestContext( - targetUrl, - eventEmitter - ); + const requestContext = createRequestContext(targetUrl, eventEmitter); for (const handler of requestHandlers) { - await handler(reqInterceptorBuilder); + await handler(requestContext); } - return reqInterceptorBuilder.build(); + return requestContext.build(); }, on(event, listener) { eventEmitter.on(event, listener); From 27bf768cc6a80ea8925912c1153b0007f27a3835 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 12 Nov 2025 14:12:45 +0100 Subject: [PATCH 8/9] Remove blockResponse function entirely --- .../interceptors/interceptorBuilder.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index e6017d9..96c1e67 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -61,20 +61,15 @@ function createRequestContext(targetUrl, eventEmitter) { /** @type {{statusCode: number, message: string} | undefined} */ let blockResponse = undefined; - /** - * @param {number} statusCode - * @param {string} message - */ - function blockRequest(statusCode, message) { - blockResponse = { statusCode, message }; - } - /** * @param {string | undefined} packageName * @param {string | undefined} version */ function blockMalware(packageName, version) { - blockRequest(403, "Forbidden - blocked by safe-chain"); + blockResponse = { + statusCode: 403, + message: "Forbidden - blocked by safe-chain", + }; // Emit the malwareBlocked event eventEmitter.emit("malwareBlocked", { From 988507f8e1ee058efd905926645df2d3cc961e3f Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 12 Nov 2025 16:15:32 +0100 Subject: [PATCH 9/9] Clarify support for ecosystems and pip status Updated README to clarify that Aikido Safe Chain currently supports only JavaScript ecosystems and marks pip and pip3 as beta. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index acea710..f169747 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aikido Safe Chain -The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Python ecosystem (through pip or pip3, including `python -m pip[...]` and `python3 -m pip[...]` where available) or in the Javascript ecosystem (through npm, npx, yarn, pnpm, pnpx, bun and bunx). It's **free** to use and does not require any token. +The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Javascript ecosystem (through npm, npx, yarn, pnpm, pnpx, bun and bunx). It's **free** to use and does not require any token. The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip/pip3 from downloading or running the malware. @@ -15,8 +15,8 @@ Aikido Safe Chain works on Node.js version 18 and above and supports the followi - ✅ **pnpx** - ✅ **bun** - ✅ **bunx** -- ✅ **pip** -- ✅ **pip3** +- ✅ **pip** (beta) +- ✅ **pip3** (beta) # Usage @@ -41,7 +41,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: npm install safe-chain-test ``` - For Python: + For Python (beta): ```shell pip3 install safe-chain-pi-test ```