Adapt per review

This commit is contained in:
Reinier Criel 2025-10-27 09:23:47 -07:00
parent 9dacf5cff3
commit 190607de92
27 changed files with 191 additions and 114 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

@ -1,20 +1,26 @@
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;
for (const knownRegistry of knownJsRegistries) {
if (url.includes(knownRegistry)) {
registry = knownRegistry;
return parseJsPackageFromUrl(url, 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);
}
}
}
for (const knownRegistry of knownPipRegistries) {
if (url.includes(knownRegistry)) {
registry = knownRegistry;
return parsePipPackageFromUrl(url, registry);
} else if (ecosystem === ECOSYSTEM_PY) {
for (const knownRegistry of knownPipRegistries) {
if (url.includes(knownRegistry)) {
registry = knownRegistry;
return parsePipPackageFromUrl(url, registry);
}
}
}
@ -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 };
}

View file

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

View file

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

View file

@ -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,9 +112,18 @@ 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)))) {
mitmConnect(req, clientSocket, isAllowedUrl);
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
tunnelRequest(req, clientSocket, head);

View file

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

View file

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

View file

@ -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") {

View file

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

View file

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

View file

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