This commit is contained in:
Reinier Criel 2025-11-10 10:46:15 -08:00
parent 76acf43128
commit e04c4b6f21
7 changed files with 92 additions and 19 deletions

View file

@ -39,3 +39,7 @@ export function setEcoSystem(setting) {
export const LOGGING_SILENT = "silent"; export const LOGGING_SILENT = "silent";
export const LOGGING_NORMAL = "normal"; export const LOGGING_NORMAL = "normal";
export const LOGGING_VERBOSE = "verbose"; export const LOGGING_VERBOSE = "verbose";
// OS trust store paths
export const DARWIN_CA_PATH = "/Library/Keychains/System.keychain";
export const LINUX_CA_PATH = "/usr/local/share/ca-certificates/safe-chain-ca.crt";

View file

@ -1,7 +1,11 @@
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; import { installSafeChainCA } from "../../registryProxy/certUtils.js";
function shouldMockCAInstall() {
return process.env.SAFE_CHAIN_TEST_SKIP_CA_INSTALL === "1";
}
/** /**
* @param {string} command * @param {string} command
@ -11,15 +15,11 @@ import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
*/ */
export async function runPip(command, args) { export async function runPip(command, args) {
try { try {
// Install Safe Chain CA in OS trust store before running pip, unless in test mode
if (!shouldMockCAInstall()) {
await installSafeChainCA();
}
const env = mergeSafeChainProxyEnvironmentVariables(process.env); 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, { const result = await safeSpawn(command, args, {
stdio: "inherit", stdio: "inherit",
env, env,

View file

@ -2,6 +2,8 @@ import forge from "node-forge";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import os from "os"; import os from "os";
import { safeSpawn } from "../utils/safeSpawn.js";
import { DARWIN_CA_PATH, LINUX_CA_PATH } from "../config/settings.js";
const certFolder = path.join(os.homedir(), ".safe-chain", "certs"); const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
const ca = loadCa(); const ca = loadCa();
@ -116,3 +118,69 @@ function generateCa() {
certificate: cert, certificate: cert,
}; };
} }
/**
* Checks if the Safe Chain CA certificate is already installed in the OS trust store.
* @returns {Promise<boolean>}
*/
export async function isSafeChainCAInstalled() {
const platform = os.platform();
try {
if (platform === "darwin") {
// macOS: check System Keychain for cert
const res = await safeSpawn("security", ["find-certificate", "-c", "safe-chain proxy", DARWIN_CA_PATH], { stdio: "pipe" });
return res.stdout.includes("safe-chain proxy");
} else if (platform === "linux") {
// Linux: check for CA file
return fs.existsSync(LINUX_CA_PATH);
} else if (platform === "win32") {
// Windows: check Root store for cert
return await safeSpawn("certutil", ["-store", "Root", "safe-chain proxy"], { stdio: "pipe" }).then(res => res.stdout.includes("safe-chain proxy"));
}
} catch (err) {
// If check fails, assume not installed
return false;
}
return false;
}
/**
* Installs the Safe Chain CA certificate in the OS trust store.
* Uses platform-specific commands. Optionally uses npm packages if available.
* @returns {Promise<void>}
*/
export async function installSafeChainCA() {
const caPath = getCaCertPath();
const platform = os.platform();
try {
const alreadyInstalled = await isSafeChainCAInstalled();
if (alreadyInstalled) {
console.log("Safe Chain CA already installed in OS trust store.");
return;
}
if (platform === "darwin") {
// macOS: use security CLI
await safeSpawn("sudo", [
"security",
"add-trusted-cert",
"-d",
"-r", "trustRoot",
"-k", DARWIN_CA_PATH,
caPath
], { stdio: "inherit" });
} else if (platform === "linux") {
// Linux: use update-ca-certificates
await safeSpawn("sudo", ["cp", caPath, LINUX_CA_PATH], { stdio: "inherit" });
await safeSpawn("sudo", ["update-ca-certificates"], { stdio: "inherit" });
} else if (platform === "win32") {
// Windows: use certutil
await safeSpawn("certutil", ["-addstore", "-f", "Root", caPath], { stdio: "inherit" });
} else {
throw new Error("Unsupported OS for automatic CA installation. Please install manually.");
}
console.log("Safe Chain CA installed in OS trust store.");
} catch (/** @type any */ error) {
console.error("Failed to install Safe Chain CA:", error.message);
throw error;
}
}

View file

@ -16,11 +16,12 @@ const yarnVersion = process.env.YARN_VERSION || "latest";
const pnpmVersion = process.env.PNPM_VERSION || "latest"; const pnpmVersion = process.env.PNPM_VERSION || "latest";
export class DockerTestContainer { export class DockerTestContainer {
constructor() { constructor({ asRootUser = false } = {}) {
this.containerName = `safe-chain-test-${Math.random() this.containerName = `safe-chain-test-${Math.random()
.toString(36) .toString(36)
.substring(2, 15)}`; .substring(2, 15)}`;
this.isRunning = false; this.isRunning = false;
this.asRootUser = asRootUser;
} }
static buildImage() { static buildImage() {
@ -50,8 +51,9 @@ export class DockerTestContainer {
try { try {
// Start a long-running container that we can exec commands into // Start a long-running container that we can exec commands into
const userFlag = this.asRootUser ? "--user root" : "";
execSync( execSync(
`docker run -d --name ${this.containerName} ${imageName} sleep infinity`, `docker run -d --name ${this.containerName} ${userFlag} ${imageName} sleep infinity`,
{ stdio: "ignore" } { stdio: "ignore" }
); );
this.isRunning = true; this.isRunning = true;

View file

@ -30,8 +30,8 @@ ARG PYTHON_VERSION=3
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
ENV BASH_ENV=~/.bashrc ENV BASH_ENV=~/.bashrc
# Install a proxy # Install a proxy and sudo
RUN apt-get update && apt-get install tinyproxy -y RUN apt-get update && apt-get install -y tinyproxy sudo
# Install zsh # Install zsh
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)" RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)"

View file

@ -10,7 +10,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => {
}); });
beforeEach(async () => { beforeEach(async () => {
container = new DockerTestContainer(); container = new DockerTestContainer({ asRootUser: true });
await container.start(); await container.start();
}); });

View file

@ -10,8 +10,8 @@ describe("E2E: pip coverage", () => {
}); });
beforeEach(async () => { beforeEach(async () => {
// Run a new Docker container for each test // Run a new Docker container for each test as root user
container = new DockerTestContainer(); container = new DockerTestContainer({ asRootUser: true });
await container.start(); await container.start();
const installationShell = await container.openShell("zsh"); const installationShell = await container.openShell("zsh");
@ -316,10 +316,9 @@ describe("E2E: pip coverage", () => {
); );
// Should NOT contain certificate verification errors // Should NOT contain certificate verification errors
const sslErrorPattern = /certificate verify failed|CERTIFICATE_VERIFY_FAILED|SSLError/i;
assert.ok( assert.ok(
!result.output.match( !sslErrorPattern.test(result.output),
/SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i
),
`Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}` `Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}`
); );
}); });