From 6c1d597d42bb53bf7860f7b04fc94610d07b3043 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:40:01 -0300 Subject: [PATCH] fix(mitm): test suite and CI must never mutate the OS trust store (OMNIROUTE_SKIP_SYSTEM_TRUST) (#6310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incident 2026-07-05 on the self-hosted release runner (VM 113): the integration test 'POST /cert: installs trust when cert exists' exercised the REAL install path, wrote a 105-byte fake PEM (FakeMITMCertForTestingOnly) into /usr/local/share/ca-certificates and update-ca-certificates baked the invalid entry into ca-certificates.crt — breaking ALL system TLS on the VM (curl error 77, apt cert failures, and the intermittent gzip-corrupted next-build artifacts that failed 6/9 e2e shards in run 28754447912). Hosted runners are ephemeral, so the same mutation went unnoticed for months. - installCert/uninstallCert: skip the OS dispatch under OMNIROUTE_SKIP_SYSTEM_TRUST=1 — AFTER the input checks, so the #4546 environment-skip contract (missing file throws -> structured skip) and the already-installed/not-installed early returns are preserved. - installTproxyCa/uninstallTproxyCa: same guard, only when no run dep is injected (DI'd tests keep exercising the full command sequence with mocks). - tests/_setup/isolateDataDir.ts sets the env for every node:test process; ci.yml/quality.yml/nightly-release-green.yml set it workflow-wide (e2e runs the real app outside the test setup). TDD: tests/unit/system-trust-test-guard.test.ts (guard exported to every test process; guarded install resolves on a real file without touching the OS; missing-file contract preserved). 82/82 across the affected cert/tproxy/ agent-bridge suites. --- .github/workflows/ci.yml | 4 ++ .github/workflows/nightly-release-green.yml | 3 ++ .github/workflows/quality.yml | 4 ++ src/mitm/cert/install.ts | 10 +++++ src/mitm/tproxy/caTrust.ts | 8 ++++ tests/_setup/isolateDataDir.ts | 7 ++++ tests/unit/system-trust-test-guard.test.ts | 42 +++++++++++++++++++++ 7 files changed, 78 insertions(+) create mode 100644 tests/unit/system-trust-test-guard.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be1d3e4465..706b6c22f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,10 @@ permissions: contents: read env: + # CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow + # test installed a fake PEM on a persistent self-hosted runner and broke all + # system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts. + OMNIROUTE_SKIP_SYSTEM_TRUST: "1" CI_NODE_VERSION: "24" CI_NODE_24_VERSION: "24" CI_NODE_26_VERSION: "26" diff --git a/.github/workflows/nightly-release-green.yml b/.github/workflows/nightly-release-green.yml index c1d5128a74..646c741811 100644 --- a/.github/workflows/nightly-release-green.yml +++ b/.github/workflows/nightly-release-green.yml @@ -31,6 +31,9 @@ concurrency: group: nightly-release-green cancel-in-progress: true +env: + OMNIROUTE_SKIP_SYSTEM_TRUST: "1" + jobs: release-green: name: Validate active release branch diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index fda787cfb9..f5c0aa9967 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -14,6 +14,10 @@ permissions: contents: read env: + # CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow + # test installed a fake PEM on a persistent self-hosted runner and broke all + # system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts. + OMNIROUTE_SKIP_SYSTEM_TRUST: "1" CI_NODE_VERSION: "24" jobs: diff --git a/src/mitm/cert/install.ts b/src/mitm/cert/install.ts index 74dcf62d45..f6871b0bd2 100644 --- a/src/mitm/cert/install.ts +++ b/src/mitm/cert/install.ts @@ -184,6 +184,11 @@ export async function installCert(sudoPassword: string, certPath: string): Promi return; } + if (process.env.OMNIROUTE_SKIP_SYSTEM_TRUST === "1") { + console.log("[cert] OMNIROUTE_SKIP_SYSTEM_TRUST=1 — skipping OS trust-store mutation"); + return; + } + if (IS_WIN) { await installCertWindows(certPath); } else if (IS_MAC) { @@ -370,6 +375,11 @@ export async function uninstallCert(sudoPassword: string, certPath: string): Pro return; } + if (process.env.OMNIROUTE_SKIP_SYSTEM_TRUST === "1") { + console.log("[cert] OMNIROUTE_SKIP_SYSTEM_TRUST=1 — skipping OS trust-store mutation"); + return; + } + if (IS_WIN) { await uninstallCertWindows(); } else if (IS_MAC) { diff --git a/src/mitm/tproxy/caTrust.ts b/src/mitm/tproxy/caTrust.ts index 7d1cea8655..be7323ef2c 100644 --- a/src/mitm/tproxy/caTrust.ts +++ b/src/mitm/tproxy/caTrust.ts @@ -78,6 +78,10 @@ export async function installTproxyCa( sudoPassword = "", deps: Partial = {} ): Promise { + if (process.env.OMNIROUTE_SKIP_SYSTEM_TRUST === "1" && deps.run === undefined) { + console.log("[tproxy-ca] OMNIROUTE_SKIP_SYSTEM_TRUST=1 — skipping OS trust-store mutation"); + return; + } const d = { ...realDeps, ...deps }; if (d.platform() !== "linux") { throw new Error("TPROXY CA trust install is Linux-only."); @@ -103,6 +107,10 @@ export async function uninstallTproxyCa( sudoPassword = "", deps: Partial = {} ): Promise { + if (process.env.OMNIROUTE_SKIP_SYSTEM_TRUST === "1" && deps.run === undefined) { + console.log("[tproxy-ca] OMNIROUTE_SKIP_SYSTEM_TRUST=1 — skipping OS trust-store mutation"); + return; + } const d = { ...realDeps, ...deps }; if (d.platform() !== "linux") return; const cfg = d.certConfig(); diff --git a/tests/_setup/isolateDataDir.ts b/tests/_setup/isolateDataDir.ts index f13db04863..e3d943748b 100644 --- a/tests/_setup/isolateDataDir.ts +++ b/tests/_setup/isolateDataDir.ts @@ -34,3 +34,10 @@ if (!process.env.DATA_DIR) { } }); } + +// System-trust guard: the suite must NEVER mutate the OS trust store. On a +// persistent self-hosted runner the cert-flow integration test installed a fake +// 105-byte PEM into /usr/local/share/ca-certificates and update-ca-certificates +// baked it into the bundle, breaking ALL system TLS on the VM (2026-07-05). +// installCert/uninstallCert/installTproxyCa/uninstallTproxyCa no-op under this. +process.env.OMNIROUTE_SKIP_SYSTEM_TRUST = "1"; diff --git a/tests/unit/system-trust-test-guard.test.ts b/tests/unit/system-trust-test-guard.test.ts new file mode 100644 index 0000000000..969c3588f8 --- /dev/null +++ b/tests/unit/system-trust-test-guard.test.ts @@ -0,0 +1,42 @@ +// Guard: the test suite must NEVER touch the OS trust store. On 2026-07-05 the +// integration test "POST /cert: installs trust when cert exists" ran the REAL +// install path on a persistent self-hosted runner and wrote a 105-byte fake PEM +// into /usr/local/share/ca-certificates — update-ca-certificates then baked the +// invalid entry into ca-certificates.crt and broke ALL system TLS on the VM +// (curl error 77, apt cert failures, corrupted artifact downloads). Hosted +// runners are ephemeral, so the same write went unnoticed for months. +// +// OMNIROUTE_SKIP_SYSTEM_TRUST=1 (set globally in tests/_setup/isolateDataDir.ts) +// makes installCert/uninstallCert no-ops before any filesystem/spawn work. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { installCert } from "../../src/mitm/cert/install.ts"; + +test("isolateDataDir setup exports the system-trust guard for every test process", () => { + assert.equal(process.env.OMNIROUTE_SKIP_SYSTEM_TRUST, "1"); +}); + +test("installCert under the guard skips the OS mutation but keeps input contracts", async () => { + // Contract preserved: a missing cert file still throws (agent-bridge fallback + // #4546 depends on it to build the environment-skip result). + await assert.rejects(() => installCert("", "/nonexistent/omniroute-guard-test.pem")); + + // With a REAL (fake-content) cert file, the un-guarded path would go on to + // sudo/update-ca-certificates — under the guard it must resolve without + // mutating the OS trust store (this exact write bricked the VM's TLS). + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-trust-guard-")); + const pem = path.join(dir, "omniroute-guard-test.pem"); + fs.writeFileSync( + pem, + "-----BEGIN CERTIFICATE-----\nMIIBpDCCAQ2gAwIBAgIUFakeGuardCertXX==\n-----END CERTIFICATE-----\n" + ); + try { + await installCert("", pem); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});