From c2e632ead21730af20a79969fbb2b5cbeddc1248 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 28 Oct 2025 08:46:07 -0700 Subject: [PATCH] Add e2e test for malware blocking + python3 fix --- README.md | 12 ++- .../startup-scripts/init-fish.fish | 9 +- .../startup-scripts/init-posix.sh | 9 +- .../startup-scripts/init-pwsh.ps1 | 5 +- packages/safe-chain/src/utils/safeSpawn.js | 9 ++ test/e2e/pip.e2e.spec.js | 87 +++++++++++++++++++ 6 files changed, 115 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e438475..1faa1f6 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,19 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: ``` 3. **❗Restart your terminal** to start using the Aikido Safe Chain. - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. -4. **Verify the installation** by running: +4. **Verify the installation** by running one of the following commands: + + For JavaScript/Node.js: ```shell npm install safe-chain-test ``` - - The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware. + + For Python: + ```shell + pip3 install safe-chain-pi-test + ``` + + - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. 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 40b8ef6..699a057 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 @@ -82,6 +82,7 @@ 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] + # python -m pip → aikido-pip, python -m pip3 → aikido-pip3 if test $mod = "pip3" wrapSafeChainCommand "pip3" "aikido-pip3" $args else @@ -95,13 +96,9 @@ 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 mod $argv[2] set args $argv[3..-1] - if test $mod = "pip3" - wrapSafeChainCommand "pip3" "aikido-pip3" $args - else - wrapSafeChainCommand "pip" "aikido-pip" $args - end + # python3 always uses pip3, regardless of whether user types `pip` or `pip3` + wrapSafeChainCommand "pip3" "aikido-pip3" $args else command 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 e4e0362..43413c3 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 @@ -74,6 +74,7 @@ function python() { if [[ "$1" == "-m" && "$2" == pip* ]]; then local mod="$2" shift 2 + # python -m pip → aikido-pip, python -m pip3 → aikido-pip3 if [[ "$mod" == "pip3" ]]; then wrapSafeChainCommand "pip3" "aikido-pip3" "$@" else @@ -87,13 +88,9 @@ function python() { # `python3 -m pip`, `python3 -m pip3'. function python3() { 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 + # python3 always uses pip3, regardless of whether user types `pip` or `pip3` + wrapSafeChainCommand "pip3" "aikido-pip3" "$@" else command python3 "$@" fi 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 b467d9e..6727bcb 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 @@ -99,6 +99,7 @@ function pip3 { function python { param([Parameter(ValueFromRemainingArguments=$true)]$Args) if ($Args.Length -ge 2 -and $Args[0] -eq '-m' -and $Args[1] -match '^pip(3)?$') { + # python -m pip → aikido-pip, python -m pip3 → aikido-pip3 $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 } @@ -112,9 +113,9 @@ function python { 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 { @() } - if ($Args[1] -eq 'pip3') { Invoke-WrappedCommand 'pip3' 'aikido-pip3' $pipArgs } - else { Invoke-WrappedCommand 'pip' 'aikido-pip' $pipArgs } + Invoke-WrappedCommand 'pip3' 'aikido-pip3' $pipArgs } else { Invoke-RealCommand 'python3' $Args diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index b88a3b1..b4602d2 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -126,6 +126,15 @@ export async function safeSpawnPy(command, args, options = {}) { }); child.on("error", (error) => { + // When stdio is inherited and spawn fails (e.g., command not found), + // we need to write the error to stderr manually since there's no child process + if (options.stdio === "inherit") { + if (error.code === "ENOENT") { + process.stderr.write(`Error: Command '${command}' not found. Please ensure it is installed and available in your PATH.\n`); + } else { + process.stderr.write(`Error: ${error.message}\n`); + } + } resolve({ status: 1, stdout: "", stderr: error.message || String(error) }); }); }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index 0ef88d3..7013121 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -106,4 +106,91 @@ describe("E2E: pip coverage", () => { ); }); + it(`safe-chain blocks installation of malicious Python packages`, async () => { + const shell = await container.openShell("zsh"); + // Clear pip cache to ensure network download through proxy + await shell.runCommand("pip3 cache purge"); + + const result = await shell.runCommand("pip3 install --break-system-packages safe-chain-pi-test"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("safe_chain_pi_test@0.0.1"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + const listResult = await shell.runCommand("pip3 list"); + assert.ok( + !listResult.output.includes("safe-chain-pi-test"), + `Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}` + ); + }); + + it(`python -m pip routes to aikido-pip (uses pip command)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand('python -m pip install --break-system-packages requests'); + + assert.ok( + result.output.includes("no malicious packages 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(`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 malicious packages 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('python3 -m pip install --break-system-packages requests'); + + assert.ok( + result.output.includes("no malicious packages 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 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 malicious packages 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}` + ); + }); + });