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}` + ); + }); + } +});