mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #124 from reiniercriel/feature/pypi
Add Python (pip) support for malware scanning
This commit is contained in:
commit
18f30ac66e
45 changed files with 2056 additions and 41 deletions
26
README.md
26
README.md
|
|
@ -1,8 +1,8 @@
|
|||
# Aikido Safe Chain
|
||||
|
||||
The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm, pnpx, bun, and bunx. It's **free** to use and does not require any token.
|
||||
The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Python ecosystem (through pip or pip3, including `python -m pip[...]` and `python3 -m pip[...]` where available) or in the Javascript ecosystem (through npm, npx, yarn, pnpm, pnpx, bun and bunx). It's **free** to use and does not require any token.
|
||||
|
||||
The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware.
|
||||
The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip/pip3 from downloading or running the malware.
|
||||
|
||||

|
||||
|
||||
|
|
@ -15,6 +15,8 @@ Aikido Safe Chain works on Node.js version 18 and above and supports the followi
|
|||
- ✅ **pnpx**
|
||||
- ✅ **bun**
|
||||
- ✅ **bunx**
|
||||
- ✅ **pip**
|
||||
- ✅ **pip3**
|
||||
|
||||
# Usage
|
||||
|
||||
|
|
@ -31,14 +33,22 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
|
|||
safe-chain setup
|
||||
```
|
||||
3. **❗Restart your terminal** to start using the Aikido Safe Chain.
|
||||
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, and bunx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||
4. **Verify the installation** by running:
|
||||
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||
4. **Verify the installation** by running one of the following commands:
|
||||
|
||||
For JavaScript/Node.js:
|
||||
```shell
|
||||
npm install safe-chain-test
|
||||
```
|
||||
- The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware.
|
||||
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, or `bunx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command.
|
||||
For Python:
|
||||
```shell
|
||||
pip3 install safe-chain-pi-test
|
||||
```
|
||||
|
||||
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
|
||||
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, or `pip3` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||
|
||||
You can check the installed version by running:
|
||||
|
||||
|
|
@ -48,9 +58,9 @@ safe-chain --version
|
|||
|
||||
## How it works
|
||||
|
||||
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry. When you run npm, npx, yarn, pnpm, pnpx, bun, or bunx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
|
||||
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
|
||||
|
||||
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, and bunx commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
|
||||
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
|
||||
|
||||
- ✅ **Bash**
|
||||
- ✅ **Zsh**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
||||
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
||||
|
||||
## Supported Shells
|
||||
|
||||
|
|
@ -28,7 +28,8 @@ This command:
|
|||
|
||||
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
||||
- Detects all supported shells on your system
|
||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx`
|
||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3`
|
||||
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
|
||||
|
||||
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
||||
|
||||
|
|
@ -77,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts:
|
|||
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
|
||||
|
||||
- Make sure Aikido Safe Chain is properly installed on your system
|
||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, and `aikido-bunx` commands exist
|
||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, and `aikido-pip3` commands exist
|
||||
- Check that these commands are in your system's PATH
|
||||
|
||||
### Manual Verification
|
||||
|
|
@ -120,4 +121,29 @@ npm() {
|
|||
}
|
||||
```
|
||||
|
||||
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
||||
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
||||
|
||||
To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:
|
||||
|
||||
```bash
|
||||
# Example for Bash/Zsh
|
||||
python() {
|
||||
if [[ "$1" == "-m" && "$2" == pip* ]]; then
|
||||
local mod="$2"; shift 2
|
||||
if [[ "$mod" == "pip3" ]]; then aikido-pip3 "$@"; else aikido-pip "$@"; fi
|
||||
else
|
||||
command python "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
python3() {
|
||||
if [[ "$1" == "-m" && "$2" == pip* ]]; then
|
||||
local mod="$2"; shift 2
|
||||
if [[ "$mod" == "pip3" ]]; then aikido-pip3 "$@"; else aikido-pip "$@"; fi
|
||||
else
|
||||
command python3 "$@"
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
Limitations: these only apply when invoking `python`/`python3` by name. Absolute paths (e.g., `/usr/bin/python -m pip`) bypass shell functions.
|
||||
|
|
|
|||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -623,6 +623,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/certifi": {
|
||||
"version": "14.5.15",
|
||||
"resolved": "https://registry.npmjs.org/certifi/-/certifi-14.5.15.tgz",
|
||||
"integrity": "sha512-NeLXuKCqSzwQNjpJ+WaSp5m8ntdTKJ8HnBu+eA7DxHfgzU7F1sjwrJFang+4U38+vmWbiFUpPZMV3uwwnHAisQ==",
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
|
||||
|
|
@ -2071,6 +2080,7 @@
|
|||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"certifi": "^14.5.15",
|
||||
"chalk": "5.4.1",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"make-fetch-happen": "14.0.3",
|
||||
|
|
@ -2084,6 +2094,8 @@
|
|||
"aikido-bunx": "bin/aikido-bunx.js",
|
||||
"aikido-npm": "bin/aikido-npm.js",
|
||||
"aikido-npx": "bin/aikido-npx.js",
|
||||
"aikido-pip": "bin/aikido-pip.js",
|
||||
"aikido-pip3": "bin/aikido-pip3.js",
|
||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||
"aikido-yarn": "bin/aikido-yarn.js",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "bun";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "bunx";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "npm";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "npx";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
|
|
|||
19
packages/safe-chain/bin/aikido-pip.js
Executable file
19
packages/safe-chain/bin/aikido-pip.js
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
process.exit(exitCode);
|
||||
19
packages/safe-chain/bin/aikido-pip3.js
Executable file
19
packages/safe-chain/bin/aikido-pip3.js
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
|
||||
|
||||
// Explicit pip3 entrypoint
|
||||
const packageManagerName = "pip3";
|
||||
|
||||
// Copy argv as-is
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
// Set ecosystem to Python
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(argv);
|
||||
|
||||
process.exit(exitCode);
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "pnpm";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "pnpx";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "yarn";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ function writeHelp() {
|
|||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain setup"
|
||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun and bunx.`
|
||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx and pip.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||
"aikido-bun": "bin/aikido-bun.js",
|
||||
"aikido-bunx": "bin/aikido-bunx.js",
|
||||
"aikido-pip": "bin/aikido-pip.js",
|
||||
"aikido-pip3": "bin/aikido-pip3.js",
|
||||
"safe-chain": "bin/safe-chain.js"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
@ -31,6 +33,7 @@
|
|||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware.",
|
||||
"dependencies": {
|
||||
"certifi": "^14.5.15",
|
||||
"chalk": "5.4.1",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"make-fetch-happen": "14.0.3",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import fetch from "make-fetch-happen";
|
||||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
const malwareDatabaseUrl =
|
||||
"https://malware-list.aikido.dev/malware_predictions.json";
|
||||
const malwareDatabaseUrls = {
|
||||
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json",
|
||||
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json",
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} MalwarePackage
|
||||
|
|
@ -14,9 +17,11 @@ const malwareDatabaseUrl =
|
|||
* @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>}
|
||||
*/
|
||||
export async function fetchMalwareDatabase() {
|
||||
const ecosystem = getEcoSystem();
|
||||
const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
|
||||
const response = await fetch(malwareDatabaseUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching malware database: ${response.statusText}`);
|
||||
throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -34,12 +39,15 @@ export async function fetchMalwareDatabase() {
|
|||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export async function fetchMalwareDatabaseVersion() {
|
||||
const ecosystem = getEcoSystem();
|
||||
const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
|
||||
const response = await fetch(malwareDatabaseUrl, {
|
||||
method: "HEAD",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Error fetching malware database version: ${response.statusText}`
|
||||
`Error fetching ${ecosystem} malware database version: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
return response.headers.get("etag") || undefined;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from "fs";
|
|||
import path from "path";
|
||||
import os from "os";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { getEcoSystem } from "./settings.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} SafeChainConfig
|
||||
|
|
@ -128,12 +129,14 @@ function readConfigFile() {
|
|||
*/
|
||||
function getDatabasePath() {
|
||||
const aikidoDir = getAikidoDirectory();
|
||||
return path.join(aikidoDir, "malwareDatabase.json");
|
||||
const ecosystem = getEcoSystem();
|
||||
return path.join(aikidoDir, `malwareDatabase_${ecosystem}.json`);
|
||||
}
|
||||
|
||||
function getDatabaseVersionPath() {
|
||||
const aikidoDir = getAikidoDirectory();
|
||||
return path.join(aikidoDir, "version.txt");
|
||||
const ecosystem = getEcoSystem();
|
||||
return path.join(aikidoDir, `version_${ecosystem}.txt`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,6 +14,28 @@ export function getLoggingLevel() {
|
|||
return LOGGING_NORMAL;
|
||||
}
|
||||
|
||||
export const MALWARE_ACTION_BLOCK = "block";
|
||||
export const MALWARE_ACTION_PROMPT = "prompt";
|
||||
|
||||
export const ECOSYSTEM_JS = "js";
|
||||
export const ECOSYSTEM_PY = "py";
|
||||
|
||||
// Default to JavaScript ecosystem
|
||||
const ecosystemSettings = {
|
||||
ecoSystem: ECOSYSTEM_JS,
|
||||
};
|
||||
|
||||
/** @returns {string} - The current ecosystem setting (ECOSYSTEM_JS or ECOSYSTEM_PY) */
|
||||
export function getEcoSystem() {
|
||||
return ecosystemSettings.ecoSystem;
|
||||
}
|
||||
/**
|
||||
* @param {string} setting - The ecosystem to set (ECOSYSTEM_JS or ECOSYSTEM_PY)
|
||||
*/
|
||||
export function setEcoSystem(setting) {
|
||||
ecosystemSettings.ecoSystem = setting;
|
||||
}
|
||||
|
||||
export const LOGGING_SILENT = "silent";
|
||||
export const LOGGING_NORMAL = "normal";
|
||||
export const LOGGING_VERBOSE = "verbose";
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
createPnpxPackageManager,
|
||||
} from "./pnpm/createPackageManager.js";
|
||||
import { createYarnPackageManager } from "./yarn/createPackageManager.js";
|
||||
import { createPipPackageManager } from "./pip/createPackageManager.js";
|
||||
|
||||
/**
|
||||
* @type {{packageManagerName: PackageManager | null}}
|
||||
|
|
@ -51,6 +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 {
|
||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
|
||||
import { runPip } from "./runPipCommand.js";
|
||||
import {
|
||||
getPipCommandForArgs,
|
||||
pipInstallCommand,
|
||||
pipDownloadCommand,
|
||||
pipWheelCommand,
|
||||
} from "./utils/pipCommands.js";
|
||||
|
||||
/**
|
||||
* @param {string} [command]
|
||||
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||
*/
|
||||
export function createPipPackageManager(command = "pip") {
|
||||
/**
|
||||
* @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 {
|
||||
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
|
||||
* @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
|
||||
*/
|
||||
function findDependencyScannerForCommand(scanners, args) {
|
||||
const command = getPipCommandForArgs(args);
|
||||
if (!command) {
|
||||
return nullScanner();
|
||||
}
|
||||
|
||||
const scanner = scanners[command];
|
||||
return scanner || nullScanner();
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { createPipPackageManager } from "./createPackageManager.js";
|
||||
|
||||
test("createPipPackageManager", async (t) => {
|
||||
await t.test("should create package manager with required interface", () => {
|
||||
const pm = createPipPackageManager();
|
||||
|
||||
assert.ok(pm);
|
||||
assert.strictEqual(typeof pm.runCommand, "function");
|
||||
assert.strictEqual(typeof pm.isSupportedCommand, "function");
|
||||
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
|
||||
});
|
||||
|
||||
await t.test("should accept pip3 as command parameter", () => {
|
||||
const pm = createPipPackageManager("pip3");
|
||||
assert.ok(pm);
|
||||
});
|
||||
|
||||
await t.test("should support install, download, and wheel commands", () => {
|
||||
const pm = createPipPackageManager();
|
||||
|
||||
assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), true);
|
||||
assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), true);
|
||||
assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), true);
|
||||
});
|
||||
|
||||
await t.test("should not support uninstall and info commands", () => {
|
||||
const pm = createPipPackageManager();
|
||||
|
||||
assert.strictEqual(pm.isSupportedCommand(["uninstall", "requests"]), false);
|
||||
assert.strictEqual(pm.isSupportedCommand(["list"]), false);
|
||||
assert.strictEqual(pm.isSupportedCommand(["show", "requests"]), false);
|
||||
});
|
||||
|
||||
await t.test("should extract packages from install command", () => {
|
||||
const pm = createPipPackageManager();
|
||||
|
||||
const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]);
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].name, "requests");
|
||||
assert.strictEqual(result[0].version, "2.28.0");
|
||||
});
|
||||
|
||||
await t.test("should return empty array for unsupported commands", () => {
|
||||
const pm = createPipPackageManager();
|
||||
|
||||
const result = pm.getDependencyUpdatesForCommand(["uninstall", "requests"]);
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
await t.test("should handle empty args gracefully", () => {
|
||||
const pm = createPipPackageManager();
|
||||
|
||||
assert.strictEqual(pm.isSupportedCommand([]), false);
|
||||
assert.deepStrictEqual(pm.getDependencyUpdatesForCommand([]), []);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
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, []);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* @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 };
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
36
packages/safe-chain/src/packagemanager/pip/runPipCommand.js
Normal file
36
packages/safe-chain/src/packagemanager/pip/runPipCommand.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
||||
|
||||
/**
|
||||
* @param {string} command
|
||||
* @param {string[]} args
|
||||
*
|
||||
* @returns {Promise<{status: number}>}
|
||||
*/
|
||||
export async function runPip(command, args) {
|
||||
try {
|
||||
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||
|
||||
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots)
|
||||
// so that any network request made by pip, including those outside explicit CLI args,
|
||||
// validates correctly under both MITM'd and tunneled HTTPS.
|
||||
const combinedCaPath = getCombinedCaBundlePath();
|
||||
env.REQUESTS_CA_BUNDLE = combinedCaPath;
|
||||
env.SSL_CERT_FILE = combinedCaPath;
|
||||
|
||||
const result = await safeSpawn(command, args, {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
return { status: result.status };
|
||||
} catch (/** @type any */ error) {
|
||||
if (error.status) {
|
||||
return { status: error.status };
|
||||
} else {
|
||||
ui.writeError("Error executing command:", error.message);
|
||||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
113
packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js
Normal file
113
packages/safe-chain/src/packagemanager/pip/runPipCommand.spec.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("runPipCommand environment variable handling", () => {
|
||||
let runPip;
|
||||
let capturedArgs = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedArgs = null;
|
||||
|
||||
// Mock safeSpawn to capture args
|
||||
mock.module("../../utils/safeSpawn.js", {
|
||||
namedExports: {
|
||||
safeSpawn: async (command, args, options) => {
|
||||
capturedArgs = { command, args, options };
|
||||
return { status: 0 };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock proxy env merge
|
||||
mock.module("../../registryProxy/registryProxy.js", {
|
||||
namedExports: {
|
||||
mergeSafeChainProxyEnvironmentVariables: (env) => ({
|
||||
...env,
|
||||
HTTPS_PROXY: "http://localhost:8080",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Mock certBundle to return a test combined bundle path
|
||||
mock.module("../../registryProxy/certBundle.js", {
|
||||
namedExports: {
|
||||
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
|
||||
},
|
||||
});
|
||||
|
||||
const mod = await import("./runPipCommand.js");
|
||||
runPip = mod.runPip;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
it("should set REQUESTS_CA_BUNDLE and SSL_CERT_FILE for default PyPI (no explicit index)", async () => {
|
||||
const res = await runPip("pip3", ["install", "requests"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
|
||||
assert.ok(capturedArgs, "safeSpawn should have been called");
|
||||
|
||||
// Check environment variables are set
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||
"/tmp/test-combined-ca.pem",
|
||||
"REQUESTS_CA_BUNDLE should be set to combined bundle path"
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.SSL_CERT_FILE,
|
||||
"/tmp/test-combined-ca.pem",
|
||||
"SSL_CERT_FILE should be set to combined bundle path"
|
||||
);
|
||||
|
||||
// Args should be unchanged (no arg injection)
|
||||
assert.deepStrictEqual(capturedArgs.args, ["install", "requests"]);
|
||||
});
|
||||
|
||||
it("should set CA environment variables even for external/test PyPI mirror (covers non-CLI traffic)", async () => {
|
||||
const res = await runPip("pip3", [
|
||||
"install",
|
||||
"certifi",
|
||||
"--index-url",
|
||||
"https://test.pypi.org/simple",
|
||||
]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
// Env vars should be set unconditionally
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||
"/tmp/test-combined-ca.pem"
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.SSL_CERT_FILE,
|
||||
"/tmp/test-combined-ca.pem"
|
||||
);
|
||||
});
|
||||
|
||||
it("should still set CA env vars for PyPI even with user --cert flag", async () => {
|
||||
// For default PyPI, we still set env vars; pip CLI --cert takes precedence
|
||||
const res = await runPip("pip3", ["install", "requests"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
|
||||
// Environment variables still set (pip CLI --cert takes precedence)
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
|
||||
"/tmp/test-combined-ca.pem"
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.SSL_CERT_FILE,
|
||||
"/tmp/test-combined-ca.pem"
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve HTTPS_PROXY from proxy merge", async () => {
|
||||
const res = await runPip("pip3", ["install", "requests"]);
|
||||
assert.strictEqual(res.status, 0);
|
||||
|
||||
assert.strictEqual(
|
||||
capturedArgs.options.env.HTTPS_PROXY,
|
||||
"http://localhost:8080",
|
||||
"HTTPS_PROXY should be set by proxy merge"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
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");
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
95
packages/safe-chain/src/registryProxy/certBundle.js
Normal file
95
packages/safe-chain/src/registryProxy/certBundle.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
// @ts-ignore - certifi has no type definitions
|
||||
import certifi from "certifi";
|
||||
import tls from "node:tls";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
|
||||
/**
|
||||
* Check if a PEM string contains only parsable cert blocks.
|
||||
* @param {string} pem - PEM-encoded certificate string
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isParsable(pem) {
|
||||
if (!pem || typeof pem !== "string") return false;
|
||||
const begin = "-----BEGIN CERTIFICATE-----";
|
||||
const end = "-----END CERTIFICATE-----";
|
||||
const blocks = [];
|
||||
|
||||
let idx = 0;
|
||||
while (idx < pem.length) {
|
||||
const start = pem.indexOf(begin, idx);
|
||||
if (start === -1) break;
|
||||
const stop = pem.indexOf(end, start + begin.length);
|
||||
if (stop === -1) break;
|
||||
const blockEnd = stop + end.length;
|
||||
blocks.push(pem.slice(start, blockEnd));
|
||||
idx = blockEnd;
|
||||
}
|
||||
|
||||
if (blocks.length === 0) return false;
|
||||
try {
|
||||
for (const b of blocks) {
|
||||
// throw if invalid
|
||||
new X509Certificate(b);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {string | null} */
|
||||
let cachedPath = null;
|
||||
|
||||
/**
|
||||
* Build a combined CA bundle for Python and Node HTTPS flows.
|
||||
* - Includes Safe Chain CA (for MITM of known registries)
|
||||
* - Includes Mozilla roots via npm `certifi` (public HTTPS)
|
||||
* - Includes Node's built-in root certificates as a portable fallback
|
||||
* @returns {string} Path to the combined CA bundle PEM file
|
||||
*/
|
||||
export function getCombinedCaBundlePath() {
|
||||
if (cachedPath && fs.existsSync(cachedPath)) return cachedPath;
|
||||
|
||||
// Concatenate PEM files
|
||||
const parts = [];
|
||||
|
||||
// 1) Safe Chain CA (for MITM'd registries)
|
||||
const safeChainPath = getCaCertPath();
|
||||
try {
|
||||
const safeChainPem = fs.readFileSync(safeChainPath, "utf8");
|
||||
if (isParsable(safeChainPem)) parts.push(safeChainPem.trim());
|
||||
} catch {
|
||||
// Ignore if Safe Chain CA is not available
|
||||
}
|
||||
|
||||
// 2) certifi (Mozilla CA bundle for all public HTTPS)
|
||||
try {
|
||||
const certifiPem = fs.readFileSync(certifi, "utf8");
|
||||
if (isParsable(certifiPem)) parts.push(certifiPem.trim());
|
||||
} catch {
|
||||
// Ignore if certifi bundle is not available
|
||||
}
|
||||
|
||||
// 3) Node's built-in root certificates
|
||||
try {
|
||||
const nodeRoots = tls.rootCertificates;
|
||||
if (Array.isArray(nodeRoots) && nodeRoots.length) {
|
||||
for (const rootPem of nodeRoots) {
|
||||
if (typeof rootPem !== "string") continue;
|
||||
if (isParsable(rootPem)) parts.push(rootPem.trim());
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore if unavailable
|
||||
}
|
||||
|
||||
const combined = parts.filter(Boolean).join("\n");
|
||||
const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem");
|
||||
fs.writeFileSync(target, combined, { encoding: "utf8" });
|
||||
cachedPath = target;
|
||||
return cachedPath;
|
||||
}
|
||||
71
packages/safe-chain/src/registryProxy/certBundle.spec.js
Normal file
71
packages/safe-chain/src/registryProxy/certBundle.spec.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, it, beforeEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import tls from "node:tls";
|
||||
|
||||
// Utility to remove the generated bundle so the module rebuilds it on demand
|
||||
function removeBundleIfExists() {
|
||||
const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem");
|
||||
try {
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
describe("certBundle.getCombinedCaBundlePath", () => {
|
||||
beforeEach(() => {
|
||||
mock.restoreAll();
|
||||
removeBundleIfExists();
|
||||
});
|
||||
|
||||
it("includes Safe Chain CA when parsable and produces a PEM bundle", async () => {
|
||||
// Prepare a temporary Safe Chain CA file with a recognizable marker and a valid cert block
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain-ca.pem");
|
||||
const marker = "# SAFE_CHAIN_TEST_MARKER";
|
||||
const rootPem = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
|
||||
assert.ok(rootPem.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
|
||||
fs.writeFileSync(safeChainPath, `${marker}\n${rootPem}`, "utf8");
|
||||
|
||||
// Mock the certUtils.getCaCertPath to return our temp file
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/);
|
||||
assert.ok(contents.includes(marker), "Bundle should include Safe Chain CA content when parsable");
|
||||
});
|
||||
|
||||
it("ignores invalid Safe Chain CA but still builds from other sources", async () => {
|
||||
// Write an invalid file (no cert blocks)
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain-invalid.pem");
|
||||
const invalidMarker = "INVALID_SAFE_CHAIN_CONTENT";
|
||||
fs.writeFileSync(safeChainPath, invalidMarker, "utf8");
|
||||
|
||||
// Mock the certUtils.getCaCertPath to return our invalid file
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure fresh build
|
||||
removeBundleIfExists();
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Bundle should contain certificate blocks from certifi/Node roots");
|
||||
assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,19 +1,44 @@
|
|||
export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
|
||||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
export const knownJsRegistries = ["registry.npmjs.org","registry.yarnpkg.com"];
|
||||
export const knownPipRegistries = ["files.pythonhosted.org", "pypi.org", "pypi.python.org", "pythonhosted.org"];
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
export function parsePackageFromUrl(url) {
|
||||
let packageName, version, registry;
|
||||
const ecosystem = getEcoSystem();
|
||||
let registry;
|
||||
|
||||
for (const knownRegistry of knownRegistries) {
|
||||
if (url.includes(knownRegistry)) {
|
||||
registry = knownRegistry;
|
||||
break;
|
||||
// Only check registries that match the current ecosystem
|
||||
if (ecosystem === ECOSYSTEM_JS) {
|
||||
for (const knownRegistry of knownJsRegistries) {
|
||||
if (url.includes(knownRegistry)) {
|
||||
registry = knownRegistry;
|
||||
return parseJsPackageFromUrl(url, registry);
|
||||
}
|
||||
}
|
||||
} else if (ecosystem === ECOSYSTEM_PY) {
|
||||
for (const knownRegistry of knownPipRegistries) {
|
||||
if (url.includes(knownRegistry)) {
|
||||
registry = knownRegistry;
|
||||
return parsePipPackageFromUrl(url, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no known registry matched, return { packageName: undefined, version: undefined }
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
function parseJsPackageFromUrl(url, registry) {
|
||||
let packageName, version;
|
||||
if (!registry || !url.endsWith(".tgz")) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
|
@ -50,3 +75,79 @@ export function parsePackageFromUrl(url) {
|
|||
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
function parsePipPackageFromUrl(url, registry) {
|
||||
let packageName, version
|
||||
|
||||
// Basic validation
|
||||
if (!registry || typeof url !== "string") {
|
||||
return { packageName, version};
|
||||
}
|
||||
|
||||
// Quick sanity check on the URL + parse
|
||||
let urlObj;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName, version};
|
||||
}
|
||||
|
||||
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
|
||||
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
||||
if (!lastSegment){
|
||||
return { packageName, version};
|
||||
}
|
||||
|
||||
const filename = decodeURIComponent(lastSegment);
|
||||
|
||||
// Parse Python package downloads from PyPI/pythonhosted.org
|
||||
// Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
|
||||
// Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
|
||||
|
||||
// Wheel (.whl)
|
||||
if (filename.endsWith(".whl")) {
|
||||
const base = filename.slice(0, -4); // remove ".whl"
|
||||
const firstDash = base.indexOf("-");
|
||||
if (firstDash > 0) {
|
||||
const dist = base.slice(0, firstDash); // may contain underscores
|
||||
const rest = base.slice(firstDash + 1); // version + the rest of tags
|
||||
const secondDash = rest.indexOf("-");
|
||||
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
|
||||
packageName = dist; // preserve underscores
|
||||
version = rawVersion;
|
||||
// Reject "latest" as it's a placeholder, not a real version
|
||||
// When version is "latest", this signals the URL doesn't contain actual version info
|
||||
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
// Source dist (sdist)
|
||||
const sdistExtMatch = filename.match(/\.(tar\.gz|zip|tar\.bz2|tar\.xz)$/i);
|
||||
if (sdistExtMatch) {
|
||||
const base = filename.slice(0, -sdistExtMatch[0].length);
|
||||
const lastDash = base.lastIndexOf("-");
|
||||
if (lastDash > 0 && lastDash < base.length - 1) {
|
||||
packageName = base.slice(0, lastDash);
|
||||
version = base.slice(lastDash + 1);
|
||||
// Reject "latest" as it's a placeholder, not a real version
|
||||
// When version is "latest", this signals the URL doesn't contain actual version info
|
||||
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown file type or invalid
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { describe, it } from "node:test";
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
describe("parsePackageFromUrl", () => {
|
||||
beforeEach(() => {
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
// Regular packages
|
||||
{
|
||||
|
|
@ -112,3 +117,85 @@ describe("parsePackageFromUrl", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePackageFromUrl - pip URLs", () => {
|
||||
beforeEach(() => {
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
});
|
||||
|
||||
const pipTestCases = [
|
||||
// Valid pip URLs
|
||||
{
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
|
||||
expected: { packageName: "foobar", version: "1.2.3" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz",
|
||||
expected: { packageName: "foobar", version: "1.2.3" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz",
|
||||
expected: { packageName: "foo-bar", version: "0.9.0" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz",
|
||||
expected: { packageName: "foo.bar", version: "1.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0b1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0rc1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0.post1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0.dev1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0a1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
|
||||
expected: { packageName: "foo_bar", version: "2.0.0" },
|
||||
},
|
||||
// Invalid pip URLs
|
||||
{
|
||||
url: "https://pypi.org/simple/",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/project/foobar/",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
{
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
];
|
||||
|
||||
pipTestCases.forEach(({ url, expected }, index) => {
|
||||
it(`should parse pip URL ${index + 1}: ${url}`, () => {
|
||||
const result = parsePackageFromUrl(url);
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,32 @@ describe("registryProxy.connectTunnel", () => {
|
|||
socket.destroy();
|
||||
});
|
||||
|
||||
it("should use destination's real certificate (not safe-chain's self-signed CA)", async () => {
|
||||
const socket = await connectToProxy(proxyHost, proxyPort);
|
||||
await establishHttpsTunnel(socket, "postman-echo.com", 443);
|
||||
|
||||
// Verifies that tunnel requests pass through the destination's real certificate
|
||||
// without interception by the safe-chain MITM proxy.
|
||||
const certInfo = await getTlsCertificateInfo(
|
||||
socket,
|
||||
new URL("https://postman-echo.com")
|
||||
);
|
||||
|
||||
// Verify the certificate is NOT issued by our safe-chain CA
|
||||
// Our self-signed CA would have issuer: "Safe-Chain Proxy CA"
|
||||
assert.ok(certInfo.issuer !== undefined, "Certificate should have an issuer");
|
||||
assert.ok(
|
||||
!certInfo.issuer.includes("Safe-Chain"),
|
||||
`Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}`
|
||||
);
|
||||
|
||||
// Verify it's a real certificate with proper hostname
|
||||
assert.strictEqual(certInfo.subject.includes("postman-echo.com"), true,
|
||||
`Certificate subject should include postman-echo.com, got: ${certInfo.subject}`);
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should return 502 Bad Gateway for invalid hostname", async () => {
|
||||
const socket = await connectToProxy(proxyHost, proxyPort);
|
||||
|
|
@ -141,12 +167,15 @@ function establishHttpsTunnel(socket, targetHost, targetPort) {
|
|||
});
|
||||
}
|
||||
|
||||
function sendHttpsRequestThroughTunnel(socket, verb, url) {
|
||||
function sendHttpsRequestThroughTunnel(socket, verb, url, rejectUnauthorized = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tlsSocket = tls.connect(
|
||||
{
|
||||
socket: socket,
|
||||
servername: url.hostname,
|
||||
// Tests should focus on tunnel behavior, not system CA state;
|
||||
// disable CA verification to avoid flakiness on machines without full roots.
|
||||
rejectUnauthorized: rejectUnauthorized,
|
||||
},
|
||||
() => {
|
||||
tlsSocket.write(
|
||||
|
|
@ -170,3 +199,35 @@ function sendHttpsRequestThroughTunnel(socket, verb, url) {
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTlsCertificateInfo(socket, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tlsSocket = tls.connect(
|
||||
{
|
||||
socket: socket,
|
||||
servername: url.hostname,
|
||||
// Don't reject unauthorized to avoid system CA issues in CI
|
||||
// We just want to inspect the certificate
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
const cert = tlsSocket.getPeerCertificate();
|
||||
|
||||
// Extract issuer and subject information
|
||||
const issuer = cert.issuer ?
|
||||
Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(", ") :
|
||||
"unknown";
|
||||
const subject = cert.subject ?
|
||||
Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(", ") :
|
||||
"unknown";
|
||||
|
||||
tlsSocket.end();
|
||||
resolve({ issuer, subject });
|
||||
}
|
||||
);
|
||||
|
||||
tlsSocket.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { mitmConnect } from "./mitmRequestHandler.js";
|
|||
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { auditChanges } from "../scanning/audit/index.js";
|
||||
import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||
import { knownJsRegistries, knownPipRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
|
|
@ -130,11 +131,18 @@ function stopServer(server) {
|
|||
function handleConnect(req, clientSocket, head) {
|
||||
// CONNECT method is used for HTTPS requests
|
||||
// It establishes a tunnel to the server identified by the request URL
|
||||
const url = req.url;
|
||||
|
||||
if (url && knownRegistries.some((reg) => url.includes(reg))) {
|
||||
// For npm and yarn registries, we want to intercept and inspect the traffic
|
||||
// so we can block packages with malware
|
||||
const ecosystem = getEcoSystem();
|
||||
const url = req.url || "";
|
||||
|
||||
let isKnownRegistry = false;
|
||||
if (ecosystem === ECOSYSTEM_JS) {
|
||||
isKnownRegistry = knownJsRegistries.some((reg) => url.includes(reg));
|
||||
} else if (ecosystem === ECOSYSTEM_PY) {
|
||||
isKnownRegistry = knownPipRegistries.some((reg) => url.includes(reg));
|
||||
}
|
||||
|
||||
if (isKnownRegistry) {
|
||||
mitmConnect(req, clientSocket, isAllowedUrl);
|
||||
} else {
|
||||
// For other hosts, just tunnel the request to the destination tcp socket
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
mergeSafeChainProxyEnvironmentVariables,
|
||||
} from "./registryProxy.js";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
import fs from "fs";
|
||||
|
||||
describe("registryProxy.mitm", () => {
|
||||
|
|
@ -19,6 +20,8 @@ describe("registryProxy.mitm", () => {
|
|||
const proxyUrl = new URL(envVars.HTTPS_PROXY);
|
||||
proxyHost = proxyUrl.hostname;
|
||||
proxyPort = parseInt(proxyUrl.port, 10);
|
||||
// Default to JS ecosystem for JS registry tests
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
|
|
@ -140,6 +143,66 @@ describe("registryProxy.mitm", () => {
|
|||
// Same hostname should get the same certificate (fingerprint)
|
||||
assert.strictEqual(cert1.fingerprint, cert2.fingerprint);
|
||||
});
|
||||
|
||||
// --- Pip registry MITM and env var tests ---
|
||||
it("should NOT set Python CA environment variables in proxy merge (handled by runPipCommand)", () => {
|
||||
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
|
||||
assert.strictEqual(envVars.PIP_CERT, undefined);
|
||||
assert.strictEqual(envVars.REQUESTS_CA_BUNDLE, undefined);
|
||||
assert.strictEqual(envVars.SSL_CERT_FILE, undefined);
|
||||
});
|
||||
|
||||
it("should intercept HTTPS requests to pypi.org for pip package", async () => {
|
||||
// Switch to Python ecosystem for pip registry MITM tests
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
"pypi.org",
|
||||
"/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz"
|
||||
);
|
||||
assert.notStrictEqual(response.statusCode, 403);
|
||||
assert.ok(typeof response.body === "string");
|
||||
});
|
||||
|
||||
it("should intercept HTTPS requests to files.pythonhosted.org for pip wheel", async () => {
|
||||
// Ensure Python ecosystem
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
"files.pythonhosted.org",
|
||||
"/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl"
|
||||
);
|
||||
assert.notStrictEqual(response.statusCode, 403);
|
||||
assert.ok(typeof response.body === "string");
|
||||
});
|
||||
|
||||
it("should handle pip package with a1 version", async () => {
|
||||
// Ensure Python ecosystem
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
"pypi.org",
|
||||
"/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz"
|
||||
);
|
||||
assert.notStrictEqual(response.statusCode, 403);
|
||||
assert.ok(typeof response.body === "string");
|
||||
});
|
||||
|
||||
it("should handle pip package with latest version (should not block)", async () => {
|
||||
// Ensure Python ecosystem
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
"pypi.org",
|
||||
"/packages/source/f/foo_bar/foo_bar-latest.tar.gz"
|
||||
);
|
||||
assert.notStrictEqual(response.statusCode, 403);
|
||||
assert.ok(typeof response.body === "string");
|
||||
});
|
||||
});
|
||||
|
||||
async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ 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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
writeDatabaseToLocalCache,
|
||||
} from "../config/configFile.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} MalwareDatabase
|
||||
|
|
@ -17,6 +18,22 @@ import { ui } from "../environment/userInteraction.js";
|
|||
/** @type {MalwareDatabase | null} */
|
||||
let cachedMalwareDatabase = null;
|
||||
|
||||
/**
|
||||
* Normalize package name for comparison.
|
||||
* For Python packages (PEP-503): lowercase and replace _, -, . with -
|
||||
* For js packages: keep as-is (case-sensitive)
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizePackageName(name) {
|
||||
const ecosystem = getEcoSystem();
|
||||
if (ecosystem === ECOSYSTEM_PY) {
|
||||
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
export async function openMalwareDatabase() {
|
||||
if (cachedMalwareDatabase) {
|
||||
return cachedMalwareDatabase;
|
||||
|
|
@ -30,10 +47,13 @@ export async function openMalwareDatabase() {
|
|||
* @returns {string}
|
||||
*/
|
||||
function getPackageStatus(name, version) {
|
||||
const normalizedName = normalizePackageName(name);
|
||||
const packageData = malwareDatabase.find(
|
||||
(pkg) =>
|
||||
pkg.package_name === name &&
|
||||
(pkg.version === version || pkg.version === "*")
|
||||
(pkg) => {
|
||||
const normalizedPkgName = normalizePackageName(pkg.package_name);
|
||||
return normalizedPkgName === normalizedName &&
|
||||
(pkg.version === version || pkg.version === "*");
|
||||
}
|
||||
);
|
||||
|
||||
if (!packageData) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export const knownAikidoTools = [
|
|||
{ tool: "pnpx", aikidoCommand: "aikido-pnpx" },
|
||||
{ tool: "bun", aikidoCommand: "aikido-bun" },
|
||||
{ tool: "bunx", aikidoCommand: "aikido-bunx" },
|
||||
{ tool: "pip", aikidoCommand: "aikido-pip" },
|
||||
{ tool: "pip3", aikidoCommand: "aikido-pip3" },
|
||||
// When adding a new tool here, also update the documentation for the new tool in the README.md
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -51,8 +51,13 @@ function createUnixShims(shimsDir) {
|
|||
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
// Create a shim for each tool
|
||||
// Create a shim for each tool except pip (CI support not yet implemented)
|
||||
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);
|
||||
|
|
@ -62,10 +67,11 @@ function createUnixShims(shimsDir) {
|
|||
|
||||
// Make the shim executable on Unix systems
|
||||
fs.chmodSync(shimPath, 0o755);
|
||||
created++;
|
||||
}
|
||||
|
||||
ui.writeInformation(
|
||||
`Created ${knownAikidoTools.length} Unix shim(s) in ${shimsDir}`
|
||||
`Created ${created} Unix shim(s) in ${shimsDir}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -92,18 +98,24 @@ function createWindowsShims(shimsDir) {
|
|||
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
// Create a shim for each tool
|
||||
// Create a shim for each tool except pip (CI support not yet implemented)
|
||||
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`);
|
||||
fs.writeFileSync(shimPath, shimContent, "utf-8");
|
||||
created++;
|
||||
}
|
||||
|
||||
ui.writeInformation(
|
||||
`Created ${knownAikidoTools.length} Windows shim(s) in ${shimsDir}`
|
||||
`Created ${created} Windows shim(s) in ${shimsDir}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,3 +68,37 @@ function npm
|
|||
|
||||
wrapSafeChainCommand "npm" "aikido-npm" $argv
|
||||
end
|
||||
|
||||
function pip
|
||||
wrapSafeChainCommand "pip" "aikido-pip" $argv
|
||||
end
|
||||
|
||||
function pip3
|
||||
wrapSafeChainCommand "pip3" "aikido-pip3" $argv
|
||||
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
|
||||
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
|
||||
end
|
||||
|
|
|
|||
|
|
@ -60,3 +60,37 @@ function npm() {
|
|||
|
||||
wrapSafeChainCommand "npm" "aikido-npm" "$@"
|
||||
}
|
||||
|
||||
function pip() {
|
||||
wrapSafeChainCommand "pip" "aikido-pip" "$@"
|
||||
}
|
||||
|
||||
function pip3() {
|
||||
wrapSafeChainCommand "pip3" "aikido-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
|
||||
}
|
||||
|
||||
# `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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,3 +86,38 @@ function npm {
|
|||
|
||||
Invoke-WrappedCommand "npm" "aikido-npm" $args
|
||||
}
|
||||
|
||||
function pip {
|
||||
Invoke-WrappedCommand "pip" "aikido-pip" $args
|
||||
}
|
||||
|
||||
function pip3 {
|
||||
Invoke-WrappedCommand "pip3" "aikido-pip3" $args
|
||||
}
|
||||
|
||||
# `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
|
||||
}
|
||||
}
|
||||
|
||||
# `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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,13 @@ describe("safeSpawn", () => {
|
|||
// Mock child_process module to capture what command string gets built
|
||||
mock.module("child_process", {
|
||||
namedExports: {
|
||||
spawn: (command, options) => {
|
||||
spawnCalls.push({ command, options });
|
||||
spawn: (command, argsOrOptions, options) => {
|
||||
// Handle both signatures: spawn(cmd, {opts}) and spawn(cmd, [args], {opts})
|
||||
if (Array.isArray(argsOrOptions)) {
|
||||
spawnCalls.push({ command, args: argsOrOptions, options: options || {} });
|
||||
} else {
|
||||
spawnCalls.push({ command, options: argsOrOptions || {} });
|
||||
}
|
||||
return {
|
||||
on: (event, callback) => {
|
||||
if (event === "close") {
|
||||
|
|
@ -211,4 +216,43 @@ describe("safeSpawn", () => {
|
|||
assert.strictEqual(spawnCalls.length, 1);
|
||||
assert.strictEqual(spawnCalls[0].command, "valid_command-123");
|
||||
});
|
||||
|
||||
it("should handle Python version specifiers with comparison operators on Windows", async () => {
|
||||
os = "win32";
|
||||
await safeSpawn("pip3", ["install", "Jinja2>=3.1,<3.2"]);
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// On Windows, args are built into a command string with proper escaping
|
||||
assert.strictEqual(spawnCalls[0].command, 'pip3 install "Jinja2>=3.1,<3.2"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
|
||||
it("should handle Python version specifiers with comparison operators on Unix", async () => {
|
||||
os = "darwin"; // or "linux"
|
||||
await safeSpawn("pip3", ["install", "Jinja2>=3.1,<3.2"]);
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// On Unix, resolves full path and passes args as array (no shell interpretation)
|
||||
assert.strictEqual(spawnCalls[0].command, "/usr/bin/pip3");
|
||||
assert.deepStrictEqual(spawnCalls[0].args, ["install", "Jinja2>=3.1,<3.2"]);
|
||||
assert.deepStrictEqual(spawnCalls[0].options, {});
|
||||
});
|
||||
|
||||
it("should handle Python not-equal version specifiers", async () => {
|
||||
os = "win32";
|
||||
await safeSpawn("pip3", ["install", "idna!=3.5,>=3.0"]);
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
assert.strictEqual(spawnCalls[0].command, 'pip3 install "idna!=3.5,>=3.0"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
|
||||
it("should handle Python extras with square brackets", async () => {
|
||||
os = "win32";
|
||||
await safeSpawn("pip3", ["install", "requests[socks]"]);
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
assert.strictEqual(spawnCalls[0].command, 'pip3 install "requests[socks]"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ ARG NODE_VERSION=latest
|
|||
ARG NPM_VERSION=latest
|
||||
ARG YARN_VERSION=latest
|
||||
ARG PNPM_VERSION=latest
|
||||
ARG PYTHON_VERSION=3
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
ENV BASH_ENV=~/.bashrc
|
||||
|
|
@ -49,6 +50,11 @@ RUN volta install pnpm@${PNPM_VERSION}
|
|||
# Install Bun
|
||||
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
|
||||
|
||||
# Copy and install Safe chain
|
||||
COPY --from=builder /app/*.tgz /pkgs/
|
||||
RUN npm install -g /pkgs/*.tgz
|
||||
|
|
|
|||
290
test/e2e/pip.e2e.spec.js
Normal file
290
test/e2e/pip.e2e.spec.js
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("E2E: pip coverage", () => {
|
||||
let container;
|
||||
|
||||
before(async () => {
|
||||
DockerTestContainer.buildImage();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Run a new Docker container for each test
|
||||
container = new DockerTestContainer();
|
||||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Stop and clean up the container after each test
|
||||
if (container) {
|
||||
await container.stop();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it(`successfully installs known safe packages with pip3`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand("pip3 install requests");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 download`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand("pip3 download requests");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 .whl`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand("pip3 wheel requests");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 install --dry-run is respected by scanner`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand("pip3 install --dry-run requests");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 install with extras such as requests[socks]`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand('pip3 install "requests[socks]==2.32.3"');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 install with range version specifier`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand('pip3 install "Jinja2>=3.1,<3.2"');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`python3 -m pip install routes through safe-chain`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand('python3 -m pip install requests');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`python3 -m pip download routes through safe-chain`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand('python3 -m pip download requests');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`safe-chain blocks installation of malicious Python packages`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
// Clear pip cache to ensure network download through proxy
|
||||
await shell.runCommand("pip3 cache purge");
|
||||
|
||||
const result = await shell.runCommand("pip3 install --break-system-packages safe-chain-pi-test");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads:"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("Exiting without installing malicious packages."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
const listResult = await shell.runCommand("pip3 list");
|
||||
assert.ok(
|
||||
!listResult.output.includes("safe-chain-pi-test"),
|
||||
`Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`python -m pip routes to aikido-pip (uses pip command)`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand('python -m pip install --break-system-packages requests');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
// Verify it completed successfully (would fail if routing was incorrect)
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`Installation did not succeed. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`python -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand('python -m pip3 install --break-system-packages requests');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
// Verify it completed successfully (would fail if routing was incorrect)
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`Installation did not succeed. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`python3 -m pip routes to aikido-pip3 (uses pip3 command)`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand('python3 -m pip install --break-system-packages requests');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
// Verify it completed successfully (would fail if routing was incorrect)
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`Installation did not succeed. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`python3 -m pip3 routes to aikido-pip3 (uses pip3 command)`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand('python3 -m pip3 install --break-system-packages requests');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
// Verify it completed successfully (would fail if routing was incorrect)
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`Installation did not succeed. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 can install from GitHub URL using the CA bundle`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
// Install a simple package from GitHub - this should use TCP tunnel, not MITM
|
||||
// Using a popular, small package for testing
|
||||
const result = await shell.runCommand('pip3 install --break-system-packages git+https://github.com/psf/requests.git@v2.32.3');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Verify installation succeeded (would fail if certificate validation via env CA bundle broke)
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Verify package was actually installed
|
||||
const listResult = await shell.runCommand("pip3 list");
|
||||
assert.ok(
|
||||
listResult.output.includes("requests"),
|
||||
`Package from GitHub was not installed. Output was:\n${listResult.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 successfully validates certificates for HTTPS downloads`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
// Clear cache to force network download through proxy
|
||||
await shell.runCommand("pip3 cache purge");
|
||||
|
||||
const result = await shell.runCommand('pip3 install --break-system-packages certifi');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Verify successful installation (would fail with SSL/certificate errors if the env CA bundle wasn't working)
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed"),
|
||||
`Installation should succeed with proper certificate validation. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Should NOT contain SSL or certificate errors
|
||||
assert.ok(
|
||||
!result.output.match(/SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i),
|
||||
`Should not have SSL/certificate errors. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 handles external HTTPS correctly (e.g., downloading from CDN)`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
// Test installing from a direct HTTPS URL (not a registry)
|
||||
// This validates that non-registry HTTPS traffic works with our env-provided CA bundle
|
||||
const result = await shell.runCommand('pip3 install --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Since this is from pythonhosted.org, it should be MITM'd by safe-chain
|
||||
// But the certificate validation should still work
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`Installation from direct HTTPS URL failed. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it(`pip3 can install from alternate PyPI mirror (tunneled, not MITM)`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
// Use Test PyPI which is NOT in knownPipRegistries
|
||||
// This tests tunneled HTTPS with our env-provided CA bundle (Safe Chain CA + Mozilla + Node roots)
|
||||
// If the CA bundle doesn't include public roots, this will fail with CERTIFICATE_VERIFY_FAILED
|
||||
const result = await shell.runCommand('pip3 install --break-system-packages --index-url https://test.pypi.org/simple certifi');
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malicious packages found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Should succeed if CA bundle properly handles tunneled hosts
|
||||
assert.ok(
|
||||
result.output.includes("Successfully installed") || result.output.includes("Requirement already satisfied"),
|
||||
`Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}`
|
||||
);
|
||||
|
||||
// Should NOT contain certificate verification errors
|
||||
assert.ok(
|
||||
!result.output.match(/SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i),
|
||||
`Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue