mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Adapt per review
This commit is contained in:
parent
9dacf5cff3
commit
190607de92
27 changed files with 191 additions and 114 deletions
16
README.md
16
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
The Aikido Safe Chain **prevents developers from installing malware** on their workstations while developing in the Python ecosystem (through pip or pip3) 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/), [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 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.
|
||||
|
||||

|
||||
|
||||
|
|
@ -16,6 +16,7 @@ Aikido Safe Chain works on Node.js version 18 and above and supports the followi
|
|||
- ✅ **bun**
|
||||
- ✅ **bunx**
|
||||
- ✅ **pip**
|
||||
- ✅ **pip3**
|
||||
|
||||
# Usage
|
||||
|
||||
|
|
@ -32,14 +33,14 @@ 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, bunx, and pip are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||
- 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:
|
||||
```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`, `bunx`, or `pip` 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.
|
||||
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. If any malware is detected, it will prompt you to exit the command.
|
||||
|
||||
You can check the installed version by running:
|
||||
```shell
|
||||
|
|
@ -48,7 +49,7 @@ 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 and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip 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, 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:
|
||||
|
||||
|
|
@ -60,13 +61,6 @@ The Aikido Safe Chain integrates with your shell to provide a seamless experienc
|
|||
|
||||
More information about the shell integration can be found in the [shell integration documentation](docs/shell-integration.md).
|
||||
|
||||
### Python support
|
||||
|
||||
- Supports `pip` and `pip3` commands.
|
||||
- Scans Python packages fetched by `pip install`, `pip download`, and `pip wheel`.
|
||||
- Intercepts downloads from PyPI and checks them against Aikido's malware intelligence before they reach your machine.
|
||||
- Included automatically when you run `safe-chain setup` (shell integration); **CI integration is not yet available for pip/pip3**.
|
||||
|
||||
## Uninstallation
|
||||
|
||||
To uninstall the Aikido Safe Chain, you can run the following command:
|
||||
|
|
|
|||
|
|
@ -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. 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,7 @@ 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`
|
||||
|
||||
❗ 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 +77,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 +120,4 @@ 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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -2,35 +2,16 @@
|
|||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem } from "../src/config/settings.js";
|
||||
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
|
||||
|
||||
// Defaults
|
||||
let packageManagerName = "pip";
|
||||
let targetVersionMajor;
|
||||
|
||||
// Copy argv so we can modify it
|
||||
// Pass through user args as-is
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
|
||||
// --target-version-major tells us which pip version is being used (2 or 3)
|
||||
if (a === "--target-version-major" && i + 1 < argv.length) {
|
||||
targetVersionMajor = argv[i + 1];
|
||||
argv.splice(i, 2);
|
||||
i -= 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If the user explicitly called python3, prefer pip3
|
||||
if (targetVersionMajor && String(targetVersionMajor).trim() === "3") {
|
||||
packageManagerName = "pip3";
|
||||
}
|
||||
|
||||
// Set eco system
|
||||
// This can be used in other parts of the code to determine which eco system we are working with
|
||||
setEcoSystem("py");
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
|
||||
initializePackageManager(packageManagerName);
|
||||
const exitCode = await main(argv);
|
||||
|
|
|
|||
19
packages/safe-chain/bin/aikido-pip3.js
Normal file
19
packages/safe-chain/bin/aikido-pip3.js
Normal 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);
|
||||
const 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));
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import fetch from "make-fetch-happen";
|
||||
import { getEcoSystem } from "../config/settings.js";
|
||||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
const malwareDatabaseUrls = {
|
||||
js: "https://malware-list.aikido.dev/malware_predictions.json",
|
||||
py: "https://malware-list.aikido.dev/malware_pypi.json",
|
||||
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json",
|
||||
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json",
|
||||
};
|
||||
|
||||
export async function fetchMalwareDatabase() {
|
||||
const ecosystem = getEcoSystem() || "js";
|
||||
const ecosystem = getEcoSystem();
|
||||
const malwareDatabaseUrl = malwareDatabaseUrls[ecosystem];
|
||||
const response = await fetch(malwareDatabaseUrl);
|
||||
if (!response.ok) {
|
||||
|
|
@ -26,8 +26,7 @@ export async function fetchMalwareDatabase() {
|
|||
}
|
||||
|
||||
export async function fetchMalwareDatabaseVersion() {
|
||||
const ecosystem = getEcoSystem() || "js";
|
||||
|
||||
const ecosystem = getEcoSystem();
|
||||
const malwareDatabaseUrl = malwareDatabaseUrls[ecosystem];
|
||||
const response = await fetch(malwareDatabaseUrl, {
|
||||
method: "HEAD",
|
||||
|
|
|
|||
|
|
@ -69,13 +69,13 @@ function readConfigFile() {
|
|||
|
||||
function getDatabasePath() {
|
||||
const aikidoDir = getAikidoDirectory();
|
||||
const ecosystem = getEcoSystem() || "js";
|
||||
const ecosystem = getEcoSystem();
|
||||
return path.join(aikidoDir, `malwareDatabase_${ecosystem}.json`);
|
||||
}
|
||||
|
||||
function getDatabaseVersionPath() {
|
||||
const aikidoDir = getAikidoDirectory();
|
||||
const ecosystem = getEcoSystem() || "js";
|
||||
const ecosystem = getEcoSystem();
|
||||
return path.join(aikidoDir, `version_${ecosystem}.txt`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ export function getMalwareAction() {
|
|||
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: "js",
|
||||
ecoSystem: ECOSYSTEM_JS,
|
||||
};
|
||||
|
||||
export function getEcoSystem() {
|
||||
|
|
|
|||
|
|
@ -19,34 +19,3 @@ export async function runPip(command, args) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function dryRunPipCommandAndOutput(command, args) {
|
||||
try {
|
||||
// Note: pip supports --dry-run for the "install" command only; "download" and "wheel" do not.
|
||||
// We don't mutate args here — callers should include --dry-run when appropriate.
|
||||
const result = await safeSpawnPy(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
stdio: "pipe",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
}
|
||||
);
|
||||
return {
|
||||
status: result.status,
|
||||
output: result.status === 0 ? result.stdout : result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.status) {
|
||||
const output =
|
||||
error.stdout?.toString() ??
|
||||
error.stderr?.toString() ??
|
||||
error.message ??
|
||||
"";
|
||||
return { status: error.status, output };
|
||||
} else {
|
||||
ui.writeError("Error executing command:", error.message);
|
||||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
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"];
|
||||
|
||||
export function parsePackageFromUrl(url) {
|
||||
const ecosystem = getEcoSystem();
|
||||
let registry;
|
||||
|
||||
// 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 };
|
||||
|
|
@ -70,21 +76,25 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
}
|
||||
|
||||
// Quick sanity check on the URL + parse
|
||||
let u;
|
||||
let urlObj;
|
||||
try {
|
||||
u = new URL(url);
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName, version};
|
||||
}
|
||||
|
||||
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
|
||||
const lastSegment = u.pathname.split("/").filter(Boolean).pop();
|
||||
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"
|
||||
|
|
@ -96,6 +106,9 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
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 };
|
||||
}
|
||||
|
|
@ -111,6 +124,9 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -114,6 +119,10 @@ describe("parsePackageFromUrl", () => {
|
|||
});
|
||||
|
||||
describe("parsePackageFromUrl - pip URLs", () => {
|
||||
beforeEach(() => {
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
});
|
||||
|
||||
const pipTestCases = [
|
||||
// Valid pip URLs
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,7 +167,7 @@ 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(
|
||||
{
|
||||
|
|
@ -149,7 +175,7 @@ function sendHttpsRequestThroughTunnel(socket, verb, url) {
|
|||
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: false,
|
||||
rejectUnauthorized: rejectUnauthorized,
|
||||
},
|
||||
() => {
|
||||
tlsSocket.write(
|
||||
|
|
@ -173,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
|||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { auditChanges } from "../scanning/audit/index.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";
|
||||
|
||||
|
|
@ -111,8 +112,17 @@ function handleConnect(req, clientSocket, head) {
|
|||
// CONNECT method is used for HTTPS requests
|
||||
// It establishes a tunnel to the server identified by the request URL
|
||||
|
||||
if ((knownJsRegistries.some((reg) => req.url.includes(reg)))
|
||||
|| (knownPipRegistries.some((reg) => req.url.includes(reg)))) {
|
||||
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 () => {
|
||||
|
|
@ -151,6 +154,8 @@ describe("registryProxy.mitm", () => {
|
|||
});
|
||||
|
||||
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,
|
||||
|
|
@ -162,6 +167,8 @@ describe("registryProxy.mitm", () => {
|
|||
});
|
||||
|
||||
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,
|
||||
|
|
@ -173,6 +180,8 @@ describe("registryProxy.mitm", () => {
|
|||
});
|
||||
|
||||
it("should handle pip package with a1 version", async () => {
|
||||
// Ensure Python ecosystem
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
|
|
@ -184,6 +193,8 @@ describe("registryProxy.mitm", () => {
|
|||
});
|
||||
|
||||
it("should handle pip package with latest version (should not block)", async () => {
|
||||
// Ensure Python ecosystem
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
writeDatabaseToLocalCache,
|
||||
} from "../config/configFile.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { getEcoSystem } from "../config/settings.js";
|
||||
import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
let cachedMalwareDatabase = null;
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ let cachedMalwareDatabase = null;
|
|||
*/
|
||||
function normalizePackageName(name) {
|
||||
const ecosystem = getEcoSystem();
|
||||
if (ecosystem === "py") {
|
||||
if (ecosystem === ECOSYSTEM_PY) {
|
||||
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,8 +46,7 @@ function createUnixShims(shimsDir) {
|
|||
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
// Create a shim for each tool except pip for now.
|
||||
// TODO(pip): Enable pip and pip3 CI support
|
||||
// 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") {
|
||||
|
|
@ -89,8 +88,7 @@ function createWindowsShims(shimsDir) {
|
|||
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
// Create a shim for each tool except pip for now.
|
||||
// TODO(pip): Enable pip and pip3 CI support
|
||||
// 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") {
|
||||
|
|
|
|||
|
|
@ -70,11 +70,9 @@ function npm
|
|||
end
|
||||
|
||||
function pip
|
||||
# Default to Python 2 major version when explicitly calling pip
|
||||
wrapSafeChainCommand "pip" "aikido-pip" --target-version-major "2" $argv
|
||||
wrapSafeChainCommand "pip" "aikido-pip" $argv
|
||||
end
|
||||
|
||||
function pip3
|
||||
# Route to Python 3 when calling pip3
|
||||
wrapSafeChainCommand "pip3" "aikido-pip" --target-version-major "3" $argv
|
||||
wrapSafeChainCommand "pip3" "aikido-pip3" $argv
|
||||
end
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@ function npm() {
|
|||
}
|
||||
|
||||
function pip() {
|
||||
wrapSafeChainCommand "pip" "aikido-pip" --target-version-major "2" "$@"
|
||||
wrapSafeChainCommand "pip" "aikido-pip" "$@"
|
||||
}
|
||||
|
||||
function pip3() {
|
||||
wrapSafeChainCommand "pip3" "aikido-pip" --target-version-major "3" "$@"
|
||||
wrapSafeChainCommand "pip3" "aikido-pip3" "$@"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,13 +88,9 @@ function npm {
|
|||
}
|
||||
|
||||
function pip {
|
||||
# Default to Python 2 major version when explicitly calling pip
|
||||
$forward = @("--target-version-major", "2") + $args
|
||||
Invoke-WrappedCommand "pip" "aikido-pip" $forward
|
||||
Invoke-WrappedCommand "pip" "aikido-pip" $args
|
||||
}
|
||||
|
||||
function pip3 {
|
||||
# Route to Python 3 when calling pip3
|
||||
$forward = @("--target-version-major", "3") + $args
|
||||
Invoke-WrappedCommand "pip3" "aikido-pip" $forward
|
||||
Invoke-WrappedCommand "pip3" "aikido-pip3" $args
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,4 +85,5 @@ describe("E2E: pip coverage", () => {
|
|||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue