diff --git a/README.md b/README.md index ead3c6d..c18be58 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Python ecosystem (through pip or pip3) 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 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 from downloading or running the malware. +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. ![demo](./docs/safe-package-manager-demo.png) @@ -16,6 +16,7 @@ Aikido Safe Chain works on Node.js version 18 and above and supports the followi - ✅ **bun** - ✅ **bunx** - ✅ **pip** +- ✅ **pip3** # Usage @@ -32,14 +33,14 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: safe-chain setup ``` 3. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. 4. **Verify the installation** by running: ```shell npm install safe-chain-test ``` - The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, or `pip` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: ```shell @@ -48,7 +49,7 @@ safe-chain --version ## How it works -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: @@ -60,13 +61,6 @@ The Aikido Safe Chain integrates with your shell to provide a seamless experienc More information about the shell integration can be found in the [shell integration documentation](docs/shell-integration.md). -### Python support - -- Supports `pip` and `pip3` commands. -- Scans Python packages fetched by `pip install`, `pip download`, and `pip wheel`. -- Intercepts downloads from PyPI and checks them against Aikido's malware intelligence before they reach your machine. -- Included automatically when you run `safe-chain setup` (shell integration); **CI integration is not yet available for pip/pip3**. - ## Uninstallation To uninstall the Aikido Safe Chain, you can run the following command: diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 4a6ac99..f1f64e7 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -2,7 +2,7 @@ ## Overview -The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. +The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. ## Supported Shells @@ -28,7 +28,7 @@ This command: - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Detects all supported shells on your system -- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx` +- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly. @@ -77,7 +77,7 @@ The system modifies the following files to source Safe Chain startup scripts: This means the shell functions are working but the Aikido commands aren't installed or available in your PATH: - Make sure Aikido Safe Chain is properly installed on your system -- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, and `aikido-bunx` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, and `aikido-pip3` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -120,4 +120,4 @@ npm() { } ``` -Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. +Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. diff --git a/packages/safe-chain/bin/aikido-bun.js b/packages/safe-chain/bin/aikido-bun.js index 01e3972..c128445 100755 --- a/packages/safe-chain/bin/aikido-bun.js +++ b/packages/safe-chain/bin/aikido-bun.js @@ -2,7 +2,9 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "bun"; initializePackageManager(packageManagerName); var exitCode = await main(process.argv.slice(2)); diff --git a/packages/safe-chain/bin/aikido-bunx.js b/packages/safe-chain/bin/aikido-bunx.js index fb378e5..2e83793 100755 --- a/packages/safe-chain/bin/aikido-bunx.js +++ b/packages/safe-chain/bin/aikido-bunx.js @@ -2,7 +2,9 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "bunx"; initializePackageManager(packageManagerName); var exitCode = await main(process.argv.slice(2)); diff --git a/packages/safe-chain/bin/aikido-npm.js b/packages/safe-chain/bin/aikido-npm.js index 0e9f302..a50d9b5 100755 --- a/packages/safe-chain/bin/aikido-npm.js +++ b/packages/safe-chain/bin/aikido-npm.js @@ -2,7 +2,9 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "npm"; initializePackageManager(packageManagerName); var exitCode = await main(process.argv.slice(2)); diff --git a/packages/safe-chain/bin/aikido-npx.js b/packages/safe-chain/bin/aikido-npx.js index d3dfdd6..e1687d3 100755 --- a/packages/safe-chain/bin/aikido-npx.js +++ b/packages/safe-chain/bin/aikido-npx.js @@ -2,7 +2,9 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "npx"; initializePackageManager(packageManagerName); var exitCode = await main(process.argv.slice(2)); diff --git a/packages/safe-chain/bin/aikido-pip.js b/packages/safe-chain/bin/aikido-pip.js index c90355d..8d483f3 100755 --- a/packages/safe-chain/bin/aikido-pip.js +++ b/packages/safe-chain/bin/aikido-pip.js @@ -2,35 +2,16 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; -import { setEcoSystem } from "../src/config/settings.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; // Defaults let packageManagerName = "pip"; -let targetVersionMajor; - -// Copy argv so we can modify it +// Pass through user args as-is const argv = process.argv.slice(2); -for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - - // --target-version-major tells us which pip version is being used (2 or 3) - if (a === "--target-version-major" && i + 1 < argv.length) { - targetVersionMajor = argv[i + 1]; - argv.splice(i, 2); - i -= 1; - continue; - } -} - -// If the user explicitly called python3, prefer pip3 -if (targetVersionMajor && String(targetVersionMajor).trim() === "3") { - packageManagerName = "pip3"; -} - // Set eco system // This can be used in other parts of the code to determine which eco system we are working with -setEcoSystem("py"); +setEcoSystem(ECOSYSTEM_PY); initializePackageManager(packageManagerName); const exitCode = await main(argv); diff --git a/packages/safe-chain/bin/aikido-pip3.js b/packages/safe-chain/bin/aikido-pip3.js new file mode 100644 index 0000000..31da8bd --- /dev/null +++ b/packages/safe-chain/bin/aikido-pip3.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; + +// Explicit pip3 entrypoint +const packageManagerName = "pip3"; + +// Copy argv as-is +const argv = process.argv.slice(2); + +// Set ecosystem to Python +setEcoSystem(ECOSYSTEM_PY); + +initializePackageManager(packageManagerName); +const exitCode = await main(argv); + +process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-pnpm.js b/packages/safe-chain/bin/aikido-pnpm.js index 0a06217..cf5125e 100755 --- a/packages/safe-chain/bin/aikido-pnpm.js +++ b/packages/safe-chain/bin/aikido-pnpm.js @@ -2,7 +2,9 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "pnpm"; initializePackageManager(packageManagerName); var exitCode = await main(process.argv.slice(2)); diff --git a/packages/safe-chain/bin/aikido-pnpx.js b/packages/safe-chain/bin/aikido-pnpx.js index cdb6504..6182810 100755 --- a/packages/safe-chain/bin/aikido-pnpx.js +++ b/packages/safe-chain/bin/aikido-pnpx.js @@ -2,7 +2,9 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "pnpx"; initializePackageManager(packageManagerName); var exitCode = await main(process.argv.slice(2)); diff --git a/packages/safe-chain/bin/aikido-yarn.js b/packages/safe-chain/bin/aikido-yarn.js index fd87606..eee14e8 100755 --- a/packages/safe-chain/bin/aikido-yarn.js +++ b/packages/safe-chain/bin/aikido-yarn.js @@ -2,7 +2,9 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; +setEcoSystem(ECOSYSTEM_JS); const packageManagerName = "yarn"; initializePackageManager(packageManagerName); var exitCode = await main(process.argv.slice(2)); diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d9c5547..ee95737 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -15,6 +15,7 @@ "aikido-bun": "bin/aikido-bun.js", "aikido-bunx": "bin/aikido-bunx.js", "aikido-pip": "bin/aikido-pip.js", + "aikido-pip3": "bin/aikido-pip3.js", "safe-chain": "bin/safe-chain.js" }, "type": "module", diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index b38a4cc..04d117e 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -1,13 +1,13 @@ import fetch from "make-fetch-happen"; -import { getEcoSystem } from "../config/settings.js"; +import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; const malwareDatabaseUrls = { - js: "https://malware-list.aikido.dev/malware_predictions.json", - py: "https://malware-list.aikido.dev/malware_pypi.json", + [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", + [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", }; export async function fetchMalwareDatabase() { - const ecosystem = getEcoSystem() || "js"; + const ecosystem = getEcoSystem(); const malwareDatabaseUrl = malwareDatabaseUrls[ecosystem]; const response = await fetch(malwareDatabaseUrl); if (!response.ok) { @@ -26,8 +26,7 @@ export async function fetchMalwareDatabase() { } export async function fetchMalwareDatabaseVersion() { - const ecosystem = getEcoSystem() || "js"; - + const ecosystem = getEcoSystem(); const malwareDatabaseUrl = malwareDatabaseUrls[ecosystem]; const response = await fetch(malwareDatabaseUrl, { method: "HEAD", diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 1091eb0..5123d83 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -69,13 +69,13 @@ function readConfigFile() { function getDatabasePath() { const aikidoDir = getAikidoDirectory(); - const ecosystem = getEcoSystem() || "js"; + const ecosystem = getEcoSystem(); return path.join(aikidoDir, `malwareDatabase_${ecosystem}.json`); } function getDatabaseVersionPath() { const aikidoDir = getAikidoDirectory(); - const ecosystem = getEcoSystem() || "js"; + const ecosystem = getEcoSystem(); return path.join(aikidoDir, `version_${ecosystem}.txt`); } diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 0e03dd5..9690ae8 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -13,9 +13,12 @@ export function getMalwareAction() { export const MALWARE_ACTION_BLOCK = "block"; export const MALWARE_ACTION_PROMPT = "prompt"; +export const ECOSYSTEM_JS = "js"; +export const ECOSYSTEM_PY = "py"; + // Default to JavaScript ecosystem const ecosystemSettings = { - ecoSystem: "js", + ecoSystem: ECOSYSTEM_JS, }; export function getEcoSystem() { diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index b09fc50..e17eeb9 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -19,34 +19,3 @@ export async function runPip(command, args) { } } } - -export async function dryRunPipCommandAndOutput(command, args) { - try { - // Note: pip supports --dry-run for the "install" command only; "download" and "wheel" do not. - // We don't mutate args here — callers should include --dry-run when appropriate. - const result = await safeSpawnPy( - command, - args, - { - stdio: "pipe", - env: mergeSafeChainProxyEnvironmentVariables(process.env), - } - ); - return { - status: result.status, - output: result.status === 0 ? result.stdout : result.stderr, - }; - } catch (error) { - if (error.status) { - const output = - error.stdout?.toString() ?? - error.stderr?.toString() ?? - error.message ?? - ""; - return { status: error.status, output }; - } else { - ui.writeError("Error executing command:", error.message); - return { status: 1 }; - } - } -} diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js index 4b440af..f7e9f82 100644 --- a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js +++ b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.js @@ -1,20 +1,26 @@ +import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; + export const knownJsRegistries = ["registry.npmjs.org","registry.yarnpkg.com"]; export const knownPipRegistries = ["files.pythonhosted.org", "pypi.org", "pypi.python.org", "pythonhosted.org"]; export function parsePackageFromUrl(url) { + const ecosystem = getEcoSystem(); let registry; - for (const knownRegistry of knownJsRegistries) { - if (url.includes(knownRegistry)) { - registry = knownRegistry; - return parseJsPackageFromUrl(url, registry); + // 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); + } } - } - - for (const knownRegistry of knownPipRegistries) { - if (url.includes(knownRegistry)) { - registry = knownRegistry; - return parsePipPackageFromUrl(url, registry); + } else if (ecosystem === ECOSYSTEM_PY) { + for (const knownRegistry of knownPipRegistries) { + if (url.includes(knownRegistry)) { + registry = knownRegistry; + return parsePipPackageFromUrl(url, registry); + } } } @@ -70,21 +76,25 @@ function parsePipPackageFromUrl(url, registry) { } // Quick sanity check on the URL + parse - let u; + let urlObj; try { - u = new URL(url); + urlObj = new URL(url); } catch { return { packageName, version}; } // Get the last path segment (filename) and decode it (strip query & fragment automatically) - const lastSegment = u.pathname.split("/").filter(Boolean).pop(); + const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop(); if (!lastSegment){ return { packageName, version}; } const filename = decodeURIComponent(lastSegment); + // Parse Python package downloads from PyPI/pythonhosted.org + // Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl + // Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz + // Wheel (.whl) if (filename.endsWith(".whl")) { const base = filename.slice(0, -4); // remove ".whl" @@ -96,6 +106,9 @@ function parsePipPackageFromUrl(url, registry) { const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest; packageName = dist; // preserve underscores version = rawVersion; + // Reject "latest" as it's a placeholder, not a real version + // When version is "latest", this signals the URL doesn't contain actual version info + // Returning undefined allows the request (see registryProxy.js isAllowedUrl) if (version === "latest" || !packageName || !version) { return { packageName: undefined, version: undefined }; } @@ -111,6 +124,9 @@ function parsePipPackageFromUrl(url, registry) { if (lastDash > 0 && lastDash < base.length - 1) { packageName = base.slice(0, lastDash); version = base.slice(lastDash + 1); + // Reject "latest" as it's a placeholder, not a real version + // When version is "latest", this signals the URL doesn't contain actual version info + // Returning undefined allows the request (see registryProxy.js isAllowedUrl) if (version === "latest" || !packageName || !version) { return { packageName: undefined, version: undefined }; } diff --git a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js index ee4c6b3..d052e9d 100644 --- a/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js +++ b/packages/safe-chain/src/registryProxy/parsePackageFromUrl.spec.js @@ -1,8 +1,13 @@ -import { describe, it } from "node:test"; +import { describe, it, beforeEach } 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); + }); + const testCases = [ // Regular packages { @@ -114,6 +119,10 @@ describe("parsePackageFromUrl", () => { }); describe("parsePackageFromUrl - pip URLs", () => { + beforeEach(() => { + setEcoSystem(ECOSYSTEM_PY); + }); + const pipTestCases = [ // Valid pip URLs { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js index a1fea55..45fd96a 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js @@ -49,6 +49,32 @@ describe("registryProxy.connectTunnel", () => { socket.destroy(); }); + it("should use destination's real certificate (not safe-chain's self-signed CA)", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + await establishHttpsTunnel(socket, "postman-echo.com", 443); + + // Verifies that tunnel requests pass through the destination's real certificate + // without interception by the safe-chain MITM proxy. + const certInfo = await getTlsCertificateInfo( + socket, + new URL("https://postman-echo.com") + ); + + // Verify the certificate is NOT issued by our safe-chain CA + // Our self-signed CA would have issuer: "Safe-Chain Proxy CA" + assert.ok(certInfo.issuer !== undefined, "Certificate should have an issuer"); + assert.ok( + !certInfo.issuer.includes("Safe-Chain"), + `Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}` + ); + + // Verify it's a real certificate with proper hostname + assert.strictEqual(certInfo.subject.includes("postman-echo.com"), true, + `Certificate subject should include postman-echo.com, got: ${certInfo.subject}`); + + socket.destroy(); + }); + describe("Error Handling", () => { it("should return 502 Bad Gateway for invalid hostname", async () => { const socket = await connectToProxy(proxyHost, proxyPort); @@ -141,7 +167,7 @@ function establishHttpsTunnel(socket, targetHost, targetPort) { }); } -function sendHttpsRequestThroughTunnel(socket, verb, url) { +function sendHttpsRequestThroughTunnel(socket, verb, url, rejectUnauthorized = false) { return new Promise((resolve, reject) => { const tlsSocket = tls.connect( { @@ -149,7 +175,7 @@ function sendHttpsRequestThroughTunnel(socket, verb, url) { servername: url.hostname, // Tests should focus on tunnel behavior, not system CA state; // disable CA verification to avoid flakiness on machines without full roots. - rejectUnauthorized: false, + rejectUnauthorized: rejectUnauthorized, }, () => { tlsSocket.write( @@ -173,3 +199,35 @@ function sendHttpsRequestThroughTunnel(socket, verb, url) { }); }); } + +function getTlsCertificateInfo(socket, url) { + return new Promise((resolve, reject) => { + const tlsSocket = tls.connect( + { + socket: socket, + servername: url.hostname, + // Don't reject unauthorized to avoid system CA issues in CI + // We just want to inspect the certificate + rejectUnauthorized: false, + }, + () => { + const cert = tlsSocket.getPeerCertificate(); + + // Extract issuer and subject information + const issuer = cert.issuer ? + Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(", ") : + "unknown"; + const subject = cert.subject ? + Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(", ") : + "unknown"; + + tlsSocket.end(); + resolve({ issuer, subject }); + } + ); + + tlsSocket.on("error", (err) => { + reject(err); + }); + }); +} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 013c470..f7ef83b 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -5,6 +5,7 @@ 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"; @@ -111,9 +112,18 @@ function handleConnect(req, clientSocket, head) { // CONNECT method is used for HTTPS requests // It establishes a tunnel to the server identified by the request URL - if ((knownJsRegistries.some((reg) => req.url.includes(reg))) - || (knownPipRegistries.some((reg) => req.url.includes(reg)))) { - mitmConnect(req, clientSocket, isAllowedUrl); + const ecosystem = getEcoSystem(); + const url = 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, isAllowedUrl); } else { // For other hosts, just tunnel the request to the destination tcp socket tunnelRequest(req, clientSocket, head); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js index 3b2a20c..130d94c 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js @@ -7,6 +7,7 @@ import { mergeSafeChainProxyEnvironmentVariables, } from "./registryProxy.js"; import { getCaCertPath } from "./certUtils.js"; +import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; import fs from "fs"; describe("registryProxy.mitm", () => { @@ -19,6 +20,8 @@ describe("registryProxy.mitm", () => { const proxyUrl = new URL(envVars.HTTPS_PROXY); proxyHost = proxyUrl.hostname; proxyPort = parseInt(proxyUrl.port, 10); + // Default to JS ecosystem for JS registry tests + setEcoSystem(ECOSYSTEM_JS); }); after(async () => { @@ -151,6 +154,8 @@ describe("registryProxy.mitm", () => { }); it("should intercept HTTPS requests to pypi.org for pip package", async () => { + // Switch to Python ecosystem for pip registry MITM tests + setEcoSystem(ECOSYSTEM_PY); const response = await makeRegistryRequest( proxyHost, proxyPort, @@ -162,6 +167,8 @@ describe("registryProxy.mitm", () => { }); it("should intercept HTTPS requests to files.pythonhosted.org for pip wheel", async () => { + // Ensure Python ecosystem + setEcoSystem(ECOSYSTEM_PY); const response = await makeRegistryRequest( proxyHost, proxyPort, @@ -173,6 +180,8 @@ describe("registryProxy.mitm", () => { }); it("should handle pip package with a1 version", async () => { + // Ensure Python ecosystem + setEcoSystem(ECOSYSTEM_PY); const response = await makeRegistryRequest( proxyHost, proxyPort, @@ -184,6 +193,8 @@ describe("registryProxy.mitm", () => { }); it("should handle pip package with latest version (should not block)", async () => { + // Ensure Python ecosystem + setEcoSystem(ECOSYSTEM_PY); const response = await makeRegistryRequest( proxyHost, proxyPort, diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index b21733a..7b4d6e6 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -7,7 +7,7 @@ import { writeDatabaseToLocalCache, } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; -import { getEcoSystem } from "../config/settings.js"; +import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js"; let cachedMalwareDatabase = null; @@ -18,7 +18,7 @@ let cachedMalwareDatabase = null; */ function normalizePackageName(name) { const ecosystem = getEcoSystem(); - if (ecosystem === "py") { + if (ecosystem === ECOSYSTEM_PY) { return name.toLowerCase().replace(/[-_.]+/g, "-"); } diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 6b1d357..eac75ab 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -46,8 +46,7 @@ function createUnixShims(shimsDir) { const template = fs.readFileSync(templatePath, "utf-8"); - // Create a shim for each tool except pip for now. - // TODO(pip): Enable pip and pip3 CI support + // Create a shim for each tool except pip (CI support not yet implemented) let created = 0; for (const toolInfo of knownAikidoTools) { if (toolInfo.tool === "pip") { @@ -89,8 +88,7 @@ function createWindowsShims(shimsDir) { const template = fs.readFileSync(templatePath, "utf-8"); - // Create a shim for each tool except pip for now. - // TODO(pip): Enable pip and pip3 CI support + // Create a shim for each tool except pip (CI support not yet implemented) let created = 0; for (const toolInfo of knownAikidoTools) { if (toolInfo.tool === "pip") { diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index efc03ca..9b6deaf 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -70,11 +70,9 @@ function npm end function pip - # Default to Python 2 major version when explicitly calling pip - wrapSafeChainCommand "pip" "aikido-pip" --target-version-major "2" $argv + wrapSafeChainCommand "pip" "aikido-pip" $argv end function pip3 - # Route to Python 3 when calling pip3 - wrapSafeChainCommand "pip3" "aikido-pip" --target-version-major "3" $argv + wrapSafeChainCommand "pip3" "aikido-pip3" $argv end diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index a433154..820c34a 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -62,9 +62,9 @@ function npm() { } function pip() { - wrapSafeChainCommand "pip" "aikido-pip" --target-version-major "2" "$@" + wrapSafeChainCommand "pip" "aikido-pip" "$@" } function pip3() { - wrapSafeChainCommand "pip3" "aikido-pip" --target-version-major "3" "$@" + wrapSafeChainCommand "pip3" "aikido-pip3" "$@" } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index 199fccc..47c996d 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -88,13 +88,9 @@ function npm { } function pip { - # Default to Python 2 major version when explicitly calling pip - $forward = @("--target-version-major", "2") + $args - Invoke-WrappedCommand "pip" "aikido-pip" $forward + Invoke-WrappedCommand "pip" "aikido-pip" $args } function pip3 { - # Route to Python 3 when calling pip3 - $forward = @("--target-version-major", "3") + $args - Invoke-WrappedCommand "pip3" "aikido-pip" $forward + Invoke-WrappedCommand "pip3" "aikido-pip3" $args } diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index 524c472..b864da9 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -85,4 +85,5 @@ describe("E2E: pip coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + });