Merge pull request #147 from AikidoSec/feature/cert-beta

Create INI file for pip to make sure behavior is predictable
This commit is contained in:
Reinier Criel 2025-11-21 13:27:57 -08:00 committed by GitHub
commit a0dc6536b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 345 additions and 12 deletions

18
package-lock.json generated
View file

@ -411,6 +411,13 @@
"node": ">=14"
}
},
"node_modules/@types/ini": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz",
"integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/make-fetch-happen": {
"version": "10.0.4",
"resolved": "https://registry.npmjs.org/@types/make-fetch-happen/-/make-fetch-happen-10.0.4.tgz",
@ -1090,6 +1097,15 @@
"node": ">=0.8.19"
}
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
@ -2083,6 +2099,7 @@
"certifi": "^14.5.15",
"chalk": "5.4.1",
"https-proxy-agent": "7.0.6",
"ini": "^6.0.0",
"make-fetch-happen": "14.0.3",
"node-forge": "1.3.1",
"npm-registry-fetch": "18.0.2",
@ -2104,6 +2121,7 @@
"safe-chain": "bin/safe-chain.js"
},
"devDependencies": {
"@types/ini": "^4.1.1",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^18.19.130",
"@types/node-forge": "^1.3.14",

View file

@ -27,4 +27,4 @@
"peerDependencies": {
"bun": ">=1.2.21"
}
}
}

View file

@ -38,6 +38,7 @@
"certifi": "^14.5.15",
"chalk": "5.4.1",
"https-proxy-agent": "7.0.6",
"ini": "^6.0.0",
"make-fetch-happen": "14.0.3",
"node-forge": "1.3.1",
"npm-registry-fetch": "18.0.2",
@ -45,6 +46,7 @@
"semver": "7.7.2"
},
"devDependencies": {
"@types/ini": "^4.1.1",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^18.19.130",
"@types/npm-registry-fetch": "^8.0.9",

View file

@ -2,12 +2,53 @@ import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import ini from "ini";
/**
* @param {string} command
* @param {string[]} args
*
* @returns {Promise<{status: number}>}
* Sets fallback CA bundle environment variables used by Python libraries.
* These are applied in addition to the PIP_CONFIG_FILE to ensure all Python
* network libraries respect the combined CA bundle, even if they don't read pip's config.
*
* @param {NodeJS.ProcessEnv} env - Environment object to modify
* @param {string} combinedCaPath - Path to the combined CA bundle
*/
function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) {
// REQUESTS_CA_BUNDLE: Used by the popular 'requests' library
if (env.REQUESTS_CA_BUNDLE) {
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
}
env.REQUESTS_CA_BUNDLE = combinedCaPath;
// SSL_CERT_FILE: Used by some Python SSL libraries and urllib
if (env.SSL_CERT_FILE) {
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
}
env.SSL_CERT_FILE = combinedCaPath;
// PIP_CERT: Pip's own environment variable for certificate verification
if (env.PIP_CERT) {
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
}
env.PIP_CERT = combinedCaPath;
}
/**
* Runs a pip command with safe-chain's certificate bundle and proxy configuration.
*
* Creates a temporary pip config file to configure:
* - Cert bundle for HTTPS verification
* - Proxy settings
*
* If the user has an existing PIP_CONFIG_FILE, a new temporary config is created that merges
* their settings with safe-chain's, leaving the original file unchanged.
*
* @param {string} command - The pip command to execute (e.g., 'pip3')
* @param {string[]} args - Command line arguments to pass to pip
* @returns {Promise<{status: number}>} Exit status of the pip command
*/
export async function runPip(command, args) {
try {
@ -17,13 +58,85 @@ export async function runPip(command, args) {
// 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;
// https://pip.pypa.io/en/stable/topics/https-certificates/ explains that the 'cert' param (which we're providing via INI file)
// will tell pip to use the provided CA bundle for HTTPS verification.
// Proxy settings: GLOBAL_AGENT_HTTP_PROXY is our safe-chain proxy (if active),
// otherwise fall back to user-defined HTTPS_PROXY or HTTP_PROXY environment variables
const proxy = env.GLOBAL_AGENT_HTTP_PROXY || env.HTTPS_PROXY || env.HTTP_PROXY || '';
const tmpDir = os.tmpdir();
const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`);
let cleanupConfigPath = null; // Track temp file for cleanup
// Note: Setting PIP_CONFIG_FILE overrides all pip config levels (Global/User/Site) per pip's loading order
if (!env.PIP_CONFIG_FILE) {
/** @type {{ global: { cert: string, proxy?: string } }} */
const configObj = { global: { cert: combinedCaPath } };
if (proxy) {
configObj.global.proxy = proxy;
}
const pipConfig = ini.stringify(configObj);
await fs.writeFile(pipConfigPath, pipConfig);
env.PIP_CONFIG_FILE = pipConfigPath;
cleanupConfigPath = pipConfigPath;
} else if (fsSync.existsSync(env.PIP_CONFIG_FILE)) {
ui.writeVerbose("Safe-chain: Merging user provided PIP_CONFIG_FILE with safe-chain certificate and proxy settings.");
const userConfig = env.PIP_CONFIG_FILE;
// Read the existing config without modifying it
let content = await fs.readFile(userConfig, "utf-8");
const parsed = ini.parse(content);
// Ensure [global] section exists
parsed.global = parsed.global || {};
// Cert
if (typeof parsed.global.cert !== "undefined") {
ui.writeWarning("Safe-chain: User defined cert found in PIP_CONFIG_FILE. It will be overwritten in the temporary config.");
}
parsed.global.cert = combinedCaPath;
// Proxy
if (typeof parsed.global.proxy !== "undefined") {
ui.writeWarning("Safe-chain: User defined proxy found in PIP_CONFIG_FILE. It will be overwritten in the temporary config.");
}
if (proxy) {
parsed.global.proxy = proxy;
}
const updated = ini.stringify(parsed);
// Save to a new temp file to avoid overwriting user's original config
await fs.writeFile(pipConfigPath, updated, "utf-8");
env.PIP_CONFIG_FILE = pipConfigPath;
cleanupConfigPath = pipConfigPath;
} else {
// The user provided PIP_CONFIG_FILE does not exist on disk
// PIP will handle this as an error and inform the user
}
// Set fallback CA bundle environment variables for Python libraries that don't read pip config
setFallbackCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn(command, args, {
stdio: "inherit",
env,
});
// Cleanup temporary config file if we created one
if (cleanupConfigPath) {
try {
await fs.unlink(cleanupConfigPath);
} catch {
// Ignore cleanup errors - the file may have already been deleted or is inaccessible
// Temp files in os.tmpdir() may eventually be cleaned by the OS, but timing varies by platform
}
}
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {

View file

@ -1,29 +1,48 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ini from "ini";
describe("runPipCommand environment variable handling", () => {
let runPip;
let capturedArgs = null;
let customEnv = null;
let capturedConfigContent = null; // Capture config file content before cleanup
beforeEach(async () => {
capturedArgs = null;
capturedConfigContent = null;
// Mock safeSpawn to capture args
// Mock safeSpawn to capture args and config file content before cleanup
mock.module("../../utils/safeSpawn.js", {
namedExports: {
safeSpawn: async (command, args, options) => {
capturedArgs = { command, args, options };
// Capture the config file content before the function cleans it up
if (options.env.PIP_CONFIG_FILE) {
try {
capturedConfigContent = await fs.readFile(options.env.PIP_CONFIG_FILE, "utf-8");
} catch {
// Ignore if file doesn't exist or can't be read
}
}
return { status: 0 };
},
},
});
// Mock proxy env merge
// Mock proxy env merge, allow custom env override
mock.module("../../registryProxy/registryProxy.js", {
namedExports: {
mergeSafeChainProxyEnvironmentVariables: (env) => ({
...env,
...customEnv,
// Force deterministic proxy for tests regardless of ambient env
GLOBAL_AGENT_HTTP_PROXY: "http://localhost:8080",
HTTPS_PROXY: "http://localhost:8080",
HTTP_PROXY: "",
}),
},
});
@ -43,6 +62,23 @@ describe("runPipCommand environment variable handling", () => {
mock.reset();
});
it("should set PIP_CERT env var and create config file", async () => {
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
// Check PIP_CERT env var
assert.strictEqual(
capturedArgs.options.env.PIP_CERT,
"/tmp/test-combined-ca.pem",
"PIP_CERT should be set to combined bundle path"
);
// Check PIP_CONFIG_FILE env var exists and is a non-empty string
const configPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.ok(configPath, "PIP_CONFIG_FILE should be set");
assert.strictEqual(typeof configPath, "string", "PIP_CONFIG_FILE should be a string");
assert.ok(configPath.length > 0, "PIP_CONFIG_FILE should be a non-empty path");
});
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);
@ -60,9 +96,6 @@ describe("runPipCommand environment variable handling", () => {
"/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 () => {
@ -110,4 +143,161 @@ describe("runPipCommand environment variable handling", () => {
"HTTPS_PROXY should be set by proxy merge"
);
});
it("should create a new temp config when existing config exists (original file untouched)", async () => {
const tmpDir = os.tmpdir();
const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`);
const initial = "[global]\nindex-url = https://example.com/simple\n";
await fs.writeFile(userCfgPath, initial, "utf-8");
customEnv = { PIP_CONFIG_FILE: userCfgPath };
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.notStrictEqual(newCfgPath, userCfgPath, "should point to a new temp config file");
// Original file unchanged
const originalContent = await fs.readFile(userCfgPath, "utf-8");
const originalParsed = ini.parse(originalContent);
assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert");
// New file has merged settings (read from captured content before cleanup)
assert.ok(capturedConfigContent, "config content should have been captured");
const newParsed = ini.parse(capturedConfigContent);
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "new config should include cert");
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "new config should include proxy from env");
assert.strictEqual(newParsed.global["index-url"], "https://example.com/simple", "index-url should be preserved");
customEnv = null;
});
it("should create new config with proxy set from env (ini-validated)", async () => {
// No PIP_CONFIG_FILE in env => creation path
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedConfigContent, "config content should have been captured");
const parsed = ini.parse(capturedConfigContent);
assert.ok(parsed.global, "[global] should exist after creation");
assert.strictEqual(
parsed.global.proxy,
"http://localhost:8080",
"proxy should be set from merged env"
);
assert.strictEqual(
parsed.global.cert,
"/tmp/test-combined-ca.pem",
"cert should be set during creation"
);
});
it("should create new temp config adding cert but preserving existing proxy (original file unchanged)", async () => {
const tmpDir = os.tmpdir();
const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`);
const initial = "[global]\nproxy = http://original:9999\n";
await fs.writeFile(userCfgPath, initial, "utf-8");
customEnv = { PIP_CONFIG_FILE: userCfgPath };
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.notStrictEqual(newCfgPath, userCfgPath, "should use a new temp config file");
// Original file unchanged
const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8"));
assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert");
assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy remains");
// New file: cert and proxy always overwritten (read from captured content)
assert.ok(capturedConfigContent, "config content should have been captured");
const newParsed = ini.parse(capturedConfigContent);
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
customEnv = null;
});
it("should create new temp config preserving existing cert and proxy while leaving original file unchanged", async () => {
const tmpDir = os.tmpdir();
const cfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`);
const initialIni = [
"[global]",
"cert = /path/to/existing.pem",
"proxy = http://original:9999",
""
].join("\n");
await fs.writeFile(cfgPath, initialIni, "utf-8");
customEnv = { PIP_CONFIG_FILE: cfgPath };
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0, "execution should succeed");
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.notStrictEqual(newCfgPath, cfgPath, "should use a newly generated temp config file");
// Original file stays untouched
const originalContent = await fs.readFile(cfgPath, "utf-8");
const originalParsed = ini.parse(originalContent);
assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert preserved");
assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy preserved");
// New temp config: cert and proxy always overwritten (read from captured content)
assert.ok(capturedConfigContent, "config content should have been captured");
const newParsed = ini.parse(capturedConfigContent);
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
customEnv = null;
});
it("should create new temp config preserving existing cert and adding missing proxy", async () => {
const tmpDir = os.tmpdir();
const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`);
const initial = "[global]\ncert = /path/to/existing.pem\n";
await fs.writeFile(userCfgPath, initial, "utf-8");
customEnv = { PIP_CONFIG_FILE: userCfgPath };
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.notStrictEqual(newCfgPath, userCfgPath, "should produce a new temp config file");
// Original remains unchanged
const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8"));
assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert unchanged");
assert.strictEqual(originalParsed.global.proxy, undefined, "original proxy still missing");
// New file: cert and proxy always overwritten (read from captured content)
assert.ok(capturedConfigContent, "config content should have been captured");
const newParsed = ini.parse(capturedConfigContent);
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
customEnv = null;
});
it("should log warnings when cert and proxy are already set in user config file", async () => {
const tmpDir = os.tmpdir();
const cfgPath = path.join(tmpDir, `safe-chain-test-pip-warn-${Date.now()}.ini`);
const initialIni = [
"[global]",
"cert = /user/cert.pem",
"proxy = http://user-proxy:9999",
""
].join("\n");
await fs.writeFile(cfgPath, initialIni, "utf-8");
customEnv = { PIP_CONFIG_FILE: cfgPath };
// Capture stdout/stderr
let output = "";
const originalWrite = process.stdout.write;
const originalError = process.stderr.write;
process.stdout.write = (chunk, ...args) => { output += chunk; return originalWrite.apply(process.stdout, [chunk, ...args]); };
process.stderr.write = (chunk, ...args) => { output += chunk; return originalError.apply(process.stderr, [chunk, ...args]); };
await runPip("pip3", ["install", "requests"]);
process.stdout.write = originalWrite;
process.stderr.write = originalError;
assert.ok(output.includes("cert found in PIP_CONFIG_FILE"), "Should warn about cert overwrite in output");
assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output");
customEnv = null;
});
});

View file

@ -48,6 +48,16 @@ export function generateCertForHost(hostname) {
digitalSignature: true,
keyEncipherment: true,
},
{
/*
extKeyUsage serverAuth is required for TLS server authentication.
This is especially important for Python venv environments, which use their own
certificate validation logic and will reject certificates lacking the serverAuth EKU.
Adding serverAuth does not impact other usages
*/
name: "extKeyUsage",
serverAuth: true,
},
]);
cert.sign(ca.privateKey, forge.md.sha256.create());