Merge branch 'main' into package-min-age

This commit is contained in:
Sander Declerck 2025-11-12 14:42:19 +01:00
commit 3b905d490b
No known key found for this signature in database
33 changed files with 407 additions and 970 deletions

2
package-lock.json generated
View file

@ -2096,6 +2096,8 @@
"aikido-npx": "bin/aikido-npx.js", "aikido-npx": "bin/aikido-npx.js",
"aikido-pip": "bin/aikido-pip.js", "aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.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-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.js", "aikido-pnpx": "bin/aikido-pnpx.js",
"aikido-yarn": "bin/aikido-yarn.js", "aikido-yarn": "bin/aikido-yarn.js",

View file

@ -3,17 +3,16 @@
import { main } from "../src/main.js"; import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
import { setCurrentPipInvocation, PIP_INVOCATIONS, PIP_PACKAGE_MANAGER } from "../src/packagemanager/pip/pipSettings.js";
// Defaults
let packageManagerName = "pip";
// Pass through user args as-is
const argv = process.argv.slice(2);
// Set eco system // 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); setEcoSystem(ECOSYSTEM_PY);
initializePackageManager(packageManagerName); // Set current invocation
var exitCode = await main(argv); 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); process.exit(exitCode);

View file

@ -3,17 +3,17 @@
import { main } from "../src/main.js"; import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.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 // Set eco system
const packageManagerName = "pip3";
// Copy argv as-is
const argv = process.argv.slice(2);
// Set ecosystem to Python
setEcoSystem(ECOSYSTEM_PY); setEcoSystem(ECOSYSTEM_PY);
initializePackageManager(packageManagerName); // Set current invocation
var exitCode = await main(argv); 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); 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

@ -54,7 +54,7 @@ function writeHelp() {
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
"safe-chain setup" "safe-chain setup"
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx and pip.` )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`
); );
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(

View file

@ -17,6 +17,8 @@
"aikido-bunx": "bin/aikido-bunx.js", "aikido-bunx": "bin/aikido-bunx.js",
"aikido-pip": "bin/aikido-pip.js", "aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.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" "safe-chain": "bin/safe-chain.js"
}, },
"type": "module", "type": "module",

View file

@ -52,8 +52,8 @@ export function initializePackageManager(packageManagerName) {
state.packageManagerName = createBunPackageManager(); state.packageManagerName = createBunPackageManager();
} else if (packageManagerName === "bunx") { } else if (packageManagerName === "bunx") {
state.packageManagerName = createBunxPackageManager(); state.packageManagerName = createBunxPackageManager();
} else if (packageManagerName === "pip" || packageManagerName === "pip3") { } else if (packageManagerName === "pip") {
state.packageManagerName = createPipPackageManager(packageManagerName); state.packageManagerName = createPipPackageManager();
} else { } else {
throw new Error("Unsupported package manager: " + packageManagerName); throw new Error("Unsupported package manager: " + packageManagerName);
} }

View file

@ -1,79 +1,21 @@
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { runPip } from "./runPipCommand.js"; import { runPip } from "./runPipCommand.js";
import { import { getCurrentPipInvocation } from "./pipSettings.js";
getPipCommandForArgs,
pipInstallCommand,
pipDownloadCommand,
pipWheelCommand,
} from "./utils/pipCommands.js";
/** /**
* @param {string} [command]
* @returns {import("../currentPackageManager.js").PackageManager} * @returns {import("../currentPackageManager.js").PackageManager}
*/ */
export function createPipPackageManager(command = "pip") { export function createPipPackageManager() {
/**
* @param {string[]} args
* @returns {boolean}
*/
function isSupportedCommand(args) {
const scanner = findDependencyScannerForCommand(
commandScannerMapping,
args
);
return scanner.shouldScan(args);
}
/**
* @param {string[]} args
* @returns {ReturnType<import("../currentPackageManager.js").PackageManager["getDependencyUpdatesForCommand"]>}
*/
function getDependencyUpdatesForCommand(args) {
const scanner = findDependencyScannerForCommand(
commandScannerMapping,
args
);
return scanner.scan(args);
}
return { return {
runCommand: /** @param {string[]} args */ (args) => runPip(command, args),
isSupportedCommand,
getDependencyUpdatesForCommand,
};
}
/** /**
* @type {Record<string, import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner>}
*/
const commandScannerMapping = {
[pipInstallCommand]: commandArgumentScanner(),
[pipDownloadCommand]: commandArgumentScanner(), // download also fetches packages from PyPI
[pipWheelCommand]: commandArgumentScanner(), // wheel downloads and builds packages
// Other commands return null scanner by default
};
/**
* @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/
function nullScanner() {
return {
shouldScan: () => false,
scan: () => [],
};
}
/**
* @param {Record<string, import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner>} scanners
* @param {string[]} args * @param {string[]} args
* @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/ */
function findDependencyScannerForCommand(scanners, args) { runCommand: (args) => {
const command = getPipCommandForArgs(args); const invocation = getCurrentPipInvocation();
if (!command) { const fullArgs = [...invocation.args, ...args];
return nullScanner(); return runPip(invocation.command, fullArgs);
},
// For pip, rely solely on MITM proxy to detect/deny downloads from known registries.
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
} }
const scanner = scanners[command];
return scanner || nullScanner();
}

View file

@ -19,10 +19,10 @@ test("createPipPackageManager", async (t) => {
await t.test("should support install, download, and wheel commands", () => { await t.test("should support install, download, and wheel commands", () => {
const pm = createPipPackageManager(); const pm = createPipPackageManager();
// MITM-only approach, pip does not scan args
assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), true); assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), false);
assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), true); assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), false);
assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), true); assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), false);
}); });
await t.test("should not support uninstall and info commands", () => { await t.test("should not support uninstall and info commands", () => {
@ -35,12 +35,9 @@ test("createPipPackageManager", async (t) => {
await t.test("should extract packages from install command", () => { await t.test("should extract packages from install command", () => {
const pm = createPipPackageManager(); const pm = createPipPackageManager();
const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]); const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]);
assert.ok(Array.isArray(result)); assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 1); assert.strictEqual(result.length, 0);
assert.strictEqual(result[0].name, "requests");
assert.strictEqual(result[0].version, "2.28.0");
}); });
await t.test("should return empty array for unsupported commands", () => { await t.test("should return empty array for unsupported commands", () => {

View file

@ -1,77 +0,0 @@
import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js";
import { hasDryRunArg } from "../utils/pipCommands.js";
/**
* @typedef {Object} ScanResult
* @property {string} name
* @property {string} version
* @property {string} type
*/
/**
* @typedef {Object} ScannerOptions
* @property {boolean} [ignoreDryRun]
*/
/**
* @typedef {Object} CommandArgumentScanner
* @property {(args: string[]) => Promise<ScanResult[]> | ScanResult[]} scan
* @property {(args: string[]) => boolean} shouldScan
*/
/**
* @param {ScannerOptions} [options]
*
* @returns {CommandArgumentScanner}
*/
export function commandArgumentScanner(options = {}) {
const { ignoreDryRun = false } = options;
/**
* @param {string[]} args
*/
function shouldScan(args) {
return shouldScanDependencies(args, ignoreDryRun);
}
/**
* @param {string[]} args
* @returns {Promise<ScanResult[]> | ScanResult[]}
*/
function scan(args) {
return scanDependencies(args);
}
return {
shouldScan,
scan,
};
}
/**
* @param {string[]} args
* @param {boolean} ignoreDryRun
*/
function shouldScanDependencies(args, ignoreDryRun) {
return ignoreDryRun || !hasDryRunArg(args);
}
/**
* @param {string[]} args
* @returns {Promise<ScanResult[]> | ScanResult[]}
*/
function scanDependencies(args) {
return checkChangesFromArgs(args);
}
/**
* @param {string[]} args
* @returns {Promise<ScanResult[]> | ScanResult[]}
*/
export function checkChangesFromArgs(args) {
const packageUpdates = parsePackagesFromInstallArgs(args);
// Parser already provides exact versions or "latest", no need to resolve
// Just return the packages with type "add"
return packageUpdates;
}

View file

@ -1,144 +0,0 @@
import { test } from "node:test";
import assert from "node:assert";
import { commandArgumentScanner, checkChangesFromArgs } from "./commandArgumentScanner.js";
test("commandArgumentScanner factory", async (t) => {
await t.test("should create scanner with required interface", () => {
const scanner = commandArgumentScanner();
assert.ok(scanner);
assert.strictEqual(typeof scanner.shouldScan, "function");
assert.strictEqual(typeof scanner.scan, "function");
});
});
test("shouldScan", async (t) => {
await t.test("should return true for normal install command", () => {
const scanner = commandArgumentScanner();
const result = scanner.shouldScan(["install", "requests"]);
assert.strictEqual(result, true);
});
await t.test("should return false for install with --dry-run", () => {
const scanner = commandArgumentScanner();
const result = scanner.shouldScan(["install", "--dry-run", "requests"]);
assert.strictEqual(result, false);
});
await t.test("should return true for install with --dry-run when ignoreDryRun is true", () => {
const scanner = commandArgumentScanner({ ignoreDryRun: true });
const result = scanner.shouldScan(["install", "--dry-run", "requests"]);
assert.strictEqual(result, true);
});
});
test("scan", async (t) => {
await t.test("should scan simple package installation", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests"]);
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "latest",
type: "add",
});
});
await t.test("should scan package with exact version", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests==2.28.0"]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
});
await t.test("should scan multiple packages", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests==2.28.0", "flask"]);
assert.strictEqual(result.length, 2);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
assert.deepEqual(result[1], {
name: "flask",
version: "latest",
type: "add",
});
});
await t.test("should skip packages with range specifiers", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests>=2.0.0", "flask==2.0.0"]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "flask",
version: "2.0.0",
type: "add",
});
});
await t.test("should skip flags with parameters", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan([
"install",
"-r",
"requirements.txt",
"requests==2.28.0",
]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
});
await t.test("should handle === exact version specifier", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests===2.28.0"]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
});
});
test("checkChangesFromArgs helper", async (t) => {
await t.test("should extract packages from args", () => {
const result = checkChangesFromArgs(["install", "requests==2.28.0", "flask"]);
assert.strictEqual(result.length, 2);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
assert.deepEqual(result[1], {
name: "flask",
version: "latest",
type: "add",
});
});
await t.test("should handle empty args", () => {
const result = checkChangesFromArgs([]);
assert.deepStrictEqual(result, []);
});
});

View file

@ -1,179 +0,0 @@
/**
* @typedef {Object} PackageDetail
* @property {string} name
* @property {string} version
* @property {string} type
*/
/**
* @typedef {Object} PipOption
* @property {string} name
* @property {number} numberOfParameters
*/
/**
* Supported formats that will be returned:
* - package_name (no version)
* - package_name==version (exact version)
* - package_name===version (exact version, PEP 440)
*
* Ranges: Because they don't specify an exact version, the following formats are skipped and we rely on the MITM scanner:
* - package_name>=version
* - package_name<=version
* - package_name>version
* - package_name<version
* - package_name~=version
* - package_name!=version
* - git+https://... (VCS URLs)
* - -r requirements.txt (handled by flag skipping)
*
* @param {string[]} args
* @returns {PackageDetail[]}
*/
export function parsePackagesFromInstallArgs(args) {
/** @type {PackageDetail[]} */
const packages = [];
let skipNext = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (skipNext) {
skipNext = false;
continue;
}
// Skip the command itself (install, etc.)
if (i === 0 && !arg.startsWith("-")) {
continue;
}
// Skip flags and their values
if (arg.startsWith("-")) {
if (isPipOptionWithParameter(arg)) {
skipNext = true;
}
continue;
}
const parsed = parsePipSpec(arg);
if (parsed) {
packages.push({ ...parsed, type: "add" });
}
}
return packages;
}
/**
* @param {string} arg
* @returns {boolean}
*/
function isPipOptionWithParameter(arg) {
// Check if a pip flag takes a parameter
const optionsWithParameters = [
// Install options
"-r",
"--requirement",
"-c",
"--constraint",
"-e",
"--editable",
"-t",
"--target",
"--platform",
"--python-version",
"--implementation",
"--abi",
"--root",
"--prefix",
"--src",
"--upgrade-strategy",
"--progress-bar",
"--root-user-action",
"--report",
"--group",
// Package index options
"-i",
"--index-url",
"--extra-index-url",
"-f",
"--find-links",
// General options
"--python",
"--log",
"--keyring-provider",
"--proxy",
"--retries",
"--timeout",
"--exists-action",
"--trusted-host",
"--cert",
"--client-cert",
"--cache-dir",
"--use-feature",
"--use-deprecated",
"--resume-retries",
];
return optionsWithParameters.includes(arg);
}
/**
* @param {string} spec
* @returns {{ name: string, version: string } | null}
*/
function parsePipSpec(spec) {
// Ignore obvious URLs and paths, rely on mitm scanner
const lower = spec.toLowerCase();
if (
lower.startsWith("git+") ||
lower.startsWith("hg+") ||
lower.startsWith("svn+") ||
lower.startsWith("bzr+") ||
lower.startsWith("http:") ||
lower.startsWith("https:") ||
lower.startsWith("file:") ||
spec.startsWith("./") ||
spec.startsWith("../") ||
spec.startsWith("/")
) {
return null;
}
// Strip extras: package[extra1,extra2]
const extrasStart = spec.indexOf("[");
const extrasEnd = extrasStart >= 0 ? spec.indexOf("]", extrasStart) : -1;
let base = spec;
if (extrasStart >= 0 && extrasEnd > extrasStart) {
base = spec.slice(0, extrasStart) + spec.slice(extrasEnd + 1);
}
// Split on first occurrence of a comparator or comma spec
// Support multi-constraint lists like ">=1,<2" by detecting the first comparator
const comparatorRegex = /(===|==|!=|~=|>=|<=|<|>)/;
const m = base.match(comparatorRegex);
if (!m) {
// No comparator => just a name, use "latest" as version
return { name: base, version: "latest" };
}
const idx = m.index;
const name = base.slice(0, idx);
const versionPart = base.slice(idx); // e.g. '==2.28.0' or '>=1,<2'
// Normalize whitespace inside versionPart
const versionWithOperator = versionPart.replace(/\s+/g, "");
// Only return packages with exact version specifiers (== or ===)
// Skip range specifiers (<, >, <=, >=, ~=, !=) since they don't provide a specific version
if (!versionWithOperator.startsWith("==")) {
return null;
}
// Strip the == or === operator to get just the version number
const version = versionWithOperator.replace(/^===?/, "");
return { name, version };
}

View file

@ -1,110 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parsePackagesFromInstallArgs } from "./parsePackagesFromInstallArgs.js";
describe("parsePackagesFromInstallArgs", () => {
it("should parse simple package name", () => {
const result = parsePackagesFromInstallArgs(["install", "requests"]);
assert.deepEqual(result, [
{ name: "requests", version: "latest", type: "add" },
]);
});
it("should parse package with version specifier", () => {
const result = parsePackagesFromInstallArgs(["install", "requests==2.28.0"]);
assert.deepEqual(result, [
{ name: "requests", version: "2.28.0", type: "add" },
]);
});
it("should skip flags", () => {
const result = parsePackagesFromInstallArgs(["install", "--upgrade", "requests"]);
assert.deepEqual(result, [
{ name: "requests", version: "latest", type: "add" },
]);
});
it("should parse multiple packages", () => {
const result = parsePackagesFromInstallArgs(["install", "requests", "flask", "django==4.0"]);
assert.deepEqual(result, [
{ name: "requests", version: "latest", type: "add" },
{ name: "flask", version: "latest", type: "add" },
{ name: "django", version: "4.0", type: "add" },
]);
});
it("should parse extras and strip them from name", () => {
const result = parsePackagesFromInstallArgs(["install", "django[postgres]==4.2.1"]);
assert.deepEqual(result, [
{ name: "django", version: "4.2.1", type: "add" },
]);
});
it("should skip ranges", () => {
const result = parsePackagesFromInstallArgs(["install", "requests>=2,<3"]);
assert.deepEqual(result, []);
});
it("should skip packages with range specifiers", () => {
const result = parsePackagesFromInstallArgs([
"install",
"requests>=2.0.0",
"flask>1.0",
"django<=4.0",
"numpy~=1.20",
"scipy!=1.5.0",
"pandas==1.3.0",
]);
// Only pandas with exact version (==) should be returned
assert.deepEqual(result, [
{ name: "pandas", version: "1.3.0", type: "add" },
]);
});
it("should support === exact version specifier", () => {
const result = parsePackagesFromInstallArgs(["install", "requests===2.28.0"]);
assert.deepEqual(result, [
{ name: "requests", version: "2.28.0", type: "add" },
]);
});
it("should skip VCS/URL/path)", () => {
const result = parsePackagesFromInstallArgs([
"install",
"git+https://github.com/pallets/flask.git",
"https://files.pythonhosted.org/packages/foo/bar.whl",
"file:/tmp/pkg.whl",
"./localpkg",
]);
assert.deepEqual(result, []);
});
it("should return empty array for no packages", () => {
const result = parsePackagesFromInstallArgs(["install", "--help"]);
assert.deepEqual(result, []);
});
it("should skip all flags with parameters", () => {
const result = parsePackagesFromInstallArgs([
"install",
"--target",
"/tmp/target",
"--platform",
"linux",
"--python-version",
"3.9",
"--index-url",
"https://pypi.org/simple",
"--trusted-host",
"pypi.org",
"requests==2.28.0",
"--cache-dir",
"/tmp/cache",
"flask",
]);
assert.deepEqual(result, [
{ name: "requests", version: "2.28.0", type: "add" },
{ name: "flask", version: "latest", type: "add" },
]);
});
});

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) { if (error.status) {
return { status: error.status }; return { status: error.status };
} else { } 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 }; return { status: 1 };
} }
} }

View file

@ -1,30 +0,0 @@
export const pipInstallCommand = "install";
export const pipDownloadCommand = "download";
export const pipWheelCommand = "wheel";
/**
* @param {string[]} args
* @returns {string | null}
*/
export function getPipCommandForArgs(args) {
if (!args || args.length === 0) {
return null;
}
// The first non-flag argument is the command
for (const arg of args) {
if (!arg.startsWith("-")) {
return arg;
}
}
return null;
}
/**
* @param {string[]} args
* @returns {boolean}
*/
export function hasDryRunArg(args) {
return args.some((arg) => arg === "--dry-run");
}

View file

@ -1,83 +0,0 @@
import { test } from "node:test";
import assert from "node:assert";
import {
getPipCommandForArgs,
hasDryRunArg,
pipInstallCommand,
pipDownloadCommand,
pipWheelCommand,
} from "./pipCommands.js";
test("getPipCommandForArgs", async (t) => {
await t.test("should return null for empty args", () => {
assert.strictEqual(getPipCommandForArgs([]), null);
});
await t.test("should return null for null args", () => {
assert.strictEqual(getPipCommandForArgs(null), null);
});
await t.test("should return the first non-flag argument", () => {
assert.strictEqual(getPipCommandForArgs(["install"]), "install");
});
await t.test("should skip flags and return command", () => {
assert.strictEqual(
getPipCommandForArgs(["-v", "--verbose", "install"]),
"install"
);
});
await t.test("should return install command", () => {
assert.strictEqual(
getPipCommandForArgs(["install", "requests"]),
"install"
);
});
await t.test("should return uninstall command", () => {
assert.strictEqual(
getPipCommandForArgs(["uninstall", "requests"]),
"uninstall"
);
});
await t.test("should return null if only flags", () => {
assert.strictEqual(getPipCommandForArgs(["--version", "-v"]), null);
});
});
test("hasDryRunArg", async (t) => {
await t.test("should return false for empty args", () => {
assert.strictEqual(hasDryRunArg([]), false);
});
await t.test("should return true if --dry-run is present", () => {
assert.strictEqual(hasDryRunArg(["install", "--dry-run", "requests"]), true);
});
await t.test("should return false if --dry-run is not present", () => {
assert.strictEqual(hasDryRunArg(["install", "requests"]), false);
});
await t.test("should return true for --dry-run with other flags", () => {
assert.strictEqual(
hasDryRunArg(["install", "-v", "--dry-run", "--upgrade", "requests"]),
true
);
});
});
test("command constants", async (t) => {
await t.test("should have correct install command", () => {
assert.strictEqual(pipInstallCommand, "install");
});
await t.test("should have correct download command", () => {
assert.strictEqual(pipDownloadCommand, "download");
});
await t.test("should have correct wheel command", () => {
assert.strictEqual(pipWheelCommand, "wheel");
});
});

View file

@ -1,41 +1,32 @@
import { EventEmitter } from "events";
/** /**
* @typedef {import('./requestInterceptorBuilder.js').RequestInterceptorBuilder} RequestInterceptorBuilder
* @typedef {import('./requestInterceptorBuilder.js').RequestInterceptor} RequestInterceptor
*
* @typedef {Object} InterceptorBuilder
* @property {(requestFunc: (requestHandlerBuilder: RequestInterceptorBuilder) => Promise<void>) => void} onRequest
* @property {() => Interceptor} build
*
* @typedef {Object} Interceptor * @typedef {Object} Interceptor
* @property {(targetUrl: string) => Promise<RequestInterceptor>} handleRequest * @property {(targetUrl: string) => Promise<RequestInterceptionHandler>} handleRequest
* @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on * @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on
* @property {(event: string, ...args: any[]) => boolean} emit * @property {(event: string, ...args: any[]) => boolean} emit
*
*
* @typedef {Object} RequestInterceptionContext
* @property {string} targetUrl
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
* @property {() => RequestInterceptionHandler} build
*
*
* @typedef {Object} RequestInterceptionHandler
* @property {{statusCode: number, message: string} | undefined} blockResponse
*/ */
import { EventEmitter } from "events";
import { createRequestInterceptorBuilder } from "./requestInterceptorBuilder.js";
/** /**
* @returns {InterceptorBuilder} * @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>} requestInterceptionFunc
* @returns {Interceptor}
*/ */
export function createInterceptorBuilder() { export function interceptRequests(requestInterceptionFunc) {
/** return buildInterceptor([requestInterceptionFunc]);
* @type {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise<void>>}
*/
const requestHandlers = [];
return {
onRequest(requestFunc) {
requestHandlers.push(requestFunc);
},
build() {
return buildInterceptor(requestHandlers);
},
};
} }
/** /**
* @param {Array<(requestHandlerBuilder: RequestInterceptorBuilder) => Promise<void>>} requestHandlers * @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>>} requestHandlers
* @returns {Interceptor} * @returns {Interceptor}
*/ */
function buildInterceptor(requestHandlers) { function buildInterceptor(requestHandlers) {
@ -43,16 +34,13 @@ function buildInterceptor(requestHandlers) {
return { return {
async handleRequest(targetUrl) { async handleRequest(targetUrl) {
const reqInterceptorBuilder = createRequestInterceptorBuilder( const requestContext = createRequestContext(targetUrl, eventEmitter);
targetUrl,
eventEmitter
);
for (const handler of requestHandlers) { for (const handler of requestHandlers) {
await handler(reqInterceptorBuilder); await handler(requestContext);
} }
return reqInterceptorBuilder.build(); return requestContext.build();
}, },
on(event, listener) { on(event, listener) {
eventEmitter.on(event, listener); eventEmitter.on(event, listener);
@ -63,3 +51,42 @@ function buildInterceptor(requestHandlers) {
}, },
}; };
} }
/**
* @param {string} targetUrl
* @param {import('events').EventEmitter} eventEmitter
* @returns {RequestInterceptionContext}
*/
function createRequestContext(targetUrl, eventEmitter) {
/** @type {{statusCode: number, message: string} | undefined} */
let blockResponse = undefined;
/**
* @param {string | undefined} packageName
* @param {string | undefined} version
*/
function blockMalware(packageName, version) {
blockResponse = {
statusCode: 403,
message: "Forbidden - blocked by safe-chain",
};
// Emit the malwareBlocked event
eventEmitter.emit("malwareBlocked", {
packageName,
version,
targetUrl,
timestamp: Date.now(),
});
}
return {
targetUrl,
blockMalware,
build() {
return {
blockResponse,
};
},
};
}

View file

@ -1,9 +1,7 @@
import chalk from "chalk";
import { getMinimumPackageAgeHours } from "../../../config/settings.js"; import { getMinimumPackageAgeHours } from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { createInterceptorBuilder } from "../interceptorBuilder.js"; import { interceptRequests } from "../interceptorBuilder.js";
import { ui } from "../../../environment/userInteraction.js"; import { ui } from "../../../environment/userInteraction.js";
import { writeFileSync } from "node:fs";
const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
@ -23,22 +21,20 @@ export function npmInterceptorForUrl(url) {
/** /**
* @param {string} registry * @param {string} registry
* @returns {import("../interceptorBuilder.js").Interceptor | undefined} * @returns {import("../interceptorBuilder.js").Interceptor}
*/ */
function buildNpmInterceptor(registry) { function buildNpmInterceptor(registry) {
const builder = createInterceptorBuilder(); return interceptRequests(async (reqContext) => {
builder.onRequest(async (req) => {
const { packageName, version } = parseNpmPackageUrl( const { packageName, version } = parseNpmPackageUrl(
req.targetUrl, reqContext.targetUrl,
registry registry
); );
if (await isMalwarePackage(packageName, version)) { if (await isMalwarePackage(packageName, version)) {
req.blockMalware(packageName, version, req.targetUrl); reqContext.blockMalware(packageName, version);
} }
if (isPackageInfoUrl(req.targetUrl)) { if (isPackageInfoUrl(reqContext.targetUrl)) {
req.modifyRequestHeaders((headers) => { reqContext.modifyRequestHeaders((headers) => {
if ( if (
headers["accept"]?.includes("application/vnd.npm.install-v1+json") headers["accept"]?.includes("application/vnd.npm.install-v1+json")
) { ) {
@ -49,13 +45,11 @@ function buildNpmInterceptor(registry) {
} }
}); });
req.modifyResponse((res) => { reqContext.modifyResponse((res) => {
res.modifyBody(modifyNpmInfoRequestBody); res.modifyBody(modifyNpmInfoRequestBody);
}); });
} }
}); });
return builder.build();
} }
/** /**

View file

@ -1,5 +1,5 @@
import { isMalwarePackage } from "../../scanning/audit/index.js"; import { isMalwarePackage } from "../../scanning/audit/index.js";
import { createInterceptorBuilder } from "./interceptorBuilder.js"; import { interceptRequests } from "./interceptorBuilder.js";
const knownPipRegistries = [ const knownPipRegistries = [
"files.pythonhosted.org", "files.pythonhosted.org",
@ -27,19 +27,15 @@ export function pipInterceptorForUrl(url) {
* @returns {import("./interceptorBuilder.js").Interceptor | undefined} * @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/ */
function buildPipInterceptor(registry) { function buildPipInterceptor(registry) {
const builder = createInterceptorBuilder(); return interceptRequests(async (reqContext) => {
builder.onRequest(async (req) => {
const { packageName, version } = parsePipPackageFromUrl( const { packageName, version } = parsePipPackageFromUrl(
req.targetUrl, reqContext.targetUrl,
registry registry
); );
if (await isMalwarePackage(packageName, version)) { if (await isMalwarePackage(packageName, version)) {
req.blockMalware(packageName, version, req.targetUrl); reqContext.blockMalware(packageName, version);
} }
}); });
return builder.build();
} }
/** /**

View file

@ -1,121 +0,0 @@
/**
* @typedef {Object} RequestInterceptorBuilder
* @property {string} targetUrl
* @property {(statusCode: number, message: string) => void} blockRequest
* @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => void) => void} modifyRequestHeaders
* @property {(requestFunc: (responseInterceptorBuilder: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void) => void} modifyResponse
* @property {() => RequestInterceptor} build
*
* @typedef {Object} RequestInterceptor
* @property {{statusCode: number, message: string} | undefined} blockResponse
* @property {(headers: NodeJS.Dict<string | string[]> | undefined) => void} modifyRequestHeaders
* @property {() => import("./responseInterceptorBuilder.js").ResponseInterceptor} handleResponse
* @property {() => boolean} modifiesResponse
*/
import { createResponseInterceptorBuilder } from "./responseInterceptorBuilder.js";
/**
* @param {string} targetUrl
* @param {import('events').EventEmitter} eventEmitter
* @returns {RequestInterceptorBuilder}
*/
export function createRequestInterceptorBuilder(targetUrl, eventEmitter) {
/** @type {{statusCode: number, message: string} | undefined} */
let blockResponse = undefined;
/** @type {Array<(headers: NodeJS.Dict<string | string[]>) => void>} */
let requestHeaderFuncs = [];
/** @type {Array<(requestFunc: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void>} */
let responseModifierFuncs = [];
/**
* @param {number} statusCode
* @param {string} message
*/
function blockRequest(statusCode, message) {
blockResponse = { statusCode, message };
}
/**
* @param {string | undefined} packageName
* @param {string | undefined} version
* @param {string} url
*/
function blockMalware(packageName, version, url) {
blockRequest(403, "Forbidden - blocked by safe-chain");
// Emit the malwareBlocked event
eventEmitter.emit("malwareBlocked", {
packageName,
version,
url,
timestamp: Date.now(),
});
}
return {
targetUrl,
blockRequest,
blockMalware,
modifyRequestHeaders(modificationFunc) {
requestHeaderFuncs.push(modificationFunc);
},
modifyResponse(modificationFunc) {
responseModifierFuncs.push(modificationFunc);
},
build() {
return createRequestInterceptor(
blockResponse,
requestHeaderFuncs,
responseModifierFuncs
);
},
};
}
/**
* @param {{statusCode: number, message: string} | undefined} blockResponse
* @param {Array<(headers: NodeJS.Dict<string | string[]>) => void>} requestHeadersModficationFuncs
* @param {Array<(requestFunc: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void>} responseModifierFuncs
* @returns {RequestInterceptor}
*/
function createRequestInterceptor(
blockResponse,
requestHeadersModficationFuncs,
responseModifierFuncs
) {
/**
* @param {NodeJS.Dict<string | string[]> | undefined} headers
*/
function modifyRequestHeaders(headers) {
if (!headers) {
return;
}
for (const modificationFunc of requestHeadersModficationFuncs) {
modificationFunc(headers);
}
}
function modifiesResponse() {
return responseModifierFuncs.length > 0;
}
function handleResponse() {
const responseInterceptorBuilder = createResponseInterceptorBuilder();
for (const func of responseModifierFuncs) {
func(responseInterceptorBuilder);
}
return responseInterceptorBuilder.build();
}
return {
blockResponse,
modifyRequestHeaders,
modifiesResponse,
handleResponse,
};
}

View file

@ -73,8 +73,6 @@ export async function auditChanges(changes) {
); );
for (const change of changes) { for (const change of changes) {
//Uncomment next line during manual testing
//console.log(" Safe-chain: auditing package:", change);
const malwarePackage = malwarePackages.find( const malwarePackage = malwarePackages.find(
(pkg) => pkg.name === change.name && pkg.version === change.version (pkg) => pkg.name === change.name && pkg.version === change.version
); );

View file

@ -22,6 +22,8 @@ export const knownAikidoTools = [
{ tool: "bunx", aikidoCommand: "aikido-bunx" }, { tool: "bunx", aikidoCommand: "aikido-bunx" },
{ tool: "pip", aikidoCommand: "aikido-pip" }, { tool: "pip", aikidoCommand: "aikido-pip" },
{ tool: "pip3", aikidoCommand: "aikido-pip3" }, { 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 // When adding a new tool here, also update the documentation for the new tool in the README.md
]; ];

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

View file

@ -79,26 +79,10 @@ end
# `python -m pip`, `python -m pip3`. # `python -m pip`, `python -m pip3`.
function python function python
if test (count $argv) -ge 2; and test $argv[1] = "-m"; and string match -qr '^pip(3)?$' -- $argv[2] wrapSafeChainCommand "python" "aikido-python" $argv
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
end end
# `python3 -m pip`, `python3 -m pip3'. # `python3 -m pip`, `python3 -m pip3'.
function python3 function python3
if test (count $argv) -ge 2; and test $argv[1] = "-m"; and string match -qr '^pip(3)?$' -- $argv[2] wrapSafeChainCommand "python3" "aikido-python3" $argv
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
end end

View file

@ -71,26 +71,10 @@ function pip3() {
# `python -m pip`, `python -m pip3`. # `python -m pip`, `python -m pip3`.
function python() { function python() {
if [[ "$1" == "-m" && "$2" == pip* ]]; then wrapSafeChainCommand "python" "aikido-python" "$@"
local mod="$2"
shift 2
if [[ "$mod" == "pip3" ]]; then
wrapSafeChainCommand "pip3" "aikido-pip3" "$@"
else
wrapSafeChainCommand "pip" "aikido-pip" "$@"
fi
else
command python "$@"
fi
} }
# `python3 -m pip`, `python3 -m pip3'. # `python3 -m pip`, `python3 -m pip3'.
function python3() { function python3() {
if [[ "$1" == "-m" && "$2" == pip* ]]; then wrapSafeChainCommand "python3" "aikido-python3" "$@"
shift 2
# python3 always uses pip3, regardless of whether user types `pip` or `pip3`
wrapSafeChainCommand "pip3" "aikido-pip3" "$@"
else
command python3 "$@"
fi
} }

View file

@ -97,27 +97,11 @@ function pip3 {
# `python -m pip`, `python -m pip3`. # `python -m pip`, `python -m pip3`.
function python { function python {
param([Parameter(ValueFromRemainingArguments=$true)]$Args) Invoke-WrappedCommand 'python' 'aikido-python' $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
}
} }
# `python3 -m pip`, `python3 -m pip3'. # `python3 -m pip`, `python3 -m pip3'.
function python3 { function python3 {
param([Parameter(ValueFromRemainingArguments=$true)]$Args) Invoke-WrappedCommand 'python3' 'aikido-python3' $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
}
} }

View file

@ -53,7 +53,19 @@ RUN curl -fsSL https://bun.sh/install | bash
# Install Python and pip (pip3) # Install Python and pip (pip3)
RUN apt-get update && apt-get install -y python${PYTHON_VERSION} python3-pip && \ 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/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 and install Safe chain
COPY --from=builder /app/*.tgz /pkgs/ 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

@ -96,7 +96,7 @@ describe("E2E: pip coverage", () => {
it(`python3 -m pip install routes through safe-chain`, async () => { it(`python3 -m pip install routes through safe-chain`, async () => {
const shell = await container.openShell("zsh"); const shell = await container.openShell("zsh");
const result = await shell.runCommand("python3 -m pip install requests"); const result = await shell.runCommand("python3 -m pip install --break-system-packages requests");
assert.ok( assert.ok(
result.output.includes("no malware found."), result.output.includes("no malware found."),
@ -323,4 +323,12 @@ describe("E2E: pip coverage", () => {
`Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}` `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}`);
});
}); });