From 6241c56fdaaf2a35fd2025115a3dbab95c98c1e1 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 4 Nov 2025 13:29:31 -0800 Subject: [PATCH 01/20] 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/20] 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/20] 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 216e16cfb1406b8fdeffbfeb0a5edb4448ca68b8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 5 Nov 2025 11:13:24 -0800 Subject: [PATCH 04/20] 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 05/20] 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 06/20] 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 07/20] 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 08/20] 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 09/20] 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 10/20] 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 11/20] 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 12/20] 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 13/20] 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 14/20] 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 15/20] 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 16/20] 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 17/20] 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 18/20] 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 19/20] 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 20/20] 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: [] },