diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index e9f05c7..339876a 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -2,31 +2,13 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; -import { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js"; +import { PIP_COMMAND, PIP3_COMMAND } from "./pipSettings.js"; import fs from "node:fs/promises"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import ini from "ini"; -/** - * Checks if this pip invocation should bypass safe-chain and spawn directly. - * Returns true if the tool is python/python3 but NOT being run with -m pip/pip3. - * @param {string} command - The command executable - * @param {string[]} args - The arguments - * @returns {boolean} - */ -function shouldBypassSafeChain(command, args) { - if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) { - // Check if args start with -m pip - if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) { - return false; - } - return true; - } - return false; -} - /** * Sets fallback CA bundle environment variables used by Python libraries. * These are applied in addition to the PIP_CONFIG_FILE to ensure all Python @@ -73,23 +55,6 @@ function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) { * @returns {Promise<{status: number}>} Exit status of the pip command */ export async function runPip(command, args) { - // Check if we should bypass safe-chain (python/python3 without -m pip) - if (shouldBypassSafeChain(command, args)) { - ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`); - // Spawn the ORIGINAL command with ORIGINAL args - const { spawn } = await import("child_process"); - return new Promise((_resolve) => { - const proc = spawn(command, args, { stdio: "inherit" }); - proc.on("exit", (/** @type {number | null} */ code) => { - process.exit(code ?? 0); - }); - proc.on("error", (/** @type {Error} */ err) => { - ui.writeError(`Error executing command: ${err.message}`); - process.exit(1); - }); - }); - } - try { const env = mergeSafeChainProxyEnvironmentVariables(process.env); diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish index 386144c..f538a6c 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-fish.fish @@ -58,12 +58,22 @@ end # `python -m pip`, `python -m pip3`. function python - wrapSafeChainCommand "python" $argv + # Intercept only `python -m pip|pip3`; otherwise call real python + if test (count $argv) -ge 2; and test $argv[1] = "-m"; and (test $argv[2] = "pip" -o test $argv[2] = "pip3") + wrapSafeChainCommand "python" $argv + else + command python $argv + end end # `python3 -m pip`, `python3 -m pip3'. function python3 - wrapSafeChainCommand "python3" $argv + # Intercept only `python3 -m pip|pip3`; otherwise call real python3 + if test (count $argv) -ge 2; and test $argv[1] = "-m"; and (test $argv[2] = "pip" -o test $argv[2] = "pip3") + wrapSafeChainCommand "python3" $argv + else + command python3 $argv + end end function printSafeChainWarning diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh index c71c741..240d37a 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-posix.sh @@ -54,12 +54,22 @@ function poetry() { # `python -m pip`, `python -m pip3`. function python() { - wrapSafeChainCommand "python" "$@" + # Intercept only `python -m pip|pip3`; otherwise run the real python to avoid loops/shadowing + if [[ "$1" == "-m" && ( "$2" == "pip" || "$2" == "pip3" ) ]]; then + wrapSafeChainCommand "python" "$@" + else + command python "$@" + fi } # `python3 -m pip`, `python3 -m pip3'. function python3() { - wrapSafeChainCommand "python3" "$@" + # Intercept only `python3 -m pip|pip3`; otherwise run the real python3 + if [[ "$1" == "-m" && ( "$2" == "pip" || "$2" == "pip3" ) ]]; then + wrapSafeChainCommand "python3" "$@" + else + command python3 "$@" + fi } function printSafeChainWarning() { diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 index 168556a..d191dfc 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 @@ -56,12 +56,22 @@ function poetry { # `python -m pip`, `python -m pip3`. function python { - Invoke-WrappedCommand 'python' $args + # Intercept only `python -m pip|pip3`; otherwise invoke real python + if (($args.Length -ge 2) -and ($args[0] -eq '-m') -and (($args[1] -eq 'pip') -or ($args[1] -eq 'pip3'))) { + Invoke-WrappedCommand 'python' $args + } else { + Invoke-RealCommand 'python' $args + } } # `python3 -m pip`, `python3 -m pip3'. function python3 { - Invoke-WrappedCommand 'python3' $args + # Intercept only `python3 -m pip|pip3`; otherwise invoke real python3 + if (($args.Length -ge 2) -and ($args[0] -eq '-m') -and (($args[1] -eq 'pip') -or ($args[1] -eq 'pip3'))) { + Invoke-WrappedCommand 'python3' $args + } else { + Invoke-RealCommand 'python3' $args + } } diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index ec1af3c..d3c6166 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -16,20 +16,22 @@ const yarnVersion = process.env.YARN_VERSION || "latest"; const pnpmVersion = process.env.PNPM_VERSION || "latest"; export class DockerTestContainer { - constructor() { + constructor(profile = "default") { this.containerName = `safe-chain-test-${Math.random() .toString(36) .substring(2, 15)}`; this.isRunning = false; + this.profile = profile; } - static buildImage() { + static buildImage(profile = "default") { try { const buildArgs = [ `--build-arg NODE_VERSION=${nodeVersion}`, `--build-arg NPM_VERSION=${npmVersion}`, `--build-arg YARN_VERSION=${yarnVersion}`, `--build-arg PNPM_VERSION=${pnpmVersion}`, + profile === "pyenv" ? "--build-arg ENABLE_PYENV=true" : "", ].join(" "); execSync( diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index c8d9c9c..8bb1b55 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -26,6 +26,7 @@ ARG NPM_VERSION=latest ARG YARN_VERSION=latest ARG PNPM_VERSION=latest ARG PYTHON_VERSION=3 +ARG ENABLE_PYENV=false SHELL ["/bin/bash", "-c"] ENV BASH_ENV=~/.bashrc @@ -84,3 +85,10 @@ RUN npm install -g /pkgs/*.tgz WORKDIR /testapp RUN npm init -y +# Optional: install pyenv and set a managed Python version when enabled +RUN if [ "$ENABLE_PYENV" = "true" ]; then \ + apt-get update && apt-get install -y git build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev libffi-dev && \ + git clone https://github.com/pyenv/pyenv.git /root/.pyenv && \ + bash -lc 'export PYENV_ROOT=/root/.pyenv; export PATH="$PYENV_ROOT/bin:$PATH"; eval "$(pyenv init -)"; pyenv install 3.12.3; pyenv global 3.12.3'; \ + fi + diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index e02d1b3..8afae22 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -569,6 +569,137 @@ describe("E2E: pip coverage", () => { ); }); + it(`python3 --version completes quickly (no recursion/loop)`, async () => { + const shell = await container.openShell("zsh"); + const start = Date.now(); + const result = await shell.runCommand("python3 --version"); + const durationMs = Date.now() - start; + + assert.ok( + durationMs < 3000, + `Command should complete quickly. Took ${durationMs}ms. Output was:\n${result.output}` + ); + assert.ok( + result.output.match(/Python 3\.\d+\.\d+/), + `Should output Python version. Output was:\n${result.output}` + ); + }); + + it(`alias python=python3 still bypasses and is fast`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand("alias python=python3"); + + const start = Date.now(); + const result = await shell.runCommand("python --version"); + const durationMs = Date.now() - start; + + assert.ok( + durationMs < 3000, + `Command should complete quickly. Took ${durationMs}ms. Output was:\n${result.output}` + ); + assert.ok( + result.output.match(/Python 3\.\d+\.\d+/), + `Should output Python 3 version via alias. Output was:\n${result.output}` + ); + assert.ok( + !result.output.includes("Safe-chain"), + `Non-pip alias should bypass safe-chain. Output was:\n${result.output}` + ); + }); + + it(`alias python=python3, python -m pip goes through safe-chain`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand("alias python=python3"); + + const start = Date.now(); + const result = await shell.runCommand( + "python -m pip install --break-system-packages certifi --safe-chain-logging=verbose" + ); + const durationMs = Date.now() - start; + + assert.ok( + durationMs < 10000, + `Install should not hang or loop under alias. Took ${durationMs}ms. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Safe-chain") || + result.output.includes("no malware found"), + `pip flow SHOULD be protected even with alias. Output was:\n${result.output}` + ); + }); + + // Shadowing function tests: user-defined shell function overrides wrapper + it(`function python(){ python3 "$@"; } bypasses non-pip and is fast`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand('function python(){ python3 "$@"; }'); + + const start = Date.now(); + const result = await shell.runCommand("python --version"); + const durationMs = Date.now() - start; + + assert.ok( + durationMs < 3000, + `Command should complete quickly under shadowing function. Took ${durationMs}ms. Output was:\n${result.output}` + ); + assert.ok( + result.output.match(/Python 3\.\d+\.\d+/), + `Should output Python 3 version via shadowing function. Output was:\n${result.output}` + ); + assert.ok( + !result.output.includes("Safe-chain"), + `Non-pip via shadowing function should bypass safe-chain. Output was:\n${result.output}` + ); + }); + + it(`function python(){ python3 "$@"; } with python -m pip is protected`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand('function python(){ python3 "$@"; }'); + + const start = Date.now(); + const result = await shell.runCommand( + "python -m pip install --break-system-packages certifi --safe-chain-logging=verbose" + ); + const durationMs = Date.now() - start; + + assert.ok( + durationMs < 10000, + `Install should not hang or loop under shadowing function. Took ${durationMs}ms. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Safe-chain") || + result.output.includes("no malware found"), + `pip flow SHOULD be protected even with shadowing function. Output was:\n${result.output}` + ); + }); + + it(`shadow function + python -m pip blocks malware`, async () => { + const shell = await container.openShell("zsh"); + await shell.runCommand('function python(){ python3 "$@"; }'); + + const result = await shell.runCommand( + "python -m pip install --break-system-packages safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Should block malware under shadow function. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Should identify malware package. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Should refuse installation. Output was:\n${result.output}` + ); + + const listResult = await shell.runCommand("python -m pip list"); + assert.ok( + !listResult.output.includes("safe-chain-pi-test"), + `Malicious package should not be installed. Output was:\n${listResult.output}` + ); + }); + it(`python --version should bypass safe-chain and work normally`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand("python --version"); @@ -817,6 +948,25 @@ describe("E2E: pip coverage", () => { ); }); + it(`python3 -m pip completes quickly and shows protection`, async () => { + const shell = await container.openShell("zsh"); + const start = Date.now(); + const result = await shell.runCommand( + "python3 -m pip install --break-system-packages certifi --safe-chain-logging=verbose" + ); + const durationMs = Date.now() - start; + + assert.ok( + durationMs < 10000, + `Install should not hang or loop. Took ${durationMs}ms. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Safe-chain") || + result.output.includes("no malware found"), + `python3 -m pip SHOULD go through safe-chain. Output was:\n${result.output}` + ); + }); + it(`python3 -m pip3 DOES go through safe-chain (sanity check)`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( diff --git a/test/e2e/pyenv.e2e.spec.js b/test/e2e/pyenv.e2e.spec.js new file mode 100644 index 0000000..fdbe172 --- /dev/null +++ b/test/e2e/pyenv.e2e.spec.js @@ -0,0 +1,155 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { DockerTestContainer } from "./DockerTestContainer.js"; + +let hasPyenv = false; + +describe("E2E: pyenv wrappers only intercept -m pip", () => { + let c; + + before(async () => { + DockerTestContainer.buildImage("pyenv"); + c = new DockerTestContainer("pyenv"); + await c.start(); + const sh = await c.openShell("bash"); + // Ensure pyenv is on PATH and shims initialized for this shell + await sh.runCommand( + 'export PYENV_ROOT=/root/.pyenv; export PATH="$PYENV_ROOT/bin:$PATH"; eval "$(pyenv init -)" || true' + ); + await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true"); + // Assert pyenv is installed and active + const v = await sh.runCommand("pyenv --version || echo MISSING"); + hasPyenv = /pyenv [0-9]/.test(v); + if (hasPyenv) { + const versions = await sh.runCommand("pyenv versions || true"); + // Non-fatal check: ensure at least one version appears + assert.match(versions, /(system|\d+\.\d+)/, "pyenv versions should list entries"); + const whichPy = await sh.runCommand("pyenv which python || true"); + assert.match(whichPy, /\.pyenv\/versions\//, "pyenv which python should point to pyenv version dir"); + } + }); + + it("pyenv local sets project-specific version", async () => { + if (!hasPyenv) return; + const sh = await c.openShell("bash"); + await sh.runCommand("mkdir -p /work/localtest && cd /work/localtest && pyenv local 3.12.3"); + const inside = await sh.runCommand("cd /work/localtest && python --version 2>&1"); + assert.match(inside, /Python 3\.12\./); + const outside = await sh.runCommand("cd / && pyenv version && python --version 2>&1"); + assert.notMatch(outside, /3\.12\./, "outside dir should not be forced to 3.12 by local file"); + }); + + it("pyenv shell overrides version for current session", async () => { + if (!hasPyenv) return; + const sh = await c.openShell("bash"); + // Set shell-specific version + await sh.runCommand("pyenv shell 3.12.3"); + const v1 = await sh.runCommand("pyenv version"); + assert.match(v1, /3\.12\.3 \(set by PYENV_VERSION/); + const p1 = await sh.runCommand("python --version 2>&1"); + assert.match(p1, /Python 3\.12\./); + // Unset and verify it reverts + await sh.runCommand("pyenv shell --unset"); + const v2 = await sh.runCommand("pyenv version"); + assert.match(v2, /system \(set by .*\.pyenv\/version\)/); + }); + + after(async () => { + await c.stop(); + }); + + it("python --version bypasses and succeeds", async () => { + const sh = await c.openShell("bash"); + await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true"); + const res = await sh.runCommand("python --version 2>&1; echo EXIT:$?\n"); + assert.ok(/Python\s+\d+\.\d+\.\d+/.test(res.output), `version missing: ${res.output}`); + assert.ok(res.output.includes("EXIT:0"), `exit not 0: ${res.output}`); + }); + + it("python3 --version bypasses and succeeds", async () => { + const sh = await c.openShell("bash"); + const res = await sh.runCommand("python3 --version 2>&1; echo EXIT:$?\n"); + assert.ok(/Python\s+3\./.test(res.output), `version missing: ${res.output}`); + assert.ok(res.output.includes("EXIT:0"), `exit not 0: ${res.output}`); + }); + + it("shadowing function: command python succeeds (explicit bypass)", async () => { + const sh = await c.openShell("bash"); + await sh.runCommand("python() { return 1; }; export -f python"); + const res = await sh.runCommand("type -a python; command python --version 2>&1; echo EXIT:$?\n"); + assert.ok(/Python\s+\d+\.\d+\.\d+/.test(res.output), `version missing: ${res.output}`); + assert.ok(res.output.includes("EXIT:0"), `exit not 0: ${res.output}`); + }); + + it("python -m pip install succeeds under wrappers", async () => { + const sh = await c.openShell("bash"); + await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true"); + const res = await sh.runCommand("python3 -m pip install --break-system-packages certifi 2>&1\n"); + assert.ok(res.output.match(/Successfully installed|Requirement already satisfied/), `pip install did not succeed: ${res.output}`); + }); + + it("python -m pip blocks malware under pyenv-style shadow", async () => { + const sh = await c.openShell("bash"); + await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true"); + await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true"); + // Emulate pyenv-style function forwarding + await sh.runCommand('python() { python3 "$@"; }; export -f python'); + + const res = await sh.runCommand("python -m pip install --break-system-packages safe-chain-pi-test 2>&1\n"); + assert.ok( + res.output.includes("blocked 1 malicious package downloads:"), + `Should block malware. Output:\n${res.output}` + ); + assert.ok( + res.output.includes("safe_chain_pi_test@0.0.1"), + `Should identify malware package. Output:\n${res.output}` + ); + assert.ok( + res.output.includes("Exiting without installing malicious packages."), + `Should refuse installation. Output:\n${res.output}` + ); + + const list = await sh.runCommand("python -m pip list 2>&1\n"); + assert.ok( + !list.output.includes("safe-chain-pi-test"), + `Malicious package should not be installed. Output:\n${list.output}` + ); + }); + + it("python -m pip works and installs (no shadow)", async () => { + const sh = await c.openShell("bash"); + await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true"); + await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true"); + + const res = await sh.runCommand("python -m pip install --break-system-packages certifi --safe-chain-logging=verbose 2>&1\n"); + assert.ok( + res.output.match(/Successfully installed|Requirement already satisfied/), + `pip install should succeed. Output:\n${res.output}` + ); + }); + + it("pip3 download works and scans (no shadow)", async () => { + const sh = await c.openShell("bash"); + await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true"); + await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true"); + + const res = await sh.runCommand("pip3 download requests --safe-chain-logging=verbose 2>&1\n"); + assert.ok( + res.output.includes("no malware found."), + `Download should be scanned. Output:\n${res.output}` + ); + }); + + it("pip3 wheel works and proxies registry (no shadow)", async () => { + const sh = await c.openShell("bash"); + await sh.runCommand("safe-chain setup --include-python >/dev/null 2>&1 || true"); + await sh.runCommand("pip3 cache purge >/dev/null 2>&1 || true"); + + const res = await sh.runCommand("pip3 wheel requests --safe-chain-logging=verbose 2>&1\n"); + assert.ok( + res.output.includes("Safe-chain: Set up MITM tunnel") || + res.output.includes("Finished proxying request"), + `Wheel should route via proxy. Output:\n${res.output}` + ); + }); +});