Merge pull request #142 from AikidoSec/feature/pypi-ci

[PYPI] Add CI Shims
This commit is contained in:
Reinier Criel 2025-11-07 06:54:28 -08:00 committed by GitHub
commit 76acf43128
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 325 additions and 94 deletions

2
package-lock.json generated
View file

@ -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",

View file

@ -3,17 +3,16 @@
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);

View file

@ -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);

View file

@ -0,0 +1,28 @@
#!/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";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
// Strip nodejs and wrapper script from args
let argv = process.argv.slice(2);
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);
// 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
const { spawn } = await import('child_process');
spawn('python', argv, { stdio: 'inherit' });
}

View file

@ -0,0 +1,28 @@
#!/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";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
// Strip nodejs and wrapper script from args
let argv = process.argv.slice(2);
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);
process.exit(exitCode);
} else {
// Forward to real python3 binary for non-pip flows
const { spawn } = await import('child_process');
spawn('python3', argv, { stdio: 'inherit' });
}

View file

@ -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",

View file

@ -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);
}

View file

@ -1,12 +1,18 @@
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];
return runPip(invocation.command, fullArgs);
},
// For pip, rely solely on MITM proxy to detect/deny downloads from known registries.
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],

View file

@ -0,0 +1,30 @@
export const PIP_PACKAGE_MANAGER = "pip";
// All supported 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"] },
PY_PIP3: { command: "python", args: ["-m", "pip3"] },
PY3_PIP3: { command: "python3", args: ["-m", "pip3"] }
};
/**
* @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) {
currentInvocation = invocation;
}
/**
* @returns {{ command: string, args: string[] }}
*/
export function getCurrentPipInvocation() {
return currentInvocation;
}

View file

@ -29,7 +29,8 @@ export async function runPip(command, args) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
ui.writeError(`Error executing command: ${error.message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: 1 };
}
}

View file

@ -57,8 +57,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
);

View file

@ -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
];

View file

@ -19,4 +19,4 @@ else
echo "Error: Could not find original {{PACKAGE_MANAGER}}" >&2
exit 1
fi
fi
fi

View file

@ -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);
@ -98,18 +94,14 @@ 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++;
}

View file

@ -147,4 +147,4 @@ describe("Setup CI shell integration", () => {
assert.ok(!fs.existsSync(unixNpmShim), "Unix npm shim should not exist on Windows");
});
});
});
});

View file

@ -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

View file

@ -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" "$@"
}

View file

@ -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
}

View file

@ -53,7 +53,19 @@ 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/pip3 /usr/local/bin/pip3
ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python && \
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/

171
test/e2e/pip-ci.e2e.spec.js Normal file
View file

@ -0,0 +1,171 @@
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;
}
});
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");
});
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"]) {
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(
"no malware found."
);
assert.ok(
hasExpectedOutput,
hasExpectedOutput
? "Expected pip3 command to be wrapped by safe-chain"
: `Output did not contain \"no malware found.\": \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("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");
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("no malware found."),
`Output did not contain scan message. Output was:\n${result.output}`
);
});
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(
"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(
"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(
result.output.includes("no malware found."),
`Output did not contain scan message. Output was:\n${result.output}`
);
});
}
});

View file

@ -323,4 +323,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}`);
});
});