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 ``` 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/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js new file mode 100644 index 0000000..96c1e67 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -0,0 +1,92 @@ +import { EventEmitter } from "events"; + +/** + * @typedef {Object} Interceptor + * @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 + */ + +/** + * @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise} requestInterceptionFunc + * @returns {Interceptor} + */ +export function interceptRequests(requestInterceptionFunc) { + return buildInterceptor([requestInterceptionFunc]); +} + +/** + * @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise>} requestHandlers + * @returns {Interceptor} + */ +function buildInterceptor(requestHandlers) { + const eventEmitter = new EventEmitter(); + + return { + async handleRequest(targetUrl) { + const requestContext = createRequestContext(targetUrl, eventEmitter); + + for (const handler of requestHandlers) { + await handler(requestContext); + } + + return requestContext.build(); + }, + on(event, listener) { + eventEmitter.on(event, listener); + return this; + }, + emit(event, ...args) { + return eventEmitter.emit(event, ...args); + }, + }; +} + +/** + * @param {string} targetUrl + * @param {import('events').EventEmitter} eventEmitter + * @returns {RequestInterceptionContext} + */ +function createRequestContext(targetUrl, eventEmitter) { + /** @type {{statusCode: number, message: string} | undefined} */ + let blockResponse = undefined; + + /** + * @param {string | undefined} packageName + * @param {string | undefined} version + */ + function blockMalware(packageName, version) { + blockResponse = { + statusCode: 403, + message: "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 new file mode 100644 index 0000000..9a80890 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/npmInterceptor.js @@ -0,0 +1,78 @@ +import { isMalwarePackage } from "../../scanning/audit/index.js"; +import { interceptRequests } 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) { + return interceptRequests(async (reqContext) => { + const { packageName, version } = parseNpmPackageUrl( + reqContext.targetUrl, + registry + ); + if (await isMalwarePackage(packageName, version)) { + reqContext.blockMalware(packageName, version); + } + }); +} + +/** + * @param {string} url + * @param {string} registry + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +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..212c830 100644 --- a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js @@ -1,79 +1,41 @@ -import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { isMalwarePackage } from "../../scanning/audit/index.js"; +import { interceptRequests } 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 }; - } - - 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 +function buildPipInterceptor(registry) { + return interceptRequests(async (reqContext) => { + const { packageName, version } = parsePipPackageFromUrl( + reqContext.targetUrl, + registry ); - if (filename.startsWith(scopedPackageName + "-")) { - version = filename.substring(scopedPackageName.length + 1); + if (await isMalwarePackage(packageName, version)) { + reqContext.blockMalware(packageName, version); } - } else { - if (filename.startsWith(packageName + "-")) { - version = filename.substring(packageName.length + 1); - } - } - - return { packageName, version }; + }); } /** @@ -82,11 +44,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 +56,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 +76,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 6f7b20e..c3ad934 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..beaa1ef 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -3,11 +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 { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; const SERVER_STOP_TIMEOUT_MS = 1000; /** @@ -132,18 +130,15 @@ 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 (interceptor) { + // Subscribe to malware blocked events + interceptor.on("malwareBlocked", (event) => { + onMalwareBlocked(event.packageName, event.version, event.url); + }); - if (isKnownRegistry) { - mitmConnect(req, clientSocket, isAllowedUrl); + 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}`); @@ -152,28 +147,13 @@ function handleConnect(req, clientSocket, head) { } /** + * + * @param {string} packageName + * @param {string} version * @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 onMalwareBlocked(packageName, version, url) { + state.blockedRequests.push({ packageName, version, url }); } function verifyNoMaliciousPackages() { diff --git a/packages/safe-chain/src/scanning/audit/index.js b/packages/safe-chain/src/scanning/audit/index.js index 4e292b3..771401e 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 *