diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b96bf31f..ea550ebd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,19 +57,15 @@ jobs: - name: Install rustfmt + clippy components + wasm32-wasip1 target run: mise exec -- rustup component add rustfmt clippy && mise exec -- rustup target add wasm32-wasip1 - # Rust cache. - # - `cache-all-crates: true` — also caches build-deps (proc-macros). - # - `cache-workspace-crates: true` — caches our 7 workspace crates' - # target/ artifacts. cargo's incremental invalidation handles the - # "did this crate actually change" question; the cache just feeds - # it the previous build state instead of starting cold. + # Cache Cargo downloads, but build target/ from scratch. Restoring target/ + # alongside the sandbox builds can exhaust the hosted runner's disk when + # a lockfile change leaves old and new artifacts side by side. # - `shared-key: rust-ci` — every workflow ref reuses the same cache # slot when the lockfile is identical. - uses: Swatinem/rust-cache@v2 with: shared-key: rust-ci - cache-all-crates: true - cache-workspace-crates: true + cache-targets: false # Engine .wasm artifacts are deterministic in their inputs: # (Go version, engine source, helm patch). Restore them by content diff --git a/CHANGELOG.md b/CHANGELOG.md index 55857e79..4b46dab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ minor bump in the SDK. ## [Unreleased] +### Fixed + +- Git HTTPS dependency fetches now preserve configured custom CA bundles while forcing certificate verification on. `GIT_SSL_CAINFO` and `http.sslCAInfo` work for initial clones and cached-repository refreshes, including `vendor add` and publish-time vendoring; `GIT_SSL_NO_VERIFY` and `http.sslVerify=false` cannot disable verification, and unrelated ambient Git HTTP options are not forwarded. + ## [0.8.23] — 2026-06-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index 786c96e8..10214a4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,7 +102,9 @@ dependencies = [ "pem", "pkcs8", "rand 0.8.6", + "rcgen", "reqwest", + "rustls", "schemars", "semver", "serde", @@ -220,7 +222,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -231,7 +233,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1397,7 +1399,7 @@ dependencies = [ "pkg-config", "rustls-ffi", "vcpkg", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1468,6 +1470,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_builder" version = "0.20.2" @@ -1785,7 +1793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3326,7 +3334,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3699,7 +3707,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4946,7 +4954,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4959,6 +4967,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -5553,6 +5567,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -5830,7 +5850,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -5867,7 +5887,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -6027,6 +6047,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -6372,7 +6405,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6454,7 +6487,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6962,7 +6995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7137,7 +7170,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7228,6 +7261,25 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -8615,7 +8667,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -9210,6 +9262,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/akua-core/Cargo.toml b/crates/akua-core/Cargo.toml index 4aed7dbf..5e169285 100644 --- a/crates/akua-core/Cargo.toml +++ b/crates/akua-core/Cargo.toml @@ -118,8 +118,7 @@ tar = { version = "0.4", optional = true } # release builds use vendored OpenSSL instead of target system OpenSSL packages. # Because this transport honours `GIT_SSL_NO_VERIFY` from the ambient # environment, `git_fetcher::force_tls_verification` pins `ssl_verify = true` on -# every connection before the handshake so a poisoned env cannot disable cert -# validation on the first (pre-pin) clone. +# every connection before the handshake while retaining configured CA bundles. gix = { version = "0.66", default-features = false, features = ["blocking-http-transport-curl-rustls", "max-performance-safe", "worktree-mutation"], optional = true } curl = { version = "0.4", optional = true, features = ["static-ssl"] } p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std", "pem", "pkcs8"], optional = true } @@ -138,6 +137,9 @@ rand = "0.8" # endpoint on `http://127.0.0.1:` so push/pull paths exercise the # full transport without a real registry. httpmock = "0.7" +# Authenticated HTTPS git fixture for the vendor regression test. +rcgen = "0.13" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } # Pure-Rust OCI client. Dev-only — used to *parse* the manifest bytes # our pusher PUTs and assert spec compliance via a real OCI parser # (independent verification that we're not just round-tripping our own diff --git a/crates/akua-core/src/git_fetcher.rs b/crates/akua-core/src/git_fetcher.rs index 257ac87f..b4dec714 100644 --- a/crates/akua-core/src/git_fetcher.rs +++ b/crates/akua-core/src/git_fetcher.rs @@ -22,9 +22,8 @@ //! environment overrides. Left to the default, a poisoned environment //! could disable certificate validation on the first clone — a TOFU //! MITM window before any commit is pinned. [`force_tls_verification`] -//! pins `ssl_verify = true` on every connection *before* the handshake, -//! so neither half of a hostile env (`*_NO_VERIFY` nor a swapped CA -//! bundle) is ever consulted. +//! pins `ssl_verify = true` on every connection *before* the handshake +//! while preserving the configured CA bundle. //! //! ## Scope //! @@ -245,7 +244,7 @@ fn clone_bare( // when the caller supplied an auth map. let map = auth.map(|m| std::sync::Arc::new(m.clone())); prep = prep.configure_connection(move |conn| { - force_tls_verification(conn); + force_tls_verification(conn)?; if let Some(map) = &map { // `Arc` over `clone()` because `configure_connection` and // `set_credentials` are both `FnMut` — refcount bumps beat @@ -293,7 +292,10 @@ fn refresh_bare( url: url.to_string(), detail: e.to_string(), })?; - force_tls_verification(&mut conn); + force_tls_verification(&mut conn).map_err(|e| GitFetchError::Refresh { + url: url.to_string(), + detail: e.to_string(), + })?; if let Some(map) = auth { set_connection_credentials(&mut conn, std::sync::Arc::new(map.clone())); } @@ -314,7 +316,7 @@ fn refresh_bare( } /// Pin TLS certificate verification ON for this connection, defeating -/// any ambient `GIT_SSL_NO_VERIFY` / `http.sslNoVerify` in the +/// any ambient `GIT_SSL_NO_VERIFY` / `http.sslVerify = false` in the /// environment. /// /// gix's curl-rustls transport derives its `http::Options` from the @@ -326,21 +328,45 @@ fn refresh_bare( /// pinned. We pre-seed the connection's transport options with a /// `ssl_verify: true` `http::Options` *before* the handshake. Because /// `Connection::prepare_fetch` only derives options from config when -/// `transport_options` is still `None`, our explicit value wins and the -/// env-derived `ssl_verify = false` is never consulted. -/// -/// `ssl_ca_info` is intentionally left `None` (curl's default trust -/// store) — we drop any env-supplied CA bundle along with the -/// env-supplied no-verify, so neither half of a poisoned env applies. -fn force_tls_verification(conn: &mut gix::remote::Connection<'_, '_, T>) +/// `transport_options` is still `None`, we derive them here first and +/// copy only `ssl_ca_info` into fresh default options with verification +/// enabled. This keeps trusted CA configuration such as +/// `GIT_SSL_CAINFO` / `http.sslCAInfo` intact without carrying ambient +/// HTTP headers, proxy credentials, or other transport configuration. +#[allow(clippy::result_large_err)] +fn force_tls_verification( + conn: &mut gix::remote::Connection<'_, '_, T>, +) -> Result<(), gix::config::transport::Error> where T: gix::protocol::transport::client::Transport, { - let opts = gix::protocol::transport::client::http::Options { + use gix::bstr::ByteSlice; + + let mut configured = { + let remote = conn.remote(); + let url = remote + .url(gix::remote::Direction::Fetch) + .expect("connected remote has a fetch URL") + .to_bstring(); + remote + .repo() + .transport_options(url.as_bstr(), remote.name().map(gix::remote::Name::as_bstr))? + }; + let Some(options) = configured.as_mut() else { + return Ok(()); + }; + let ssl_ca_info = options + .downcast_mut::() + .expect("HTTP transport configuration has the expected options type") + .ssl_ca_info + .take(); + let options = gix::protocol::transport::client::http::Options { + ssl_ca_info, ssl_verify: true, ..Default::default() }; - conn.set_transport_options(Box::new(opts)); + conn.set_transport_options(Box::new(options)); + Ok(()) } #[allow(clippy::result_large_err)] diff --git a/crates/akua-core/src/helm_repo_fetcher.rs b/crates/akua-core/src/helm_repo_fetcher.rs index df7cd100..ec636ba9 100644 --- a/crates/akua-core/src/helm_repo_fetcher.rs +++ b/crates/akua-core/src/helm_repo_fetcher.rs @@ -784,7 +784,10 @@ entries: } }; - let header = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); // A client abort mid-write is fine — ignore the error. let _ = stream.write_all(header.as_bytes()); let _ = stream.write_all(&body); diff --git a/crates/akua-core/tests/vendor_https_auth.rs b/crates/akua-core/tests/vendor_https_auth.rs new file mode 100644 index 00000000..103460be --- /dev/null +++ b/crates/akua-core/tests/vendor_https_auth.rs @@ -0,0 +1,417 @@ +#![cfg(feature = "git-fetch")] + +use std::collections::HashMap; +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; + +use akua_core::host_auth::{BasicAuth, HostAuthMap}; +use base64::Engine; +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, + KeyPair, KeyUsagePurpose, +}; +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use rustls::{ + ClientConfig, ClientConnection, RootCertStore, ServerConfig, ServerConnection, StreamOwned, +}; + +struct HttpsGitServer { + address: std::net::SocketAddr, + ca_path: PathBuf, + auth_seen: Arc, + ambient_header_seen: Arc, + stop: Arc, + thread: Option>, +} + +impl HttpsGitServer { + fn start(repo: PathBuf, fixture_root: &Path, username: &str, password: &str) -> Self { + let _ = rustls::crypto::ring::default_provider().install_default(); + let (ca_cert, ca_key) = make_ca("Akua HTTPS fixture CA"); + let ca_path = fixture_root.join("ca.pem"); + fs::write(&ca_path, ca_cert.pem()).unwrap(); + + let server_key = KeyPair::generate().unwrap(); + let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + server_params + .distinguished_name + .push(DnType::CommonName, "localhost"); + server_params + .key_usages + .push(KeyUsagePurpose::DigitalSignature); + server_params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + let server_cert = server_params + .signed_by(&server_key, &ca_cert, &ca_key) + .unwrap(); + + let key = PrivateKeyDer::Pkcs8(server_key.serialize_der().into()); + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![server_cert.der().clone()], key) + .unwrap(); + let config = Arc::new(config); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let auth_seen = Arc::new(AtomicBool::new(false)); + let ambient_header_seen = Arc::new(AtomicBool::new(false)); + let expected_auth = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")) + ); + + let thread_stop = Arc::clone(&stop); + let thread_auth_seen = Arc::clone(&auth_seen); + let thread_ambient_header_seen = Arc::clone(&ambient_header_seen); + let thread = thread::spawn(move || { + while !thread_stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + if let Err(error) = handle_request( + stream, + Arc::clone(&config), + &repo, + &expected_auth, + &thread_auth_seen, + &thread_ambient_header_seen, + ) { + eprintln!("HTTPS git fixture request failed: {error}"); + } + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(std::time::Duration::from_millis(5)); + } + Err(_) => break, + } + } + }); + + Self { + address, + ca_path, + auth_seen, + ambient_header_seen, + stop, + thread: Some(thread), + } + } + + fn url(&self) -> String { + format!("https://localhost:{}/repo.git", self.address.port()) + } +} + +fn make_ca(common_name: &str) -> (Certificate, KeyPair) { + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(Vec::new()).unwrap(); + params + .distinguished_name + .push(DnType::CommonName, common_name); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params.key_usages.push(KeyUsagePurpose::KeyCertSign); + params.key_usages.push(KeyUsagePurpose::CrlSign); + (params.self_signed(&key).unwrap(), key) +} + +impl Drop for HttpsGitServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + let _ = TcpStream::connect(self.address); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} + +fn handle_request( + stream: TcpStream, + config: Arc, + repo: &Path, + expected_auth: &str, + auth_seen: &AtomicBool, + ambient_header_seen: &AtomicBool, +) -> std::io::Result<()> { + let connection = ServerConnection::new(config).unwrap(); + let mut stream = StreamOwned::new(connection, stream); + let request = read_request(&mut stream)?; + let header_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + .ok_or_else(|| std::io::Error::other("missing HTTP header terminator"))?; + let headers = String::from_utf8_lossy(&request[..header_end]); + if headers + .lines() + .any(|line| line == "X-Akua-Ambient-Secret: must-not-leak") + { + ambient_header_seen.store(true, Ordering::Relaxed); + } + let mut request_line = headers + .lines() + .next() + .unwrap_or_default() + .split_whitespace(); + let method = request_line.next().unwrap_or_default(); + let target = request_line.next().unwrap_or_default(); + + let authenticated = headers.lines().any(|line| { + line.strip_prefix("Authorization: ") + .is_some_and(|value| value.trim() == expected_auth) + }); + if !authenticated { + write_response( + &mut stream, + "401 Unauthorized", + "text/plain", + b"", + Some("WWW-Authenticate: Basic realm=\"akua-test\"\r\n"), + )?; + stream.conn.send_close_notify(); + return stream.flush(); + } + auth_seen.store(true, Ordering::Relaxed); + + match method { + "GET" if target.starts_with("/repo.git/info/refs?service=git-upload-pack") => { + let advertised = git_upload_pack(repo, true, &[])?; + let service = b"# service=git-upload-pack\n"; + let mut body = format!("{:04x}", service.len() + 4).into_bytes(); + body.extend_from_slice(service); + body.extend_from_slice(b"0000"); + body.extend_from_slice(&advertised); + write_response( + &mut stream, + "200 OK", + "application/x-git-upload-pack-advertisement", + &body, + None, + ) + } + "POST" if target == "/repo.git/git-upload-pack" => { + let result = git_upload_pack(repo, false, &request[header_end..])?; + write_response( + &mut stream, + "200 OK", + "application/x-git-upload-pack-result", + &result, + None, + ) + } + _ => write_response(&mut stream, "404 Not Found", "text/plain", b"", None), + }?; + stream.conn.send_close_notify(); + stream.flush() +} + +fn read_request(stream: &mut impl Read) -> std::io::Result> { + let mut request = Vec::new(); + let mut content_length = None; + loop { + let mut chunk = [0_u8; 8192]; + let read = stream.read(&mut chunk)?; + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + if let Some(header_start) = request.windows(4).position(|window| window == b"\r\n\r\n") { + let header_end = header_start + 4; + let length = *content_length.get_or_insert_with(|| { + String::from_utf8_lossy(&request[..header_end]) + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length: ") + .and_then(|value| value.trim().parse().ok()) + }) + .unwrap_or(0) + }); + if request.len() >= header_end + length { + break; + } + } + } + Ok(request) +} + +fn write_response( + stream: &mut impl Write, + status: &str, + content_type: &str, + body: &[u8], + extra_header: Option<&str>, +) -> std::io::Result<()> { + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n{}Connection: close\r\n\r\n", + body.len(), + extra_header.unwrap_or_default() + )?; + stream.write_all(body)?; + stream.flush() +} + +fn git_upload_pack(repo: &Path, advertise: bool, input: &[u8]) -> std::io::Result> { + let mut command = Command::new("git"); + command.arg("upload-pack").arg("--stateless-rpc"); + if advertise { + command.arg("--advertise-refs"); + } + let mut child = command + .arg(repo) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + if !input.is_empty() { + child.stdin.take().unwrap().write_all(input)?; + } + let output = child.wait_with_output()?; + if !output.status.success() { + return Err(std::io::Error::other("git upload-pack failed")); + } + Ok(output.stdout) +} + +fn git(args: &[&str], cwd: &Path) { + let status = Command::new("git") + .args(args) + .current_dir(cwd) + .status() + .unwrap(); + assert!(status.success(), "git command failed: {args:?}"); +} + +fn make_tagged_repo(root: &Path) -> PathBuf { + let work = root.join("work"); + let bare = root.join("origin.git"); + fs::create_dir_all(work.join("templates")).unwrap(); + git(&["init", "--bare", bare.to_str().unwrap()], root); + git(&["init"], &work); + git(&["config", "user.name", "Akua Test"], &work); + git(&["config", "user.email", "test@akua.dev"], &work); + fs::write( + work.join("Chart.yaml"), + "apiVersion: v2\nname: protected\nversion: 1.0.0\n", + ) + .unwrap(); + fs::write(work.join("templates/configmap.yaml"), "kind: ConfigMap\n").unwrap(); + git(&["add", "."], &work); + git(&["commit", "-m", "fixture"], &work); + git(&["tag", "v1.0.0"], &work); + git(&["remote", "add", "origin", bare.to_str().unwrap()], &work); + git( + &["push", "origin", "HEAD:refs/heads/main", "refs/tags/v1.0.0"], + &work, + ); + bare +} + +#[test] +fn unauthenticated_response_closes_tls_cleanly() { + let fixture = tempfile::tempdir().unwrap(); + let bare = make_tagged_repo(fixture.path()); + let server = HttpsGitServer::start(bare, fixture.path(), "alice", "secret-token"); + + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from_pem_file(&server.ca_path).unwrap()) + .unwrap(); + let config = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let connection = + ClientConnection::new(Arc::new(config), ServerName::try_from("localhost").unwrap()) + .unwrap(); + let mut stream = StreamOwned::new(connection, TcpStream::connect(server.address).unwrap()); + stream + .write_all( + b"GET /repo.git/info/refs?service=git-upload-pack HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .expect("401 response must end with a TLS close_notify"); + assert!(response.starts_with(b"HTTP/1.1 401 Unauthorized\r\n")); +} + +#[test] +fn vendor_add_forces_verification_and_preserves_environment_ca_bundle() { + let fixture = tempfile::tempdir().unwrap(); + let bare = make_tagged_repo(fixture.path()); + let server = HttpsGitServer::start(bare, fixture.path(), "alice", "secret-token"); + + let workspace = fixture.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + workspace.join("akua.toml"), + format!( + r#"[package] +name = "vendor-https-test" +version = "0.1.0" +edition = "akua.dev/v1alpha1" + +[dependencies] +upstream = {{ git = "{}", tag = "v1.0.0" }} +"#, + server.url() + ), + ) + .unwrap(); + + std::env::set_var("XDG_CACHE_HOME", fixture.path().join("untrusted-cache")); + + let auth: HostAuthMap = HashMap::from([( + format!("localhost:{}", server.address.port()), + BasicAuth { + username: "alice".to_string(), + password: "secret-token".to_string(), + }, + )]); + + let (unrelated_ca, _) = make_ca("Unrelated HTTPS fixture CA"); + let unrelated_ca_path = fixture.path().join("unrelated-ca.pem"); + fs::write(&unrelated_ca_path, unrelated_ca.pem()).unwrap(); + std::env::set_var("GIT_SSL_NO_VERIFY", "true"); + std::env::set_var("GIT_SSL_CAINFO", &unrelated_ca_path); + std::env::set_var("GIT_CONFIG_COUNT", "1"); + std::env::set_var("GIT_CONFIG_KEY_0", "http.extraHeader"); + std::env::set_var("GIT_CONFIG_VALUE_0", "X-Akua-Ambient-Secret: must-not-leak"); + let untrusted = akua_core::vendor::add(&workspace, "upstream", Some(&auth)); + assert!( + untrusted.is_err(), + "vendorAdd must force verification despite GIT_SSL_NO_VERIFY" + ); + + std::env::set_var("GIT_SSL_CAINFO", &server.ca_path); + // A failed clone can leave transport-specific partial cache state. The + // trusted clone is a separate assertion and must start from a clean cache. + std::env::set_var("XDG_CACHE_HOME", fixture.path().join("trusted-cache")); + let result = akua_core::vendor::add(&workspace, "upstream", Some(&auth)); + + std::env::remove_var("GIT_SSL_NO_VERIFY"); + std::env::remove_var("GIT_SSL_CAINFO"); + std::env::remove_var("GIT_CONFIG_COUNT"); + std::env::remove_var("GIT_CONFIG_KEY_0"); + std::env::remove_var("GIT_CONFIG_VALUE_0"); + std::env::remove_var("XDG_CACHE_HOME"); + + let output = result.expect("vendorAdd must trust GIT_SSL_CAINFO while verifying HTTPS"); + assert!(server.auth_seen.load(Ordering::Relaxed)); + assert!(!server.ambient_header_seen.load(Ordering::Relaxed)); + assert!(output.path.join("Chart.yaml").is_file()); + assert!(output.path.join("templates/configmap.yaml").is_file()); +} diff --git a/docs/cli.md b/docs/cli.md index 3b114021..6060ec72 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -241,6 +241,10 @@ Subcommands: "akua-git.cnap.tech/org-A" = { username = "org-A", password = "tokA" } ``` +### Git HTTPS trust + +All native Git fetch paths preserve the configured CA bundle from `GIT_SSL_CAINFO` or Git's `http.sslCAInfo`, including initial clones and cached-repository refreshes used by dependency resolution, `vendor add`, `pack`, and publish-time vendoring. Akua always forces certificate verification on: `GIT_SSL_NO_VERIFY` and `http.sslVerify=false` are ignored, so a CA bundle must actually validate the remote certificate. Only the CA bundle is copied from Git's HTTP transport configuration; ambient extra headers, proxy credentials, and other Git HTTP options are not forwarded by this trust setup. + Lockfile guarantee: regardless of the credential used to fetch, `akua.lock`'s `source` field stores the canonicalized URL with userinfo, default ports, and `.git` suffix stripped. Credentials never leak into `akua.lock`. See `examples/12-vendor-offline/` for the end-to-end offline-render contract demonstrated against a path dep with the canonical source deleted. diff --git a/docs/sdk.md b/docs/sdk.md index 089c181a..4b3b75e5 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -184,6 +184,8 @@ await akua.inspectOciPackage({ The SDK does not read ambient credential files such as `~/.netrc`, `$XDG_CONFIG_HOME/akua/auth.toml`, or `~/.docker/config.json`. +That explicit credential rule is separate from HTTPS trust configuration. Native Git fetches preserve the process's configured CA bundle from `GIT_SSL_CAINFO` or Git's `http.sslCAInfo` for both initial clones and cached-repository refreshes. Certificate verification is always forced on despite `GIT_SSL_NO_VERIFY` or `http.sslVerify=false`, and other ambient Git HTTP options such as extra headers and proxy credentials are not copied into the fetch connection. + --- ## Errors diff --git a/docs/security-audit-2026-05-29.md b/docs/security-audit-2026-05-29.md index 4191bc69..42a580f1 100644 --- a/docs/security-audit-2026-05-29.md +++ b/docs/security-audit-2026-05-29.md @@ -133,9 +133,12 @@ serde shape). The `blocking-http-transport-curl-rustls` gix feature applies Git-compatible TLS env settings, including `GIT_SSL_NO_VERIFY`. On a poisoned environment, an attacker can disable TLS validation and MITM the *first* `akua add` of a git dep (TOFU window); -subsequent fetches are protected by the lockfile commit pin. **Fix:** pin TLS config -in the gix client and ignore `GIT_SSL_NO_VERIFY`, or document that git deps require -a trusted environment on first resolve. +subsequent fetches are protected by the lockfile commit pin. **Fix:** every initial +clone and cached-repository refresh now forces `ssl_verify = true` before the TLS +handshake, ignoring `GIT_SSL_NO_VERIFY` and `http.sslVerify=false`. The connection +retains only `ssl_ca_info`, so custom trust configured through `GIT_SSL_CAINFO` or +`http.sslCAInfo` still works without forwarding ambient HTTP headers, proxy +credentials, or other Git transport options. ### 9. [MEDIUM] Cosign verification is opt-in, not "verify by default" `crates/akua-cli/src/verbs/render.rs:482-498`, `verify.rs:280-285`, `oci_fetcher.rs:439-449` diff --git a/site/cli/vendor.html b/site/cli/vendor.html index 0012cade..f63ffc1a 100644 --- a/site/cli/vendor.html +++ b/site/cli/vendor.html @@ -314,6 +314,8 @@

Auth flags (private git remotes)

[auth]
 "akua-git.cnap.tech" = { username = "svc", password = "tok" }
 "akua-git.cnap.tech/org-A" = { username = "org-A", password = "tokA" }
+

Git HTTPS trust

+

All native Git fetch paths preserve the configured CA bundle from GIT_SSL_CAINFO or Git's http.sslCAInfo, including initial clones and cached-repository refreshes used by dependency resolution, vendor add, pack, and publish-time vendoring. Akua always forces certificate verification on: GIT_SSL_NO_VERIFY and http.sslVerify=false are ignored, so a CA bundle must actually validate the remote certificate. Only the CA bundle is copied from Git's HTTP transport configuration; ambient extra headers, proxy credentials, and other Git HTTP options are not forwarded by this trust setup.

Lockfile guarantee: regardless of the credential used to fetch, akua.lock's source field stores the canonicalized URL with userinfo, default ports, and .git suffix stripped. Credentials never leak into akua.lock.

See examples/12-vendor-offline/ for the end-to-end offline-render contract demonstrated against a path dep with the canonical source deleted.

diff --git a/site/concepts/sdk.html b/site/concepts/sdk.html index 79052475..758d7ef2 100644 --- a/site/concepts/sdk.html +++ b/site/concepts/sdk.html @@ -383,6 +383,7 @@

Credentials

}, });

The SDK does not read ambient credential files such as ~/.netrc or ~/.docker/config.json.

+

That explicit credential rule is separate from HTTPS trust configuration. Native Git fetches preserve the process's configured CA bundle from GIT_SSL_CAINFO or Git's http.sslCAInfo for both initial clones and cached-repository refreshes. Certificate verification is always forced on despite GIT_SSL_NO_VERIFY or http.sslVerify=false, and other ambient Git HTTP options such as extra headers and proxy credentials are not copied into the fetch connection.


Errors

SDK methods throw AkuaError subclasses built from the structured error emitted by the native layer. User errors, rate limits, timeouts, and system failures keep their stable Akua error codes so callers can branch without parsing prose.