From e04c4b6f21b56cf8d68c71c63a55cc771b11e938 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 10 Nov 2025 10:46:15 -0800 Subject: [PATCH] Skeleton --- packages/safe-chain/src/config/settings.js | 4 ++ .../src/packagemanager/pip/runPipCommand.js | 18 ++--- .../safe-chain/src/registryProxy/certUtils.js | 68 +++++++++++++++++++ test/e2e/DockerTestContainer.js | 6 +- test/e2e/Dockerfile | 4 +- test/e2e/pip-ci.e2e.spec.js | 2 +- test/e2e/pip.e2e.spec.js | 9 ++- 7 files changed, 92 insertions(+), 19 deletions(-) diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 46cc30c..90f080b 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -39,3 +39,7 @@ export function setEcoSystem(setting) { export const LOGGING_SILENT = "silent"; export const LOGGING_NORMAL = "normal"; 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"; diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index 793302d..16b3f1d 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -1,7 +1,11 @@ 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 { installSafeChainCA } from "../../registryProxy/certUtils.js"; + +function shouldMockCAInstall() { + return process.env.SAFE_CHAIN_TEST_SKIP_CA_INSTALL === "1"; +} /** * @param {string} command @@ -11,15 +15,11 @@ import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; */ export async function runPip(command, args) { 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); - - // 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, diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index a2fb7bb..8d2e89c 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -2,6 +2,8 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; 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 ca = loadCa(); @@ -116,3 +118,69 @@ function generateCa() { certificate: cert, }; } + +/** + * Checks if the Safe Chain CA certificate is already installed in the OS trust store. + * @returns {Promise} + */ +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} + */ +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; + } +} diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index ec1af3c..02370d2 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -16,11 +16,12 @@ const yarnVersion = process.env.YARN_VERSION || "latest"; const pnpmVersion = process.env.PNPM_VERSION || "latest"; export class DockerTestContainer { - constructor() { + constructor({ asRootUser = false } = {}) { this.containerName = `safe-chain-test-${Math.random() .toString(36) .substring(2, 15)}`; this.isRunning = false; + this.asRootUser = asRootUser; } static buildImage() { @@ -50,8 +51,9 @@ export class DockerTestContainer { try { // Start a long-running container that we can exec commands into + const userFlag = this.asRootUser ? "--user root" : ""; execSync( - `docker run -d --name ${this.containerName} ${imageName} sleep infinity`, + `docker run -d --name ${this.containerName} ${userFlag} ${imageName} sleep infinity`, { stdio: "ignore" } ); this.isRunning = true; diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index cf5f39b..c9084de 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -30,8 +30,8 @@ ARG PYTHON_VERSION=3 SHELL ["/bin/bash", "-c"] ENV BASH_ENV=~/.bashrc -# Install a proxy -RUN apt-get update && apt-get install tinyproxy -y +# Install a proxy and sudo +RUN apt-get update && apt-get install -y tinyproxy sudo # Install zsh RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)" diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index fe013bb..113424f 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -10,7 +10,7 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { }); beforeEach(async () => { - container = new DockerTestContainer(); + container = new DockerTestContainer({ asRootUser: true }); await container.start(); }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index 3d3b4dd..5d96fbb 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -10,8 +10,8 @@ describe("E2E: pip coverage", () => { }); beforeEach(async () => { - // Run a new Docker container for each test - container = new DockerTestContainer(); + // Run a new Docker container for each test as root user + container = new DockerTestContainer({ asRootUser: true }); await container.start(); const installationShell = await container.openShell("zsh"); @@ -316,10 +316,9 @@ describe("E2E: pip coverage", () => { ); // Should NOT contain certificate verification errors + const sslErrorPattern = /certificate verify failed|CERTIFICATE_VERIFY_FAILED|SSLError/i; assert.ok( - !result.output.match( - /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i - ), + !sslErrorPattern.test(result.output), `Should not have SSL/certificate errors for tunneled hosts. Output was:\n${result.output}` ); });