Skeleton for CI support

This commit is contained in:
Reinier Criel 2025-11-04 13:29:31 -08:00
parent 18f30ac66e
commit 6241c56fda
5 changed files with 316 additions and 11 deletions

View file

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

View file

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

View file

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

View file

@ -51,13 +51,9 @@ function createUnixShims(shimsDir) {
const template = fs.readFileSync(templatePath, "utf-8"); 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; let created = 0;
for (const toolInfo of knownAikidoTools) { for (const toolInfo of knownAikidoTools) {
if (toolInfo.tool === "pip") {
continue; // Skip pip shims in CI for now
}
const shimContent = template const shimContent = template
.replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool)
.replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand);
@ -70,11 +66,54 @@ function createUnixShims(shimsDir) {
created++; created++;
} }
// Also create python and python3 shims to support `python[3] -m pip[3]` in CI
createUnixPythonShims(shimsDir);
ui.writeInformation( ui.writeInformation(
`Created ${created} Unix shim(s) in ${shimsDir}` `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 * @param {string} shimsDir
* *
@ -98,27 +137,65 @@ function createWindowsShims(shimsDir) {
const template = fs.readFileSync(templatePath, "utf-8"); 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; let created = 0;
for (const toolInfo of knownAikidoTools) { for (const toolInfo of knownAikidoTools) {
if (toolInfo.tool === "pip") {
continue; // Skip pip shims in CI for now
}
const shimContent = template const shimContent = template
.replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool)
.replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); .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"); fs.writeFileSync(shimPath, shimContent, "utf-8");
created++; created++;
} }
// Also create python and python3 shims for Windows to support `python[3] -m pip[3]` in CI
createWindowsPythonShims(shimsDir);
ui.writeInformation( ui.writeInformation(
`Created ${created} Windows shim(s) in ${shimsDir}` `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 * @param {string} shimsDir
* *

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

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