diff --git a/Cargo.lock b/Cargo.lock index cceeb32c5..57c8c92dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -590,12 +606,14 @@ dependencies = [ "impeccable-core", "once_cell", "regex", + "rustls-native-certs", "serde", "serde_json", "sha2", "tiny_http", "unicode-normalization", "ureq", + "webpki-roots 1.0.9", ] [[package]] @@ -830,6 +848,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1063,7 +1087,7 @@ dependencies = [ "getrandom 0.2.17", "libc", "untrusted", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1096,6 +1120,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1122,6 +1158,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1142,6 +1187,29 @@ dependencies = [ "tendril", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.38.0" @@ -1603,6 +1671,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/README.npm.md b/README.npm.md index 551efa436..fc0133e47 100644 --- a/README.npm.md +++ b/README.npm.md @@ -81,6 +81,7 @@ impeccable detect [options] [file-or-dir-or-url...] - Node.js 22.18+ to run `npx impeccable`. The engine itself is a self-contained binary and needs no runtime; the skill installed into your harness calls it directly. - For URL scans, an installed Chrome, Chromium, or Edge (set `IMPECCABLE_BROWSER` to point at one). +- Behind a TLS-inspecting proxy, downloads trust your OS certificate store as well as the bundled Mozilla roots. Set `SSL_CERT_FILE` or `SSL_CERT_DIR` to use a specific CA bundle instead. Binary lookup order: `IMPECCABLE_BIN`, the platform package, `~/.impeccable/bin//`, then a download of the pinned version into that cache. Set `IMPECCABLE_BIN` to a local build to skip all of that. diff --git a/crates/context/Cargo.toml b/crates/context/Cargo.toml index 0b1957fac..bc370e250 100644 --- a/crates/context/Cargo.toml +++ b/crates/context/Cargo.toml @@ -21,5 +21,7 @@ once_cell = { workspace = true } sha2 = "0.10" flate2 = { version = "1", default-features = false, features = ["zlib-rs"] } ureq = { version = "2", default-features = false, features = ["tls", "json"] } +rustls-native-certs = "0.8" +webpki-roots = "1" tiny_http = "0.12" unicode-normalization = "0.1.25" diff --git a/crates/context/src/concept_seed.rs b/crates/context/src/concept_seed.rs index fb0545782..64ee6dbf6 100644 --- a/crates/context/src/concept_seed.rs +++ b/crates/context/src/concept_seed.rs @@ -47,7 +47,7 @@ fn card_base(env: &Env) -> String { } fn agent(timeout: Duration) -> ureq::Agent { - ureq::AgentBuilder::new().timeout_connect(timeout).timeout(timeout).build() + crate::http::agent_builder().timeout_connect(timeout).timeout(timeout).build() } /// URLSearchParams serialization (application/x-www-form-urlencoded). diff --git a/crates/context/src/context_cli.rs b/crates/context/src/context_cli.rs index d0576b50f..9c631b8e7 100644 --- a/crates/context/src/context_cli.rs +++ b/crates/context/src/context_cli.rs @@ -468,7 +468,7 @@ fn fetch_latest_skill_version(env: &Env) -> Option { .cloned() .unwrap_or_else(|| "https://impeccable.style".to_string()); let host = host.strip_suffix('/').unwrap_or(&host).to_string(); - let agent = ureq::AgentBuilder::new() + let agent = crate::http::agent_builder() .timeout(std::time::Duration::from_millis(FETCH_TIMEOUT_MS)) .build(); let res = agent.get(&format!("{}/api/version", host)).call().ok()?; diff --git a/crates/context/src/generate_image.rs b/crates/context/src/generate_image.rs index e6916110c..26ee29aad 100644 --- a/crates/context/src/generate_image.rs +++ b/crates/context/src/generate_image.rs @@ -294,7 +294,7 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } } } - let agent = ureq::AgentBuilder::new().build(); + let agent = crate::http::agent_builder().build(); let response = if !refs.is_empty() { let boundary = format!("----impeccable{:x}", crate::util::now_ms() as u64); let mut body: Vec = Vec::new(); diff --git a/crates/context/src/http.rs b/crates/context/src/http.rs new file mode 100644 index 000000000..c5423984d --- /dev/null +++ b/crates/context/src/http.rs @@ -0,0 +1,104 @@ +//! One TLS trust configuration for every HTTPS request the engine makes. +//! +//! `ureq`'s default rustls config trusts only the Mozilla roots compiled in +//! through `webpki-roots`. On a machine where an endpoint security agent +//! inspects TLS (Aikido, Zscaler, Netskope: routine in managed corporate +//! setups), every connection terminates at a proxy whose root lives in the +//! OS trust store and nowhere else, so `update` and `install` failed with +//! `invalid peer certificate: UnknownIssuer` while curl and npm on the same +//! machine succeeded (#757). +//! +//! The store built here is the union of the OS trust store +//! (`rustls-native-certs`: the macOS Keychain, the Windows store, the +//! OpenSSL paths on Linux) and the bundled Mozilla roots. A union, not a +//! replacement: a container without `ca-certificates`, or a store that +//! fails to load, verifies against the bundled roots exactly as before. +//! `SSL_CERT_FILE` / `SSL_CERT_DIR` stand in for the OS store, as they do +//! for OpenSSL and curl; the bundled roots stay either way. + +use std::sync::Arc; + +use once_cell::sync::Lazy; +use ureq::rustls::pki_types::CertificateDer; +use ureq::rustls::{self, ClientConfig, RootCertStore}; + +/// `ureq::AgentBuilder::new()` with the engine's trust store installed. +/// Every HTTPS call site builds its agent from this; the plain-HTTP calls +/// to the live server on localhost do not need it. +pub fn agent_builder() -> ureq::AgentBuilder { + ureq::AgentBuilder::new().tls_config(tls_config()) +} + +fn tls_config() -> Arc { + static CONFIG: Lazy> = Lazy::new(|| { + // Mirrors ureq's own default config (provider and protocol versions); + // only the root store differs. + let config = + ClientConfig::builder_with_provider(rustls::crypto::ring::default_provider().into()) + .with_protocol_versions(&[&rustls::version::TLS12, &rustls::version::TLS13]) + .expect("the ring provider supports TLS 1.2 and 1.3") + .with_root_certificates(root_store(rustls_native_certs::load_native_certs().certs)) + .with_no_client_auth(); + Arc::new(config) + }); + CONFIG.clone() +} + +/// The bundled Mozilla roots plus every parsable certificate in `native`. +/// Unparsable entries are dropped, so one broken certificate in the OS +/// store cannot take the bundled roots down with it. +fn root_store(native: Vec>) -> RootCertStore { + let mut store = RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + store.add_parsable_certificates(native); + store +} + +#[cfg(test)] +mod tests { + use super::*; + use ureq::rustls::pki_types::pem::PemObject; + + /// Self-signed CA minted for this test (P-256, v3, CA:TRUE): the shape + /// of the root a TLS-inspecting proxy installs into the OS store. + const PROXY_ROOT_PEM: &str = "-----BEGIN CERTIFICATE----- +MIIBdTCCARugAwIBAgIJANhTZvQvv7HJMAoGCCqGSM49BAMCMB0xGzAZBgNVBAMM +EmltcGVjY2FibGUgdGVzdCBDQTAgFw0yNjA5MDcwNjQxMjdaGA8yMTI2MDgxNDA2 +NDEyN1owHTEbMBkGA1UEAwwSaW1wZWNjYWJsZSB0ZXN0IENBMFkwEwYHKoZIzj0C +AQYIKoZIzj0DAQcDQgAEYVZtCOXaZsY71/0Roy62iBVcyx8UfMDkPbEbf/IEw5Bm +yNBfKTFS/8FbRBMWHXOwNE0Ns1BLVOB1oQ1XFC5Bz6NCMEAwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFONzBxi7ewOfuP6cBIIqsxu +3pEiMAoGCCqGSM49BAMCA0gAMEUCIQD98Q0ZRe8ceuopnUwQKYleZd5IzfWhhpmO +tB0WGTOG3QIgdJa8gBPU9Y6WsrursItsnUeGTYHKDCZZ6MjlekLFuoc= +-----END CERTIFICATE----- +"; + + fn bundled() -> usize { + webpki_roots::TLS_SERVER_ROOTS.len() + } + + #[test] + fn bundled_roots_alone_when_the_os_store_is_empty() { + assert_eq!(root_store(Vec::new()).len(), bundled()); + } + + #[test] + fn os_store_root_joins_the_bundled_roots() { + let proxy = CertificateDer::from_pem_slice(PROXY_ROOT_PEM.as_bytes()).unwrap(); + assert_eq!(root_store(vec![proxy]).len(), bundled() + 1); + } + + #[test] + fn unparsable_os_store_entry_is_dropped() { + let junk = CertificateDer::from(b"not a certificate".to_vec()); + assert_eq!(root_store(vec![junk]).len(), bundled()); + } + + #[test] + fn agent_builds_from_this_hosts_store() { + // Runs the real rustls-native-certs load: it must not panic, and the + // shared config must be accepted by a ureq agent. + let _agent = agent_builder().build(); + } +} diff --git a/crates/context/src/lib.rs b/crates/context/src/lib.rs index 6da405245..578329372 100644 --- a/crates/context/src/lib.rs +++ b/crates/context/src/lib.rs @@ -5,6 +5,7 @@ pub mod jsp; pub mod util; pub mod url; +pub mod http; pub mod provider; pub mod hook_markers; pub mod target_args; diff --git a/crates/skills/src/bundle.rs b/crates/skills/src/bundle.rs index 379fa73f8..83c6ff810 100644 --- a/crates/skills/src/bundle.rs +++ b/crates/skills/src/bundle.rs @@ -49,7 +49,7 @@ const RATIO_GUARD_FLOOR: u64 = 1024 * 1024; /// through `download_file`, which streams to disk. Reads are capped at /// [`MAX_DOWNLOAD_BYTES`]; a longer response is an error, not a truncation. pub fn download(url: &str) -> Result, String> { - let agent = ureq::AgentBuilder::new() + let agent = impeccable_context::http::agent_builder() .timeout_connect(std::time::Duration::from_secs(30)) .build(); match agent.get(url).call() { @@ -79,7 +79,7 @@ pub struct FetchResponse { } fn ureq_fetch(url: &str) -> Result { - let agent = ureq::AgentBuilder::new() + let agent = impeccable_context::http::agent_builder() .timeout_connect(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(120)) .redirects(0) diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 7e8f9a74c..8ab809cf3 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -352,6 +352,8 @@ content, and failures fetching either asset exit nonzero, including when installed skill or hook files. Explicit `IMPECCABLE_BUNDLE_PATH` and `link` retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNING.md). +**TLS trust (#757):** every HTTPS request the engine makes (bundle and signature downloads, `/api/version`, `/api/commands`, the roll API, image generation) verifies against the OS trust store plus the bundled Mozilla roots, built once in `crates/context/src/http.rs`. `SSL_CERT_FILE` / `SSL_CERT_DIR` replace the OS store, never the bundled roots. The live server calls on localhost are plain HTTP and unaffected. + - **Invoked from**: README.md ("npx impeccable install / update"), README.npm.md Quick Start (`npx impeccable skills install`, `... install -y --providers=claude,codex --scope=project`, `... update`, `... install --no-hooks`, `... link --source=.impeccable --providers=claude,cursor`, `... skills help`), `README.md:360` (hook consent explanation). - `run(args)`: `args[0]` ∈ `undefined|help|--help|-h` → `showHelp()`; `install` → `install(rest)`; `link`; `update`; `check` (ignores flags); else `stderr> Unknown skills command: ${sub}` + `Run 'impeccable --help' for available commands.`, `exit 1`. - Constants: `API_BASE = 'https://impeccable.style'`; `PROVIDER_DIRS = ['.claude','.cursor','.dsh','.gemini','.agents','.agent','.github','.grok','.hermes','.kiro','.opencode','.pi','.qoder','.trae','.trae-cn','.rovodev','.vibe']`; aliases (`agent`→`.agent`, `agents`/`codex`→`.agents`, `antigravity`→`.agent`, `claude`/`claude-code`→`.claude`, `copilot`/`github`→`.github`, `cursor`, `deepseek`/`deepseek-harness`/`dsh`→`.dsh`, `gemini`, `grok`/`grok-build`/`xai`→`.grok`, `hermes`, `kiro`, `opencode`, `pi`, `qoder`, `rovo-dev`/`rovodev`→`.rovodev`, `trae`, `trae-cn`, `vibe`); leading `.` stripped and lowercased before alias lookup; a literal PROVIDER_DIR value is accepted as-is. `DEFAULT_TARGETS = ['.claude','.agents']`. User-scope skill dir overrides: `.agent`→`~/.gemini/config/skills`, `.dsh`→`$DSH_HOME/skills` (only when DSH_HOME under home) else `~/.dsh/skills`, `.hermes`→`$HERMES_HOME/skills` (only when HERMES_HOME under home) else `~/.hermes/skills`, `.pi`→`~/.pi/agent/skills`, `.opencode`→`$OPENCODE_CONFIG_DIR|$XDG_CONFIG_HOME/opencode|~/.config/opencode` + `/skills`; others `~//skills`. Project scope: `//skills`.