From 6241c56fdaaf2a35fd2025115a3dbab95c98c1e1 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 4 Nov 2025 13:29:31 -0800 Subject: [PATCH 01/28] Skeleton for CI support --- README.md | 19 +++ .../templates/unix-python-wrapper.template.sh | 31 ++++ .../windows-python-wrapper.template.cmd | 39 +++++ .../src/shell-integration/setup-ci.js | 99 +++++++++++-- test/e2e/pip-ci.e2e.spec.js | 139 ++++++++++++++++++ 5 files changed, 316 insertions(+), 11 deletions(-) create mode 100644 packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-python-wrapper.template.sh create mode 100644 packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd create mode 100644 test/e2e/pip-ci.e2e.spec.js diff --git a/README.md b/README.md index acea710..0f69d7f 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,22 @@ This automatically configures your CI environment to use Aikido Safe Chain for a ``` After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. + +### Python (pip/pip3) example + +```yaml +- name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + +- name: Setup safe-chain + run: | + npm i -g @aikidosec/safe-chain + safe-chain setup-ci + +- name: Install Python dependencies + run: | + pip3 install --upgrade pip + pip3 install -r requirements.txt +``` diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-python-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-python-wrapper.template.sh new file mode 100644 index 0000000..c4edf2a --- /dev/null +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-python-wrapper.template.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Generated wrapper for python/python3 by safe-chain +# Intercepts `python[3] -m pip[...]` in CI environments + +# Function to remove shim from PATH (POSIX-compliant) +remove_shim_from_path() { + echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" +} + +# Determine which python variant we were invoked as based on the script name +invoked=$(basename "$0") + +# If invoked as `python -m pip[...]` or `python3 -m pip[...]`, route to aikido +if [ "$1" = "-m" ] && [ -n "$2" ] && echo "$2" | grep -Eq '^pip(3)?$'; then + mod="$2" + shift 2 + if [ "$invoked" = "python3" ] || [ "$mod" = "pip3" ]; then + PATH=$(remove_shim_from_path) exec aikido-pip3 "$@" + else + PATH=$(remove_shim_from_path) exec aikido-pip "$@" + fi +fi + +# Otherwise, find and exec the real python/python3 matching the invoked name +original_cmd=$(PATH=$(remove_shim_from_path) command -v "$invoked") +if [ -n "$original_cmd" ]; then + exec "$original_cmd" "$@" +else + echo "Error: Could not find original $invoked" >&2 + exit 1 +fi diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd new file mode 100644 index 0000000..c9f1eda --- /dev/null +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd @@ -0,0 +1,39 @@ +@echo off +REM Generated wrapper for python/python3 by safe-chain +REM Intercepts `python[3] -m pip[...]` in CI environments + +REM Remove shim directory from PATH to prevent infinite loops +set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" +call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" + +REM Determine invoked name (python or python3) from the script name +set "INVOKED=%~n0" + +REM Check for -m pip or -m pip3 +if "%1"=="-m" ( + if /I "%2"=="pip3" ( + shift + shift + set "PATH=%CLEAN_PATH%" & aikido-pip3 %* + goto :eof + ) + if /I "%2"=="pip" ( + shift + shift + if /I "%INVOKED%"=="python3" ( + set "PATH=%CLEAN_PATH%" & aikido-pip3 %* + ) else ( + set "PATH=%CLEAN_PATH%" & aikido-pip %* + ) + goto :eof + ) +) + +REM Fallback to real python/python3 matching the invoked name +for /f "tokens=*" %%i in ('set "PATH=%CLEAN_PATH%" ^& where %INVOKED% 2^>nul') do ( + "%%i" %* + goto :eof +) + +echo Error: Could not find original %INVOKED% 1>&2 +exit /b 1 diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 64fff16..b061bb6 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -51,13 +51,9 @@ function createUnixShims(shimsDir) { const template = fs.readFileSync(templatePath, "utf-8"); - // Create a shim for each tool except pip (CI support not yet implemented) + // Create a shim for each tool let created = 0; for (const toolInfo of knownAikidoTools) { - if (toolInfo.tool === "pip") { - continue; // Skip pip shims in CI for now - } - const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); @@ -70,11 +66,54 @@ function createUnixShims(shimsDir) { created++; } + // Also create python and python3 shims to support `python[3] -m pip[3]` in CI + createUnixPythonShims(shimsDir); + ui.writeInformation( `Created ${created} Unix shim(s) in ${shimsDir}` ); } +/** + * @param {string} shimsDir + */ +function createUnixPythonShims(shimsDir) { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + + const entries = [ + { + name: "python", + template: path.resolve( + __dirname, + "path-wrappers", + "templates", + "unix-python-wrapper.template.sh" + ), + }, + { + name: "python3", + template: path.resolve( + __dirname, + "path-wrappers", + "templates", + "unix-python-wrapper.template.sh" + ), + }, + ]; + + for (const entry of entries) { + if (!fs.existsSync(entry.template)) { + ui.writeError(`Template file not found: ${entry.template}`); + continue; + } + const shimContent = fs.readFileSync(entry.template, "utf-8"); + const shimPath = `${shimsDir}/${entry.name}`; + fs.writeFileSync(shimPath, shimContent, "utf-8"); + fs.chmodSync(shimPath, 0o755); + } +} + /** * @param {string} shimsDir * @@ -98,27 +137,65 @@ function createWindowsShims(shimsDir) { const template = fs.readFileSync(templatePath, "utf-8"); - // Create a shim for each tool except pip (CI support not yet implemented) + // Create a shim for each tool let created = 0; for (const toolInfo of knownAikidoTools) { - if (toolInfo.tool === "pip") { - continue; // Skip pip shims in CI for now - } - const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); - const shimPath = path.join(shimsDir, `${toolInfo.tool}.cmd`); + const shimPath = `${shimsDir}/${toolInfo.tool}.cmd`; fs.writeFileSync(shimPath, shimContent, "utf-8"); created++; } + // Also create python and python3 shims for Windows to support `python[3] -m pip[3]` in CI + createWindowsPythonShims(shimsDir); + ui.writeInformation( `Created ${created} Windows shim(s) in ${shimsDir}` ); } +/** + * @param {string} shimsDir + */ +function createWindowsPythonShims(shimsDir) { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + + const entries = [ + { + name: "python.cmd", + template: path.resolve( + __dirname, + "path-wrappers", + "templates", + "windows-python-wrapper.template.cmd" + ), + }, + { + name: "python3.cmd", + template: path.resolve( + __dirname, + "path-wrappers", + "templates", + "windows-python-wrapper.template.cmd" + ), + }, + ]; + + for (const entry of entries) { + if (!fs.existsSync(entry.template)) { + ui.writeError(`Windows template file not found: ${entry.template}`); + continue; + } + const shimContent = fs.readFileSync(entry.template, "utf-8"); + const shimPath = `${shimsDir}/${entry.name}`; + fs.writeFileSync(shimPath, shimContent, "utf-8"); + } +} + /** * @param {string} shimsDir * diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js new file mode 100644 index 0000000..7b2bfa0 --- /dev/null +++ b/test/e2e/pip-ci.e2e.spec.js @@ -0,0 +1,139 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: safe-chain setup-ci command for pip/pip3", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + for (let shell of ["bash", "zsh"]) { + it(`safe-chain setup-ci wraps pip3 command with PATH shim after installation for ${shell}`, async () => { + // Setup safe-chain CI shims + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup-ci"); + + // Add $HOME/.safe-chain/shims to PATH for subsequent shells + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + // Use --break-system-packages to avoid Debian/Ubuntu external management restrictions + const result = await projectShell.runCommand( + "pip3 install --break-system-packages certifi" + ); + + const hasExpectedOutput = result.output.includes( + "Scanning for malicious packages..." + ); + assert.ok( + hasExpectedOutput, + hasExpectedOutput + ? "Expected pip3 command to be wrapped by safe-chain" + : `Output did not contain \"Scanning for malicious packages...\": \n${result.output}` + ); + }); + + it(`setup-ci routes python -m pip through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup-ci"); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "python -m pip install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("Scanning for malicious packages..."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + + it(`setup-ci routes python -m pip3 through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup-ci"); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "python -m pip3 install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("Scanning for malicious packages..."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + + it(`setup-ci routes python3 -m pip through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup-ci"); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "python3 -m pip install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("Scanning for malicious packages..."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + + it(`setup-ci routes python3 -m pip3 through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup-ci"); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "python3 -m pip3 install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("Scanning for malicious packages..."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + } +}); From 58a5e837f79b97b91572298fb5178a99bc703e9f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 4 Nov 2025 13:32:07 -0800 Subject: [PATCH 02/28] Add unit tests --- .../src/shell-integration/setup-ci.spec.js | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 0a26124..5471f36 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -146,5 +146,66 @@ describe("Setup CI shell integration", () => { const unixNpmShim = path.join(mockShimsDir, "npm"); assert.ok(!fs.existsSync(unixNpmShim), "Unix npm shim should not exist on Windows"); }); + + it("should create python and python3 shims from unix-python wrapper template", async () => { + // Add unix-python wrapper template to mock templates + const unixPythonTemplatePath = path.join( + mockTemplateDir, + "path-wrappers", + "templates", + "unix-python-wrapper.template.sh" + ); + fs.writeFileSync( + unixPythonTemplatePath, + "#!/bin/bash\n# Python wrapper\nexec aikido-pip \"$@\"\n", + "utf-8" + ); + + await setupCi(); + + // Check if python shim was created + const pythonShimPath = path.join(mockShimsDir, "python"); + assert.ok(fs.existsSync(pythonShimPath), "python shim should exist"); + // Check if python3 shim was created + const python3ShimPath = path.join(mockShimsDir, "python3"); + assert.ok(fs.existsSync(python3ShimPath), "python3 shim should exist"); + // Check content of python shim + const pythonShimContent = fs.readFileSync(pythonShimPath, "utf-8"); + assert.ok(pythonShimContent.includes("Python wrapper"), "python shim should use unix-python wrapper template"); + // Check content of python3 shim + const python3ShimContent = fs.readFileSync(python3ShimPath, "utf-8"); + assert.ok(python3ShimContent.includes("Python wrapper"), "python3 shim should use unix-python wrapper template"); + }); + + it("should create python.cmd and python3.cmd shims from windows-python wrapper template on win32 platform", async () => { + mockPlatform = "win32"; + // Add windows-python wrapper template to mock templates + const windowsPythonTemplatePath = path.join( + mockTemplateDir, + "path-wrappers", + "templates", + "windows-python-wrapper.template.cmd" + ); + fs.writeFileSync( + windowsPythonTemplatePath, + "@echo off\nREM Python wrapper\n{{AIKIDO_COMMAND}} %*\n", + "utf-8" + ); + + await setupCi(); + + // Check if python.cmd shim was created + const pythonCmdShimPath = path.join(mockShimsDir, "python.cmd"); + assert.ok(fs.existsSync(pythonCmdShimPath), "python.cmd shim should exist"); + // Check if python3.cmd shim was created + const python3CmdShimPath = path.join(mockShimsDir, "python3.cmd"); + assert.ok(fs.existsSync(python3CmdShimPath), "python3.cmd shim should exist"); + // Check content of python.cmd shim + const pythonCmdShimContent = fs.readFileSync(pythonCmdShimPath, "utf-8"); + assert.ok(pythonCmdShimContent.includes("Python wrapper"), "python.cmd should use windows-python wrapper template"); + // Check content of python3.cmd shim + const python3CmdShimContent = fs.readFileSync(python3CmdShimPath, "utf-8"); + assert.ok(python3CmdShimContent.includes("Python wrapper"), "python3.cmd should use windows-python wrapper template"); + }); }); }); \ No newline at end of file From 03312cd7077e97de5d18ab9cda7a4273aa9e7fac Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 4 Nov 2025 14:34:26 -0800 Subject: [PATCH 03/28] Clean up logging --- packages/safe-chain/src/scanning/audit/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/safe-chain/src/scanning/audit/index.js b/packages/safe-chain/src/scanning/audit/index.js index 2d215cb..784b95f 100644 --- a/packages/safe-chain/src/scanning/audit/index.js +++ b/packages/safe-chain/src/scanning/audit/index.js @@ -34,8 +34,6 @@ export async function auditChanges(changes) { ); for (const change of changes) { - //Uncomment next line during manual testing - //console.log(" Safe-chain: auditing package:", change); const malwarePackage = malwarePackages.find( (pkg) => pkg.name === change.name && pkg.version === change.version ); From f0a3ae51dba9a6586fd49970c0cd0bde05c9b694 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 08:34:40 -0800 Subject: [PATCH 04/28] Only use mitm for pip packages --- .../pip/createPackageManager.js | 70 +------ .../pip/createPackageManager.spec.js | 14 +- .../commandArgumentScanner.js | 77 -------- .../commandArgumentScanner.spec.js | 144 -------------- .../parsing/parsePackagesFromInstallArgs.js | 179 ------------------ .../parsePackagesFromInstallArgs.spec.js | 110 ----------- .../packagemanager/pip/utils/pipCommands.js | 30 --- .../pip/utils/pipCommands.spec.js | 83 -------- 8 files changed, 9 insertions(+), 698 deletions(-) delete mode 100644 packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js delete mode 100644 packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js index af3036f..cb5484d 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js @@ -1,79 +1,15 @@ -import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; import { runPip } from "./runPipCommand.js"; -import { - getPipCommandForArgs, - pipInstallCommand, - pipDownloadCommand, - pipWheelCommand, -} from "./utils/pipCommands.js"; /** * @param {string} [command] * @returns {import("../currentPackageManager.js").PackageManager} */ export function createPipPackageManager(command = "pip") { - /** - * @param {string[]} args - * @returns {boolean} - */ - function isSupportedCommand(args) { - const scanner = findDependencyScannerForCommand( - commandScannerMapping, - args - ); - return scanner.shouldScan(args); - } - - /** - * @param {string[]} args - * @returns {ReturnType} - */ - function getDependencyUpdatesForCommand(args) { - const scanner = findDependencyScannerForCommand( - commandScannerMapping, - args - ); - return scanner.scan(args); - } - return { runCommand: /** @param {string[]} args */ (args) => runPip(command, args), - isSupportedCommand, - getDependencyUpdatesForCommand, + // For pip, rely solely on MITM proxy to detect/deny downloads from known registries. + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], }; } -/** - * @type {Record} - */ -const commandScannerMapping = { - [pipInstallCommand]: commandArgumentScanner(), - [pipDownloadCommand]: commandArgumentScanner(), // download also fetches packages from PyPI - [pipWheelCommand]: commandArgumentScanner(), // wheel downloads and builds packages - // Other commands return null scanner by default -}; - -/** - * @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} - */ -function nullScanner() { - return { - shouldScan: () => false, - scan: () => [], - }; -} - -/** - * @param {Record} scanners - * @param {string[]} args - * @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner} - */ -function findDependencyScannerForCommand(scanners, args) { - const command = getPipCommandForArgs(args); - if (!command) { - return nullScanner(); - } - - const scanner = scanners[command]; - return scanner || nullScanner(); -} diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js index 2d38b0d..69fc242 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js @@ -19,10 +19,10 @@ test("createPipPackageManager", async (t) => { await t.test("should support install, download, and wheel commands", () => { const pm = createPipPackageManager(); - - assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), true); - assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), true); - assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), true); + // With MITM-only approach, pip does not pre-scan by args + assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), false); + assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), false); }); await t.test("should not support uninstall and info commands", () => { @@ -35,12 +35,10 @@ test("createPipPackageManager", async (t) => { await t.test("should extract packages from install command", () => { const pm = createPipPackageManager(); - + // MITM-only: no dependency extraction from args const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]); assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].name, "requests"); - assert.strictEqual(result[0].version, "2.28.0"); + assert.strictEqual(result.length, 0); }); await t.test("should return empty array for unsupported commands", () => { diff --git a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js deleted file mode 100644 index 27a07c2..0000000 --- a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js +++ /dev/null @@ -1,77 +0,0 @@ -import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js"; -import { hasDryRunArg } from "../utils/pipCommands.js"; - -/** - * @typedef {Object} ScanResult - * @property {string} name - * @property {string} version - * @property {string} type - */ - -/** - * @typedef {Object} ScannerOptions - * @property {boolean} [ignoreDryRun] - */ - -/** - * @typedef {Object} CommandArgumentScanner - * @property {(args: string[]) => Promise | ScanResult[]} scan - * @property {(args: string[]) => boolean} shouldScan - */ - -/** - * @param {ScannerOptions} [options] - * - * @returns {CommandArgumentScanner} - */ -export function commandArgumentScanner(options = {}) { - const { ignoreDryRun = false } = options; - - /** - * @param {string[]} args - */ - function shouldScan(args) { - return shouldScanDependencies(args, ignoreDryRun); - } - - /** - * @param {string[]} args - * @returns {Promise | ScanResult[]} - */ - function scan(args) { - return scanDependencies(args); - } - - return { - shouldScan, - scan, - }; -} - -/** - * @param {string[]} args - * @param {boolean} ignoreDryRun - */ -function shouldScanDependencies(args, ignoreDryRun) { - return ignoreDryRun || !hasDryRunArg(args); -} - -/** - * @param {string[]} args - * @returns {Promise | ScanResult[]} - */ -function scanDependencies(args) { - return checkChangesFromArgs(args); -} - -/** - * @param {string[]} args - * @returns {Promise | ScanResult[]} - */ -export function checkChangesFromArgs(args) { - const packageUpdates = parsePackagesFromInstallArgs(args); - - // Parser already provides exact versions or "latest", no need to resolve - // Just return the packages with type "add" - return packageUpdates; -} diff --git a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js b/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js deleted file mode 100644 index 9570756..0000000 --- a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js +++ /dev/null @@ -1,144 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { commandArgumentScanner, checkChangesFromArgs } from "./commandArgumentScanner.js"; - -test("commandArgumentScanner factory", async (t) => { - await t.test("should create scanner with required interface", () => { - const scanner = commandArgumentScanner(); - - assert.ok(scanner); - assert.strictEqual(typeof scanner.shouldScan, "function"); - assert.strictEqual(typeof scanner.scan, "function"); - }); -}); - -test("shouldScan", async (t) => { - await t.test("should return true for normal install command", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.shouldScan(["install", "requests"]); - assert.strictEqual(result, true); - }); - - await t.test("should return false for install with --dry-run", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.shouldScan(["install", "--dry-run", "requests"]); - assert.strictEqual(result, false); - }); - - await t.test("should return true for install with --dry-run when ignoreDryRun is true", () => { - const scanner = commandArgumentScanner({ ignoreDryRun: true }); - - const result = scanner.shouldScan(["install", "--dry-run", "requests"]); - assert.strictEqual(result, true); - }); -}); - -test("scan", async (t) => { - await t.test("should scan simple package installation", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests"]); - assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "requests", - version: "latest", - type: "add", - }); - }); - - await t.test("should scan package with exact version", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests==2.28.0"]); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - }); - - await t.test("should scan multiple packages", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests==2.28.0", "flask"]); - assert.strictEqual(result.length, 2); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - assert.deepEqual(result[1], { - name: "flask", - version: "latest", - type: "add", - }); - }); - - await t.test("should skip packages with range specifiers", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests>=2.0.0", "flask==2.0.0"]); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "flask", - version: "2.0.0", - type: "add", - }); - }); - - await t.test("should skip flags with parameters", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan([ - "install", - "-r", - "requirements.txt", - "requests==2.28.0", - ]); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - }); - - await t.test("should handle === exact version specifier", () => { - const scanner = commandArgumentScanner(); - - const result = scanner.scan(["install", "requests===2.28.0"]); - assert.strictEqual(result.length, 1); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - }); -}); - -test("checkChangesFromArgs helper", async (t) => { - await t.test("should extract packages from args", () => { - const result = checkChangesFromArgs(["install", "requests==2.28.0", "flask"]); - - assert.strictEqual(result.length, 2); - assert.deepEqual(result[0], { - name: "requests", - version: "2.28.0", - type: "add", - }); - assert.deepEqual(result[1], { - name: "flask", - version: "latest", - type: "add", - }); - }); - - await t.test("should handle empty args", () => { - const result = checkChangesFromArgs([]); - assert.deepStrictEqual(result, []); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js b/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js deleted file mode 100644 index ac3d99f..0000000 --- a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js +++ /dev/null @@ -1,179 +0,0 @@ -/** - * @typedef {Object} PackageDetail - * @property {string} name - * @property {string} version - * @property {string} type - */ - -/** - * @typedef {Object} PipOption - * @property {string} name - * @property {number} numberOfParameters - */ - -/** - * Supported formats that will be returned: - * - package_name (no version) - * - package_name==version (exact version) - * - package_name===version (exact version, PEP 440) - * - * Ranges: Because they don't specify an exact version, the following formats are skipped and we rely on the MITM scanner: - * - package_name>=version - * - package_name<=version - * - package_name>version - * - package_name= 0 ? spec.indexOf("]", extrasStart) : -1; - let base = spec; - if (extrasStart >= 0 && extrasEnd > extrasStart) { - base = spec.slice(0, extrasStart) + spec.slice(extrasEnd + 1); - } - - // Split on first occurrence of a comparator or comma spec - // Support multi-constraint lists like ">=1,<2" by detecting the first comparator - const comparatorRegex = /(===|==|!=|~=|>=|<=|<|>)/; - const m = base.match(comparatorRegex); - if (!m) { - // No comparator => just a name, use "latest" as version - return { name: base, version: "latest" }; - } - - const idx = m.index; - const name = base.slice(0, idx); - const versionPart = base.slice(idx); // e.g. '==2.28.0' or '>=1,<2' - - // Normalize whitespace inside versionPart - const versionWithOperator = versionPart.replace(/\s+/g, ""); - - // Only return packages with exact version specifiers (== or ===) - // Skip range specifiers (<, >, <=, >=, ~=, !=) since they don't provide a specific version - if (!versionWithOperator.startsWith("==")) { - return null; - } - - // Strip the == or === operator to get just the version number - const version = versionWithOperator.replace(/^===?/, ""); - - return { name, version }; -} diff --git a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js b/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js deleted file mode 100644 index 8a653c9..0000000 --- a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { parsePackagesFromInstallArgs } from "./parsePackagesFromInstallArgs.js"; - -describe("parsePackagesFromInstallArgs", () => { - it("should parse simple package name", () => { - const result = parsePackagesFromInstallArgs(["install", "requests"]); - assert.deepEqual(result, [ - { name: "requests", version: "latest", type: "add" }, - ]); - }); - - it("should parse package with version specifier", () => { - const result = parsePackagesFromInstallArgs(["install", "requests==2.28.0"]); - assert.deepEqual(result, [ - { name: "requests", version: "2.28.0", type: "add" }, - ]); - }); - - it("should skip flags", () => { - const result = parsePackagesFromInstallArgs(["install", "--upgrade", "requests"]); - assert.deepEqual(result, [ - { name: "requests", version: "latest", type: "add" }, - ]); - }); - - it("should parse multiple packages", () => { - const result = parsePackagesFromInstallArgs(["install", "requests", "flask", "django==4.0"]); - assert.deepEqual(result, [ - { name: "requests", version: "latest", type: "add" }, - { name: "flask", version: "latest", type: "add" }, - { name: "django", version: "4.0", type: "add" }, - ]); - }); - - it("should parse extras and strip them from name", () => { - const result = parsePackagesFromInstallArgs(["install", "django[postgres]==4.2.1"]); - assert.deepEqual(result, [ - { name: "django", version: "4.2.1", type: "add" }, - ]); - }); - - it("should skip ranges", () => { - const result = parsePackagesFromInstallArgs(["install", "requests>=2,<3"]); - assert.deepEqual(result, []); - }); - - it("should skip packages with range specifiers", () => { - const result = parsePackagesFromInstallArgs([ - "install", - "requests>=2.0.0", - "flask>1.0", - "django<=4.0", - "numpy~=1.20", - "scipy!=1.5.0", - "pandas==1.3.0", - ]); - // Only pandas with exact version (==) should be returned - assert.deepEqual(result, [ - { name: "pandas", version: "1.3.0", type: "add" }, - ]); - }); - - it("should support === exact version specifier", () => { - const result = parsePackagesFromInstallArgs(["install", "requests===2.28.0"]); - assert.deepEqual(result, [ - { name: "requests", version: "2.28.0", type: "add" }, - ]); - }); - - it("should skip VCS/URL/path)", () => { - const result = parsePackagesFromInstallArgs([ - "install", - "git+https://github.com/pallets/flask.git", - "https://files.pythonhosted.org/packages/foo/bar.whl", - "file:/tmp/pkg.whl", - "./localpkg", - ]); - assert.deepEqual(result, []); - }); - - it("should return empty array for no packages", () => { - const result = parsePackagesFromInstallArgs(["install", "--help"]); - assert.deepEqual(result, []); - }); - - it("should skip all flags with parameters", () => { - const result = parsePackagesFromInstallArgs([ - "install", - "--target", - "/tmp/target", - "--platform", - "linux", - "--python-version", - "3.9", - "--index-url", - "https://pypi.org/simple", - "--trusted-host", - "pypi.org", - "requests==2.28.0", - "--cache-dir", - "/tmp/cache", - "flask", - ]); - assert.deepEqual(result, [ - { name: "requests", version: "2.28.0", type: "add" }, - { name: "flask", version: "latest", type: "add" }, - ]); - }); -}); diff --git a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js b/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js deleted file mode 100644 index 92699ac..0000000 --- a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js +++ /dev/null @@ -1,30 +0,0 @@ -export const pipInstallCommand = "install"; -export const pipDownloadCommand = "download"; -export const pipWheelCommand = "wheel"; - -/** - * @param {string[]} args - * @returns {string | null} - */ -export function getPipCommandForArgs(args) { - if (!args || args.length === 0) { - return null; - } - - // The first non-flag argument is the command - for (const arg of args) { - if (!arg.startsWith("-")) { - return arg; - } - } - - return null; -} - -/** - * @param {string[]} args - * @returns {boolean} - */ -export function hasDryRunArg(args) { - return args.some((arg) => arg === "--dry-run"); -} diff --git a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js b/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js deleted file mode 100644 index 346ad8f..0000000 --- a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js +++ /dev/null @@ -1,83 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import { - getPipCommandForArgs, - hasDryRunArg, - pipInstallCommand, - pipDownloadCommand, - pipWheelCommand, -} from "./pipCommands.js"; - -test("getPipCommandForArgs", async (t) => { - await t.test("should return null for empty args", () => { - assert.strictEqual(getPipCommandForArgs([]), null); - }); - - await t.test("should return null for null args", () => { - assert.strictEqual(getPipCommandForArgs(null), null); - }); - - await t.test("should return the first non-flag argument", () => { - assert.strictEqual(getPipCommandForArgs(["install"]), "install"); - }); - - await t.test("should skip flags and return command", () => { - assert.strictEqual( - getPipCommandForArgs(["-v", "--verbose", "install"]), - "install" - ); - }); - - await t.test("should return install command", () => { - assert.strictEqual( - getPipCommandForArgs(["install", "requests"]), - "install" - ); - }); - - await t.test("should return uninstall command", () => { - assert.strictEqual( - getPipCommandForArgs(["uninstall", "requests"]), - "uninstall" - ); - }); - - await t.test("should return null if only flags", () => { - assert.strictEqual(getPipCommandForArgs(["--version", "-v"]), null); - }); -}); - -test("hasDryRunArg", async (t) => { - await t.test("should return false for empty args", () => { - assert.strictEqual(hasDryRunArg([]), false); - }); - - await t.test("should return true if --dry-run is present", () => { - assert.strictEqual(hasDryRunArg(["install", "--dry-run", "requests"]), true); - }); - - await t.test("should return false if --dry-run is not present", () => { - assert.strictEqual(hasDryRunArg(["install", "requests"]), false); - }); - - await t.test("should return true for --dry-run with other flags", () => { - assert.strictEqual( - hasDryRunArg(["install", "-v", "--dry-run", "--upgrade", "requests"]), - true - ); - }); -}); - -test("command constants", async (t) => { - await t.test("should have correct install command", () => { - assert.strictEqual(pipInstallCommand, "install"); - }); - - await t.test("should have correct download command", () => { - assert.strictEqual(pipDownloadCommand, "download"); - }); - - await t.test("should have correct wheel command", () => { - assert.strictEqual(pipWheelCommand, "wheel"); - }); -}); From 87606def48cd00749e27a710fbd54cad31379c8e Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 09:18:18 -0800 Subject: [PATCH 05/28] Fix comments --- .../src/packagemanager/pip/createPackageManager.spec.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js index 69fc242..d2668c0 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js @@ -19,7 +19,7 @@ test("createPipPackageManager", async (t) => { await t.test("should support install, download, and wheel commands", () => { const pm = createPipPackageManager(); - // With MITM-only approach, pip does not pre-scan by args + // MITM-only approach, pip does not scan args assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), false); assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), false); assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), false); @@ -35,7 +35,6 @@ test("createPipPackageManager", async (t) => { await t.test("should extract packages from install command", () => { const pm = createPipPackageManager(); - // MITM-only: no dependency extraction from args const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]); assert.ok(Array.isArray(result)); assert.strictEqual(result.length, 0); From bded1fe6607dcac5a1b0c8fe272cdef7fb1375a9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 09:28:57 -0800 Subject: [PATCH 06/28] Fix test --- test/e2e/pip.e2e.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index adabe9f..5d046a7 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -96,7 +96,7 @@ describe("E2E: pip coverage", () => { it(`python3 -m pip install routes through safe-chain`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("python3 -m pip install requests"); + const result = await shell.runCommand("python3 -m pip install --break-system-packages requests"); assert.ok( result.output.includes("no malware found."), From 3b56a0181f43cb0dd3d5096bc8ed38ba11de14d4 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 09:55:09 -0800 Subject: [PATCH 07/28] Update comment --- packages/safe-chain/bin/safe-chain.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index b416f43..30f4086 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -54,7 +54,7 @@ function writeHelp() { ui.writeInformation( `- ${chalk.cyan( "safe-chain setup" - )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx and pip.` + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.` ); ui.writeInformation( `- ${chalk.cyan( From 216e16cfb1406b8fdeffbfeb0a5edb4448ca68b8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 11:13:24 -0800 Subject: [PATCH 08/28] Fix e2e test --- test/e2e/pip-ci.e2e.spec.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index 7b2bfa0..a32a677 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -42,13 +42,13 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); const hasExpectedOutput = result.output.includes( - "Scanning for malicious packages..." + "no malware found." ); assert.ok( hasExpectedOutput, hasExpectedOutput ? "Expected pip3 command to be wrapped by safe-chain" - : `Output did not contain \"Scanning for malicious packages...\": \n${result.output}` + : `Output did not contain \"no malware found.\": \n${result.output}` ); }); @@ -68,7 +68,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); assert.ok( - result.output.includes("Scanning for malicious packages..."), + result.output.includes("no malware found."), `Output did not contain scan message. Output was:\n${result.output}` ); }); @@ -89,7 +89,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); assert.ok( - result.output.includes("Scanning for malicious packages..."), + result.output.includes("no malware found."), `Output did not contain scan message. Output was:\n${result.output}` ); }); @@ -110,7 +110,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); assert.ok( - result.output.includes("Scanning for malicious packages..."), + result.output.includes("no malware found."), `Output did not contain scan message. Output was:\n${result.output}` ); }); @@ -131,7 +131,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); assert.ok( - result.output.includes("Scanning for malicious packages..."), + result.output.includes("no malware found."), `Output did not contain scan message. Output was:\n${result.output}` ); }); From ec4228edc148676a5a4fa14f16e897515c0d0bbc Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 11:23:37 -0800 Subject: [PATCH 09/28] Add more test cases --- test/e2e/pip-ci.e2e.spec.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index a32a677..bcca90a 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -21,6 +21,22 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { } }); + describe("E2E: pip CI support", () => { + it("does not intercept python3 --version", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python3 --version"); + assert.ok(result.output.match(/Python \d+\.\d+\.\d+/), `Output was: ${result.output}`); + assert.ok(!result.output.includes("Safe-chain"), "Safe Chain should not intercept generic python3 command"); + }); + + it("does not intercept python3 -c 'print(\"hello\")'", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("python3 -c 'print(\"hello\")'"); + assert.ok(result.output.includes("hello"), `Output was: ${result.output}`); + assert.ok(!result.output.includes("Safe-chain"), "Safe Chain should not intercept generic python3 -c command"); + }); + }); + for (let shell of ["bash", "zsh"]) { it(`safe-chain setup-ci wraps pip3 command with PATH shim after installation for ${shell}`, async () => { // Setup safe-chain CI shims From 7cff2818e421f61069d7a86638092a85dc7c153e Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 15:40:54 -0800 Subject: [PATCH 10/28] Fix Windows template --- .../windows-python-wrapper.template.cmd | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd index c9f1eda..5b4ddd9 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd @@ -9,27 +9,32 @@ call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Determine invoked name (python or python3) from the script name set "INVOKED=%~n0" -REM Check for -m pip or -m pip3 -if "%1"=="-m" ( - if /I "%2"=="pip3" ( - shift - shift - set "PATH=%CLEAN_PATH%" & aikido-pip3 %* - goto :eof - ) - if /I "%2"=="pip" ( - shift - shift - if /I "%INVOKED%"=="python3" ( - set "PATH=%CLEAN_PATH%" & aikido-pip3 %* - ) else ( - set "PATH=%CLEAN_PATH%" & aikido-pip %* - ) - goto :eof - ) +REM Check for -m pip or -m pip3 without parentheses to avoid parser issues +if /I "%1" NEQ "-m" goto FALLBACK + +set "SECOND=%2" +if /I "%SECOND%"=="pip3" goto CALL_PIP3 +if /I "%SECOND%"=="pip" goto CALL_PIP +goto FALLBACK + +:CALL_PIP3 +shift +shift +set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 +goto :eof + +:CALL_PIP +shift +shift +if /I "%INVOKED%"=="python3" ( + set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 +) else ( + set "PATH=%CLEAN_PATH%" & aikido-pip %1 %2 %3 %4 %5 %6 %7 %8 %9 ) +goto :eof REM Fallback to real python/python3 matching the invoked name +:FALLBACK for /f "tokens=*" %%i in ('set "PATH=%CLEAN_PATH%" ^& where %INVOKED% 2^>nul') do ( "%%i" %* goto :eof From fa4c46c23dd2874e1540365ff3c9f5aa83144f05 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 15:47:41 -0800 Subject: [PATCH 11/28] Cleanup readme --- README.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/README.md b/README.md index 0f69d7f..acea710 100644 --- a/README.md +++ b/README.md @@ -165,22 +165,3 @@ This automatically configures your CI environment to use Aikido Safe Chain for a ``` After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. - -### Python (pip/pip3) example - -```yaml -- name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - -- name: Setup safe-chain - run: | - npm i -g @aikidosec/safe-chain - safe-chain setup-ci - -- name: Install Python dependencies - run: | - pip3 install --upgrade pip - pip3 install -r requirements.txt -``` From 84cf485b31541f054ac5159cc9d7af1b072bea95 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 16:24:57 -0800 Subject: [PATCH 12/28] Add comment explaining forwarding --- .../templates/windows-python-wrapper.template.cmd | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd index 5b4ddd9..2974ee5 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd @@ -20,12 +20,18 @@ goto FALLBACK :CALL_PIP3 shift shift -set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 +REM Note on argument forwarding: +REM - We cannot use %* here because SHIFT does not update %* (it still contains the original argv). +REM - CMD only exposes nine positional parameters at a time: %1 .. %9. %10 is parsed as %1 followed by '0'. +\set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 goto :eof :CALL_PIP shift shift +REM Note on argument forwarding: +REM - We cannot use %* here because SHIFT does not update %* (it still contains the original argv). +REM - CMD only exposes nine positional parameters at a time: %1 .. %9. %10 is parsed as %1 followed by '0'. if /I "%INVOKED%"=="python3" ( set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 ) else ( From 0a3028329f83e718d7590a51b15cd0d7892c3cc9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 16:32:57 -0800 Subject: [PATCH 13/28] Fix template --- .../path-wrappers/templates/windows-python-wrapper.template.cmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd index 2974ee5..abeee5f 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd @@ -23,7 +23,7 @@ shift REM Note on argument forwarding: REM - We cannot use %* here because SHIFT does not update %* (it still contains the original argv). REM - CMD only exposes nine positional parameters at a time: %1 .. %9. %10 is parsed as %1 followed by '0'. -\set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 +set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 goto :eof :CALL_PIP From f400c5576a66c7e3840138407461c0aa9ccdb65f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 08:32:25 -0800 Subject: [PATCH 14/28] WIP --- package-lock.json | 2 + packages/safe-chain/bin/aikido-python.js | 22 +++++ packages/safe-chain/bin/aikido-python3.js | 22 +++++ packages/safe-chain/package.json | 2 + .../src/shell-integration/helpers.js | 2 + .../templates/unix-python-wrapper.template.sh | 31 ------- .../templates/unix-wrapper.template.sh | 4 +- .../windows-python-wrapper.template.cmd | 44 ---------- .../src/shell-integration/setup-ci.js | 83 ------------------- .../startup-scripts/init-fish.fish | 20 +---- .../startup-scripts/init-posix.sh | 20 +---- .../startup-scripts/init-pwsh.ps1 | 20 +---- 12 files changed, 59 insertions(+), 213 deletions(-) create mode 100644 packages/safe-chain/bin/aikido-python.js create mode 100644 packages/safe-chain/bin/aikido-python3.js delete mode 100644 packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-python-wrapper.template.sh delete mode 100644 packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd diff --git a/package-lock.json b/package-lock.json index ee38fa8..a9c32df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2096,6 +2096,8 @@ "aikido-npx": "bin/aikido-npx.js", "aikido-pip": "bin/aikido-pip.js", "aikido-pip3": "bin/aikido-pip3.js", + "aikido-python": "bin/aikido-python.js", + "aikido-python3": "bin/aikido-python3.js", "aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpx": "bin/aikido-pnpx.js", "aikido-yarn": "bin/aikido-yarn.js", diff --git a/packages/safe-chain/bin/aikido-python.js b/packages/safe-chain/bin/aikido-python.js new file mode 100644 index 0000000..c22c601 --- /dev/null +++ b/packages/safe-chain/bin/aikido-python.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + + +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; +import { main } from "../src/main.js"; + +const argv = process.argv.slice(2); + +const supportedArgs = ["pip", "pip3"]; + +if (argv[0] === "-m" && argv[1] && supportedArgs.includes(argv[1])) { + setEcoSystem(ECOSYSTEM_PY); + + initializePackageManager(argv[1]); + var exitCode = await main(argv.slice(2)); + process.exit(exitCode); +} else { + // Fallback: run the real python + const { spawn } = await import("child_process"); + spawn("python", argv, { stdio: "inherit" }); +} diff --git a/packages/safe-chain/bin/aikido-python3.js b/packages/safe-chain/bin/aikido-python3.js new file mode 100644 index 0000000..48659e5 --- /dev/null +++ b/packages/safe-chain/bin/aikido-python3.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + + +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; +import { main } from "../src/main.js"; + +const argv = process.argv.slice(2); + +const supportedArgs = ["pip", "pip3"]; + +if (argv[0] === "-m" && argv[1] && supportedArgs.includes(argv[1])) { + setEcoSystem(ECOSYSTEM_PY); + // python3 -m pip or python3 -m pip3: always use pip3 package manager + initializePackageManager("pip3"); + var exitCode = await main(argv.slice(2)); + process.exit(exitCode); +} else { + // Fallback: run the real python3 + const { spawn } = await import("child_process"); + spawn("python3", argv, { stdio: "inherit" }); +} diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d93a058..f21a372 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -17,6 +17,8 @@ "aikido-bunx": "bin/aikido-bunx.js", "aikido-pip": "bin/aikido-pip.js", "aikido-pip3": "bin/aikido-pip3.js", + "aikido-python": "bin/aikido-python.js", + "aikido-python3": "bin/aikido-python3.js", "safe-chain": "bin/safe-chain.js" }, "type": "module", diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 4ba7c24..c405c54 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -22,6 +22,8 @@ export const knownAikidoTools = [ { tool: "bunx", aikidoCommand: "aikido-bunx" }, { tool: "pip", aikidoCommand: "aikido-pip" }, { tool: "pip3", aikidoCommand: "aikido-pip3" }, + { tool: "python", aikidoCommand: "aikido-python" }, + { tool: "python3", aikidoCommand: "aikido-python3" }, // When adding a new tool here, also update the documentation for the new tool in the README.md ]; diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-python-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-python-wrapper.template.sh deleted file mode 100644 index c4edf2a..0000000 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-python-wrapper.template.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# Generated wrapper for python/python3 by safe-chain -# Intercepts `python[3] -m pip[...]` in CI environments - -# Function to remove shim from PATH (POSIX-compliant) -remove_shim_from_path() { - echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" -} - -# Determine which python variant we were invoked as based on the script name -invoked=$(basename "$0") - -# If invoked as `python -m pip[...]` or `python3 -m pip[...]`, route to aikido -if [ "$1" = "-m" ] && [ -n "$2" ] && echo "$2" | grep -Eq '^pip(3)?$'; then - mod="$2" - shift 2 - if [ "$invoked" = "python3" ] || [ "$mod" = "pip3" ]; then - PATH=$(remove_shim_from_path) exec aikido-pip3 "$@" - else - PATH=$(remove_shim_from_path) exec aikido-pip "$@" - fi -fi - -# Otherwise, find and exec the real python/python3 matching the invoked name -original_cmd=$(PATH=$(remove_shim_from_path) command -v "$invoked") -if [ -n "$original_cmd" ]; then - exec "$original_cmd" "$@" -else - echo "Error: Could not find original $invoked" >&2 - exit 1 -fi diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 6e6d826..7663006 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -7,6 +7,8 @@ remove_shim_from_path() { echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" } +echo "[safe-chain debug] command -v {{AIKIDO_COMMAND}} (raw PATH): $(command -v {{AIKIDO_COMMAND}} 2>/dev/null || echo notfound)" >&2 +echo "[safe-chain debug] PATH (raw): $PATH" >&2 if command -v {{AIKIDO_COMMAND}} >/dev/null 2>&1; then # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops PATH=$(remove_shim_from_path) exec {{AIKIDO_COMMAND}} "$@" @@ -19,4 +21,4 @@ else echo "Error: Could not find original {{PACKAGE_MANAGER}}" >&2 exit 1 fi -fi \ No newline at end of file +fi diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd deleted file mode 100644 index 5b4ddd9..0000000 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-python-wrapper.template.cmd +++ /dev/null @@ -1,44 +0,0 @@ -@echo off -REM Generated wrapper for python/python3 by safe-chain -REM Intercepts `python[3] -m pip[...]` in CI environments - -REM Remove shim directory from PATH to prevent infinite loops -set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" -call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" - -REM Determine invoked name (python or python3) from the script name -set "INVOKED=%~n0" - -REM Check for -m pip or -m pip3 without parentheses to avoid parser issues -if /I "%1" NEQ "-m" goto FALLBACK - -set "SECOND=%2" -if /I "%SECOND%"=="pip3" goto CALL_PIP3 -if /I "%SECOND%"=="pip" goto CALL_PIP -goto FALLBACK - -:CALL_PIP3 -shift -shift -set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 -goto :eof - -:CALL_PIP -shift -shift -if /I "%INVOKED%"=="python3" ( - set "PATH=%CLEAN_PATH%" & aikido-pip3 %1 %2 %3 %4 %5 %6 %7 %8 %9 -) else ( - set "PATH=%CLEAN_PATH%" & aikido-pip %1 %2 %3 %4 %5 %6 %7 %8 %9 -) -goto :eof - -REM Fallback to real python/python3 matching the invoked name -:FALLBACK -for /f "tokens=*" %%i in ('set "PATH=%CLEAN_PATH%" ^& where %INVOKED% 2^>nul') do ( - "%%i" %* - goto :eof -) - -echo Error: Could not find original %INVOKED% 1>&2 -exit /b 1 diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index b061bb6..926386d 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -66,53 +66,12 @@ function createUnixShims(shimsDir) { created++; } - // Also create python and python3 shims to support `python[3] -m pip[3]` in CI - createUnixPythonShims(shimsDir); - ui.writeInformation( `Created ${created} Unix shim(s) in ${shimsDir}` ); } -/** - * @param {string} shimsDir - */ -function createUnixPythonShims(shimsDir) { - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - const entries = [ - { - name: "python", - template: path.resolve( - __dirname, - "path-wrappers", - "templates", - "unix-python-wrapper.template.sh" - ), - }, - { - name: "python3", - template: path.resolve( - __dirname, - "path-wrappers", - "templates", - "unix-python-wrapper.template.sh" - ), - }, - ]; - - for (const entry of entries) { - if (!fs.existsSync(entry.template)) { - ui.writeError(`Template file not found: ${entry.template}`); - continue; - } - const shimContent = fs.readFileSync(entry.template, "utf-8"); - const shimPath = `${shimsDir}/${entry.name}`; - fs.writeFileSync(shimPath, shimContent, "utf-8"); - fs.chmodSync(shimPath, 0o755); - } -} /** * @param {string} shimsDir @@ -149,53 +108,11 @@ function createWindowsShims(shimsDir) { created++; } - // Also create python and python3 shims for Windows to support `python[3] -m pip[3]` in CI - createWindowsPythonShims(shimsDir); - ui.writeInformation( `Created ${created} Windows shim(s) in ${shimsDir}` ); } -/** - * @param {string} shimsDir - */ -function createWindowsPythonShims(shimsDir) { - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - - const entries = [ - { - name: "python.cmd", - template: path.resolve( - __dirname, - "path-wrappers", - "templates", - "windows-python-wrapper.template.cmd" - ), - }, - { - name: "python3.cmd", - template: path.resolve( - __dirname, - "path-wrappers", - "templates", - "windows-python-wrapper.template.cmd" - ), - }, - ]; - - for (const entry of entries) { - if (!fs.existsSync(entry.template)) { - ui.writeError(`Windows template file not found: ${entry.template}`); - continue; - } - const shimContent = fs.readFileSync(entry.template, "utf-8"); - const shimPath = `${shimsDir}/${entry.name}`; - fs.writeFileSync(shimPath, shimContent, "utf-8"); - } -} - /** * @param {string} shimsDir * 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 13494d1..ebf89ff 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 @@ -79,26 +79,10 @@ end # `python -m pip`, `python -m pip3`. function python - if test (count $argv) -ge 2; and test $argv[1] = "-m"; and string match -qr '^pip(3)?$' -- $argv[2] - set mod $argv[2] - set args $argv[3..-1] - if test $mod = "pip3" - wrapSafeChainCommand "pip3" "aikido-pip3" $args - else - wrapSafeChainCommand "pip" "aikido-pip" $args - end - else - command python $argv - end + wrapSafeChainCommand "python" "aikido-python" $argv end # `python3 -m pip`, `python3 -m pip3'. function python3 - if test (count $argv) -ge 2; and test $argv[1] = "-m"; and string match -qr '^pip(3)?$' -- $argv[2] - set args $argv[3..-1] - # python3 always uses pip3, regardless of whether user types `pip` or `pip3` - wrapSafeChainCommand "pip3" "aikido-pip3" $args - else - command python3 $argv - end + wrapSafeChainCommand "python3" "aikido-python3" $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 05b8b81..278b31a 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 @@ -71,26 +71,10 @@ function pip3() { # `python -m pip`, `python -m pip3`. function python() { - if [[ "$1" == "-m" && "$2" == pip* ]]; then - local mod="$2" - shift 2 - if [[ "$mod" == "pip3" ]]; then - wrapSafeChainCommand "pip3" "aikido-pip3" "$@" - else - wrapSafeChainCommand "pip" "aikido-pip" "$@" - fi - else - command python "$@" - fi + wrapSafeChainCommand "python" "aikido-python" "$@" } # `python3 -m pip`, `python3 -m pip3'. function python3() { - if [[ "$1" == "-m" && "$2" == pip* ]]; then - shift 2 - # python3 always uses pip3, regardless of whether user types `pip` or `pip3` - wrapSafeChainCommand "pip3" "aikido-pip3" "$@" - else - command python3 "$@" - fi + wrapSafeChainCommand "python3" "aikido-python3" "$@" } 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 6425f2f..b692107 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 @@ -97,27 +97,11 @@ function pip3 { # `python -m pip`, `python -m pip3`. function python { - param([Parameter(ValueFromRemainingArguments=$true)]$Args) - if ($Args.Length -ge 2 -and $Args[0] -eq '-m' -and $Args[1] -match '^pip(3)?$') { - $pipArgs = if ($Args.Length -gt 2) { $Args | Select-Object -Skip 2 } else { @() } - if ($Args[1] -eq 'pip3') { Invoke-WrappedCommand 'pip3' 'aikido-pip3' $pipArgs } - else { Invoke-WrappedCommand 'pip' 'aikido-pip' $pipArgs } - } - else { - Invoke-RealCommand 'python' $Args - } + Invoke-WrappedCommand 'python' 'aikido-python' $args } # `python3 -m pip`, `python3 -m pip3'. function python3 { - param([Parameter(ValueFromRemainingArguments=$true)]$Args) - if ($Args.Length -ge 2 -and $Args[0] -eq '-m' -and $Args[1] -match '^pip(3)?$') { - # python3 always uses pip3, regardless of whether user types `pip` or `pip3` - $pipArgs = if ($Args.Length -gt 2) { $Args | Select-Object -Skip 2 } else { @() } - Invoke-WrappedCommand 'pip3' 'aikido-pip3' $pipArgs - } - else { - Invoke-RealCommand 'python3' $Args - } + Invoke-WrappedCommand 'python3' 'aikido-python3' $args } From 28d24bb6eaabd66253d159a111a75da3ec0d3b3b Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 10:26:26 -0800 Subject: [PATCH 15/28] Another iteration --- packages/safe-chain/bin/aikido-pip.js | 16 ++--- packages/safe-chain/bin/aikido-pip3.js | 18 +++--- packages/safe-chain/bin/aikido-python.js | 25 ++++---- packages/safe-chain/bin/aikido-python3.js | 27 ++++---- .../packagemanager/currentPackageManager.js | 4 +- .../pip/createPackageManager.js | 15 +++-- .../src/packagemanager/pip/pipSettings.js | 31 ++++++++++ .../src/packagemanager/pip/runPipCommand.js | 2 +- .../src/registryProxy/registryProxy.js | 4 +- test/e2e/Dockerfile | 1 + test/e2e/pip-ci.e2e.spec.js | 62 ++++++++++++------- test/e2e/pip.e2e.spec.js | 36 ----------- 12 files changed, 134 insertions(+), 107 deletions(-) create mode 100644 packages/safe-chain/src/packagemanager/pip/pipSettings.js diff --git a/packages/safe-chain/bin/aikido-pip.js b/packages/safe-chain/bin/aikido-pip.js index 92ba4e3..59951ed 100755 --- a/packages/safe-chain/bin/aikido-pip.js +++ b/packages/safe-chain/bin/aikido-pip.js @@ -1,19 +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"; - -// Defaults -let packageManagerName = "pip"; -// Pass through user args as-is -const argv = process.argv.slice(2); +import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js"; // Set eco system -// This can be used in other parts of the code to determine which eco system we are working with setEcoSystem(ECOSYSTEM_PY); -initializePackageManager(packageManagerName); -var exitCode = await main(argv); +// Set current invocation +setCurrentPipInvocation(PIP_INVOCATIONS.PIP); +initializePackageManager(PIP_PACKAGE_MANAGER); + +// Pass through only user-supplied pip args +var exitCode = await main(process.argv.slice(2)); process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-pip3.js b/packages/safe-chain/bin/aikido-pip3.js index e24fda4..e388383 100755 --- a/packages/safe-chain/bin/aikido-pip3.js +++ b/packages/safe-chain/bin/aikido-pip3.js @@ -3,17 +3,17 @@ import { main } from "../src/main.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; +import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js"; -// Explicit pip3 entrypoint -const packageManagerName = "pip3"; - -// Copy argv as-is -const argv = process.argv.slice(2); - -// Set ecosystem to Python +// Set eco system setEcoSystem(ECOSYSTEM_PY); -initializePackageManager(packageManagerName); -var exitCode = await main(argv); +// Set current invocation +setCurrentPipInvocation(PIP_INVOCATIONS.PIP3); +// Create package manager +initializePackageManager(PIP_PACKAGE_MANAGER); + +// Pass through only user-supplied pip args +var exitCode = await main(process.argv.slice(2)); process.exit(exitCode); diff --git a/packages/safe-chain/bin/aikido-python.js b/packages/safe-chain/bin/aikido-python.js index c22c601..fba6b70 100644 --- a/packages/safe-chain/bin/aikido-python.js +++ b/packages/safe-chain/bin/aikido-python.js @@ -1,22 +1,25 @@ #!/usr/bin/env node - import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js"; import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; import { main } from "../src/main.js"; -const argv = process.argv.slice(2); +// Set eco system +setEcoSystem(ECOSYSTEM_PY); -const supportedArgs = ["pip", "pip3"]; -if (argv[0] === "-m" && argv[1] && supportedArgs.includes(argv[1])) { +// Strip '-m pip' or '-m pip3' from args if present +let argv = process.argv.slice(2); +if (argv[0] === '-m' && argv[1] === 'pip') { setEcoSystem(ECOSYSTEM_PY); - - initializePackageManager(argv[1]); - var exitCode = await main(argv.slice(2)); - process.exit(exitCode); + setCurrentPipInvocation(PIP_INVOCATIONS.PY_PIP); + initializePackageManager(PIP_PACKAGE_MANAGER); + argv = argv.slice(2); + var exitCode = await main(argv); + process.exit(exitCode); } else { - // Fallback: run the real python - const { spawn } = await import("child_process"); - spawn("python", argv, { stdio: "inherit" }); + // Forward to real python binary for non-pip flows + const { spawn } = await import('child_process'); + spawn('python', argv, { stdio: 'inherit' }); } diff --git a/packages/safe-chain/bin/aikido-python3.js b/packages/safe-chain/bin/aikido-python3.js index 48659e5..c74a3f3 100644 --- a/packages/safe-chain/bin/aikido-python3.js +++ b/packages/safe-chain/bin/aikido-python3.js @@ -1,22 +1,25 @@ #!/usr/bin/env node - import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js"; import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; import { main } from "../src/main.js"; -const argv = process.argv.slice(2); +// Set eco system +setEcoSystem(ECOSYSTEM_PY); -const supportedArgs = ["pip", "pip3"]; - -if (argv[0] === "-m" && argv[1] && supportedArgs.includes(argv[1])) { +// Strip nodejs and wrapper script from args +let argv = process.argv.slice(2); +if (argv[0] === '-m' && argv[1] === 'pip') { setEcoSystem(ECOSYSTEM_PY); - // python3 -m pip or python3 -m pip3: always use pip3 package manager - initializePackageManager("pip3"); - var exitCode = await main(argv.slice(2)); - process.exit(exitCode); + setCurrentPipInvocation(PIP_INVOCATIONS.PY3_PIP); + initializePackageManager(PIP_PACKAGE_MANAGER); + // Strip '-m pip' or '-m pip3' from args if present + argv = argv.slice(2); + var exitCode = await main(argv); + process.exit(exitCode); } else { - // Fallback: run the real python3 - const { spawn } = await import("child_process"); - spawn("python3", argv, { stdio: "inherit" }); + // Forward to real python3 binary for non-pip flows + const { spawn } = await import('child_process'); + spawn('python3', argv, { stdio: 'inherit' }); } diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index 42cb93e..2db4167 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -52,8 +52,8 @@ export function initializePackageManager(packageManagerName) { state.packageManagerName = createBunPackageManager(); } else if (packageManagerName === "bunx") { state.packageManagerName = createBunxPackageManager(); - } else if (packageManagerName === "pip" || packageManagerName === "pip3") { - state.packageManagerName = createPipPackageManager(packageManagerName); + } else if (packageManagerName === "pip") { + state.packageManagerName = createPipPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js index cb5484d..6415dcc 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js @@ -1,12 +1,19 @@ import { runPip } from "./runPipCommand.js"; - +import { getCurrentPipInvocation } from "./pipSettings.js"; /** - * @param {string} [command] * @returns {import("../currentPackageManager.js").PackageManager} */ -export function createPipPackageManager(command = "pip") { +export function createPipPackageManager() { return { - runCommand: /** @param {string[]} args */ (args) => runPip(command, args), + /** + * @param {string[]} args + */ + runCommand: (args) => { + const invocation = getCurrentPipInvocation(); + const fullArgs = [...invocation.args, ...args]; + console.debug('[safe-chain debug] runCommand:', invocation.command, fullArgs); + return runPip(invocation.command, fullArgs); + }, // For pip, rely solely on MITM proxy to detect/deny downloads from known registries. isSupportedCommand: () => false, getDependencyUpdatesForCommand: () => [], diff --git a/packages/safe-chain/src/packagemanager/pip/pipSettings.js b/packages/safe-chain/src/packagemanager/pip/pipSettings.js new file mode 100644 index 0000000..2dd7929 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/pipSettings.js @@ -0,0 +1,31 @@ +// Constant for pip package manager name +export const PIP_PACKAGE_MANAGER = "pip"; + +// Enum of possible Python/pip invocations for Safe Chain interception +export const PIP_INVOCATIONS = { + PIP: { command: "pip", args: [] }, + PIP3: { command: "pip3", args: [] }, + PY_PIP: { command: "python", args: ["-m", "pip"] }, + PY3_PIP: { command: "python3", args: ["-m", "pip"] } +}; + +/** + * @type {{ command: string, args: string[] }} + */ +let currentInvocation = PIP_INVOCATIONS.PY3_PIP; // Default to python3 -m pip + +/** + * @param {{ command: string, args: string[] }} invocation + */ +export function setCurrentPipInvocation(invocation) { + console.debug('[safe-chain debug] setCurrentPipInvocation:', invocation); + currentInvocation = invocation; +} + +/** + * @returns {{ command: string, args: string[] }} + */ +export function getCurrentPipInvocation() { + console.debug('[safe-chain debug] getCurrentPipInvocation:', currentInvocation); + return currentInvocation; +} diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index 6fae388..e3252f9 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -26,10 +26,10 @@ export async function runPip(command, args) { }); return { status: result.status }; } catch (/** @type any */ error) { + ui.writeError("Error executing command:", error.message); if (error.status) { return { status: error.status }; } else { - ui.writeError("Error executing command:", error.message); return { status: 1 }; } } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index c5e272b..57027fc 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -142,10 +142,12 @@ function handleConnect(req, clientSocket, head) { isKnownRegistry = knownPipRegistries.some((reg) => url.includes(reg)); } + // Debug: log CONNECT request URL and MITM/tunnel decision + ui.writeVerbose(`[Safe-chain debug] CONNECT request: url=${url}, ecosystem=${ecosystem}, isKnownRegistry=${isKnownRegistry}`); + if (isKnownRegistry) { mitmConnect(req, clientSocket, isAllowedUrl); } else { - // For other hosts, just tunnel the request to the destination tcp socket ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); tunnelRequest(req, clientSocket, head); } diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index e590d19..6c9743e 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -53,6 +53,7 @@ RUN curl -fsSL https://bun.sh/install | bash # Install Python and pip (pip3) RUN apt-get update && apt-get install -y python${PYTHON_VERSION} python3-pip && \ ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python3 && \ + ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python && \ ln -sf /usr/bin/pip3 /usr/local/bin/pip3 # Copy and install Safe chain diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index bcca90a..fe013bb 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -35,6 +35,22 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { assert.ok(result.output.includes("hello"), `Output was: ${result.output}`); assert.ok(!result.output.includes("Safe-chain"), "Safe Chain should not intercept generic python3 -c command"); }); + + it("does not intercept python3 test.py", async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand("echo 'print(\"Hello from test.py!\")' > test.py"); + const result = await shell.runCommand("python3 test.py"); + assert.ok(result.output.includes("Hello from test.py!"), `Output was: ${result.output}`); + assert.ok(!result.output.includes("Safe-chain"), "Safe Chain should not intercept generic python3 script execution"); + }); + + it("does not intercept python test.py", async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand("echo 'print(\"Hello from test.py!\")' > test.py"); + const result = await shell.runCommand("python test.py"); + assert.ok(result.output.includes("Hello from test.py!"), `Output was: ${result.output}`); + assert.ok(!result.output.includes("Safe-chain"), "Safe Chain should not intercept generic python script execution"); + }); }); for (let shell of ["bash", "zsh"]) { @@ -89,27 +105,6 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); }); - it(`setup-ci routes python -m pip3 through safe-chain for ${shell}`, async () => { - const installationShell = await container.openShell(shell); - await installationShell.runCommand("safe-chain setup-ci"); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" - ); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" - ); - - const projectShell = await container.openShell(shell); - const result = await projectShell.runCommand( - "python -m pip3 install --break-system-packages certifi" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not contain scan message. Output was:\n${result.output}` - ); - }); - it(`setup-ci routes python3 -m pip through safe-chain for ${shell}`, async () => { const installationShell = await container.openShell(shell); await installationShell.runCommand("safe-chain setup-ci"); @@ -131,7 +126,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); }); - it(`setup-ci routes python3 -m pip3 through safe-chain for ${shell}`, async () => { + it(`setup-ci routes pip through safe-chain for ${shell}`, async () => { const installationShell = await container.openShell(shell); await installationShell.runCommand("safe-chain setup-ci"); await installationShell.runCommand( @@ -143,7 +138,28 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { const projectShell = await container.openShell(shell); const result = await projectShell.runCommand( - "python3 -m pip3 install --break-system-packages certifi" + "pip install --break-system-packages certifi" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not contain scan message. Output was:\n${result.output}` + ); + }); + + it(`setup-ci routes pip3 through safe-chain for ${shell}`, async () => { + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup-ci"); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand( + "pip3 install --break-system-packages certifi" ); assert.ok( diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index 5d046a7..0cb6c2b 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -161,24 +161,6 @@ describe("E2E: pip coverage", () => { ); }); - it(`python -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python -m pip3 install --break-system-packages requests" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - // Verify it completed successfully (would fail if routing was incorrect) - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation did not succeed. Output was:\n${result.output}` - ); - }); - it(`python3 -m pip routes to aikido-pip3 (uses pip3 command)`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( @@ -197,24 +179,6 @@ describe("E2E: pip coverage", () => { ); }); - it(`python3 -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "python3 -m pip3 install --break-system-packages requests" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - // Verify it completed successfully (would fail if routing was incorrect) - assert.ok( - result.output.includes("Successfully installed") || - result.output.includes("Requirement already satisfied"), - `Installation did not succeed. Output was:\n${result.output}` - ); - }); - it(`pip3 can install from GitHub URL using the CA bundle`, async () => { const shell = await container.openShell("zsh"); // Install a simple package from GitHub - this should use TCP tunnel, not MITM From a6956db8dc8f9a6ea5237bfcba0ff1273f584366 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 10:27:49 -0800 Subject: [PATCH 16/28] Remove debug log --- packages/safe-chain/src/registryProxy/registryProxy.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 57027fc..3344e8f 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -142,9 +142,6 @@ function handleConnect(req, clientSocket, head) { isKnownRegistry = knownPipRegistries.some((reg) => url.includes(reg)); } - // Debug: log CONNECT request URL and MITM/tunnel decision - ui.writeVerbose(`[Safe-chain debug] CONNECT request: url=${url}, ecosystem=${ecosystem}, isKnownRegistry=${isKnownRegistry}`); - if (isKnownRegistry) { mitmConnect(req, clientSocket, isAllowedUrl); } else { From 9bd29056c699554c643b119f3ff453595b40d05e Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 11:02:03 -0800 Subject: [PATCH 17/28] Some cleanup --- packages/safe-chain/bin/aikido-python.js | 8 +-- packages/safe-chain/bin/aikido-python3.js | 8 +-- .../src/shell-integration/setup-ci.spec.js | 63 +------------------ test/e2e/Dockerfile | 1 + 4 files changed, 10 insertions(+), 70 deletions(-) diff --git a/packages/safe-chain/bin/aikido-python.js b/packages/safe-chain/bin/aikido-python.js index fba6b70..e3d9046 100644 --- a/packages/safe-chain/bin/aikido-python.js +++ b/packages/safe-chain/bin/aikido-python.js @@ -8,12 +8,12 @@ import { main } from "../src/main.js"; // Set eco system setEcoSystem(ECOSYSTEM_PY); - -// Strip '-m pip' or '-m pip3' from args if present +// Strip nodejs and wrapper script from args let argv = process.argv.slice(2); -if (argv[0] === '-m' && argv[1] === 'pip') { +// If no args are passed, argv[0] and argv[1] are undefined, so this condition just evaluates to false and does not throw. +if (argv[0] === '-m' && (argv[1] === 'pip' || argv[1] === 'pip3')) { setEcoSystem(ECOSYSTEM_PY); - setCurrentPipInvocation(PIP_INVOCATIONS.PY_PIP); + setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY3_PIP : PIP_INVOCATIONS.PY_PIP); initializePackageManager(PIP_PACKAGE_MANAGER); argv = argv.slice(2); var exitCode = await main(argv); diff --git a/packages/safe-chain/bin/aikido-python3.js b/packages/safe-chain/bin/aikido-python3.js index c74a3f3..8e16d6c 100644 --- a/packages/safe-chain/bin/aikido-python3.js +++ b/packages/safe-chain/bin/aikido-python3.js @@ -10,12 +10,12 @@ setEcoSystem(ECOSYSTEM_PY); // Strip nodejs and wrapper script from args let argv = process.argv.slice(2); -if (argv[0] === '-m' && argv[1] === 'pip') { +// If no args are passed, argv[0] and argv[1] are undefined, so this condition just evaluates to false and does not throw. +if (argv[0] === '-m' && (argv[1] === 'pip' || argv[1] === 'pip3')) { setEcoSystem(ECOSYSTEM_PY); - setCurrentPipInvocation(PIP_INVOCATIONS.PY3_PIP); + setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY3_PIP : PIP_INVOCATIONS.PY_PIP); initializePackageManager(PIP_PACKAGE_MANAGER); - // Strip '-m pip' or '-m pip3' from args if present - argv = argv.slice(2); + argv = argv.slice(2); var exitCode = await main(argv); process.exit(exitCode); } else { diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 5471f36..92ef82e 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -146,66 +146,5 @@ describe("Setup CI shell integration", () => { const unixNpmShim = path.join(mockShimsDir, "npm"); assert.ok(!fs.existsSync(unixNpmShim), "Unix npm shim should not exist on Windows"); }); - - it("should create python and python3 shims from unix-python wrapper template", async () => { - // Add unix-python wrapper template to mock templates - const unixPythonTemplatePath = path.join( - mockTemplateDir, - "path-wrappers", - "templates", - "unix-python-wrapper.template.sh" - ); - fs.writeFileSync( - unixPythonTemplatePath, - "#!/bin/bash\n# Python wrapper\nexec aikido-pip \"$@\"\n", - "utf-8" - ); - - await setupCi(); - - // Check if python shim was created - const pythonShimPath = path.join(mockShimsDir, "python"); - assert.ok(fs.existsSync(pythonShimPath), "python shim should exist"); - // Check if python3 shim was created - const python3ShimPath = path.join(mockShimsDir, "python3"); - assert.ok(fs.existsSync(python3ShimPath), "python3 shim should exist"); - // Check content of python shim - const pythonShimContent = fs.readFileSync(pythonShimPath, "utf-8"); - assert.ok(pythonShimContent.includes("Python wrapper"), "python shim should use unix-python wrapper template"); - // Check content of python3 shim - const python3ShimContent = fs.readFileSync(python3ShimPath, "utf-8"); - assert.ok(python3ShimContent.includes("Python wrapper"), "python3 shim should use unix-python wrapper template"); - }); - - it("should create python.cmd and python3.cmd shims from windows-python wrapper template on win32 platform", async () => { - mockPlatform = "win32"; - // Add windows-python wrapper template to mock templates - const windowsPythonTemplatePath = path.join( - mockTemplateDir, - "path-wrappers", - "templates", - "windows-python-wrapper.template.cmd" - ); - fs.writeFileSync( - windowsPythonTemplatePath, - "@echo off\nREM Python wrapper\n{{AIKIDO_COMMAND}} %*\n", - "utf-8" - ); - - await setupCi(); - - // Check if python.cmd shim was created - const pythonCmdShimPath = path.join(mockShimsDir, "python.cmd"); - assert.ok(fs.existsSync(pythonCmdShimPath), "python.cmd shim should exist"); - // Check if python3.cmd shim was created - const python3CmdShimPath = path.join(mockShimsDir, "python3.cmd"); - assert.ok(fs.existsSync(python3CmdShimPath), "python3.cmd shim should exist"); - // Check content of python.cmd shim - const pythonCmdShimContent = fs.readFileSync(pythonCmdShimPath, "utf-8"); - assert.ok(pythonCmdShimContent.includes("Python wrapper"), "python.cmd should use windows-python wrapper template"); - // Check content of python3.cmd shim - const python3CmdShimContent = fs.readFileSync(python3CmdShimPath, "utf-8"); - assert.ok(python3CmdShimContent.includes("Python wrapper"), "python3.cmd should use windows-python wrapper template"); - }); }); -}); \ No newline at end of file +}); diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 6c9743e..778924a 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -50,6 +50,7 @@ RUN volta install pnpm@${PNPM_VERSION} # Install Bun RUN curl -fsSL https://bun.sh/install | bash + # Install Python and pip (pip3) RUN apt-get update && apt-get install -y python${PYTHON_VERSION} python3-pip && \ ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python3 && \ From 032fc3847f6a79a88798579542192ab517c3d571 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 11:09:28 -0800 Subject: [PATCH 18/28] Fix args --- packages/safe-chain/bin/aikido-python.js | 2 +- packages/safe-chain/bin/aikido-python3.js | 2 +- packages/safe-chain/src/packagemanager/pip/pipSettings.js | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/bin/aikido-python.js b/packages/safe-chain/bin/aikido-python.js index e3d9046..8d19a9f 100644 --- a/packages/safe-chain/bin/aikido-python.js +++ b/packages/safe-chain/bin/aikido-python.js @@ -13,7 +13,7 @@ let argv = process.argv.slice(2); // If no args are passed, argv[0] and argv[1] are undefined, so this condition just evaluates to false and does not throw. if (argv[0] === '-m' && (argv[1] === 'pip' || argv[1] === 'pip3')) { setEcoSystem(ECOSYSTEM_PY); - setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY3_PIP : PIP_INVOCATIONS.PY_PIP); + setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY_PIP3 : PIP_INVOCATIONS.PY_PIP); initializePackageManager(PIP_PACKAGE_MANAGER); argv = argv.slice(2); var exitCode = await main(argv); diff --git a/packages/safe-chain/bin/aikido-python3.js b/packages/safe-chain/bin/aikido-python3.js index 8e16d6c..be96d6f 100644 --- a/packages/safe-chain/bin/aikido-python3.js +++ b/packages/safe-chain/bin/aikido-python3.js @@ -13,7 +13,7 @@ let argv = process.argv.slice(2); // If no args are passed, argv[0] and argv[1] are undefined, so this condition just evaluates to false and does not throw. if (argv[0] === '-m' && (argv[1] === 'pip' || argv[1] === 'pip3')) { setEcoSystem(ECOSYSTEM_PY); - setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY3_PIP : PIP_INVOCATIONS.PY_PIP); + setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY3_PIP3 : PIP_INVOCATIONS.PY3_PIP); initializePackageManager(PIP_PACKAGE_MANAGER); argv = argv.slice(2); var exitCode = await main(argv); diff --git a/packages/safe-chain/src/packagemanager/pip/pipSettings.js b/packages/safe-chain/src/packagemanager/pip/pipSettings.js index 2dd7929..2b2f6ad 100644 --- a/packages/safe-chain/src/packagemanager/pip/pipSettings.js +++ b/packages/safe-chain/src/packagemanager/pip/pipSettings.js @@ -6,7 +6,9 @@ export const PIP_INVOCATIONS = { PIP: { command: "pip", args: [] }, PIP3: { command: "pip3", args: [] }, PY_PIP: { command: "python", args: ["-m", "pip"] }, - PY3_PIP: { command: "python3", args: ["-m", "pip"] } + PY3_PIP: { command: "python3", args: ["-m", "pip"] }, + PY_PIP3: { command: "python", args: ["-m", "pip3"] }, + PY3_PIP3: { command: "python3", args: ["-m", "pip3"] } }; /** @@ -18,7 +20,6 @@ let currentInvocation = PIP_INVOCATIONS.PY3_PIP; // Default to python3 -m pip * @param {{ command: string, args: string[] }} invocation */ export function setCurrentPipInvocation(invocation) { - console.debug('[safe-chain debug] setCurrentPipInvocation:', invocation); currentInvocation = invocation; } @@ -26,6 +27,5 @@ export function setCurrentPipInvocation(invocation) { * @returns {{ command: string, args: string[] }} */ export function getCurrentPipInvocation() { - console.debug('[safe-chain debug] getCurrentPipInvocation:', currentInvocation); return currentInvocation; } From dd2894faabfcd76968f869a83ec042cde6a010e2 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 11:30:13 -0800 Subject: [PATCH 19/28] Extend test --- test/e2e/pip.e2e.spec.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index 0cb6c2b..305e312 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -287,4 +287,12 @@ describe("E2E: pip coverage", () => { `Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}` ); }); + + it(`pip3 install requests with --safe-chain-logging=verbose`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "pip3 install --break-system-packages requests --safe-chain-logging=verbose" + ); + assert.ok(result.output.includes("no malware found."), `Output did not include expected text. Output was:\n${result.output}`); + }); }); From e88aede939a170c60bdcfee856ad18a18182ed24 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 12:25:55 -0800 Subject: [PATCH 20/28] Remove some debug logging --- packages/safe-chain/bin/aikido-python.js | 0 packages/safe-chain/bin/aikido-python3.js | 0 .../safe-chain/src/packagemanager/pip/createPackageManager.js | 1 - .../path-wrappers/templates/unix-wrapper.template.sh | 2 -- .../src/shell-integration/startup-scripts/init-posix.sh | 1 - 5 files changed, 4 deletions(-) mode change 100644 => 100755 packages/safe-chain/bin/aikido-python.js mode change 100644 => 100755 packages/safe-chain/bin/aikido-python3.js diff --git a/packages/safe-chain/bin/aikido-python.js b/packages/safe-chain/bin/aikido-python.js old mode 100644 new mode 100755 diff --git a/packages/safe-chain/bin/aikido-python3.js b/packages/safe-chain/bin/aikido-python3.js old mode 100644 new mode 100755 diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js index 6415dcc..6ec5d1a 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPackageManager.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js @@ -11,7 +11,6 @@ export function createPipPackageManager() { runCommand: (args) => { const invocation = getCurrentPipInvocation(); const fullArgs = [...invocation.args, ...args]; - console.debug('[safe-chain debug] runCommand:', invocation.command, fullArgs); return runPip(invocation.command, fullArgs); }, // For pip, rely solely on MITM proxy to detect/deny downloads from known registries. diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 7663006..e914e5b 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -7,8 +7,6 @@ remove_shim_from_path() { echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" } -echo "[safe-chain debug] command -v {{AIKIDO_COMMAND}} (raw PATH): $(command -v {{AIKIDO_COMMAND}} 2>/dev/null || echo notfound)" >&2 -echo "[safe-chain debug] PATH (raw): $PATH" >&2 if command -v {{AIKIDO_COMMAND}} >/dev/null 2>&1; then # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops PATH=$(remove_shim_from_path) exec {{AIKIDO_COMMAND}} "$@" 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 278b31a..d78b9a4 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 @@ -21,7 +21,6 @@ function wrapSafeChainCommand() { else # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" - command "$original_cmd" "$@" fi } From a293c76ed998b69195c3051a2768598d51079af8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 12:53:24 -0800 Subject: [PATCH 21/28] Add better logging --- packages/safe-chain/src/packagemanager/pip/runPipCommand.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index e3252f9..058f38f 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -26,7 +26,8 @@ export async function runPip(command, args) { }); return { status: result.status }; } catch (/** @type any */ error) { - ui.writeError("Error executing command:", error.message); + ui.writeError(`Error executing command: ${error.message}`); + ui.writeError(`Is '${command}' installed and available on your system?`); if (error.status) { return { status: error.status }; } else { From 61a53b24fd92de1b6c3bbd8c603ba6d782e11693 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 13:24:00 -0800 Subject: [PATCH 22/28] Some cleanup --- packages/safe-chain/bin/aikido-pip.js | 1 - packages/safe-chain/src/packagemanager/pip/pipSettings.js | 1 - packages/safe-chain/src/packagemanager/pip/runPipCommand.js | 4 ++-- packages/safe-chain/src/registryProxy/registryProxy.js | 1 + packages/safe-chain/src/shell-integration/setup-ci.js | 2 -- .../src/shell-integration/startup-scripts/init-posix.sh | 1 + test/e2e/Dockerfile | 1 - 7 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/safe-chain/bin/aikido-pip.js b/packages/safe-chain/bin/aikido-pip.js index 59951ed..39184f0 100755 --- a/packages/safe-chain/bin/aikido-pip.js +++ b/packages/safe-chain/bin/aikido-pip.js @@ -1,6 +1,5 @@ #!/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"; diff --git a/packages/safe-chain/src/packagemanager/pip/pipSettings.js b/packages/safe-chain/src/packagemanager/pip/pipSettings.js index 2b2f6ad..5e47644 100644 --- a/packages/safe-chain/src/packagemanager/pip/pipSettings.js +++ b/packages/safe-chain/src/packagemanager/pip/pipSettings.js @@ -1,4 +1,3 @@ -// Constant for pip package manager name export const PIP_PACKAGE_MANAGER = "pip"; // Enum of possible Python/pip invocations for Safe Chain interception diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index 058f38f..793302d 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -26,11 +26,11 @@ export async function runPip(command, args) { }); return { status: result.status }; } catch (/** @type any */ error) { - ui.writeError(`Error executing command: ${error.message}`); - ui.writeError(`Is '${command}' installed and available on your system?`); if (error.status) { return { status: error.status }; } else { + ui.writeError(`Error executing command: ${error.message}`); + ui.writeError(`Is '${command}' installed and available on your system?`); return { status: 1 }; } } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 3344e8f..c5e272b 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -145,6 +145,7 @@ function handleConnect(req, clientSocket, head) { if (isKnownRegistry) { mitmConnect(req, clientSocket, isAllowedUrl); } else { + // For other hosts, just tunnel the request to the destination tcp socket ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`); tunnelRequest(req, clientSocket, head); } diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 926386d..8793832 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -71,8 +71,6 @@ function createUnixShims(shimsDir) { ); } - - /** * @param {string} shimsDir * 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 d78b9a4..278b31a 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 @@ -21,6 +21,7 @@ function wrapSafeChainCommand() { else # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" + command "$original_cmd" "$@" fi } diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 778924a..6c9743e 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -50,7 +50,6 @@ RUN volta install pnpm@${PNPM_VERSION} # Install Bun RUN curl -fsSL https://bun.sh/install | bash - # Install Python and pip (pip3) RUN apt-get update && apt-get install -y python${PYTHON_VERSION} python3-pip && \ ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python3 && \ From 01cc0b06c02209f30f5d03cd222ac4c1eb84fa42 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 13:40:09 -0800 Subject: [PATCH 23/28] Reverse e2e test removals --- test/e2e/Dockerfile | 13 ++++++++++++- test/e2e/pip.e2e.spec.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 6c9743e..cf5f39b 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -54,7 +54,18 @@ RUN curl -fsSL https://bun.sh/install | bash RUN apt-get update && apt-get install -y python${PYTHON_VERSION} python3-pip && \ ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python3 && \ ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python && \ - ln -sf /usr/bin/pip3 /usr/local/bin/pip3 + ln -sf /usr/bin/pip3 /usr/local/bin/pip3 && \ + cat <<'EOF' > /usr/lib/python3/dist-packages/pip3.py +""" +Shim module so 'python[3] -m pip3 …' resolves to pip's CLI entry point. +""" +try: + import pip._internal + pip._internal.main() +except Exception as exc: + print("pip3 module shim failed:", exc) + raise +EOF # Copy and install Safe chain COPY --from=builder /app/*.tgz /pkgs/ diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index 305e312..3d3b4dd 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -161,6 +161,24 @@ describe("E2E: pip coverage", () => { ); }); + it(`python -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python -m pip3 install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + // Verify it completed successfully (would fail if routing was incorrect) + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation did not succeed. Output was:\n${result.output}` + ); + }); + it(`python3 -m pip routes to aikido-pip3 (uses pip3 command)`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( @@ -179,6 +197,24 @@ describe("E2E: pip coverage", () => { ); }); + it(`python3 -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "python3 -m pip3 install --break-system-packages requests" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + // Verify it completed successfully (would fail if routing was incorrect) + assert.ok( + result.output.includes("Successfully installed") || + result.output.includes("Requirement already satisfied"), + `Installation did not succeed. Output was:\n${result.output}` + ); + }); + it(`pip3 can install from GitHub URL using the CA bundle`, async () => { const shell = await container.openShell("zsh"); // Install a simple package from GitHub - this should use TCP tunnel, not MITM From d3a4f81b3c84eba5271bac1686b3332e482e71f3 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 6 Nov 2025 13:44:34 -0800 Subject: [PATCH 24/28] More cleanup --- packages/safe-chain/bin/aikido-python.js | 9 ++++++--- packages/safe-chain/bin/aikido-python3.js | 7 +++++-- .../safe-chain/src/packagemanager/pip/pipSettings.js | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/bin/aikido-python.js b/packages/safe-chain/bin/aikido-python.js index 8d19a9f..1ef4e34 100755 --- a/packages/safe-chain/bin/aikido-python.js +++ b/packages/safe-chain/bin/aikido-python.js @@ -10,13 +10,16 @@ setEcoSystem(ECOSYSTEM_PY); // Strip nodejs and wrapper script from args let argv = process.argv.slice(2); -// If no args are passed, argv[0] and argv[1] are undefined, so this condition just evaluates to false and does not throw. + if (argv[0] === '-m' && (argv[1] === 'pip' || argv[1] === 'pip3')) { setEcoSystem(ECOSYSTEM_PY); setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY_PIP3 : PIP_INVOCATIONS.PY_PIP); initializePackageManager(PIP_PACKAGE_MANAGER); - argv = argv.slice(2); - var exitCode = await main(argv); + + // Strip off the '-m pip' or '-m pip3' from the args + argv = argv.slice(2); + + var exitCode = await main(argv); process.exit(exitCode); } else { // Forward to real python binary for non-pip flows diff --git a/packages/safe-chain/bin/aikido-python3.js b/packages/safe-chain/bin/aikido-python3.js index be96d6f..f53e5d2 100755 --- a/packages/safe-chain/bin/aikido-python3.js +++ b/packages/safe-chain/bin/aikido-python3.js @@ -10,13 +10,16 @@ setEcoSystem(ECOSYSTEM_PY); // Strip nodejs and wrapper script from args let argv = process.argv.slice(2); -// If no args are passed, argv[0] and argv[1] are undefined, so this condition just evaluates to false and does not throw. + if (argv[0] === '-m' && (argv[1] === 'pip' || argv[1] === 'pip3')) { setEcoSystem(ECOSYSTEM_PY); setCurrentPipInvocation(argv[1] === 'pip3' ? PIP_INVOCATIONS.PY3_PIP3 : PIP_INVOCATIONS.PY3_PIP); initializePackageManager(PIP_PACKAGE_MANAGER); + + // Strip off the '-m pip' or '-m pip3' from the args argv = argv.slice(2); - var exitCode = await main(argv); + + var exitCode = await main(argv); process.exit(exitCode); } else { // Forward to real python3 binary for non-pip flows diff --git a/packages/safe-chain/src/packagemanager/pip/pipSettings.js b/packages/safe-chain/src/packagemanager/pip/pipSettings.js index 5e47644..0316b77 100644 --- a/packages/safe-chain/src/packagemanager/pip/pipSettings.js +++ b/packages/safe-chain/src/packagemanager/pip/pipSettings.js @@ -1,6 +1,6 @@ export const PIP_PACKAGE_MANAGER = "pip"; -// Enum of possible Python/pip invocations for Safe Chain interception +// All supported python/pip invocations for Safe Chain interception export const PIP_INVOCATIONS = { PIP: { command: "pip", args: [] }, PIP3: { command: "pip3", args: [] }, From 2cf23d5109e9cd1a0f0e1a4e0835b27cc7dc65f7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 12 Nov 2025 13:43:47 +0100 Subject: [PATCH 25/28] 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 26/28] 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 27/28] 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 28/28] 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", {