From 27d3eaeea9000eb3fe79364f572a454704bbb819 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Wed, 10 Jun 2026 12:35:48 -0700 Subject: [PATCH 01/11] Create the CanonicalRequest model --- proxy_agent/src/proxy.rs | 1 + .../src/proxy/canonical/destination.rs | 726 ++++++++++++++++++ proxy_agent/src/proxy/canonical/mod.rs | 492 ++++++++++++ proxy_agent/src/proxy/canonical/path.rs | 638 +++++++++++++++ proxy_agent/src/proxy/canonical/query.rs | 170 ++++ proxy_agent/src/proxy/canonical/rule.rs | 485 ++++++++++++ 6 files changed, 2512 insertions(+) create mode 100644 proxy_agent/src/proxy/canonical/destination.rs create mode 100644 proxy_agent/src/proxy/canonical/mod.rs create mode 100644 proxy_agent/src/proxy/canonical/path.rs create mode 100644 proxy_agent/src/proxy/canonical/query.rs create mode 100644 proxy_agent/src/proxy/canonical/rule.rs diff --git a/proxy_agent/src/proxy.rs b/proxy_agent/src/proxy.rs index b0be815c..721e912c 100644 --- a/proxy_agent/src/proxy.rs +++ b/proxy_agent/src/proxy.rs @@ -32,6 +32,7 @@ //! ``` pub mod authorization_rules; +pub mod canonical; pub mod proxy_authorizer; pub mod proxy_connection; pub mod proxy_server; diff --git a/proxy_agent/src/proxy/canonical/destination.rs b/proxy_agent/src/proxy/canonical/destination.rs new file mode 100644 index 00000000..4464fc85 --- /dev/null +++ b/proxy_agent/src/proxy/canonical/destination.rs @@ -0,0 +1,726 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT + +//! Destination classification. +//! +//! Maps the host+port of an incoming request to one of GPA's known +//! endpoints (IMDS, WireServer, HostGAPlugin). Numeric host forms +//! (decimal, hex, octal, IPv4-mapped IPv6, etc.) all canonicalize to the +//! same variant — this is the defense against pentest C7. +//! +//! Hostnames that are not IP literals are *not* DNS-resolved here. DNS at +//! the matcher would be a confused-deputy surface; instead we surface the +//! host text in [`Destination::Unknown`] so rule authors can write +//! explicit allow rules keyed on host text if they need it. + +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use hyper::Uri; + +use crate::common::constants; + +use super::CanonError; + +/// Address family of an [`Destination::Unknown`] target. Kept narrow so +/// we don't accidentally treat numeric strings as hostnames. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AddrFamily { + V4, + V6, + Name, +} + +/// Canonical destination. Matching uses the typed enum only; the raw +/// `host_text` on `Unknown` is for audit, never for matching decisions. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Destination { + /// Instance Metadata Service: 169.254.169.254:80 in any encoding. + Imds, + /// Azure WireServer: 168.63.129.16:80. + WireServer, + /// Host GuestAgent Plugin: 168.63.129.16:32526. + HostGaPlugin, + /// Anything else. The matcher denies unknowns unless an explicit rule + /// allows them. + Unknown { + family: AddrFamily, + ip: Option, + port: u16, + host_text: Option, + }, +} + +impl fmt::Display for Destination { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Destination::Imds => f.write_str("imds"), + Destination::WireServer => f.write_str("wireserver"), + Destination::HostGaPlugin => f.write_str("hostga"), + Destination::Unknown { .. } => f.write_str("unknown"), + } + } +} + +/// Classify the destination of a request URI. See module docs. +pub fn classify(uri: &Uri) -> Result { + // Reject userinfo (`user@host` smuggling). + if uri + .authority() + .map(|a| a.as_str().contains('@')) + .unwrap_or(false) + { + return Err(CanonError::UserinfoPresent); + } + + let host = match uri.host() { + Some(h) => h, + // Origin-form requests (the common proxy case) have no authority. + // We must still allow them to flow: the destination is decided by + // the redirector at the socket layer, not by the URL. + None => { + return Ok(Destination::Unknown { + family: AddrFamily::Name, + ip: None, + port: 0, + host_text: None, + }); + } + }; + + let port = uri.port_u16().unwrap_or(constants::IMDS_PORT); + + let ip = parse_host_as_ip(host)?; + match ip { + Some(IpAddr::V4(v4)) => Ok(known_v4(v4, port).unwrap_or(Destination::Unknown { + family: AddrFamily::V4, + ip: Some(IpAddr::V4(v4)), + port, + host_text: Some(host.to_string()), + })), + Some(IpAddr::V6(v6)) => { + // IPv4-mapped IPv6 (::ffff:a.b.c.d) projects down to IPv4 so + // it shares the same Destination as the dotted form. + if let Some(v4) = v6.to_ipv4_mapped() { + if let Some(known) = known_v4(v4, port) { + return Ok(known); + } + return Ok(Destination::Unknown { + family: AddrFamily::V4, + ip: Some(IpAddr::V4(v4)), + port, + host_text: Some(host.to_string()), + }); + } + Ok(Destination::Unknown { + family: AddrFamily::V6, + ip: Some(IpAddr::V6(v6)), + port, + host_text: Some(host.to_string()), + }) + } + None => Ok(Destination::Unknown { + family: AddrFamily::Name, + ip: None, + port, + host_text: Some(host.to_string()), + }), + } +} + +fn known_v4(v4: Ipv4Addr, port: u16) -> Option { + let imds: Ipv4Addr = constants::IMDS_IP.parse().ok()?; + let wire: Ipv4Addr = constants::WIRE_SERVER_IP.parse().ok()?; + + if v4 == imds && port == constants::IMDS_PORT { + return Some(Destination::Imds); + } + if v4 == wire && port == constants::WIRE_SERVER_PORT { + return Some(Destination::WireServer); + } + if v4 == wire && port == constants::GA_PLUGIN_PORT { + return Some(Destination::HostGaPlugin); + } + None +} + +/// Parse a host string into an `IpAddr` when it is an IP literal in any +/// historical numeric form. Returns `Ok(None)` for true hostnames (i.e. +/// not an IP), which the caller treats as `Destination::Unknown`. +/// +/// Supports: +/// - dotted quad `169.254.169.254` +/// - 32-bit decimal `2852039166` +/// - 32-bit hex `0xa9fea9fe` +/// - octal-quad `0251.0376.0251.0376` +/// - hex-quad `0xa9.0xfe.0xa9.0xfe` +/// - mixed forms allowed per RFC 3493 / inet_aton tradition +/// - bracketed IPv6 `[::ffff:169.254.169.254]` (brackets handled by hyper) +fn parse_host_as_ip(host: &str) -> Result, CanonError> { + // Tolerate both forms (hyper strips brackets in most versions but + // not all). Strip surrounding `[]` if present before parsing IPv6. + let host_unbracketed = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + if let Ok(v6) = host_unbracketed.parse::() { + return Ok(Some(IpAddr::V6(v6))); + } + if let Ok(v4) = host.parse::() { + return Ok(Some(IpAddr::V4(v4))); + } + + // Trailing dot on hostname (`metadata.azure.internal.`) — strip then + // re-classify. A bare `.` is not a host. + let trimmed = host.trim_end_matches('.'); + if trimmed.is_empty() { + return Err(CanonError::BadHost); + } + + if let Some(v4) = parse_inet_aton(trimmed)? { + return Ok(Some(IpAddr::V4(v4))); + } + + // Not an IP literal in any supported form. Distinguish "valid + // hostname" from "garbage": at least one ASCII alphanumeric and no + // forbidden characters. + if trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') + && trimmed.chars().any(|c| c.is_ascii_alphanumeric()) + { + Ok(None) + } else { + Err(CanonError::BadHost) + } +} + +/// `inet_aton`-style numeric IPv4 parser. +/// +/// Implemented by hand (rather than calling out to libc) because +/// `inet_aton` behavior is platform-dependent: glibc accepts `0x` and +/// leading-zero octal; musl is stricter; Windows differs again. A +/// hand-rolled parser keeps Linux and Windows builds identical. +fn parse_inet_aton(input: &str) -> Result, CanonError> { + // Must look numeric. Reject early if it has any character outside the + // numeric/separator set so we don't shadow a legitimate hostname. + if input.is_empty() { + return Err(CanonError::BadHost); + } + let looks_numeric = input + .chars() + .all(|c| c.is_ascii_hexdigit() || c == 'x' || c == 'X' || c == '.'); + if !looks_numeric { + return Ok(None); + } + + let parts: Vec<&str> = input.split('.').collect(); + if parts.is_empty() || parts.len() > 4 { + return Err(CanonError::BadHost); + } + // Empty parts (e.g. trailing dot already stripped, double dot here) + // are illegal. + if parts.iter().any(|p| p.is_empty()) { + return Err(CanonError::BadHost); + } + + let nums: Vec = parts + .iter() + .map(|p| parse_numeric_octet(p)) + .collect::, _>>()?; + + let addr: u32 = match nums.len() { + // single 32-bit number: maps directly + 1 => nums[0], + // a.b => a in top 8 bits, b in low 24 + 2 => { + if nums[0] > 0xFF || nums[1] > 0x00FF_FFFF { + return Err(CanonError::BadHost); + } + (nums[0] << 24) | nums[1] + } + // a.b.c => a,b top 16 bits, c low 16 + 3 => { + if nums[0] > 0xFF || nums[1] > 0xFF || nums[2] > 0xFFFF { + return Err(CanonError::BadHost); + } + (nums[0] << 24) | (nums[1] << 16) | nums[2] + } + // a.b.c.d => standard dotted quad + 4 => { + if nums.iter().any(|&n| n > 0xFF) { + return Err(CanonError::BadHost); + } + (nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3] + } + _ => return Err(CanonError::BadHost), + }; + + Ok(Some(Ipv4Addr::from(addr))) +} + +fn parse_numeric_octet(s: &str) -> Result { + // 0x... => hex + if let Some(rest) = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + { + if rest.is_empty() || rest.len() > 8 { + return Err(CanonError::BadHost); + } + return u32::from_str_radix(rest, 16).map_err(|_| CanonError::BadHost); + } + // 0... (and not just "0") => octal + if s.len() > 1 && s.starts_with('0') { + return u32::from_str_radix(&s[1..], 8).map_err(|_| CanonError::BadHost); + } + // decimal + s.parse::().map_err(|_| CanonError::BadHost) +} + +#[cfg(test)] +mod destination_tests { + use super::*; + + fn aton(s: &str) -> Result, CanonError> { + parse_inet_aton(s) + } + fn host(s: &str) -> Result, CanonError> { + parse_host_as_ip(s) + } + fn ip(s: &str) -> Ipv4Addr { + s.parse().unwrap() + } + fn uri(s: &str) -> Uri { + s.parse().unwrap() + } + + // ----------------------------------------------------------------- + // parse_numeric_octet + // ----------------------------------------------------------------- + + #[test] + fn numeric_octet_accepts_valid_forms() { + // (input, expected). Covers decimal (incl. u32::MAX and lone + // zero), hex with both prefix cases, and octal. + let cases: &[(&str, u32)] = &[ + ("0", 0), + ("169", 169), + ("255", 255), + ("4294967295", u32::MAX), + ("0x0", 0), + ("0xa9", 0xA9), + ("0XA9", 0xA9), + ("0xa9fea9fe", 0xA9FE_A9FE), + ("0xFFFFFFFF", u32::MAX), + ("0251", 0o251), // 169 + ("0376", 0o376), // 254 + ("00", 0), + ]; + for (input, expected) in cases { + assert_eq!( + parse_numeric_octet(input).unwrap(), + *expected, + "input={input:?}" + ); + } + } + + #[test] + fn numeric_octet_rejects_invalid_forms() { + // All of these must be BadHost: decimal overflow, hex without + // digits, hex too long for u32, non-hex digits in hex, non-octal + // digits in an octal-prefixed string, and empty input. + let bad: &[&str] = &[ + "4294967296", // decimal overflow + "0x", // empty hex + "0X", // empty hex (upper prefix) + "0x100000000", // hex too long for u32 + "0xZZ", // bad hex digit + "08", // 8 is not octal + "0129", // 9 is not octal + "", // empty + ]; + for input in bad { + assert_eq!( + parse_numeric_octet(input).unwrap_err(), + CanonError::BadHost, + "input={input:?}" + ); + } + } + + // ----------------------------------------------------------------- + // parse_inet_aton + // ----------------------------------------------------------------- + + #[test] + fn inet_aton_accepts_all_numeric_forms() { + let imds = Ipv4Addr::new(169, 254, 169, 254); + // (input, expected). Covers dotted quad, single decimal, single + // hex, octal/hex/mixed quads, and the 2-/3-part legacy forms. + let two_part = { + let v: u32 = (169u32 << 24) | 16_624_894; + Ipv4Addr::from(v) + }; + let cases: &[(&str, Ipv4Addr)] = &[ + ("169.254.169.254", imds), + ("2852039166", imds), + ("0xa9fea9fe", imds), + ("0251.0376.0251.0376", imds), + ("0xa9.0xfe.0xa9.0xfe", imds), + ("169.0xfe.0251.254", imds), + ("169.254.43518", imds), // 3-part form + ("169.16624894", two_part), // 2-part form + ]; + for (input, expected) in cases { + assert_eq!( + aton(input).unwrap(), + Some(*expected), + "input={input:?}" + ); + } + } + + #[test] + fn inet_aton_rejects_malformed_inputs() { + // Empty parts, too-many parts, octet overflow at each supported + // arity, and the empty string itself. + let bad: &[&str] = &[ + "", + "1.2.3.4.5", // too many parts + "1..2.3", // double dot + ".1.2.3", // leading dot + "1.2.3.", // trailing dot + "300.1.1.1", // 4-part octet > 0xFF + "256.0.0.0", // 4-part octet > 0xFF + "256.1", // 2-part top byte > 0xFF + "1.16777216", // 2-part low value > 0x00FF_FFFF + "1.2.65536", // 3-part last value > 0xFFFF + ]; + for input in bad { + assert_eq!( + aton(input).unwrap_err(), + CanonError::BadHost, + "input={input:?}" + ); + } + } + + #[test] + fn inet_aton_passes_through_non_numeric_hostnames() { + // Hostnames must fall through with Ok(None), not error — the + // caller decides whether to allow them. + for input in ["metadata", "metadata.azure.internal", "host-with-dash"] { + assert_eq!(aton(input).unwrap(), None, "input={input:?}"); + } + } + + // ----------------------------------------------------------------- + // parse_host_as_ip + // ----------------------------------------------------------------- + + #[test] + fn host_parses_all_ip_literal_forms() { + // (input, expected IpAddr). Covers IPv4 dotted, IPv4 numeric + // (falls through to inet_aton), IPv6 plain, IPv6 bracketed + // (tolerance for hyper versions that don't strip brackets), and + // IPv4-mapped IPv6. + let v6_mapped: Ipv6Addr = "::ffff:169.254.169.254".parse().unwrap(); + let cases: &[(&str, IpAddr)] = &[ + ("127.0.0.1", IpAddr::V4(Ipv4Addr::LOCALHOST)), + ( + "2852039166", + IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)), + ), + ("::1", IpAddr::V6(Ipv6Addr::LOCALHOST)), + ("[::1]", IpAddr::V6(Ipv6Addr::LOCALHOST)), + ("::ffff:169.254.169.254", IpAddr::V6(v6_mapped)), + ]; + for (input, expected) in cases { + assert_eq!( + host(input).unwrap(), + Some(*expected), + "input={input:?}" + ); + } + } + + #[test] + fn host_returns_none_for_valid_hostnames() { + // Hostnames (including the RFC 1034 trailing-dot form) must not + // be silently treated as IPs. + for input in [ + "metadata.azure.internal", + "metadata.azure.internal.", + "foo", + "a-b-c.example", + ] { + assert_eq!(host(input).unwrap(), None, "input={input:?}"); + } + } + + #[test] + fn host_rejects_garbage() { + // Empty, bare dot, and any input containing characters outside + // the hostname/IP alphabet must be rejected — not silently + // treated as a hostname. + for input in ["", ".", "foo bar", "foo/bar", "foo_bar"] { + assert_eq!( + host(input).unwrap_err(), + CanonError::BadHost, + "input={input:?}" + ); + } + } + + // ----------------------------------------------------------------- + // known_v4 + // ----------------------------------------------------------------- + + #[test] + fn known_v4_maps_recognized_endpoints() { + let cases: &[(&str, u16, Destination)] = &[ + (constants::IMDS_IP, constants::IMDS_PORT, Destination::Imds), + ( + constants::WIRE_SERVER_IP, + constants::WIRE_SERVER_PORT, + Destination::WireServer, + ), + ( + constants::WIRE_SERVER_IP, + constants::GA_PLUGIN_PORT, + Destination::HostGaPlugin, + ), + ]; + for (addr, port, expected) in cases { + assert_eq!( + known_v4(ip(addr), *port), + Some(expected.clone()), + "ip={addr}, port={port}" + ); + } + } + + #[test] + fn known_v4_returns_none_for_misses() { + // Correct IP / wrong port and any unrelated IP must not be + // promoted to a known destination. + let cases: &[(&str, u16)] = &[ + (constants::IMDS_IP, 8080), + (constants::WIRE_SERVER_IP, 8080), + ("8.8.8.8", constants::IMDS_PORT), + ("127.0.0.1", constants::IMDS_PORT), + ]; + for (addr, port) in cases { + assert_eq!(known_v4(ip(addr), *port), None, "ip={addr}, port={port}"); + } + } + + // ----------------------------------------------------------------- + // classify (URI-level entrypoint) + // ----------------------------------------------------------------- + + #[test] + fn classify_resolves_known_destinations() { + // (url, expected). Covers default-port inference for IMDS, + // explicit-port forms, WireServer, HostGA, and the IPv4-mapped + // IPv6 projection (pentest C7). + let cases: &[(&str, Destination)] = &[ + ("http://169.254.169.254/x", Destination::Imds), + ("http://169.254.169.254:80/x", Destination::Imds), + ("http://168.63.129.16:80/x", Destination::WireServer), + ("http://168.63.129.16:32526/x", Destination::HostGaPlugin), + ( + "http://[::ffff:169.254.169.254]/x", + Destination::Imds, + ), + ]; + for (url, expected) in cases { + assert_eq!(classify(&uri(url)).unwrap(), *expected, "url={url}"); + } + } + + #[test] + fn classify_falls_back_to_unknown_for_unrecognized_targets() { + // IMDS IP on the wrong port must NOT inherit the IMDS variant. + match classify(&uri("http://169.254.169.254:8080/x")).unwrap() { + Destination::Unknown { + family: AddrFamily::V4, + ip: Some(IpAddr::V4(v4)), + port: 8080, + .. + } => assert_eq!(v4, ip(constants::IMDS_IP)), + other => panic!("expected Unknown V4 on port 8080, got {other:?}"), + } + + // Arbitrary public IP -> Unknown V4 with host_text preserved. + match classify(&uri("http://1.2.3.4/x")).unwrap() { + Destination::Unknown { + family: AddrFamily::V4, + ip: Some(IpAddr::V4(v4)), + host_text: Some(_), + .. + } => assert_eq!(v4, Ipv4Addr::new(1, 2, 3, 4)), + other => panic!("expected Unknown V4, got {other:?}"), + } + + // Hostname -> Unknown Name with host_text preserved (no DNS). + match classify(&uri("http://metadata.azure.internal/x")).unwrap() { + Destination::Unknown { + family: AddrFamily::Name, + ip: None, + host_text: Some(s), + .. + } => assert_eq!(s, "metadata.azure.internal"), + other => panic!("expected Unknown Name, got {other:?}"), + } + + // Non-mapped IPv6 -> Unknown V6. + match classify(&uri("http://[::1]/x")).unwrap() { + Destination::Unknown { + family: AddrFamily::V6, + ip: Some(IpAddr::V6(v6)), + .. + } => assert_eq!(v6, Ipv6Addr::LOCALHOST), + other => panic!("expected Unknown V6, got {other:?}"), + } + + // Origin-form (no authority) -> stub Unknown with no info. + let origin_form: Uri = "/metadata/identity".parse().unwrap(); + match classify(&origin_form).unwrap() { + Destination::Unknown { + family: AddrFamily::Name, + ip: None, + port: 0, + host_text: None, + } => {} + other => panic!("expected stub Unknown for origin-form, got {other:?}"), + } + } + + #[test] + fn classify_rejects_bad_inputs() { + // (url, expected error). Userinfo smuggling and host text with + // characters outside the hostname/IP alphabet. + let cases: &[(&str, CanonError)] = &[ + ("http://user@169.254.169.254/x", CanonError::UserinfoPresent), + ("http://foo_bar/x", CanonError::BadHost), + ]; + for (url, expected) in cases { + assert_eq!(classify(&uri(url)).unwrap_err(), *expected, "url={url}"); + } + } + + // ----------------------------------------------------------------- + // Display + // ----------------------------------------------------------------- + + #[test] + fn display_strings_are_stable() { + // These strings appear in audit logs; pin them so a rename + // doesn't silently break downstream log consumers. + let cases: &[(Destination, &str)] = &[ + (Destination::Imds, "imds"), + (Destination::WireServer, "wireserver"), + (Destination::HostGaPlugin, "hostga"), + ( + Destination::Unknown { + family: AddrFamily::Name, + ip: None, + port: 0, + host_text: None, + }, + "unknown", + ), + ]; + for (dest, expected) in cases { + assert_eq!(&format!("{dest}"), expected); + } + } + + // ----------------------------------------------------------------- + // Appendix A.2 — host golden vectors + end-to-end classification + // + // Spec-conformance tests for destination classification. Vector + // labels (`A2.xxx`) appear in every failure message so a regression + // points straight back to the row in `Innovation-2.1-canonical-request.md`. + // + // These call the full canonicalize_str() entrypoint rather than + // classify() directly so the assertion target matches what a caller + // would see — the assertion target is still the destination output. + // ----------------------------------------------------------------- + + fn dest_via_pipeline(url: &str) -> Destination { + super::super::canonicalize_str(url).unwrap().destination + } + + #[test] + fn appendix_a2_host_vectors_classify_as_imds() { + // Every numeric / packed / IPv4-mapped-IPv6 form of + // 169.254.169.254 must classify to Destination::Imds — this is + // the SSRF-defeating contract that justifies the whole module's + // existence. + let cases: &[(&str, &str)] = &[ + ("A2.dotted_quad", "http://169.254.169.254/x"), + ("A2.decimal_32bit", "http://2852039166/x"), + ("A2.hex_packed", "http://0xa9fea9fe/x"), + ("A2.octal_quad", "http://0251.0376.0251.0376/x"), + ( + "A2.ipv4_mapped_dotted", + "http://[::ffff:169.254.169.254]/x", + ), + ("A2.ipv4_mapped_hex", "http://[::ffff:a9fe:a9fe]/x"), + // Explicit :80 — must still classify as IMDS (default port). + ("A2.explicit_default_port", "http://169.254.169.254:80/x"), + ]; + for (label, url) in cases { + assert_eq!(dest_via_pipeline(url), Destination::Imds, "vector={label}"); + } + } + + #[test] + fn hostnames_classify_as_unknown_for_dns_rebinding_defense() { + // Hostnames are NEVER trusted — even one that resolves to IMDS + // at runtime must canonicalize to Unknown (with host_text + // preserved for audit). This is the OWASP DNS-rebinding + // defense. + match dest_via_pipeline("http://metadata.azure.internal/x") { + Destination::Unknown { + host_text: Some(s), .. + } => assert!( + s.contains("metadata.azure.internal"), + "host_text must preserve original hostname for audit, got {s:?}" + ), + d => panic!("expected Unknown with host_text, got {d:?}"), + } + } + + #[test] + fn destination_classified_by_host_and_port() { + // WireServer and HostGAPlugin share an IP but differ by port — + // pin both the default-port and the :32526 branch. + let cases: &[(&str, &str, Destination)] = &[ + ( + "wireserver default port", + "http://168.63.129.16/x", + Destination::WireServer, + ), + ( + "wireserver explicit :80", + "http://168.63.129.16:80/x", + Destination::WireServer, + ), + ( + "hostgaplugin on :32526", + "http://168.63.129.16:32526/x", + Destination::HostGaPlugin, + ), + ]; + for (label, url, expected) in cases { + assert_eq!(dest_via_pipeline(url), *expected, "{label}"); + } + } +} + + diff --git a/proxy_agent/src/proxy/canonical/mod.rs b/proxy_agent/src/proxy/canonical/mod.rs new file mode 100644 index 00000000..9f9c0287 --- /dev/null +++ b/proxy_agent/src/proxy/canonical/mod.rs @@ -0,0 +1,492 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT + +//! Canonical request model (Innovation 2.1). +//! +//! Provides a single, total, idempotent normalization step that reduces +//! every incoming [`hyper::Uri`] (and, separately, every authorization +//! rule pattern) to the same [`CanonicalRequest`] form before they meet +//! the matcher. The goal is to eliminate the rule/request asymmetries +//! that produce SSRF-style AuthZ bypasses (pentest categories D1, C7). +//! +//! ## Pipeline +//! +//! ```text +//! hyper::Uri +//! │ +//! ▼ parse_scheme_method (http only; allow-list of methods) +//! ▼ classify_destination (IP/host -> Destination; covers numeric forms) +//! ▼ validate_userinfo (must be empty) +//! ▼ decode_path_once (single percent-decode; strict UTF-8) +//! ▼ reject_control_chars (no CR/LF/NUL/HTAB after decode) +//! ▼ ascii_lowercase_path (case-insensitive matching) +//! ▼ split_segments (split '/'; collapse '.'; resolve '..') +//! ▼ strip_matrix_params (drop `;k=v` suffix on each segment) +//! ▼ decode_query_once (k/v percent-decode once; lowercase keys) +//! ▼ reject_embedded_query (decoded path must not contain literal '?') +//! ▼ fold_into_btreemap (group by key) +//! CanonicalRequest +//! ``` +//! +//! ## Fail-closed +//! +//! Every error variant in [`CanonError`] denies the request. There is no +//! "best effort" branch. +//! +//! ## Idempotency +//! +//! `canonicalize(canonicalize(x).render()) == canonicalize(x)`. This is +//! enforced via property tests in `tests::proptests`. + +pub mod destination; +pub mod path; +pub mod query; +pub mod rule; + +use std::collections::BTreeMap; +use std::fmt; + +use hyper::{Method, Uri}; +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; + +pub use destination::{AddrFamily, Destination}; +pub use rule::CanonicalPattern; + +/// Bytes that must be percent-encoded inside a path segment so that +/// re-parsing the rendered output yields the same canonical form. +/// +/// - `%` would otherwise be interpreted as the start of an escape on +/// the second decode pass. +/// - `#` would be stripped by hyper as a fragment delimiter. +/// - `?` would be flagged by the canonicalizer as `EmbeddedQuery`. +/// - `;` would be re-stripped as a matrix-param sentinel. +/// - space and the C0 controls are invalid in a URI per RFC 3986. +/// +/// `/` is intentionally NOT in this set because [`path::split_and_resolve`] +/// already guarantees no literal `/` survives inside a segment. +const PATH_SEG_ENCODE: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'%') + .add(b'#') + .add(b'?') + .add(b';'); + +/// Bytes that must be percent-encoded inside a query key or value. +/// +/// In addition to the path-segment hazards, query strings treat `&` and +/// `=` as delimiters, and [`query::decode_query_component`] turns `+` +/// into a literal space — so a raw `+` in the rendered output would +/// round-trip to a space and lose data. All three must be encoded. +const QUERY_ENCODE: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'%') + .add(b'#') + .add(b'&') + .add(b'=') + .add(b'+'); + +/// Fully-normalized form of an HTTP request as it is fed to the matcher. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CanonicalRequest { + /// HTTP method, restricted to the allow-list. + pub method: Method, + + /// Classified destination. Matching uses the typed enum only; the raw + /// host text is never compared. + pub destination: Destination, + + /// Canonical path segments: percent-decoded once, ASCII-lowercased, + /// `.` collapsed, `..` resolved, matrix params stripped. + /// + /// Always begins with the empty root segment (so `/metadata/identity` + /// becomes `["", "metadata", "identity"]`). + pub path_segments: Vec, + + /// Whether the original path ended in `/`. Preserved as a single bit + /// so rules can opt to be slash-sensitive without re-introducing + /// string-level asymmetry. + pub trailing_slash: bool, + + /// Query parameters, canonical form: keys lowercased & decoded once, + /// values decoded once, grouped by key. Insertion order within a key + /// is preserved; key order is lexicographic. + pub query: BTreeMap>, +} + +impl CanonicalRequest { + /// Stable textual rendering. Re-parsing this string and canonicalizing + /// it must yield the same `CanonicalRequest` (idempotency invariant). + /// + /// Path segments and query components are percent-encoded on the way + /// out (using [`PATH_SEG_ENCODE`] / [`QUERY_ENCODE`]) so that bytes + /// which the pipeline decoded once — `%`, `&`, `=`, `+`, ` `, `#` and + /// friends — survive a parse-decode-canonicalize round trip without + /// being re-interpreted as delimiters. + pub fn render(&self) -> String { + let mut out = String::new(); + if self.path_segments.is_empty() { + out.push('/'); + } else { + for (i, seg) in self.path_segments.iter().enumerate() { + if i == 0 && seg.is_empty() { + // root marker + out.push('/'); + continue; + } + if i > 0 { + out.push('/'); + } + out.extend(utf8_percent_encode(seg, PATH_SEG_ENCODE)); + } + } + if self.trailing_slash && !out.ends_with('/') { + out.push('/'); + } + if !self.query.is_empty() { + out.push('?'); + let mut first = true; + for (k, values) in self.query.iter() { + for v in values { + if !first { + out.push('&'); + } + first = false; + out.extend(utf8_percent_encode(k, QUERY_ENCODE)); + out.push('='); + out.extend(utf8_percent_encode(v, QUERY_ENCODE)); + } + } + } + out + } +} + +impl fmt::Display for CanonicalRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {} {}", self.method, self.destination, self.render()) + } +} + +/// Typed errors produced by the canonicalizer. All variants are +/// **fail-closed**: callers must deny the request when any of these is +/// returned. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CanonError { + #[error("scheme not http")] + SchemeNotHttp, + #[error("method not allowed")] + MethodNotAllowed, + #[error("userinfo present in URL")] + UserinfoPresent, + #[error("malformed percent-encoding")] + MalformedPercent, + #[error("overlong UTF-8 in path/query")] + OverlongUtf8, + #[error("invalid UTF-8 in path/query")] + InvalidUtf8, + #[error("control character in path/query")] + ControlChar, + #[error("path traversal past root")] + PathUnderflow, + #[error("embedded '?' after decoding")] + EmbeddedQuery, + #[error("unparseable host")] + BadHost, + #[error("unparseable port")] + BadPort, +} + +impl CanonError { + /// Stable short code suitable for audit logs and pentest assertions. + pub fn code(&self) -> &'static str { + match self { + CanonError::SchemeNotHttp => "CANON_SCHEME", + CanonError::MethodNotAllowed => "CANON_METHOD", + CanonError::UserinfoPresent => "CANON_USERINFO", + CanonError::MalformedPercent => "CANON_PCT", + CanonError::OverlongUtf8 => "CANON_OVERLONG", + CanonError::InvalidUtf8 => "CANON_UTF8", + CanonError::ControlChar => "CANON_CTRL", + CanonError::PathUnderflow => "CANON_UNDERFLOW", + CanonError::EmbeddedQuery => "CANON_EMBQ", + CanonError::BadHost => "CANON_HOST", + CanonError::BadPort => "CANON_PORT", + } + } +} + +/// HTTP methods accepted by the canonicalizer. Anything not on this list +/// is rejected with [`CanonError::MethodNotAllowed`]. +/// +/// Kept in sync with `proxy_server::ProxyServer::ALLOWED_METHODS`. +const ALLOWED_METHODS: &[Method] = &[ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::HEAD, + Method::OPTIONS, + Method::PATCH, +]; + +fn check_method(method: &Method) -> Result<(), CanonError> { + if ALLOWED_METHODS.iter().any(|m| m == method) { + Ok(()) + } else { + Err(CanonError::MethodNotAllowed) + } +} + +fn check_scheme(uri: &Uri) -> Result<(), CanonError> { + match uri.scheme_str() { + // Hyper guarantees the connect-target form for absolute URIs has a + // scheme; the proxy receives origin-form requests where the scheme + // is omitted. Both cases are acceptable. A non-http scheme (https, + // ws, gopher, ...) is a hard reject. + None => Ok(()), + Some(s) if s.eq_ignore_ascii_case("http") => Ok(()), + Some(_) => Err(CanonError::SchemeNotHttp), + } +} + +/// Canonicalize a parsed request. +/// +/// Returns `Ok(CanonicalRequest)` for inputs that survive every stage of +/// the pipeline, or a typed [`CanonError`] otherwise. The function is +/// **total** — every well-formed `hyper::Uri` produces exactly one of +/// these two outcomes; it never panics. +pub fn canonicalize(uri: &Uri, method: &Method) -> Result { + check_scheme(uri)?; + check_method(method)?; + + let destination = destination::classify(uri)?; + + let (path_segments, trailing_slash) = path::canonicalize_path(uri.path())?; + let query = query::canonicalize_query(uri.query().unwrap_or(""))?; + + Ok(CanonicalRequest { + method: method.clone(), + destination, + path_segments, + trailing_slash, + query, + }) +} + +/// Convenience: parse and canonicalize a string. Useful for tests and the +/// shadow-mode shim that takes raw URLs from telemetry replay. +#[allow(dead_code)] +pub fn canonicalize_str(url: &str) -> Result { + let uri: Uri = url.parse().map_err(|_| CanonError::BadHost)?; + canonicalize(&uri, &Method::GET) +} + +#[cfg(test)] +mod mod_tests { + //! Cross-cutting tests for the canonical pipeline. + //! + //! Helper-specific golden vectors live next to their helpers + //! (`path::path_tests::appendix_a1_*`, + //! `destination::destination_tests::appendix_a2_*`). What lives + //! here is what cuts across every helper: + //! + //! - Scheme / method / userinfo gating (the top-level checks in + //! [`canonicalize`]). + //! - Idempotency: `canonicalize(canonicalize(x).render()) == + //! canonicalize(x)` — verifies the renderer round-trips every + //! helper's output. + //! - Total / no-panic on adversarial inputs. + //! - Stability of [`CanonError::code`] strings (audit-log contract). + + use hyper::{Method, Uri}; + + use super::*; + + #[test] + fn userinfo_rejected_at_pipeline_entry() { + // hyper may either parse-and-reject or refuse outright. Either + // is a deny, but UserinfoPresent is the preferred surfacing. + let err = canonicalize_str("http://user@169.254.169.254/x").unwrap_err(); + assert!( + matches!(err, CanonError::UserinfoPresent | CanonError::BadHost), + "userinfo: got {err:?}" + ); + } + + #[test] + fn allowed_methods_are_accepted_others_rejected() { + let uri: Uri = "http://169.254.169.254/x".parse().unwrap(); + + // Positive: every method in ALLOWED_METHODS canonicalizes + // successfully. Locks the slice's contents against accidental + // shrinking — a removal would surface here as a method that + // used to work and now doesn't. + let allowed = &[ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::HEAD, + Method::OPTIONS, + Method::PATCH, + ]; + for m in allowed { + assert!( + canonicalize(&uri, m).is_ok(), + "method {m:?} should be accepted" + ); + } + + // Negative: methods explicitly NOT on the list deny with + // MethodNotAllowed. + let denied = &[Method::CONNECT, Method::TRACE]; + for m in denied { + assert_eq!( + canonicalize(&uri, m).unwrap_err(), + CanonError::MethodNotAllowed, + "method {m:?} should be rejected" + ); + } + } + + #[test] + fn non_http_schemes_rejected() { + // Anything not bare `http://` is a deny. Hyper may refuse to + // parse some of these as absolute URIs; either path is a deny, + // but the explicit canonicalize() rejection is what we want to + // pin for the schemes hyper does accept. + let cases: &[(&str, &str)] = &[ + ("https", "https://169.254.169.254/x"), + ("ftp", "ftp://169.254.169.254/x"), + ("file", "file://169.254.169.254/x"), + ]; + for (label, url) in cases { + match url.parse::() { + Ok(uri) => assert_eq!( + canonicalize(&uri, &Method::GET).unwrap_err(), + CanonError::SchemeNotHttp, + "scheme={label}" + ), + Err(_) => { + // hyper refused to parse — equally a deny, no-op. + } + } + } + } + + #[test] + fn canonical_form_is_idempotent() { + // canonicalize(canonicalize(x).render()) == canonicalize(x). + // + // Per-vector idempotency catches a class of bugs where the + // renderer and the parser disagree on something subtle + // (encoding of `+`, matrix-param re-emergence, etc). + let cases: &[(&str, &str)] = &[ + ( + "typical imds token", + "http://169.254.169.254/Metadata/Identity/oauth2/token?api-version=2018-02-01&Resource=https%3A%2F%2Fmanagement.azure.com%2F", + ), + ("root only", "http://169.254.169.254/"), + ( + "empty query", + "http://169.254.169.254/metadata/identity?", + ), + ( + "multi-key query with case fold", + "http://169.254.169.254/m?Foo=1&BAR=2&baz=3", + ), + ( + // Value contains decoded space (`%20`) and decoded `&` + // (`%26`). render() must re-encode both, otherwise the + // re-parse would either fail (space is invalid in a + // URI) or split the value on the spurious `&`. + "query value with reserved chars", + "http://169.254.169.254/m?k=a%20b%26c", + ), + ( + // Value contains decoded `+` (`%2B`). + // decode_query_component turns raw `+` into a space, so + // render must re-encode `+` as `%2B` to round-trip. + "query value with literal plus", + "http://169.254.169.254/m?k=a%2Bb", + ), + ( + // Path segment contains a literal `%` after one decode + // (`%252F` -> `%2f` literal). render() must emit + // `%252f` so the second decode lands on the same + // literal byte. + "path segment with literal percent", + "http://169.254.169.254/metadata%252Fidentity", + ), + ( + "dot segments collapse first time", + "http://169.254.169.254/a/./b/../c", + ), + ]; + for (label, url) in cases { + let c1 = canonicalize_str(url).expect(label); + // render() drops host; reattach so the second pass sees the + // same destination. + let rendered = format!("http://169.254.169.254{}", c1.render()); + let c2 = canonicalize_str(&rendered).expect(label); + assert_eq!(c1, c2, "not idempotent: {label}"); + } + } + + #[test] + fn canonicalize_never_panics_on_adversarial_inputs() { + // Sanity smoke test — a proptest target lives in a follow-up + // PR. For every shape hyper consents to parse, the + // canonicalizer must return either Ok or a typed CanonError. + // Panics here are bugs. + let paths = &[ + "/", + "//", + "/.", + "/..", + "/%00", + "/a/b/c?", + "/?", + "/a;", + "/a;b;c;", + "/%", + "/%%", + "/%%%", + "/%C0%AF", // overlong utf-8 + "/very/long/path/that/repeats/very/long/path/that/repeats", + "/a/../../..", // underflow + "/a/b/c/../../..", // exact-root underflow + ]; + let methods = &[Method::GET, Method::POST, Method::CONNECT]; + for raw in paths { + if let Ok(uri) = format!("http://169.254.169.254{raw}").parse::() { + for m in methods { + let _ = canonicalize(&uri, m); + } + } + } + } + + #[test] + fn error_codes_are_stable() { + // Stability of these strings is a CONTRACT with the audit log + // and pentest scripts. Changing any one of these is a breaking + // change that must bump the canonical-request schema version. + let cases: &[(CanonError, &str)] = &[ + (CanonError::SchemeNotHttp, "CANON_SCHEME"), + (CanonError::MethodNotAllowed, "CANON_METHOD"), + (CanonError::UserinfoPresent, "CANON_USERINFO"), + (CanonError::MalformedPercent, "CANON_PCT"), + (CanonError::OverlongUtf8, "CANON_OVERLONG"), + (CanonError::InvalidUtf8, "CANON_UTF8"), + (CanonError::ControlChar, "CANON_CTRL"), + (CanonError::PathUnderflow, "CANON_UNDERFLOW"), + (CanonError::EmbeddedQuery, "CANON_EMBQ"), + (CanonError::BadHost, "CANON_HOST"), + (CanonError::BadPort, "CANON_PORT"), + ]; + for (err, expected_code) in cases { + assert_eq!(err.code(), *expected_code, "variant={err:?}"); + } + } +} diff --git a/proxy_agent/src/proxy/canonical/path.rs b/proxy_agent/src/proxy/canonical/path.rs new file mode 100644 index 00000000..da7ce893 --- /dev/null +++ b/proxy_agent/src/proxy/canonical/path.rs @@ -0,0 +1,638 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT + +//! Path canonicalization. +//! +//! Steps (each is a small pure function so they can be unit-tested +//! independently): +//! +//! 1. Single percent-decode of the raw path; reject malformed `%XX`. +//! 2. Reject overlong UTF-8 encodings of ASCII (`%C0%AF` etc.). +//! 3. Reject control characters (`\r`, `\n`, `\0`, `\t`). +//! 4. Reject non-ASCII bytes (paths to IMDS / WireServer are ASCII; +//! accepting non-ASCII would require NFC normalization that adds a +//! dependency we do not currently take). +//! 5. ASCII-lowercase. +//! 6. Split on `/`, drop empty segments, drop `.`, resolve `..` +//! (RFC 3986 §5.2.4). Underflow past the root is an error, not a +//! no-op — a real client would never produce it. +//! 7. Strip matrix params (`;jsessionid=...`) from each segment. +//! 8. Reject an embedded `?` in the decoded path (caused by `%3F` +//! smuggling) — the matcher must never see ambiguous input. +//! +//! The output is `(segments, trailing_slash)`. `segments` always begins +//! with the empty root segment, so the canonical form of `/` is +//! `vec![""]` and the canonical form of `/metadata/identity` is +//! `vec!["", "metadata", "identity"]`. + +use super::CanonError; + +const ROOT: &str = ""; + +/// Run the path pipeline. Public for unit tests; the canonicalizer +/// entrypoint is [`super::canonicalize`]. +pub fn canonicalize_path(raw: &str) -> Result<(Vec, bool), CanonError> { + // hyper guarantees the path starts with '/'. + let raw = if raw.is_empty() { "/" } else { raw }; + let trailing_slash = raw.len() > 1 && raw.ends_with('/'); + + let decoded = decode_path_once(raw)?; + reject_overlong_utf8(decoded.as_bytes())?; + reject_control_chars(&decoded)?; + reject_non_ascii(&decoded)?; + if decoded.contains('?') { + return Err(CanonError::EmbeddedQuery); + } + + let lowered = decoded.to_ascii_lowercase(); + let segments = split_and_resolve(&lowered)?; + + Ok((segments, trailing_slash)) +} + +/// Single-pass percent-decode. Rejects truncated (`%2`) and non-hex +/// (`%ZZ`) sequences as `MalformedPercent`. Never decodes twice — that +/// is exactly the asymmetry the canonical model is built to remove. +fn decode_path_once(raw: &str) -> Result { + let bytes = raw.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'%' { + if i + 2 >= bytes.len() { + return Err(CanonError::MalformedPercent); + } + let h = hex_value(bytes[i + 1])?; + let l = hex_value(bytes[i + 2])?; + out.push((h << 4) | l); + i += 3; + } else { + out.push(b); + i += 1; + } + } + // Strict UTF-8: lossy decoding is what allowed the silent-replacement + // bypass in the legacy matcher. + String::from_utf8(out).map_err(|_| CanonError::InvalidUtf8) +} + +fn hex_value(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(10 + b - b'a'), + b'A'..=b'F' => Ok(10 + b - b'A'), + _ => Err(CanonError::MalformedPercent), + } +} + +/// Detect classic overlong UTF-8 encodings (e.g. `%C0%AF` for `/`). +/// +/// `String::from_utf8` already rejects overlong sequences as invalid, so +/// by the time we run this the bytes are *guaranteed* well-formed +/// UTF-8 — meaning the overlong forms below would already have produced +/// `InvalidUtf8`. We run this pass *before* UTF-8 validation in case the +/// caller ever switches to a lossy decoder; today it is a defense in +/// depth that also gives us a more specific telemetry code. +fn reject_overlong_utf8(bytes: &[u8]) -> Result<(), CanonError> { + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + // 2-byte overlong: lead byte 0xC0 or 0xC1 (would encode <0x80). + if b == 0xC0 || b == 0xC1 { + return Err(CanonError::OverlongUtf8); + } + // 3-byte overlong: 0xE0 0x80..0x9F (would encode <0x800). + if b == 0xE0 && i + 1 < bytes.len() && (0x80..=0x9F).contains(&bytes[i + 1]) { + return Err(CanonError::OverlongUtf8); + } + // 4-byte overlong: 0xF0 0x80..0x8F (would encode <0x10000). + if b == 0xF0 && i + 1 < bytes.len() && (0x80..=0x8F).contains(&bytes[i + 1]) { + return Err(CanonError::OverlongUtf8); + } + i += 1; + } + Ok(()) +} + +fn reject_control_chars(s: &str) -> Result<(), CanonError> { + for b in s.bytes() { + // CR, LF, NUL, HTAB, plus the rest of the C0 control block and + // DEL. Anything below 0x20 or equal to 0x7F is rejected. + if b < 0x20 || b == 0x7F { + return Err(CanonError::ControlChar); + } + } + Ok(()) +} + +fn reject_non_ascii(s: &str) -> Result<(), CanonError> { + if s.is_ascii() { + Ok(()) + } else { + Err(CanonError::InvalidUtf8) + } +} + +/// Split on `/`, strip matrix params, drop empty/`.` segments, resolve +/// `..` with underflow detection. +fn split_and_resolve(path: &str) -> Result, CanonError> { + let mut segments: Vec = vec![ROOT.to_string()]; + // RFC 3986 defines a path as a sequence of segments separated by '/'. + for raw_seg in path.split('/') { + if raw_seg.is_empty() || raw_seg == "." { + // `//`, leading `/`, and `.` collapse away. + continue; + } + if raw_seg == ".." { + // Pop the previous segment. Popping the root is an error. + if segments.len() <= 1 { + return Err(CanonError::PathUnderflow); + } + segments.pop(); + continue; + } + // ';' is a sub-delim — it's a perfectly legal character inside a path segment. + // Strip matrix params as matrix params are never used in authorization decisions + // e.g. `segment;k=v;k2=v2` -> `segment`. + let cleaned = match raw_seg.find(';') { + Some(pos) => &raw_seg[..pos], + None => raw_seg, + }; + // A segment that's pure matrix params (`;k=v`) collapses to the + // empty string after stripping. Drop it, the same way we drop + // `//` — otherwise the canonical form of `/a/;k=v/b` would + // differ from `/a/b`, re-introducing exactly the kind of + // request/rule asymmetry the canonical model exists to remove. + if cleaned.is_empty() { + continue; + } + segments.push(cleaned.to_string()); + } + Ok(segments) +} + +#[cfg(test)] +mod path_tests { + use super::*; + + fn segs(parts: &[&str]) -> Vec { + parts.iter().map(|s| (*s).to_string()).collect() + } + + // ----------------------------------------------------------------- + // canonicalize_path — end-to-end happy paths + // ----------------------------------------------------------------- + + #[test] + fn canonicalize_path_accepts_valid_inputs() { + // (input, expected_segments, expected_trailing_slash). Covers: + // root, empty-string fallback, simple path, ASCII case folding, + // double-slash collapse, `.` removal, `..` resolution + // (including chained), single percent-decode, double-encoding + // leaving a literal `%` after one decode, mixed-case percent + // (`%2f` and `%2F`), matrix-param stripping on one and many + // segments, and a segment that becomes empty after matrix + // stripping. + let cases: &[(&str, &[&str], bool)] = &[ + ("/", &[""], false), + ("", &[""], false), + ("/metadata/identity", &["", "metadata", "identity"], false), + ("/Metadata/Identity", &["", "metadata", "identity"], false), + ("/metadata//identity", &["", "metadata", "identity"], false), + ("/metadata/./identity", &["", "metadata", "identity"], false), + ( + "/metadata/x/../identity", + &["", "metadata", "identity"], + false, + ), + ("/a/b/c/../../identity", &["", "a", "identity"], false), + ("/metadata%2Fidentity", &["", "metadata", "identity"], false), + ("/metadata%2fidentity", &["", "metadata", "identity"], false), + ( + "/metadata%252Fidentity", + &["", "metadata%2fidentity"], + false, + ), + ( + "/metadata/identity;jsessionid=abc", + &["", "metadata", "identity"], + false, + ), + ( + "/metadata;a=1;b=2/identity;c=3", + &["", "metadata", "identity"], + false, + ), + ("/a/;jsessionid=x/b", &["", "a", "b"], false), + ("/metadata/", &["", "metadata"], true), + ]; + for (input, expected_segs, expected_ts) in cases { + let got = canonicalize_path(input) + .unwrap_or_else(|e| panic!("expected Ok for {input:?}, got {e:?}")); + assert_eq!(got, (segs(expected_segs), *expected_ts), "input={input:?}"); + } + } + + // ----------------------------------------------------------------- + // canonicalize_path — end-to-end errors + // ----------------------------------------------------------------- + + #[test] + fn canonicalize_path_rejects_invalid_inputs() { + // Each row is (input, expected_error). Exercises every typed + // error the path pipeline can produce except OverlongUtf8 / + // InvalidUtf8 for overlong sequences, which have their own test + // because either variant is acceptable. + let cases: &[(&str, CanonError)] = &[ + // Malformed percent: dangling `%`, truncated, non-hex digit + ("/abc%", CanonError::MalformedPercent), + ("/abc%2", CanonError::MalformedPercent), + ("/abc%ZZ", CanonError::MalformedPercent), + ("/abc%2G", CanonError::MalformedPercent), + // Control characters: NUL, HTAB, LF, CR, DEL + ("/x%00", CanonError::ControlChar), + ("/x%09", CanonError::ControlChar), + ("/x%0A", CanonError::ControlChar), + ("/x%0D", CanonError::ControlChar), + ("/x%7F", CanonError::ControlChar), + // Non-ASCII after decode: U+4E2D `中` in UTF-8 + ("/x%E4%B8%AD", CanonError::InvalidUtf8), + // Embedded `?` from %3F smuggling + ( + "/metadata/identity%3Fapi-version=2018", + CanonError::EmbeddedQuery, + ), + // Path traversal past root: chained, immediate, and a lone `..` + ("/a/../..", CanonError::PathUnderflow), + ("/..", CanonError::PathUnderflow), + ("/a/b/../../../c", CanonError::PathUnderflow), + ]; + for (input, expected) in cases { + assert_eq!( + canonicalize_path(input).unwrap_err(), + *expected, + "input={input:?}" + ); + } + } + + #[test] + fn canonicalize_path_rejects_overlong_utf8() { + // %C0%AF is the classic 2-byte overlong for `/` (IDS bypass). + // %E0%80%AF is the 3-byte overlong for `/`. + // %F0%80%80%AF is the 4-byte overlong for `/`. + // + // Each MUST be rejected. The exact variant depends on whether + // reject_overlong_utf8 catches it first or String::from_utf8 does + // (both happen to fire on these inputs); either way the request + // is denied, so we accept either error code. + for input in ["/x%C0%AFy", "/x%E0%80%AFy", "/x%F0%80%80%AFy"] { + let err = canonicalize_path(input).unwrap_err(); + assert!( + matches!(err, CanonError::OverlongUtf8 | CanonError::InvalidUtf8), + "input={input:?} got={err:?}" + ); + } + } + + // ----------------------------------------------------------------- + // decode_path_once + // ----------------------------------------------------------------- + + #[test] + fn decode_path_once_handles_hex_correctly() { + // Happy cases: identity (no `%`), upper, lower, and mixed hex. + // The decoder must accept all of them — case asymmetry was one + // of the legacy bypass vectors. + let ok: &[(&str, &str)] = &[ + ("/plain/path", "/plain/path"), + ("/a%2Fb", "/a/b"), + ("/a%2fb", "/a/b"), + ("/a%2fb%2F%2f", "/a/b//"), + ("/space%20here", "/space here"), + ]; + for (input, expected) in ok { + assert_eq!( + decode_path_once(input).unwrap(), + *expected, + "input={input:?}" + ); + } + + // Malformed: `%` at very end, `%X` (only one nibble), invalid + // hex digits, surrounded by garbage. + let bad: &[&str] = &["%", "abc%", "%2", "abc%2", "%ZZ", "abc%2G", "%9Q"]; + for input in bad { + assert_eq!( + decode_path_once(input).unwrap_err(), + CanonError::MalformedPercent, + "input={input:?}" + ); + } + } + + // ----------------------------------------------------------------- + // hex_value + // ----------------------------------------------------------------- + + #[test] + fn hex_value_accepts_digits_and_rejects_others() { + // Every valid hex digit, both letter cases. + let valid: &[(u8, u8)] = &[ + (b'0', 0), + (b'1', 1), + (b'2', 2), + (b'3', 3), + (b'4', 4), + (b'5', 5), + (b'6', 6), + (b'7', 7), + (b'8', 8), + (b'9', 9), + (b'a', 10), + (b'b', 11), + (b'c', 12), + (b'd', 13), + (b'e', 14), + (b'f', 15), + (b'A', 10), + (b'B', 11), + (b'C', 12), + (b'D', 13), + (b'E', 14), + (b'F', 15), + ]; + for (byte, expected) in valid { + assert_eq!( + hex_value(*byte).unwrap(), + *expected, + "byte={}", + *byte as char + ); + } + + // A handful of representative non-hex bytes: G/g (just past f), + // punctuation, whitespace, high bytes. + for byte in [b'g', b'G', b'/', b' ', b'\0', 0xFFu8] { + assert_eq!( + hex_value(byte).unwrap_err(), + CanonError::MalformedPercent, + "byte=0x{byte:02X}" + ); + } + } + + // ----------------------------------------------------------------- + // reject_overlong_utf8 — direct byte-level tests + // ----------------------------------------------------------------- + + #[test] + fn reject_overlong_utf8_catches_all_overlong_forms() { + // Each "bad" buffer carries an overlong sequence of arity 2, 3, + // or 4. Each "ok" buffer is a well-formed UTF-8 sequence of the + // same arity that must NOT be flagged. This pins both the + // detection AND the absence of false positives. + let bad: &[&[u8]] = &[ + // 2-byte overlong: leading 0xC0 / 0xC1 always overlong. + &[0xC0, 0xAF], + &[b'/', 0xC1, 0xAF, b'/'], + // 3-byte overlong: 0xE0 followed by 0x80..=0x9F. + &[0xE0, 0x80, 0xAF], + &[0xE0, 0x9F, 0xBF], + // 4-byte overlong: 0xF0 followed by 0x80..=0x8F. + &[0xF0, 0x80, 0x80, 0xAF], + &[0xF0, 0x8F, 0xBF, 0xBF], + ]; + for buf in bad { + assert_eq!( + reject_overlong_utf8(buf).unwrap_err(), + CanonError::OverlongUtf8, + "buf={buf:?}" + ); + } + + let ok: &[&[u8]] = &[ + b"plain ascii", + // Well-formed 2-byte sequence (U+00A9 ©): 0xC2 0xA9 + &[0xC2, 0xA9], + // Well-formed 3-byte sequence (U+4E2D 中): 0xE4 0xB8 0xAD + &[0xE4, 0xB8, 0xAD], + // Well-formed 4-byte sequence (U+1F600): 0xF0 0x9F 0x98 0x80 + &[0xF0, 0x9F, 0x98, 0x80], + b"", + ]; + for buf in ok { + assert!(reject_overlong_utf8(buf).is_ok(), "buf={buf:?}"); + } + } + + // ----------------------------------------------------------------- + // reject_control_chars — direct + // ----------------------------------------------------------------- + + #[test] + fn reject_control_chars_blocks_c0_block_and_del() { + // Every byte in the C0 control block (0x00..=0x1F) plus DEL + // (0x7F) must be rejected. We check the full block, not just + // a few representative bytes, because each byte is a separate + // CRLF/NUL/HTAB injection vector. + for b in 0u8..=0x1F { + let s = std::str::from_utf8(&[b]).unwrap().to_string(); + assert_eq!( + reject_control_chars(&s).unwrap_err(), + CanonError::ControlChar, + "byte=0x{b:02X}" + ); + } + let del = String::from_utf8(vec![0x7F]).unwrap(); + assert_eq!( + reject_control_chars(&del).unwrap_err(), + CanonError::ControlChar + ); + + // Printable ASCII (0x20..=0x7E) and the empty string must pass. + assert!(reject_control_chars("").is_ok()); + assert!(reject_control_chars(" !#0AZaz~").is_ok()); + assert!(reject_control_chars("/metadata/identity?x=1&y=2").is_ok()); + } + + // ----------------------------------------------------------------- + // reject_non_ascii — direct + // ----------------------------------------------------------------- + + #[test] + fn reject_non_ascii_blocks_high_bytes() { + // Anything within the ASCII range (incl. the C0 block — that's + // a different helper's job) must pass. + for s in ["", "/", "/abc", "/metadata/identity?api-version=2018"] { + assert!(reject_non_ascii(s).is_ok(), "input={s:?}"); + } + // Any non-ASCII character (1, 2, 3, or 4 UTF-8 bytes wide) must + // be rejected. + for s in ["é", "中", "🙂", "/abc/中文/x"] { + assert_eq!( + reject_non_ascii(s).unwrap_err(), + CanonError::InvalidUtf8, + "input={s:?}" + ); + } + } + + // ----------------------------------------------------------------- + // split_and_resolve — direct + // ----------------------------------------------------------------- + + #[test] + fn split_and_resolve_handles_dot_segments_and_matrix_params() { + // (input lowered path, expected segments). Inputs are passed as + // they would be after decode + lowercase, so this isolates the + // segment-level behavior. Covers: root, double-slash collapse, + // `.` removal, `..` chain, matrix params on one and many + // segments, a segment that becomes empty after matrix strip, + // and a trailing matrix-param-only segment. + let cases: &[(&str, &[&str])] = &[ + ("/", &[""]), + ("//", &[""]), + ("/a/b", &["", "a", "b"]), + ("/a//b", &["", "a", "b"]), + ("/a/./b", &["", "a", "b"]), + ("/a/b/../c", &["", "a", "c"]), + ("/a/b/c/../../d", &["", "a", "d"]), + ("/a;k=v/b", &["", "a", "b"]), + ("/a;k=v;k2=v2/b;k3=v3", &["", "a", "b"]), + ("/a/;k=v/b", &["", "a", "b"]), + ("/a/b/;k=v", &["", "a", "b"]), + ]; + for (input, expected) in cases { + assert_eq!( + split_and_resolve(input).unwrap(), + segs(expected), + "input={input:?}" + ); + } + + // Underflow: every form must fail-closed with PathUnderflow. + for input in ["/..", "/a/../..", "/../a", "/a/b/../../../c"] { + assert_eq!( + split_and_resolve(input).unwrap_err(), + CanonError::PathUnderflow, + "input={input:?}" + ); + } + } + + // ----------------------------------------------------------------- + // Appendix A.1 — path golden vectors + // + // Spec-conformance tests for the path pipeline. The vector labels + // (`A1.xxx`) appear in every failure message so a regression points + // straight back to the row in `Innovation-2.1-canonical-request.md`. + // + // These call the full canonicalize_str() entrypoint rather than + // canonicalize_path() directly so the assertions match the form a + // caller would see — the assertion target is still the path output. + // ----------------------------------------------------------------- + + fn canon_path_via_pipeline(url: &str) -> String { + super::super::canonicalize_str(url) + .unwrap() + .path_segments + .join("/") + } + + #[test] + fn appendix_a1_path_vectors_canonicalize_successfully() { + // (vector_label, raw_url, expected_canonical_path) + let cases: &[(&str, &str, &str)] = &[ + ( + "A1.plain", + "http://169.254.169.254/metadata/identity", + "/metadata/identity", + ), + ( + "A1.mixed_case", + "http://169.254.169.254/Metadata/Identity", + "/metadata/identity", + ), + ( + "A1.double_slash", + "http://169.254.169.254/metadata//identity", + "/metadata/identity", + ), + ( + "A1.dot_segment", + "http://169.254.169.254/metadata/./identity", + "/metadata/identity", + ), + ( + "A1.dotdot_segment", + "http://169.254.169.254/metadata/x/../identity", + "/metadata/identity", + ), + ( + "A1.encoded_slash_decodes", + "http://169.254.169.254/metadata%2Fidentity", + "/metadata/identity", + ), + ( + // Decoding happens once: %252F -> %2F (literal, not a separator). + "A1.double_encoding_decoded_once", + "http://169.254.169.254/metadata%252Fidentity", + "/metadata%2fidentity", + ), + ( + "A1.matrix_param_stripped", + "http://169.254.169.254/metadata/identity;jsessionid=abc", + "/metadata/identity", + ), + ]; + for (label, url, expected) in cases { + assert_eq!(canon_path_via_pipeline(url), *expected, "vector={label}"); + } + } + + #[test] + fn appendix_a1_path_vectors_rejected() { + // (vector_label, raw_url, expected_error) + let exact: &[(&str, &str, CanonError)] = &[ + ( + "A1.path_underflow", + // /metadata/identity -> pop x2 -> root; the third .. underflows. + "http://169.254.169.254/metadata/identity/../../..", + CanonError::PathUnderflow, + ), + ( + "A1.embedded_query", + "http://169.254.169.254/metadata/identity%3Fapi-version=2018", + CanonError::EmbeddedQuery, + ), + ( + "A1.control_char", + "http://169.254.169.254/metadata/identity%0A", + CanonError::ControlChar, + ), + ]; + for (label, url, expected) in exact { + assert_eq!( + super::super::canonicalize_str(url).unwrap_err(), + *expected, + "vector={label}" + ); + } + + // Either-of class: overlong UTF-8 may surface as OverlongUtf8 or + // InvalidUtf8 depending on decoder state — both are equally a deny. + let either: &[(&str, &str)] = &[( + "A1.overlong_utf8", + "http://169.254.169.254/metadata/%C0%AFidentity", + )]; + for (label, url) in either { + let err = super::super::canonicalize_str(url).unwrap_err(); + assert!( + matches!(err, CanonError::OverlongUtf8 | CanonError::InvalidUtf8), + "vector={label} got={err:?}" + ); + } + } +} diff --git a/proxy_agent/src/proxy/canonical/query.rs b/proxy_agent/src/proxy/canonical/query.rs new file mode 100644 index 00000000..8de3eef8 --- /dev/null +++ b/proxy_agent/src/proxy/canonical/query.rs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT + +//! Query string canonicalization. +//! +//! - Split on `&`, then on the first `=` (additional `=` characters +//! become part of the value). +//! - Single percent-decode of both key and value. +//! - Lowercase the key (case-insensitive matching). +//! - Reject control characters and non-ASCII in both key and value +//! (same rationale as for the path). +//! - Fold into a `BTreeMap>`: deterministic key +//! ordering, insertion order preserved within a key. + +use std::collections::BTreeMap; + +use super::CanonError; + +/// Canonicalize a raw query string (the part after `?`, without it). +pub fn canonicalize_query(raw: &str) -> Result>, CanonError> { + let mut map: BTreeMap> = BTreeMap::new(); + if raw.is_empty() { + return Ok(map); + } + for pair in raw.split('&') { + if pair.is_empty() { + // `?a=1&&b=2` -> skip empty pairs. + continue; + } + let (k_raw, v_raw) = match pair.find('=') { + Some(pos) => (&pair[..pos], &pair[pos + 1..]), + None => (pair, ""), + }; + let k = decode_query_component(k_raw)?; + let v = decode_query_component(v_raw)?; + if k.is_empty() { + // `?=foo` is malformed — silently drop instead of injecting a + // ghost empty key into the map. + continue; + } + map.entry(k.to_ascii_lowercase()).or_default().push(v); + } + Ok(map) +} + +fn decode_query_component(raw: &str) -> Result { + // `+` in a query component is a legacy form for space (application/x-www-form-urlencoded). + // IMDS / WireServer don't use form-encoded queries, but normalize it so a rule author + // can't tell the difference between `?key=a+b` and `?key=a%20b`. + let bytes = raw.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + match b { + b'%' => { + if i + 2 >= bytes.len() { + return Err(CanonError::MalformedPercent); + } + let h = hex_value(bytes[i + 1])?; + let l = hex_value(bytes[i + 2])?; + out.push((h << 4) | l); + i += 3; + } + b'+' => { + out.push(b' '); + i += 1; + } + _ => { + out.push(b); + i += 1; + } + } + } + let s = String::from_utf8(out).map_err(|_| CanonError::InvalidUtf8)?; + for b in s.bytes() { + if b < 0x20 || b == 0x7F { + return Err(CanonError::ControlChar); + } + } + if !s.is_ascii() { + return Err(CanonError::InvalidUtf8); + } + Ok(s) +} + +fn hex_value(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(10 + b - b'a'), + b'A'..=b'F' => Ok(10 + b - b'A'), + _ => Err(CanonError::MalformedPercent), + } +} + +#[cfg(test)] +mod query_tests { + use super::*; + + #[test] + fn empty() { + assert!(canonicalize_query("").unwrap().is_empty()); + } + + #[test] + fn single_pair() { + let q = canonicalize_query("api-version=2018-02-01").unwrap(); + assert_eq!(q.get("api-version"), Some(&vec!["2018-02-01".to_string()])); + } + + #[test] + fn key_lowercased() { + let q = canonicalize_query("API-Version=2018").unwrap(); + assert_eq!(q.get("api-version"), Some(&vec!["2018".to_string()])); + assert!(!q.contains_key("API-Version")); + } + + #[test] + fn percent_decoded_once() { + let q = canonicalize_query("resource=https%3A%2F%2Fmanagement.azure.com%2F").unwrap(); + assert_eq!( + q.get("resource"), + Some(&vec!["https://management.azure.com/".to_string()]) + ); + } + + #[test] + fn plus_to_space() { + let q = canonicalize_query("k=a+b").unwrap(); + assert_eq!(q.get("k"), Some(&vec!["a b".to_string()])); + } + + #[test] + fn repeated_key_preserves_order() { + let q = canonicalize_query("k=1&k=2&k=3").unwrap(); + assert_eq!( + q.get("k"), + Some(&vec!["1".to_string(), "2".to_string(), "3".to_string()]) + ); + } + + #[test] + fn no_value() { + let q = canonicalize_query("foo").unwrap(); + assert_eq!(q.get("foo"), Some(&vec!["".to_string()])); + } + + #[test] + fn malformed_percent_rejected() { + assert_eq!( + canonicalize_query("k=%2").unwrap_err(), + CanonError::MalformedPercent + ); + } + + #[test] + fn empty_key_dropped() { + let q = canonicalize_query("=value&k=v").unwrap(); + assert!(!q.contains_key("")); + assert_eq!(q.get("k"), Some(&vec!["v".to_string()])); + } + + #[test] + fn control_char_rejected() { + assert_eq!( + canonicalize_query("k=%0A").unwrap_err(), + CanonError::ControlChar + ); + } +} diff --git a/proxy_agent/src/proxy/canonical/rule.rs b/proxy_agent/src/proxy/canonical/rule.rs new file mode 100644 index 00000000..1ebb17ef --- /dev/null +++ b/proxy_agent/src/proxy/canonical/rule.rs @@ -0,0 +1,485 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT + +//! Canonical form of a rule pattern. +//! +//! Rules go through the same pipeline as requests, with two +//! differences: +//! +//! 1. There is no scheme/method on a rule (the matcher inherits those +//! from the request). +//! 2. A rule's destination is `RuleDestination`, which adds an `Any` +//! variant for rules that intentionally span endpoints. +//! +//! Matching is then a pure structural comparison: +//! +//! - `Destination` must equal the rule's destination (or the rule is +//! `Any`). +//! - The rule's path is a **prefix** of the request's canonical path, +//! compared segment-by-segment (not character-by-character — this is +//! what prevents `starts_with("/metadata")` from matching +//! `/metadata-attacker`). +//! - For each query key constrained by the rule, the request must +//! have at least one matching value (case-insensitive after the +//! canonical pipeline already lowercased both sides). + +use std::collections::BTreeMap; + +use crate::key_keeper::key::Privilege; + +use super::destination::Destination; +use super::path::canonicalize_path; +use super::query::canonicalize_query; +use super::{CanonError, CanonicalRequest}; + +/// Destination constraint on a rule. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum RuleDestination { + /// Rule applies to a single classified destination. + Only(Destination), + /// Rule applies regardless of destination (used for the per-endpoint + /// rule files where the file itself already partitions the rules). + Any, +} + +/// Canonical form of an authorization rule. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CanonicalPattern { + pub destination: RuleDestination, + /// Segments to prefix-match against [`CanonicalRequest::path_segments`]. + /// Always starts with the root marker (empty string). + pub path_prefix: Vec, + /// Required query parameters. Empty map means "no query constraint". + /// All present keys must match at least one of the supplied values. + pub required_query: BTreeMap>, +} + +impl CanonicalPattern { + /// Build from a raw `Privilege` (the on-disk rule format). + /// + /// The privilege's path is run through the canonical path pipeline, + /// and its query parameters are run through the canonical query + /// pipeline. Rules that fail canonicalization are **rejected** by + /// the loader — fail-closed. + pub fn from_privilege(p: &Privilege) -> Result { + let (segments, _trailing) = canonicalize_path(&p.path)?; + let required_query = match &p.queryParameters { + None => BTreeMap::new(), + Some(qp) => { + let mut joined = String::new(); + for (k, v) in qp.iter() { + if !joined.is_empty() { + joined.push('&'); + } + joined.push_str(k); + joined.push('='); + joined.push_str(v); + } + canonicalize_query(&joined)? + } + }; + Ok(CanonicalPattern { + destination: RuleDestination::Any, + path_prefix: segments, + required_query, + }) + } + + /// Structural match against a canonical request. + pub fn matches(&self, req: &CanonicalRequest) -> bool { + // Destination + if let RuleDestination::Only(d) = &self.destination { + if d != &req.destination { + return false; + } + } + + // Path: segment-by-segment prefix match. + if req.path_segments.len() < self.path_prefix.len() { + return false; + } + for (i, seg) in self.path_prefix.iter().enumerate() { + if &req.path_segments[i] != seg { + return false; + } + } + + // Query: every required key must be present and at least one + // of its required values must appear among the request's values + // for that key. + for (k, required_values) in &self.required_query { + let actual = match req.query.get(k) { + Some(v) => v, + None => return false, + }; + let any_match = required_values + .iter() + .any(|rv| actual.iter().any(|av| av == rv)); + if !any_match { + return false; + } + } + true + } +} + +#[cfg(test)] +mod rule_tests { + use std::collections::HashMap; + + use hyper::{Method, Uri}; + + use super::*; + use crate::proxy::canonical::canonicalize; + + // ---------- helpers ---------- + + fn priv_of(path: &str, qp: Option<&[(&str, &str)]>) -> Privilege { + Privilege { + name: "test".to_string(), + path: path.to_string(), + queryParameters: qp.map(|pairs| { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>() + }), + } + } + + fn req_of(uri: &str, method: &Method) -> CanonicalRequest { + let u: Uri = uri.parse().unwrap(); + canonicalize(&u, method).unwrap() + } + + fn segs(parts: &[&str]) -> Vec { + parts.iter().map(|s| s.to_string()).collect() + } + + fn pat( + destination: RuleDestination, + prefix: &[&str], + query: &[(&str, &[&str])], + ) -> CanonicalPattern { + let mut required = BTreeMap::new(); + for (k, vs) in query { + required.insert( + (*k).to_string(), + vs.iter().map(|v| (*v).to_string()).collect(), + ); + } + CanonicalPattern { + destination, + path_prefix: segs(prefix), + required_query: required, + } + } + + // ---------- from_privilege ---------- + + #[test] + fn from_privilege_canonicalizes_and_normalizes() { + // (raw_path, raw_query_pairs) -> (expected_path_prefix, expected_required_query as sorted pairs) + let cases: &[(&str, Option<&[(&str, &str)]>, &[&str], &[(&str, &[&str])])] = &[ + // case folding + leading-slash root marker preserved. + ("/Metadata", None, &["", "metadata"], &[]), + // dot-segments collapse on the rule side too. + ( + "/a/./b/../c", + None, + &["", "a", "c"], + &[], + ), + // trailing slash on rule is dropped from segments (same as request side). + ("/metadata/identity/", None, &["", "metadata", "identity"], &[]), + // None vs Some(empty) both yield an empty required_query. + ("/x", None, &["", "x"], &[]), + ("/x", Some(&[]), &["", "x"], &[]), + // single query param canonicalized (key case-folded). + ( + "/m", + Some(&[("Api-Version", "2018-02-01")]), + &["", "m"], + &[("api-version", &["2018-02-01"])], + ), + // value with encoded specials kept literal (not re-split on '='). + ( + "/m", + Some(&[("k", "a%3Db")]), + &["", "m"], + &[("k", &["a=b"])], + ), + // root-only rule. + ("/", None, &[""], &[]), + ]; + + for (path, qp, expect_segs, expect_q) in cases { + let p = CanonicalPattern::from_privilege(&priv_of(path, *qp)).unwrap(); + assert_eq!( + p.destination, + RuleDestination::Any, + "from_privilege must default to Any (input path={path:?})" + ); + assert_eq!(p.path_prefix, segs(expect_segs), "path={path:?}"); + let actual: Vec<(String, Vec)> = + p.required_query.into_iter().collect(); + let expected: Vec<(String, Vec)> = expect_q + .iter() + .map(|(k, vs)| ((*k).to_string(), vs.iter().map(|v| (*v).to_string()).collect())) + .collect(); + assert_eq!(actual, expected, "path={path:?}"); + } + } + + #[test] + fn from_privilege_rejects_invalid_inputs() { + // Garbage propagates from the canonical pipeline as a CanonError; + // the loader is expected to drop the rule rather than admit it. + let bad: &[(&str, Option<&[(&str, &str)]>)] = &[ + // Malformed percent in rule path. + ("/bad%ZZ", None), + // Control byte in rule path. + ("/bad\x01path", None), + // Non-ASCII in rule path. + ("/café", None), + // Malformed percent in rule query value. + ("/x", Some(&[("k", "%ZZ")])), + // Control byte in rule query key. + ("/x", Some(&[("k\x01", "v")])), + // Non-ASCII in rule query value. + ("/x", Some(&[("k", "café")])), + ]; + for (path, qp) in bad { + let r = CanonicalPattern::from_privilege(&priv_of(path, *qp)); + assert!( + r.is_err(), + "expected canonical rejection for path={path:?} qp={qp:?}" + ); + } + } + + // ---------- destination matching ---------- + + #[test] + fn matches_destination_constraint() { + // Any always matches; Only matches only its own classified destination. + let any = pat(RuleDestination::Any, &[""], &[]); + let only_imds = pat(RuleDestination::Only(Destination::Imds), &[""], &[]); + let only_ws = pat(RuleDestination::Only(Destination::WireServer), &[""], &[]); + + let imds_req = req_of("http://169.254.169.254/x", &Method::GET); + let ws_req = req_of("http://168.63.129.16/x", &Method::GET); + let unk_req = req_of("http://10.0.0.1/x", &Method::GET); + + let cases: &[(&CanonicalPattern, &CanonicalRequest, bool, &str)] = &[ + (&any, &imds_req, true, "Any+Imds"), + (&any, &ws_req, true, "Any+WireServer"), + (&any, &unk_req, true, "Any+Unknown"), + (&only_imds, &imds_req, true, "Only(Imds)+Imds"), + (&only_imds, &ws_req, false, "Only(Imds)+WireServer rejects"), + (&only_imds, &unk_req, false, "Only(Imds)+Unknown rejects"), + (&only_ws, &ws_req, true, "Only(WS)+WireServer"), + (&only_ws, &imds_req, false, "Only(WS)+Imds rejects"), + ]; + for (rule, req, expected, label) in cases { + assert_eq!(rule.matches(req), *expected, "{label}"); + } + } + + // ---------- path matching ---------- + + #[test] + fn matches_path_prefix_semantics() { + // (rule_path, request_uri, expected, label) + // Method is intentionally varied to confirm it is NOT part of rule matching. + let cases: &[(&str, &str, &Method, bool, &str)] = &[ + // Segment-boundary safety: /metadata must not match /metadata-attacker. + ( + "/metadata", + "http://169.254.169.254/metadata/identity", + &Method::GET, + true, + "rule shorter than request matches at boundary", + ), + ( + "/metadata", + "http://169.254.169.254/metadata-attacker/identity", + &Method::GET, + false, + "rule must not bleed across segment boundary", + ), + // Exact match. + ( + "/metadata/identity", + "http://169.254.169.254/metadata/identity", + &Method::POST, + true, + "exact path equality (method irrelevant)", + ), + // Request strictly shorter than rule prefix -> reject. + ( + "/metadata/identity/oauth2/token", + "http://169.254.169.254/metadata/identity", + &Method::GET, + false, + "request shorter than rule rejects", + ), + // Mid-segment differ (not just at the end). + ( + "/a/b/c", + "http://169.254.169.254/a/X/c", + &Method::GET, + false, + "differing mid segment rejects", + ), + // Case-insensitive (both rule and request lowercased by pipeline). + ( + "/Metadata", + "http://169.254.169.254/METADATA/Identity", + &Method::GET, + true, + "case-insensitive path match", + ), + // Percent-encoded slash decoded once -> rule that includes the slash matches. + ( + "/metadata/identity", + "http://169.254.169.254/metadata%2Fidentity/oauth2/token", + &Method::GET, + true, + "encoded slash in request decodes to rule path", + ), + // Root-only rule matches any request path. + ( + "/", + "http://169.254.169.254/anything/at/all", + &Method::GET, + true, + "root-only rule is universal on path", + ), + ( + "/", + "http://169.254.169.254/", + &Method::GET, + true, + "root-only rule on root request", + ), + ]; + for (rule_path, uri, method, expected, label) in cases { + let p = CanonicalPattern::from_privilege(&priv_of(rule_path, None)).unwrap(); + let r = req_of(uri, method); + assert_eq!(p.matches(&r), *expected, "{label}"); + } + } + + // ---------- query matching ---------- + + #[test] + fn matches_query_constraint_semantics() { + // Across keys: AND. Within a key: OR over the rule's allowed values. + // Build patterns directly so we can exercise multiple values per key + // (the on-disk Privilege format only supports a single value per key). + let multi_value = + pat(RuleDestination::Any, &["", "m"], &[("v", &["a", "b"])]); + let multi_key = pat( + RuleDestination::Any, + &["", "m"], + &[("a", &["1"]), ("b", &["2"])], + ); + let no_query = pat(RuleDestination::Any, &["", "m"], &[]); + + let cases: &[(&CanonicalPattern, &str, bool, &str)] = &[ + // No required_query -> any query (including none) matches. + (&no_query, "http://169.254.169.254/m", true, "no constraint + no query"), + ( + &no_query, + "http://169.254.169.254/m?anything=here", + true, + "no constraint + extra query", + ), + // OR within a key. + ( + &multi_value, + "http://169.254.169.254/m?v=a", + true, + "OR within key, first value", + ), + ( + &multi_value, + "http://169.254.169.254/m?v=b", + true, + "OR within key, second value", + ), + ( + &multi_value, + "http://169.254.169.254/m?v=c", + false, + "value outside allowed set rejects", + ), + ( + &multi_value, + "http://169.254.169.254/m", + false, + "missing required key rejects", + ), + // Request supplies the same key twice; rule accepts if ANY request value matches. + ( + &multi_value, + "http://169.254.169.254/m?v=c&v=b", + true, + "request-side repeat: any value matches rule", + ), + // Extra request keys are allowed (rule is a minimum requirement, not an exact match). + ( + &multi_value, + "http://169.254.169.254/m?v=a&extra=zzz", + true, + "extra request keys allowed", + ), + // AND across keys. + ( + &multi_key, + "http://169.254.169.254/m?a=1&b=2", + true, + "AND across keys satisfied", + ), + ( + &multi_key, + "http://169.254.169.254/m?a=1", + false, + "AND across keys: missing second key rejects", + ), + ( + &multi_key, + "http://169.254.169.254/m?a=1&b=9", + false, + "AND across keys: wrong value on one key rejects", + ), + // Case folding on keys (Api-Version vs api-version) via canonical pipeline. + ( + &CanonicalPattern::from_privilege(&priv_of( + "/metadata/identity", + Some(&[("Api-Version", "2018-02-01")]), + )) + .unwrap(), + "http://169.254.169.254/metadata/identity?API-VERSION=2018-02-01", + true, + "case-insensitive key fold both sides", + ), + // Encoded value on the request side decodes once to match the rule. + ( + &CanonicalPattern::from_privilege(&priv_of( + "/m", + Some(&[("k", "a b")]), + )) + .unwrap(), + "http://169.254.169.254/m?k=a%20b", + true, + "request value decoded once matches rule value", + ), + ]; + for (rule, uri, expected, label) in cases { + let r = req_of(uri, &Method::GET); + assert_eq!(rule.matches(&r), *expected, "{label}"); + } + } +} From af70688b0fa420f666498c4a2477317504eac2aa Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Wed, 10 Jun 2026 13:53:30 -0700 Subject: [PATCH 02/11] Add innovation plan files (#355) * Add innovation plan files * Update 7.3 md files * fix spelling * fix * update spelling * Update spelling * Update check-spelling metadata --------- Co-authored-by: Zhidong Peng --- .github/actions/spelling/excludes.txt | 4 +- .github/actions/spelling/expect.txt | 197 ++++--- doc/plans/Innovation-1.1-pop-tokens.md | 172 ++++++ doc/plans/Innovation-1.2-vtpm-sealing.md | 159 ++++++ doc/plans/Innovation-1.3-measured-identity.md | 157 ++++++ doc/plans/Innovation-1.4-capability-scopes.md | 144 +++++ doc/plans/Innovation-2.1-canonical-request.md | 506 ++++++++++++++++++ .../Innovation-2.2-typed-policy-cedar.md | 137 +++++ .../Innovation-2.3-versioned-snapshots.md | 123 +++++ .../Innovation-2.4-differential-testing.md | 119 ++++ doc/plans/Innovation-3.1-hash-chained-log.md | 114 ++++ doc/plans/Innovation-3.2-otel-export.md | 99 ++++ doc/plans/Innovation-3.3-self-attestation.md | 108 ++++ doc/plans/Innovation-3.4-supply-chain.md | 91 ++++ doc/plans/Innovation-4.1-sk-lookup-bpf-lsm.md | 117 ++++ doc/plans/Innovation-4.2-core-unify-ebpf.md | 103 ++++ doc/plans/Innovation-4.3-ipv6-dual-stack.md | 110 ++++ .../Innovation-4.4-ebpf-throttling-lru.md | 111 ++++ .../Innovation-5.1-aks-container-native.md | 177 ++++++ .../Innovation-5.2-gate-more-endpoints.md | 104 ++++ doc/plans/Innovation-5.3-cross-cloud-port.md | 100 ++++ doc/plans/Innovation-6.1-policy-simulator.md | 110 ++++ doc/plans/Innovation-6.2-gpa-doctor.md | 88 +++ doc/plans/Innovation-6.3-rule-authoring-ux.md | 101 ++++ doc/plans/Innovation-6.4-wasm-rule-sandbox.md | 106 ++++ doc/plans/Innovation-7.1-io-uring-hot-path.md | 84 +++ doc/plans/Innovation-7.2-zero-copy-splice.md | 75 +++ .../Innovation-7.3-crate-consolidation.md | 192 +++++++ doc/plans/Innovation-Directions.md | 452 ++++++++++++++++ doc/plans/Innovation-Plans-Milestones.md | 436 +++++++++++++++ proxy_agent/src/provision.rs | 2 +- 31 files changed, 4520 insertions(+), 78 deletions(-) create mode 100644 doc/plans/Innovation-1.1-pop-tokens.md create mode 100644 doc/plans/Innovation-1.2-vtpm-sealing.md create mode 100644 doc/plans/Innovation-1.3-measured-identity.md create mode 100644 doc/plans/Innovation-1.4-capability-scopes.md create mode 100644 doc/plans/Innovation-2.1-canonical-request.md create mode 100644 doc/plans/Innovation-2.2-typed-policy-cedar.md create mode 100644 doc/plans/Innovation-2.3-versioned-snapshots.md create mode 100644 doc/plans/Innovation-2.4-differential-testing.md create mode 100644 doc/plans/Innovation-3.1-hash-chained-log.md create mode 100644 doc/plans/Innovation-3.2-otel-export.md create mode 100644 doc/plans/Innovation-3.3-self-attestation.md create mode 100644 doc/plans/Innovation-3.4-supply-chain.md create mode 100644 doc/plans/Innovation-4.1-sk-lookup-bpf-lsm.md create mode 100644 doc/plans/Innovation-4.2-core-unify-ebpf.md create mode 100644 doc/plans/Innovation-4.3-ipv6-dual-stack.md create mode 100644 doc/plans/Innovation-4.4-ebpf-throttling-lru.md create mode 100644 doc/plans/Innovation-5.1-aks-container-native.md create mode 100644 doc/plans/Innovation-5.2-gate-more-endpoints.md create mode 100644 doc/plans/Innovation-5.3-cross-cloud-port.md create mode 100644 doc/plans/Innovation-6.1-policy-simulator.md create mode 100644 doc/plans/Innovation-6.2-gpa-doctor.md create mode 100644 doc/plans/Innovation-6.3-rule-authoring-ux.md create mode 100644 doc/plans/Innovation-6.4-wasm-rule-sandbox.md create mode 100644 doc/plans/Innovation-7.1-io-uring-hot-path.md create mode 100644 doc/plans/Innovation-7.2-zero-copy-splice.md create mode 100644 doc/plans/Innovation-7.3-crate-consolidation.md create mode 100644 doc/plans/Innovation-Directions.md create mode 100644 doc/plans/Innovation-Plans-Milestones.md diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 3fcc16a7..aa4efc67 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -1,6 +1,8 @@ .github\actions\spelling\expect.txt +^\Q.github/actions/spelling/expect.txt\E$ +^pkg_debian/rules$ Cargo.lock doc/GPA Arch Diagram.vsdx doc/GuestProxyAgent.png e2etest/GuestProxyAgentTest/Resources/GuestProxyAgentLoadedModulesBaseline.txt -proxy_agent_shared/src/secrets_redactor.rs \ No newline at end of file +proxy_agent_shared/src/secrets_redactor.rs diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 929d8ec1..113389e7 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -1,31 +1,40 @@ aab AAFFBB +AAppendix aarch +abcd abcdefghijkmnopqrstuvwxyz ABCDEFGHJKLMNPQRSTUVWXYZ abe +addrlen addrpair advapi +AFidentity +aks almalinux ATL ATLMFC +aton +Authenticode autobuild autocrlf +autonumber +AWI aya AZUREPUBLICCLOUD azuretools -azureuser backcompat backdoored bak bierner -binpath +bindgen binskim bitflag boofuzz bpf bpftool btf +btreemap btrfs bufptr Bufs @@ -33,41 +42,41 @@ BUILDIN buildroot buildshell byos -cacheline +bytecodealliance callouts +canonicalizer cbl ccbdee ccbf +CEL +CES cgroups cgroupv cgtop chokepoint cicd Cim -cimv cla closehandle -cmds codeofconduct codeql -collectguestlogs commandline COMPUTERNAME -comspec +confusables consoleloggerparameters +containerd coredumpctl -covrec CPlat -cplusplus cpptools +cri crpteste -CRYPTOAPI CSPRNG -csum customout customoutput cvd CVEs +CVM +cyclonedx czf DABC DACL @@ -85,7 +94,6 @@ defattr dentity deploymentid desync -desyncs detbox detlbl dettitle @@ -97,13 +105,11 @@ distros dllmain dministrator dnf -dockerenv -dodce dodce dotnet -doxygen DPAPI dport +DSL dtolnay dumps'd Dvm @@ -112,13 +118,15 @@ eaeef EAF ebpf ebpfapi -EBPFCORE eef egor ele +EMBQ ent +entra entriesread esac +esapi EStorage etest etestoutputs @@ -134,10 +142,9 @@ exampleosdiskname examplevmname exepath exfil -EXTCONFIG extconfig -exthandlers fafbfc +failmode Fapi Fbar fde @@ -146,16 +153,14 @@ ffcecb ffebe fff ffff -FFFF FFFFFFFF fffi ffi +fidentity FIXEDFILEINFO Fmanagement -FOF Fresource FSETID -FSO fsprogs fstorage fstype @@ -163,13 +168,13 @@ ftoken fuzzer fwlink Fzpeng -gaplugin -getifaddrs +gcp goalstate gpa gpalinuxdev gpapen gpawindev +GSP guestproxyagentmsis guiddef handleapi @@ -180,38 +185,50 @@ HMACs homoglyph hostga hostingenvironmentconfig +HTAB httpwg httpx Iaa +iat ICredentials idstepsrun IEnumerable ieq +ietf iex -ifaddrs -ifindex +ikm IList +ima imds +IMDSv imm -immediateruncommandservice intellectualproperty Intelli intellij INVALIDMETHOD INVM -Ioctl +ioctl +iss iusr jetbrains -jqlang +Jhb JOBOBJECT jobsjob journalctl joutvhu -JScript +jqlang +jsessionid +jti +JWKS +jws +KEK keyonly +keyvault +kfunc kinvolk kmemleak kotlin +kpi kprobe ktime kusto @@ -221,12 +238,13 @@ libbpf libbpfcc libloading linkid +llc llvmorg logdir -Loggerhas logon Lrs lsa +lsm ltsc luid macikgo @@ -235,73 +253,85 @@ mcr MEMORYSTATUSEX metabuild MFC -microsoftcblmariner +mgmt microsoftlosangeles microsoftwindowsdesktop misconfig +mlock +mmap'd MMdd mmm mnt +monoio monomorphization +MRTD msasn msc -msp msrc +MSRs multilib +nbf ncurl +NDJSON netapi netcoreapp -netebpfext -nethook netns netsh Newtonsoft +nfc nftables NICs nifs nmake nmap +NNN nocapture -NOCONFIRMATION nodet -NOERRORUI NONINFRINGEMENT +norestart nosuchuser notcontains notjson notlike -norestart npidof nprintf NSG nsudo ntdll -NTSTATUS ntimeout +ntohs +NTSTATUS OICI -onscreen +OIDC onebranch -openprocess oneshot +onscreen opencode +openprocess opensource +opentelemetry +optin osinfo +otel +OTLP +OWASP +pahole PAI -parseable passwordless -peekable pcap -PCAP pcapng -pcaps +pcr +PDBs +peekable PEERCRED -pentest PENTEST +pentest PERCPU pgpkey pgrep pidof PIDs +PII pipefail pkgversion pktmon @@ -310,40 +340,46 @@ portaddr portpair postinst pprev -prandom prctl predef -prefixer Prefixer +prefixer prefmaxlen preprovisioned Prereqs printk -PROCESSINFOCLASS procdump +PROCESSINFOCLASS processthreadsapi +proptest proxyagent proxyagentextensionvalidation proxyagentvalidation pscustomobject ptrace pwstr +raci radamsa Razr rcv RDFE +RECVORIGDSTADDR redhat Redist refcnt registereventsourcew registrykey +reimplementation +rekor relativeurl +Reprovisioning resf reuseport rgr rgs rhel RINGBUF +roadmap Roboto rockylinux rolename @@ -351,23 +387,26 @@ rootdir rpmbuild RPMS rstr +RTMR rul ruleset runas runthis -Runtimes +runtimes +RUSTFLAGS rustfmt rustup saddr sandboxing sas +sbom scapy -schtasks scm SDDL secauthz Segoe -serice +selftest +serviceaccount SETFCAP SETPCAP sev @@ -376,19 +415,26 @@ shellcheck sids SIEM sigid +Sigstore SIO +SIV skc +sklookup sku sles sln -smp +slo +SNAT +snp sourced spellright +SPIFFE splitn SRPMS SSRF SSZ stackoverflow +starttime stdbool stdint stdoutput @@ -399,17 +445,18 @@ Substatuses SUIDSGID suse Swatinem -SWbem SYD SYG +syscalls sysinfoapi -sysinit SYSLIB SYSTEMDRIVE taiki TASKKILL -tcpdump +tcb TCPDUMP +tcpdump +tdx telemetrydata tensin testcasesetting @@ -420,7 +467,6 @@ THH thiserror Thu timedout -timeup tlsv tmpfs tnc @@ -431,31 +477,35 @@ TOCTOU tokio topdir totalentries +tpmrm transitioning +tripable trustlevel trustyuser tshark -tsv -Tsv TSV +Tsv +tsv +UAMI UBR UBRSTRING udev -uers -uninstalls unistd unmark unparseable -Unregistering -unregisters -unspec +updateable +uppercased +uring +userinfo uzers valu +VCEK VCpus vcruntime vendored vflji vhd +VLEK vmagentlog VMGA vmhwm @@ -469,22 +519,19 @@ vns VTeam vtpm waagent -waappagent walinuxagent -wasecagentprov -Wbem +wasmtime +WDAC wdk wdksetup Werror westus wevtapi -wfp WFP +wfp winapi winbase -windowsazureguestagent winget -winmgmts winnt winres wireserver @@ -492,14 +539,11 @@ wireserverand wireserverandimds WMI workarounds -WORKINGSET -workdir WORKDIR +workdir +WORKINGSET wrk wrongvalue -WScript -wsf -Wsh WSL wstr wsum @@ -507,15 +551,16 @@ wyy xamarin xbb xbf -xef xcopy XDP +xef xfsprogs xsi xxxx XXXXXX xxxxxxxx xxxxxxxxxxx +Zeroizing zipsas zureuser -zypper \ No newline at end of file +zypper diff --git a/doc/plans/Innovation-1.1-pop-tokens.md b/doc/plans/Innovation-1.1-pop-tokens.md new file mode 100644 index 00000000..1c4f4a03 --- /dev/null +++ b/doc/plans/Innovation-1.1-pop-tokens.md @@ -0,0 +1,172 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Token format](#token) +4. [4. Mint & verify](#mint) +5. [5. Wire protocol](#wire) +6. [6. Integration](#integration) +7. [7. Rollout](#rollout) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 1.1** · **AuthN** + +# Detailed Design — Short-lived Proof-of-Possession Tokens + +Replace the long-lived HMAC signature header with a compact, audience-scoped, time-bound token derived from the latched key, so a leaked key file cannot be used to sign arbitrary requests offline or to replay captured ones. + +**Files affected:** `proxy_agent_shared/src/` (new `pop_token` module), `proxy_agent/src/key_keeper/key.rs`, `proxy_agent/src/proxy/proxy_server.rs`. + +> **Prerequisites:** None — foundational identity-layer change. Strengthened by [1.2 vTPM sealing](Innovation-1.2-vtpm-sealing.md) (key-source hardening) and [1.3 Measured identity](Innovation-1.3-measured-identity.md) (binding the signer). + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------------------|------------|---------------------|-----------------------------| +| **High** kills replay + key-leak | **Medium** | **Fabric coupling** | **agent + WireServer/IMDS** | + +### 1.1 Goals + +- Each request bears a token valid for ≤ 30 s, bound to *caller*, *destination*, and *URL*. +- The latched key never appears on the wire and never signs raw HTTP — it signs a derived session key. +- Replay (pentest `B2`) becomes structurally impossible. +- A leaked key file (pentest `B3`) is still useless without live caller-fingerprint material (cgroup, pid-starttime, vTPM PCRs from direction 1.2). + +### 1.2 Non-goals + +- Replacing the underlying primitive (still HMAC-SHA256 in v1; PQ migration is out of scope). +- Asymmetric tokens — would force a fabric crypto change we want to defer. + +## 2. Today's Behavior + +GPA signs each authorized request with an HMAC over a static string derived from method, URL, and a coarse "time tick"; the signature is placed in `x-ms-azure-signature` with a sibling `x-ms-azure-time-tick` header. The HMAC key is the latched key written at provisioning. Once disclosed, it can sign anything indefinitely. + +The fabric checks the signature against the latched key it holds for this VM. There is no per-request nonce, no audience binding, and no caller binding. + +## 3. Token Format + +Compact, JWS-like, three base64url segments joined by `.`: + + HEADER = { "alg":"HS256", "kid":, "v":2 } + PAYLOAD = { + "iss": "gpa", // issuer + "aud": "wireserver" | "imds" | "hostga", + "sub": , // see §3.1 + "iat": , + "exp": , + "nbf": , + "jti": <128-bit random>, // nonce (anti-replay) + "url": , + "dip": , + "src": + } + SIG = HMAC-SHA256( derive_session_key(latched, jti), HEADER || "." || PAYLOAD ) + +### 3.1 Caller fingerprint (`sub`) + +- `sub = sha256( cgroup_id || pid_starttime_ns || exe_hash )`. +- `exe_hash` is the IMA / fs-verity hash from direction 1.3 when available; falls back to `processFullPath` bytes. +- The fabric does not interpret `sub`; it only ensures the same `sub` isn't reused after expiry. + +### 3.2 Session key derivation + + session_key = HKDF-SHA256( + ikm = latched_key, + salt = jti, + info = "gpa-pop-v2" || aud || dip + ) + +This means the latched key never directly produces a tag visible on the wire; recovering the latched key requires inverting HKDF, not HMAC. + +## 4. Mint & Verify + +### 4.1 Rust API + + pub struct PopToken(String); + + pub struct MintParams<'a> { + pub aud: Audience, + pub canonical_url_method_hash: [u8; 32], + pub destination: SocketAddr, + pub caller: &'a CallerFingerprint, + pub ttl: Duration, // clamp to <=30s + } + + impl PopToken { + pub fn mint(key: &LatchedKey, p: &MintParams) -> Result; + pub fn verify(token: &str, key: &LatchedKey, now: SystemTime, + expected_aud: Audience) -> Result; + } + +### 4.2 Constant-time comparison + +- Use `subtle::ConstantTimeEq` for the signature compare in `verify`. +- HMAC computation uses `hmac` crate with `sha2::Sha256`; both are constant-time and already in the dependency tree. + +### 4.3 Anti-replay storage + +- Agent side: nothing (tokens are stateless on the way out). +- Fabric side: bloom filter or LRU of recently-seen `jti` values keyed by `(aud, sub)` with TTL ≥ 2× max `exp - iat`. Detail belongs to the fabric design but the agent must pick `jti` from a CSPRNG with at least 128 bits. + +## 5. Wire Protocol + +### 5.1 Headers (v2) + +| Header | Direction | Notes | +|---------------------------|----------------|-----------------------------------------------------------------| +| `x-ms-azure-pop` | agent → fabric | The compact token from §3. | +| `x-ms-azure-pop-aud` | agent → fabric | Redundant audience hint to allow fast rejection before parsing. | +| `x-ms-azure-signature` | agent → fabric | Legacy header, still emitted during dual-emit phase. | +| `x-ms-azure-pop-rejected` | fabric → agent | Reason code on 401; consumed by GPA telemetry only. | + +### 5.2 Header stripping + +GPA **always** strips any inbound `x-ms-azure-pop*` and `x-ms-azure-signature*` headers from the client request before forwarding (pentest `B4`); never propagates client-supplied values. + +## 6. Integration Points + +| File | Change | +|---------------------------------------------|----------------------------------------------------------------------------------------------| +| `proxy_agent_shared/src/pop_token/` | New module: types, mint, verify, fuzz target. | +| `proxy_agent/src/key_keeper/key.rs` | Add `derive_session_key`; expose `kid()`. | +| `proxy_agent/src/proxy/proxy_server.rs` | Replace HMAC mint with `PopToken::mint`; keep legacy header behind `pop_v2.mode != enforce`. | +| `proxy_agent/src/proxy/proxy_authorizer.rs` | Compute canonical URL hash (reuse `CanonicalRequest` from 2.1) and caller fingerprint. | +| `config/GuestProxyAgent.*.json` | New `popToken.mode` = `off|dual|enforce`. | + +## 7. Rollout + +1. **Phase A — Off:** ship code, dormant. Unit / fuzz tests run in CI only. +2. **Phase B — Dual-emit:** emit both headers; fabric ignores PoP. Telemetry only. +3. **Phase C — Dual-verify:** fabric verifies PoP if present, accepts either. Agent telemetry tracks fabric verdicts via the `x-ms-azure-pop-rejected` header. +4. **Phase D — PoP-only:** fabric rejects requests without PoP. Legacy header removed in the next release. + +A region only advances to the next phase when error rate \< 0.001 % for 14 days. + +## 8. Test Strategy + +- Golden vectors signed by a reference implementation; agent must verify identical bytes. +- Property test: round-trip `mint → verify` always succeeds for fresh tokens; modifying any byte fails. +- Property test: skewing the clock by \> 60 s rejects; within ±60 s accepts (configurable skew). +- `cargo fuzz` on `verify`: must never panic. +- Pentest reruns: `B2` replay → REJECT; `B3` stolen-key replay on a different VM → REJECT once fabric checks `sub` stickiness. +- Soak: 1 million mint/verify pairs/sec on a single core baseline; track regressions. + +## 9. Risks & Mitigations + +- **Clock drift:** ±60 s tolerance; if NTP is broken GPA can request fabric time via WireServer health endpoint. +- **Header bloat:** typical token ≈ 380 bytes b64u; bounded. +- **Fabric rollout coupling:** dual-emit/dual-verify phases decouple agent and fabric releases. +- **HSM-bound key future:** session-key derivation already isolates the latched key, easing later migration to a vTPM-resident key (direction 1.2). + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|----------------------------------------|----------------------------------------| +| M1 | `pop_token` crate + 200 golden vectors | Fuzz clean for 1 CPU-day | +| M2 | Dual-emit behind flag in canary region | Zero correctness regressions vs legacy | +| M3 | Fabric dual-verify enabled | Pentest B2/B3 PASS | +| M4 | PoP-only enforcement | Legacy header deletion PR merged | + +Detail design for direction 1.1. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-1.2-vtpm-sealing.md b/doc/plans/Innovation-1.2-vtpm-sealing.md new file mode 100644 index 00000000..2a820fbf --- /dev/null +++ b/doc/plans/Innovation-1.2-vtpm-sealing.md @@ -0,0 +1,159 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Sealing design](#design) +4. [4. Backends](#backends) +5. [5. PCR / report bindings](#pcrs) +6. [6. Provisioning flow](#provision) +7. [7. Unseal flow](#unseal) +8. [8. Integration](#integration) +9. [9. Tests](#tests) +10. [10. Risks](#risks) +11. [11. Milestones](#milestones) + +**GPA** · **Direction 1.2** · **Hardware root of trust** + +# Detailed Design — vTPM / CVM Attestation Binding for the Latched Key + +Seal the latched key to a hardware root of trust so that copying `/var/lib/azure-proxy-agent/keys/*` to another VM, restoring an older snapshot, or booting a tampered image yields an unrecoverable key blob. + +**Files affected:** new `proxy_agent/src/key_keeper/sealing/` module, `proxy_agent/src/key_keeper/key.rs`, `proxy_agent/src/provision.rs`. + +> **Prerequisites:** None — foundational TPM/sealing layer. Enables [1.3 Measured identity](Innovation-1.3-measured-identity.md) and [3.3 Self-attestation](Innovation-3.3-self-attestation.md). + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|-------------------------------------|------------|---------------------|---------------------------------| +| **High** kills key-theft & rollback | **Medium** | **Hardware matrix** | **agent + fabric KID registry** | + +### Goals + +- Stolen key blob on disk cannot be used on a different VM (pentest `B3`). +- Older sealed blob cannot be replayed after rotation (pentest `E5`). +- Booting a tampered kernel/agent invalidates the seal and forces re-provisioning. +- CVM (SEV-SNP / TDX) deployments get cryptographic guest-identity binding. + +### Non-goals + +- Generic vTPM management or attestation service implementation — we consume Azure's MAA (Microsoft Azure Attestation) where applicable. +- Migrating the HMAC algorithm itself (PoP work is direction 1.1). + +## 2. Today's Behavior + +The latched key is written as a plain file under `/var/lib/azure-proxy-agent/keys/` with mode 0600. Any root-level compromise reads it; a snapshot of the directory survives migration to a different VM; a previous file restored after rotation works against the fabric until rotation logic catches up. + +## 3. Sealing Design + +### 3.1 On-disk format + + // .sealed file format (binary, versioned) + struct SealedBlob { + magic: [u8;4] = b"GSP1", + version: u8 = 1, + backend: u8 = TPM2 | SNP | TDX | NOOP, + kid: [u8;16], // key id, same as PoP header kid + counter: u64, // monotonic, signed by backend (anti-rollback) + attestation_ref: [u8;32], // sha256 of attestation report or PCR digest + ciphertext_len: u32, + ciphertext: [u8], // AES-256-GCM-SIV under a backend-managed KEK + tag_len: u32, + tag: [u8], // backend-specific attestation/seal proof + } + +### 3.2 Layered keys + +- **LatchedKey** (random, 32 bytes) — what the rest of GPA already uses. +- **KEK** (key-encryption key) — derived inside the backend (vTPM sealed object, SNP-derived key, or TDX MRTD-bound key). +- Plaintext LatchedKey is unwrapped only into protected memory (`mlock` + `zeroize::Zeroizing`) and never reaches disk. + +## 4. Backends + +| Backend | Crate | Detection | Notes | +|---------|----------------|----------------------------------------|----------------------------------------------------------------------------------------------------------| +| `tpm2` | `tss-esapi` | `/dev/tpmrm0` on Linux; TBS on Windows | Uses TPM 2.0 sealed object + PolicyPCR. | +| `snp` | `sev` + custom | `SEV_STATUS` MSR / `/dev/sev-guest` | Derives KEK from SNP `VLEK`/`VCEK`; attestation report embeds VM measurement. | +| `tdx` | `tdx-attest` | TDX guest module device | KEK from TDX RTMR; attestation report from TD QUOTE. | +| `noop` | — | fallback | Encrypts with a host-stored DPAPI / Linux kernel keyring entry; explicitly weaker, only for legacy SKUs. | + +### 4.1 Backend trait + + pub trait SealingBackend: Send + Sync { + fn id(&self) -> BackendId; + fn seal(&self, plaintext: &[u8], policy: &SealPolicy) + -> Result; + fn unseal(&self, blob: &SealedBlob) + -> Result>, SealError>; + fn attest(&self, nonce: &[u8]) -> Result; + fn monotonic_counter_get(&self) -> Result; + fn monotonic_counter_increment(&self) -> Result; + } + +## 5. PCR / Report Bindings + +### 5.1 TPM2 (PCR selection) + +| PCR | Measures | Why | +|--------|----------------------------------------|--------------------------------| +| 0 | Firmware code | Detect firmware swap. | +| 4 | Bootloader | Detect bootloader swap. | +| 7 | Secure Boot policy | Detect SB disable / new keys. | +| 8 | Kernel + initrd (via grub measurement) | Detect kernel swap. | +| 9 / 14 | IMA log root | Detect agent binary tampering. | + +### 5.2 SNP / TDX + +- Bind the seal to the launch measurement (`MEASUREMENT` field in SNP report, `MRTD`+`RTMR` in TDX QUOTE). +- Include the agent binary digest in `REPORT_DATA`/`RTMR3` so post-launch upgrades trigger a controlled re-seal rather than failing open. + +### 5.3 Anti-rollback counter + +- TPM: NV index with `NVCounter`; agent reads and compares against blob counter on every unseal. +- SNP/TDX: use the agent's own VM-persistent virtual counter file *plus* a hash of the latest signed counter embedded in the next attestation request to the fabric (anchor to fabric monotonicity). + +## 6. Provisioning Flow + +agent fabric │ probe backend ───────────────────► │ ◄──── selected: tpm2 │ │ attest(nonce_from_fabric) ─────► │ ◄──── ack + bound_kid │ │ generate LatchedKey (CSPRNG) │ seal(LatchedKey, policy{PCR set, counter+1}) → SealedBlob │ persist SealedBlob to disk │ register(bound_kid, attestation_doc) ─► │ ◄──── 200 OK + +## 7. Unseal Flow on Service Start + +1. Read `.sealed` blob; reject if magic/version mismatch (fail-closed). +2. Call `backend.unseal`; on policy mismatch (PCR changed), enter **reprovision** state, request a fresh latch from fabric, do not serve traffic until success. +3. Verify `blob.counter ≥ backend.monotonic_counter_get()`; equal allowed once per boot, lesser rejected as rollback. +4. Place plaintext in `Zeroizing>`, `mlock` the buffer. +5. Erase plaintext on shutdown / SIGTERM (already happens with `zeroize` drop). + +## 8. Integration Points + +- `proxy_agent/src/key_keeper/key.rs` — add `load_sealed` / `store_sealed`, gated on a config flag. Existing plain-file path is the `noop` backend. +- `proxy_agent/src/provision.rs` — attestation handshake; expose `Reprovisioning` state to the status endpoint. +- `proxy_agent/src/service/` — surface backend id and `kid` in startup log + status JSON. +- Build: feature flags `seal-tpm2`, `seal-snp`, `seal-tdx` so distros without those crates still build. + +## 9. Tests + +- **Hermetic backend simulator** for CI — implements the trait with in-memory PCRs to validate flows without real hardware. +- Modify a simulated PCR after seal → unseal fails → agent enters reprovision; verify it serves nothing in the interim (closes a fail-open window). +- Rollback test: write `counter-1` blob → unseal rejected with `SealError::Rollback`. +- Cross-VM test in staging: snapshot `/var/lib/azure-proxy-agent` from VM-A, place on VM-B → unseal fails with policy mismatch. +- Tamper kernel cmdline → next boot PCR9 differs → reprovision triggered. +- Pentest `E5`: rollback rejected; `B3`: stolen blob useless on new VM. + +## 10. Risks & Mitigations + +- **Routine kernel updates trigger reprovision storms.** Mitigation: ride the OS update pipeline; pre-stage a new seal during update window before old kernel reboots. +- **Backend crate maturity.** Mitigation: ship behind feature flags; default to `noop` on legacy SKUs. +- **Latency at start.** TPM unseal ≈ 20–50 ms; acceptable because it's once per boot. +- **NV counter exhaustion (TPM).** Mitigation: increment only on rotation, not on each boot. + +## 11. Milestones + +| M | Deliverable | Exit | +|-----|------------------------------------|------------------------------------------------| +| M1 | Trait + `noop` + simulator backend | All current tests still pass | +| M2 | TPM2 backend behind `seal-tpm2` | Tamper/rollback tests PASS on a TPM-enabled VM | +| M3 | SNP + TDX backends | Cross-VM pentest `B3` PASS on CVM SKUs | +| M4 | Default-on for CVM SKUs | Field error rate \< 0.001 % for 14 days | + +Detail design for direction 1.2. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-1.3-measured-identity.md b/doc/plans/Innovation-1.3-measured-identity.md new file mode 100644 index 00000000..0b8f20e7 --- /dev/null +++ b/doc/plans/Innovation-1.3-measured-identity.md @@ -0,0 +1,157 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Hash sources](#sources) +4. [4. eBPF event shape](#ebpf) +5. [5. Rule schema](#rule) +6. [6. Matcher](#matcher) +7. [7. Hash enrollment tool](#enroll) +8. [8. Rollout](#rollout) +9. [9. Tests](#tests) +10. [10. Risks](#risks) +11. [11. Milestones](#milestones) + +**GPA** · **Direction 1.3** · **Identity** + +# Detailed Design — Measured Caller Identity + +Replace path-string identity matching with a kernel-measured binary hash (IMA / fs-verity on Linux; code-integrity hash on Windows) so that bind-mount tricks, symlinks, and renamed exploits cannot impersonate allow-listed binaries. + +**Files affected:** `linux-ebpf/ebpf_cgroup.c`, `ebpf/redirect.bpf.c`, `proxy_agent/src/redirector/`, `proxy_agent/src/key_keeper/key.rs`, `proxy_agent/src/proxy/authorization_rules.rs`. + +> **Prerequisites:** [1.2 vTPM sealing](Innovation-1.2-vtpm-sealing.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|------------------------|------------|---------------------------|------------------| +| **High** kills C3 / D2 | **Medium** | **Kernel feature matrix** | **agent + eBPF** | + +### Goals + +- Identity rules match the binary that ran, not a filesystem path the caller can control. +- Bind-mount `/proc/self/exe` (pentest `C3`) and symlink-as-allowed-binary (pentest `D2`) both fail. +- Path rules continue to work for back-compat; hash rules are opt-in per identity. + +### Non-goals + +- Full TCB attestation of the executing process — that needs IPE/eBPF-LSM and is broader scope. +- Hash-based identity for scripts (the interpreter is what matters; document this). + +## 2. Today's Behavior + +The redirector reads the caller's executable via `/proc//exe` (Linux) or `NtQueryInformationProcess` (Windows) and reports the textual path as `processFullPath`. The rule engine compares this path string against `Identity::exePath`. Both ends can be spoofed in a user namespace by a non-root attacker with mount privileges in their own ns. + +## 3. Hash Sources by Platform + +| Platform | Source | Notes | +|---------------------|----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| Linux (modern) | **fs-verity** root hash via `FS_IOC_MEASURE_VERITY` or `ima_file_hash` kfunc | Available on ext4/btrfs/f2fs with kernel ≥ 5.4; root hash is signed and cannot be modified. | +| Linux (fallback) | **IMA-Measurement** from `/sys/kernel/security/ima/ascii_runtime_measurements` | Requires `ima_policy=tcb`; agent reads at process exec via kprobe. | +| Linux (last resort) | SHA-256 of mmap'd file from kernel side via bpf helper `bpf_d_path` + read | More CPU; mark as "advisory" in rule. | +| Windows | **Code Integrity** Authenticode hash from `NtQuerySystemInformation(SystemModuleInformationEx)` or WDAC policy cache | Already computed by CI; reuse. | + +## 4. eBPF Event Shape + +Extend the audit map value to carry the measurement: + + // linux-ebpf/audit_event.h (shared with userspace via libbpf) + struct gpa_audit_event { + __u64 cgroup_id; + __u32 pid; + __u64 pid_starttime_ns; + __u32 uid; + __u32 gid; + __u32 measurement_kind; // 0=none, 1=fs-verity, 2=ima, 3=fallback-sha256 + __u8 measurement[32]; // sha256 truncated to 32 bytes (fs-verity uses root hash) + char exe_path[256]; // kept for diagnostics, NEVER used for matching when measurement_kind != 0 + }; + +The collector populates `measurement` in-kernel for fs-verity (single ioctl-equivalent), and lazily for IMA paths (cache by cgroup+pid_starttime). + +## 5. Rule Schema + + // JSON + "identities": [ + { + "name": "walinuxagent", + "userName": "root", + "exePath": "/usr/sbin/walinuxagent", // legacy, advisory + "exeMeasurement": { + "kind": "fs-verity|ima|sha256", + "value": "0x9f8a...", // hex sha256 + "enforce": true // when true, path mismatch -> reject + } + } + ] + +### 5.1 Compatibility + +- Rules without `exeMeasurement` behave as today. +- If `exeMeasurement.enforce == true` and the caller has no measurement available, identity does *not* match (fail-closed). +- `enforce=false` is "audit only": agent logs measurement mismatch but still applies path-based decision. + +## 6. Matcher Changes + + impl Identity { + pub fn is_match(&self, logger: &mut ConnectionLogger, claims: &Claims) -> bool { + // existing user/group/processName checks ... + + match (&self.exeMeasurement, &claims.exe_measurement) { + (Some(rule_m), Some(claim_m)) if rule_m.kind == claim_m.kind => { + if !constant_time_eq(&rule_m.value, &claim_m.value) { + logger.warn("measurement mismatch"); + return false; + } + } + (Some(rule_m), _) if rule_m.enforce => { + logger.warn("measurement required but unavailable"); + return false; // fail-closed + } + _ => {} // advisory mode or no measurement rule + } + + // existing exePath fallback ... + } + } + +## 7. Hash Enrollment Tool + +New CLI: `gpa identity hash `. + +- Detects available measurement source (fs-verity enabled? IMA active? otherwise sha256). +- Prints a ready-to-paste JSON snippet for the rules file. +- `--enable-verity` flag enables fs-verity on the file (`FS_IOC_ENABLE_VERITY`) if FS supports it. +- Batch mode for enumerating allow-listed extension handlers during package build. + +## 8. Rollout + +1. Ship eBPF + claims plumbing, no rule changes. Audit-log measurement values only. +2. Customers add `exeMeasurement.enforce=false` entries; observe divergence in logs. +3. Flip to `enforce=true` per rule when customers are ready. +4. Document fs-verity prerequisites and provide enable scripts for standard images. + +## 9. Tests + +- Bind-mount a copy of `/bin/cat` over `/usr/sbin/walinuxagent` path → measurement mismatch → deny (pentest `C3`). +- Symlink an allowed binary → fs-verity / IMA root hash differs → deny (pentest `D2`). +- Disable IMA, no fs-verity → with `enforce=true`, deny; with `enforce=false`, allow + audit warning. +- Property test: scriptable interpreter (`python /opt/foo.py`) reports interpreter hash, not script — assert documentation explicit. + +## 10. Risks + +- **fs-verity coverage is partial.** Mitigation: tooling to enable per file; document IMA fallback. +- **Updates flip the hash.** Mitigation: rule schema accepts `"value": ["hash_a", "hash_b"]` list during rolling upgrade windows. +- **Interpreter scripts.** Document — hash is over the interpreter; identify scripts by additional `cmdline` match if needed. + +## 11. Milestones + +| M | Deliverable | Exit | +|-----|-----------------------------------------------------------------------|------------------------------------------------------------------| +| M1 | Extend eBPF audit event + claims | Measurements visible in connection log; no rule semantics change | +| M2 | Rule schema + matcher | Round-trip tests for advisory mode | +| M3 | `gpa identity hash` CLI | Shipped in setup package | +| M4 | Enforce-mode for first-party rules (walinuxagent, host-side handlers) | Pentest C3, D2 PASS | + +Detail design for direction 1.3. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-1.4-capability-scopes.md b/doc/plans/Innovation-1.4-capability-scopes.md new file mode 100644 index 00000000..f07df5f2 --- /dev/null +++ b/doc/plans/Innovation-1.4-capability-scopes.md @@ -0,0 +1,144 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Scope model](#model) +3. [3. URL classifier](#classifier) +4. [4. Rule schema](#schema) +5. [5. Evaluation](#eval) +6. [6. Static analysis](#analysis) +7. [7. Migration](#migration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 1.4** · **AuthZ** + +# Detailed Design — Capability-style Scoped Grants + +Move from "path X is allowed for identity Y" to verifiable, typed scopes (e.g. `imds:identity:read`). A classifier maps each request to a typed `(Action, Resource)` pair; the matcher just checks scope containment. + +**Files affected:** new `proxy_agent/src/proxy/scope/` module, integrates with the canonical request model (2.1). + +> **Prerequisites:** [2.1 Canonical request](Innovation-2.1-canonical-request.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|--------------------------------|------------|------------------------|----------------| +| **High** enables analyzability | **Medium** | **Low** additive layer | **agent only** | + +### Goals + +- Decouple "what the customer wants to allow" from "how the URL happens to be spelled." +- Make rules statically analyzable: "does any rule grant unauthenticated WireServer write?" +- Eliminate URL-encoding bypass categories because the classifier normalizes once per endpoint. + +## 2. Scope Model + + // proxy_agent/src/proxy/scope/mod.rs + pub struct Scope { + pub service: ServiceId, // imds | wireserver | hostga + pub resource: ResourceId, // instance | identity | goalstate | extensions | ... + pub action: ActionId, // read | write | invoke | enumerate + pub qualifier: Option, // e.g. tenant id, extension name + } + + impl Scope { + pub fn satisfies(&self, required: &Scope) -> bool; + // exact match, or wildcard semantics: read < write < admin; * matches all. + } + +Wire form: `service:resource:action[:qualifier]` e.g. `imds:identity:read`, `wireserver:goalstate:read`, `hostga:extensions:status:write:GuestProxyAgent`. + +## 3. URL Classifier + +A single table maps `(Destination, CanonicalRequest)` → required `Scope`. Built from the canonical model (2.1) so the matcher never re-parses strings. + + // proxy_agent/src/proxy/scope/classifier.rs + pub fn required_scope(req: &CanonicalRequest) -> Result; + + // Backing table (compile-time built): + const IMDS_TABLE: &[(&[&str], Method, Scope)] = &[ + (&["metadata","instance"], Method::GET, scope!("imds:instance:read")), + (&["metadata","identity","oauth2","token"], Method::GET, scope!("imds:identity:read")), + (&["metadata","attested","document"], Method::GET, scope!("imds:attested:read")), + // ... + ]; + +### 3.1 Unknown URLs + +- Anything not in the table maps to a synthetic `imds:unknown:read` scope. +- Default rules deny unknown scopes; explicit allow-listing per scope keeps rules small. + +## 4. Rule Schema + + { + "version": 2, + "grants": [ + { + "identity": "walinuxagent", + "scopes": ["wireserver:goalstate:read", "wireserver:extensions:status:write"] + }, + { + "identity": "*", + "scopes": ["imds:instance:read"] + } + ] + } + +Legacy `privileges + roles + assignments` shape is compiled down to capability grants at load time. + +## 5. Evaluation + + fn authorize(req: &CanonicalRequest, caller: &ResolvedIdentity) -> Decision { + let required = classifier::required_scope(req)?; + let granted = caller.scopes(); // pre-computed at rule-load time + if granted.iter().any(|g| g.satisfies(&required)) { + Decision::Allow { matched_scope: required } + } else { + Decision::Deny { required } + } + } + +- O(N_scopes) per request where N_scopes is typically ≤ 10 — much smaller than today's privilege list. +- Match metadata captured in the decision so audit and divergence telemetry can attribute precisely. + +## 6. Static Analysis + +Because scopes are typed and finite, a separate `gpa policy analyze` command can answer: + +- "Which identities can write to WireServer?" +- "Are there grants for the synthetic `*:unknown:*` scope?" +- "Which scopes are unreachable given the URL classifier?" +- "Diff between current and proposed rule files in scope-space, not text-space." + +## 7. Migration + +1. **Phase A:** ship classifier + scope evaluator behind feature flag; dual-evaluate (legacy + scope), log divergences (re-use the same shadow-mode plumbing as 2.1). +2. **Phase B:** tool to auto-convert legacy `privileges` to scope grants; require human review of conversions. +3. **Phase C:** flip enforcement to scopes; keep legacy adapter for one release. +4. **Phase D:** delete legacy path. + +## 8. Tests + +- Property test: every canonical request produces exactly one required scope (totality). +- Golden vectors: every documented IMDS / WireServer endpoint has a stable scope mapping pinned in tests. +- Differential test: scope-evaluator decision == legacy decision for every request in a captured production trace. +- Pentest re-runs: `D1`/`C7` bypasses produce the same scope as the canonical form, so they cannot escape via spelling tricks. + +## 9. Risks + +- **Classifier table drift** when IMDS adds endpoints. Mitigation: scope mapping ships with the agent; an unknown URL falls into `:unknown:` and is denied by default — fail-closed. +- **Customer rules written in old style.** Mitigation: dual-run for one release; provide auto-converter. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|-------------------------------------------|----------------------------------------------| +| M1 | Scope + classifier types + table for IMDS | Unit tests green for IMDS endpoints | +| M2 | Table for WireServer + HostGAPlugin | Full endpoint coverage doc reviewed | +| M3 | Dual-eval in shadow mode | Zero divergence vs legacy | +| M4 | Auto-converter + enforce | One release in enforce mode without rollback | +| M5 | Legacy removal + `policy analyze` | Codebase reduction recorded | + +Detail design for direction 1.4. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-2.1-canonical-request.md b/doc/plans/Innovation-2.1-canonical-request.md new file mode 100644 index 00000000..7cfb50d0 --- /dev/null +++ b/doc/plans/Innovation-2.1-canonical-request.md @@ -0,0 +1,506 @@ +## Sections + +1. [1. Overview & Goals](#overview) +2. [2. Today's Behavior](#today) +3. [3. Threats & Bypass Patterns](#threats) +4. [4. CanonicalRequest Model](#model) +5. [5. Normalization Pipeline](#pipeline) +6. [6. Public API & Rust Sketch](#api) +7. [7. Error Taxonomy & Fail-Closed](#errors) +8. [8. Integration Points](#integration) +9. [9. Shadow-Mode Rollout](#shadow) +10. [10. Test Strategy](#tests) +11. [11. Performance Budget](#perf) +12. [12. Telemetry & Observability](#telemetry) +13. [13. Risks & Open Questions](#risks) +14. [14. Milestones](#milestones) +15. [Appendix A — Vector Table](#appendix) + +**GPA** · **Direction 2.1** · **Security-critical refactor** + +# Detailed Design — Canonical Request Model + +A single, total, well-tested normalization step shared by rule loading and request matching, designed to eliminate the rule/request asymmetry that produces SSRF-style AuthZ bypasses. + +**Primary files affected:** `proxy_agent/src/proxy/authorization_rules.rs`, `proxy_agent/src/key_keeper/key.rs`, new module `proxy_agent/src/proxy/canonical/`. + +> **Prerequisites:** None — foundational request-normalization layer. Required by [1.4](Innovation-1.4-capability-scopes.md), [2.2](Innovation-2.2-typed-policy-cedar.md), [2.4](Innovation-2.4-differential-testing.md), [5.2](Innovation-5.2-gate-more-endpoints.md), [5.3](Innovation-5.3-cross-cloud-port.md), [6.1](Innovation-6.1-policy-simulator.md). + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|-------------------------------|-------------------------|-----------------------------|----------------| +| **High** closes a vuln family | **Medium** ~2–3 sprints | **Low** shadow-mode rollout | **agent only** | + +### 1.1 Problem statement + +Today the rule-matching pipeline performs ad-hoc, partial normalization in *two different places* — once when rules are loaded and once when requests are matched. The two normalizations are not byte-identical, which creates a class of bypass where the attacker crafts a URL that the agent considers different from the rule pattern but that the upstream metadata service treats as semantically equivalent. + +### 1.2 Goals + +- **One normalizer, one type.** Both rules and requests are reduced to a single canonical form (`CanonicalRequest`) before they ever meet the matcher. +- **Total function with explicit failure.** The normalizer either returns a fully-canonical value or a typed error; the matcher never sees ambiguous input. +- **Fail-closed semantics.** Any normalization error denies the request and logs a structured event. +- **Byte-stable output.** Round-tripping a canonical form through the normalizer yields the same bytes (idempotent). This is the property property-tests will enforce. +- **Zero behavior change at cutover.** Shadow-mode dual-evaluation must show 0 divergences for N days before flipping enforcement. + +### 1.3 Non-goals + +- Replacing the policy language itself (that is Direction 2.2 — Cedar). +- Identity normalization for users/processes (Direction 1.3 — measured identity). +- Changing the on-wire request format sent upstream. We canonicalize for *matching*; the request forwarded to IMDS / WireServer is the original. + +## 2. Today's Behavior (and why it's fragile) + +### 2.1 Normalization in `authorization_rules.rs` + +At rule load time (`ComputedAuthorizationItem::from_authorization_item`), each privilege's path and query parameters are lowercased: + + let normalized = Privilege { + name: privilege.name, + path: privilege.path.to_lowercase(), + queryParameters: privilege.queryParameters.map(|qp| { + qp.into_iter() + .map(|(k, v)| (k.to_lowercase(), v.to_lowercase())) + .collect() + }), + }; + +At request time (`ComputedAuthorizationItem::is_allowed`), the request URL is percent-decoded once then lowercased: + + let decoded_path = percent_encoding::percent_decode_str(request_url.path()) + .decode_utf8_lossy(); + let lowered_request_path = decoded_path.to_lowercase(); + +The actual match (`Privilege::is_match` in `key.rs`) does: + +- `actual_path.starts_with(&self.path)` +- splits on `?` in the *decoded* path to harvest extra query pairs (handles `%3F` trick) +- compares query parameters case-insensitively with one more percent-decode on the key + +### 2.2 The asymmetries + +| Step | Rule side | Request side | Risk | +|-------------------------------------|--------------------------|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| Percent decoding | Not applied to rule path | Applied once to request path | A rule containing `%2F` by accident becomes unreachable; a request can introduce decoded characters the rule author did not anticipate. | +| Path segment collapsing (`..`, `.`) | None | None | `/metadata/identity/../identity/oauth2/token` bypasses a deny on `/metadata/identity/oauth2`. | +| Trailing slash | Author-controlled | Author-controlled | Prefix `starts_with` means rule `/metadata` matches `/metadata-attacker`. | +| Matrix params `;foo=bar` | Not handled | Not handled | Some HTTP stacks strip them, some don't. | +| Host normalization | N/A (no host in rule) | N/A | Pentest C7: `0xa9fea9fe`, `2852039166`, `[::ffff:169.254.169.254]` reach the same IMDS. | +| UTF-8 validity | Assumed | `decode_utf8_lossy` silently replaces | Lossy substitution may yield matches the rule author didn't intend. | +| Query key/value decoding | Lowercased only | Decoded again at match time | Double-encoding (`%2525`) yields different views. | + +## 3. Threats & Bypass Patterns + +The canonical model targets, at minimum, every pattern in pentest scenarios `D1` and `C7`. + +### 3.1 URL-encoding differentials (pentest D1) + +- `%2F` vs `/`, mixed case `%2f`. +- Double-encoding: `%252e%252e`. +- Overlong UTF-8 for `/`. +- Semicolon matrix params on path segments. +- Trailing dot or whitespace in path. +- Embedded `?` via `%3F` that re-introduces query parameters into the path string. + +### 3.2 Host-form differentials (pentest C7) + +- IPv4 dotted: `169.254.169.254` +- IPv4 decimal: `2852039166` +- IPv4 hex: `0xa9fea9fe` +- IPv4 octal: `0251.0376.0251.0376` +- IPv4-mapped IPv6: `[::ffff:169.254.169.254]`, `[::ffff:a9fe:a9fe]` +- Uppercased hostnames, trailing dots: `METADATA.azure.internal.` +- Userinfo smuggling: `http://attacker@169.254.169.254/` +- Port-form smuggling: `http://169.254.169.254:80@evil/` + +### 3.3 Header / line smuggling (out of scope, but related) + +Request smuggling at the HTTP framing layer is handled separately by Hyper config + pentest A3/A4. The canonical model assumes Hyper produced a well-formed `hyper::Uri`. + +## 4. The CanonicalRequest Model + +### 4.1 Type + + // proxy_agent/src/proxy/canonical/mod.rs + #[derive(Clone, Debug, PartialEq, Eq, Hash)] + pub struct CanonicalRequest { + /// HTTP method, uppercased ASCII (GET, POST, ...). + pub method: Method, + + /// Canonical destination (already classified as one of GPA's known endpoints). + pub destination: Destination, + + /// Canonical path segments: percent-decoded, NFC-normalized, lowercased, + /// with `.` collapsed, `..` resolved against earlier segments, matrix params stripped. + /// Always begins with the empty root segment; never contains empty segments + /// except the final one when the original ended with `/`. + pub path_segments: Vec, + + /// Whether the original path had a trailing slash (preserved as a single bit so + /// rules can opt to be slash-sensitive without re-introducing string-level asymmetry). + pub trailing_slash: bool, + + /// Query parameters in a canonical multi-map form: keys lowercased + decoded once, + /// values decoded once, preserved order is not significant (BTreeMap of Vec). + pub query: BTreeMap>, + } + + #[derive(Clone, Debug, PartialEq, Eq, Hash)] + pub enum Destination { + Imds, // 169.254.169.254 in any encoding, port 80 + WireServer, // 168.63.129.16:80 + HostGaPlugin, // 168.63.129.16:32526 + Unknown { // anything else; matcher will deny unless an explicit rule allows + family: AddrFamily, + ip: IpAddr, + port: u16, + host_text: Option, // original host text for audit, never used for matching + }, + } + +### 4.2 Invariants (checked by debug asserts and property tests) + +- **Idempotent:** `canonicalize(canonicalize(x)) == canonicalize(x)`. +- **Total:** for every `hyper::Uri`, the function returns either `Ok(CanonicalRequest)` or a typed `CanonError`; it never panics. +- **Round-trip-stable rendering:** a debug `Display` impl produces a string that, when re-parsed and canonicalized, yields the same value. +- **UTF-8 strict:** invalid UTF-8 in path or query is an error, not a lossy replacement. +- **No host text in matching:** the matcher only sees the typed `Destination` enum, never the raw host string. + +## 5. The Normalization Pipeline + +Each step is a small pure function with its own unit tests. + +hyper::Uri │ ▼ parse_scheme_method (must be http; reject https/ws/...; method allow-list) │ ▼ classify_destination (IP/host -\> Destination enum; covers numeric forms) │ ▼ validate_userinfo (must be empty; reject \`user@host\`) │ ▼ decode_path_once (single percent-decode; reject malformed %XY; reject overlong UTF-8) │ ▼ reject_control_chars (no CR/LF/NUL/HTAB after decode) │ ▼ nfc_normalize (Unicode NFC) │ ▼ ascii_lowercase_path (path is matched case-insensitively) │ ▼ split_segments (split on '/'; collapse \`.\`; resolve \`..\`; error on underflow) │ ▼ strip_matrix_params (drop \`;k=v\` suffix on each segment, preserve segment text only) │ ▼ decode_query_once (k/v percent-decode once; error on malformed; lowercase keys) │ ▼ reject_embedded_query (if decoded path now contains '?' -\> error: ambiguous) │ ▼ fold_into_btreemap (group by key; values preserve insertion order within a key) │ ▼ CanonicalRequest + +### 5.1 Step details + +#### 5.1.1 `classify_destination` + +- If host is a bracketed IPv6, parse with `std::net::Ipv6Addr`; if it is IPv4-mapped, project to IPv4 and continue. +- If host parses as `Ipv4Addr` directly, use it. +- Else attempt the historic numeric forms manually: dotted-quad with any base per octet (octal-leading-zero, hex-leading-`0x`, plain decimal), and 32-bit packed forms. A small dedicated parser, not `inet_aton`, because `inet_aton` behavior is libc-dependent. +- Map the resolved IP+port to `Destination` via a constant table; unknown destinations land in `Destination::Unknown`. +- Hostnames that are not IPs (e.g. `metadata.azure.internal`) are *not* resolved at this layer — DNS is a confused-deputy surface. They are returned as `Unknown { host_text: Some(...) }` and require an explicit allow rule keyed on host text. + +#### 5.1.2 `decode_path_once` + +- One pass of percent decoding. A second pass is never attempted — that is exactly the asymmetry we want to remove. +- Malformed sequences (`%2`, `%ZZ`) → `CanonError::MalformedPercent`. +- Detect overlong UTF-8 encodings of ASCII (e.g. `%C0%AF` for `/`) → `CanonError::OverlongUtf8`. + +#### 5.1.3 `split_segments` + dot-segment resolution (RFC 3986 §5.2.4) + +- Empty segments collapsed (treat `//` as `/`). +- `.` dropped. +- `..` pops the previous segment; popping past root is an error (`CanonError::PathUnderflow`) rather than a no-op, because a real client would never produce it. + +#### 5.1.4 `strip_matrix_params` + +- For each segment, drop everything after the first `;`. +- Document this clearly: matrix params are **never** used in authorization decisions. + +#### 5.1.5 `reject_embedded_query` + +- If the decoded-and-rebuilt path contains a literal `?`, the request is ambiguous: an attacker may have used `%3F` to smuggle query into the path. Today's matcher tries to rescue this; the new model rejects it as an error and logs. + +## 6. Public API & Rust Sketch + +### 6.1 Module layout + + proxy_agent/src/proxy/canonical/ + ├── mod.rs // CanonicalRequest, Destination, CanonError, canonicalize() + ├── destination.rs // IP/host classification + numeric-form parser + ├── path.rs // decode + dot-segment + matrix-strip + ├── query.rs // decode + fold into BTreeMap + ├── rule.rs // canonicalize a rule pattern into CanonicalPattern + └── tests/ + ├── vectors.rs // 300+ golden vectors from pentest D1 / C7 + ├── proptests.rs // proptest invariants + └── differential.rs // dual-evaluate against legacy matcher in shadow mode + +### 6.2 Public surface + + pub fn canonicalize(uri: &hyper::Uri, method: &hyper::Method) + -> Result; + + /// Canonical form of a rule pattern. Same pipeline, but path segments may end + /// in a "*" sentinel to mark prefix match, and `Destination` may be `Any` for + /// rules that intentionally span endpoints. + pub struct CanonicalPattern { /* ... */ } + + pub fn canonicalize_pattern(raw: &RawPrivilege) -> Result; + + /// Matching is now a pure structural comparison on canonical forms. + impl CanonicalPattern { + pub fn matches(&self, req: &CanonicalRequest) -> bool; + } + +### 6.3 Error type + + #[derive(Debug, thiserror::Error)] + pub enum CanonError { + #[error("scheme not http")] SchemeNotHttp, + #[error("method not allowed")] MethodNotAllowed, + #[error("userinfo present in URL")] UserinfoPresent, + #[error("malformed percent-encoding")] MalformedPercent, + #[error("overlong UTF-8 in path/query")] OverlongUtf8, + #[error("invalid UTF-8 in path/query")] InvalidUtf8, + #[error("control character in path/query")]ControlChar, + #[error("path traversal past root")] PathUnderflow, + #[error("embedded '?' after decoding")] EmbeddedQuery, + #[error("unparseable host")] BadHost, + #[error("unparseable port")] BadPort, + } + + impl CanonError { + /// All variants are fail-closed; this is here so callers can record a + /// stable string for telemetry / pentest assertions. + pub fn code(&self) -> &'static str { /* ... */ } + } + +### 6.4 Matcher call site (after the change) + + // Replaces ComputedAuthorizationItem::is_allowed's URL handling. + let canon = match canonical::canonicalize(&request.uri(), request.method()) { + Ok(c) => c, + Err(e) => { + logger.write(LoggerLevel::Warn, + format!("Canonicalization failed: {} ({})", e, e.code())); + return false; // fail-closed + } + }; + for pattern in self.compiled_patterns.iter() { + if pattern.matches(&canon) { /* identity check ... */ } + } + +## 7. Error Taxonomy & Fail-Closed Semantics + +| Error | Likely cause | Action | Audit event code | +|--------------------|---------------------------------------|-----------|-------------------| +| `SchemeNotHttp` | WS upgrade probe (pentest A4) | Deny; 405 | `CANON_SCHEME` | +| `MethodNotAllowed` | CONNECT / TRACE | Deny; 405 | `CANON_METHOD` | +| `UserinfoPresent` | Host smuggling attempt | Deny; 400 | `CANON_USERINFO` | +| `MalformedPercent` | Truncated / non-hex `%XX` | Deny; 400 | `CANON_PCT` | +| `OverlongUtf8` | Classic IDS-bypass payload | Deny; 400 | `CANON_OVERLONG` | +| `InvalidUtf8` | Random bytes or wrong codec | Deny; 400 | `CANON_UTF8` | +| `ControlChar` | CRLF injection attempt | Deny; 400 | `CANON_CTRL` | +| `PathUnderflow` | Too many `..` | Deny; 400 | `CANON_UNDERFLOW` | +| `EmbeddedQuery` | `%3F` smuggling | Deny; 400 | `CANON_EMBQ` | +| `BadHost` | Mixed numeric forms that fail parsing | Deny; 400 | `CANON_HOST` | +| `BadPort` | Out-of-range port | Deny; 400 | `CANON_PORT` | + +**Fail-closed rule:** every `CanonError` path returns `false` from the matcher and emits a structured audit entry that includes the error code, the original (redacted) URL, the caller cgroup id, and the active `policy_epoch`. There is no "best effort" branch. + +## 8. Integration Points + +| File | Today | After change | +|------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------| +| `proxy_agent/src/proxy/authorization_rules.rs` | Lowercases privilege path/query during `from_authorization_item`; percent-decodes request path during `is_allowed`. | Calls `canonical::canonicalize_pattern` at load time, `canonical::canonicalize` at request time; *no* ad-hoc string ops. | +| `proxy_agent/src/key_keeper/key.rs` | `Privilege::is_match` does `starts_with` + embedded-query rescue + per-key percent-decode. | Method removed (or wraps `CanonicalPattern::matches` for back-compat callers). | +| `proxy_agent/src/proxy/proxy_authorizer.rs` | Calls `is_allowed(uri, claims)`. | Calls `canonicalize` once, then `is_allowed(canon, claims)`; logs `CanonError` on failure. | +| `proxy_agent/src/proxy/proxy_server.rs` | Hands raw `Uri` down. | Unchanged; canonical form is computed once inside the authorizer and cached on the connection context. | +| `proxy_agent/src/key_keeper/local_rules.rs` | Lowercases rule fields during merge. | Calls `canonicalize_pattern`; rejects rules that fail canonicalization (fail-closed). | + +## 9. Shadow-Mode Rollout + +The canonicalizer ships before the matcher cuts over. + +### 9.1 Mode flag + + // In GuestProxyAgent.linux.json / .windows.json + "canonicalRequest": { + "mode": "shadow" // "off" | "shadow" | "enforce" + } + +- **off** — legacy path only (default in first release). +- **shadow** — legacy decides; canonical runs in parallel; divergences logged. +- **enforce** — canonical decides; legacy still computes for divergence telemetry. + +### 9.2 Divergence record + + { + "ts": "2026-06-01T12:34:56Z", + "policy_epoch": 174, + "request_uri_redacted": "/metadata/identity/oauth2/token?api-version=2018-02-01", + "legacy_decision": "allow", + "canon_decision": "deny", + "canon_error": null, + "matched_rule_id": "imds.identity.read", + "caller_cgroup": "/sys/fs/cgroup/system.slice/walinuxagent.service", + "delta_reason": "trailing_slash_difference" + } + +### 9.3 Cutover criteria + +- ≥ 14 consecutive days with zero divergences across the production fleet sample. +- All pentest D1 and C7 vectors PASS in enforcement mode in the CI canary. +- p99 added latency \< 100 µs (measured during shadow mode). +- One full release in **shadow** behind a feature flag before any region flips to **enforce**. + +## 10. Test Strategy + +### 10.1 Golden vectors + +A frozen table of `(input_uri, expected_canonical | expected_error)`. The seed set comes from: + +- Every `D1` and `C7` case in `pentest/linux/DESIGN.md`. +- OWASP URL-canonicalization corpus. +- Hand-curated IMDS / WireServer real-world URLs harvested from production logs (redacted). + +### 10.2 Property tests (`proptest`) + + proptest! { + #[test] + fn idempotent(uri in any_uri()) { + if let Ok(c1) = canonicalize_uri(&uri) { + let c2 = canonicalize_uri(&c1.render()).unwrap(); + prop_assert_eq!(c1, c2); + } + } + + #[test] + fn no_panics(uri_bytes in any::>()) { + let _ = std::panic::catch_unwind(|| { + if let Ok(uri) = hyper::Uri::try_from(uri_bytes) { + let _ = canonicalize(&uri, &hyper::Method::GET); + } + }).unwrap(); + } + + #[test] + fn host_form_equivalence(ip in any::()) { + let dotted = format!("http://{}/x", ip); + let decimal = format!("http://{}/x", u32::from(ip)); + let hex = format!("http://0x{:x}/x", u32::from(ip)); + prop_assert_eq!( + canonicalize_str(&dotted).map(|c| c.destination), + canonicalize_str(&decimal).map(|c| c.destination), + ); + prop_assert_eq!( + canonicalize_str(&dotted).map(|c| c.destination), + canonicalize_str(&hex).map(|c| c.destination), + ); + } + } + +### 10.3 Differential test against legacy matcher + +Bound the legacy matcher and the new canonical matcher to the same rule set and the same request stream (harvested from production logs). Any divergence is a CI failure during the enforce-prep window. + +### 10.4 Fuzzing + +- `cargo fuzz` target on `canonicalize(bytes)` — must never panic. +- Second target on `CanonicalPattern::matches` — must never panic; pattern produced from random rule JSON. +- Run for ≥ 1 CPU-day before each release; record corpora in `proxy_agent/src/proxy/canonical/tests/corpus/`. + +### 10.5 Pentest re-runs + +Add a new pentest phase in `pentest/linux/phase4_rules_fuzz/`: + +- **S20** — every D1 vector must return identical decisions in legacy and canonical modes (or canonical-strictly-stricter). +- **S21** — every C7 host form must resolve to the same `Destination` as the dotted form. +- **S22** — invalid UTF-8, overlong UTF-8, and embedded `?` must produce `CanonError` and a deny. + +## 11. Performance Budget + +| Operation | Target p50 | Target p99 | Notes | +|-----------------------------------|------------|------------|------------------------------------------------------------------------------| +| `canonicalize` (typical IMDS GET) | ≤ 5 µs | ≤ 30 µs | One pass each over path and query; no allocations beyond small Vec/BTreeMap. | +| `CanonicalPattern::matches` | ≤ 1 µs | ≤ 5 µs | Slice-equality over pre-sized segment vec. | +| Total added latency vs legacy | — | ≤ 100 µs | Measured end-to-end during shadow mode. | + +### 11.1 Allocation strategy + +- Use `SmallVec<[Cow<'a, str>; 8]>` for path segments; most IMDS paths are ≤ 6 segments. +- Borrow from the source `Uri` wherever the decode is a no-op (no `%` in the segment). +- BTreeMap is acceptable here because query maps are tiny (typical: 1–3 keys); benchmark before optimizing. + +### 11.2 Hot path caching + +- Compiled patterns are stored once at rule load, swapped via `arc_swap::ArcSwap>` (this also satisfies the TOCTOU concern from pentest `D5`). + +## 12. Telemetry & Observability + +### 12.1 Metrics + +- `gpa_canon_calls_total{result="ok|error"}` +- `gpa_canon_errors_total{code="CANON_PCT|CANON_OVERLONG|..."}` +- `gpa_canon_divergence_total{reason="trailing_slash|embedded_query|host_form|..."}` (shadow mode only) +- `gpa_canon_latency_microseconds` (histogram) + +### 12.2 Audit log fields + +New fields appended to each entry in `ProxyAgent.Connection.log`: + +- `canon_path` — rendered canonical path (redacted: identifiers replaced with placeholder). +- `canon_dest` — `imds|wireserver|hostga|unknown`. +- `canon_error` — error code or null. +- `policy_epoch` — snapshot id used for this request. + +### 12.3 Operator-visible signal + +A non-zero divergence rate after the first week of shadow mode is the single most important signal: it directly identifies rules whose authors implicitly relied on the legacy normalization quirks. Surface this in `gpa-doctor` (Direction 6.2) so operators can fix their rules *before* enforce mode is enabled. + +## 13. Risks & Open Questions + +### 13.1 Risks + +- **Existing rules may rely on quirks.** Mitigation: shadow mode + divergence reporting + a one-release overlap period. +- **Hostname rules.** If a customer has a rule keyed on a hostname rather than an IP, our refusal to DNS-resolve at the matcher means the rule will only match if the client also uses that exact hostname text. Document clearly; provide a migration tool. +- **IPv6 link-local zone IDs** (`fe80::1%eth0`) — decide whether to strip or reject; current proposal is to reject (fail-closed). +- **Performance regression** on tiny VMs with high IMDS QPS. Mitigation: benchmark suite gated in CI; SmallVec; borrow-when-possible decoding. + +### 13.2 Open questions + +1. Should `CanonicalPattern` support glob/regex on segments, or only exact + prefix? Recommendation: exact + prefix only; richer matching is the policy-language work in Direction 2.2. +2. Do we expose the canonical form on `/.well-known/gpa/attestation` (Direction 3.3) for diagnostic use? Recommendation: yes, but redact identifiers. +3. For unknown destinations, do we ever forward, or strictly deny? Current proposal: strictly deny. + +## 14. Milestones + +| M | Deliverable | Exit criteria | +|-----|--------------------------------------------------------------------|-------------------------------------------------------------------------------------| +| M1 | Module skeleton + types + error taxonomy | Compiles; unit tests for each helper at \> 90% line coverage | +| M2 | Golden vectors + property tests + fuzz target | Zero panics in 1 CPU-day of fuzzing; all D1/C7 vectors pass | +| M3 | Shadow-mode integration in `proxy_authorizer.rs` | Divergence telemetry visible in dev/test; behavior unchanged for production traffic | +| M4 | Rule-loader uses canonical patterns; legacy `Privilege` deprecated | All existing rule files still load; old API marked `#[deprecated]` | +| M5 | Region-by-region cutover to enforce mode | Zero divergence for 14 days per region; pentest S20–S22 pass | +| M6 | Removal of legacy matcher | All call sites migrated; legacy code deleted; codebase reduction recorded | + +## AAppendix — Representative Vector Table + +A sample of the golden vectors. The full table lives in `proxy_agent/src/proxy/canonical/tests/vectors.rs`. + +### A.1 Path vectors + +| Input path | Canonical | Or error | +|-----------------------------------------|------------------------|------------------------| +| `/metadata/identity` | `/metadata/identity` | — | +| `/Metadata/Identity` | `/metadata/identity` | — | +| `/metadata//identity` | `/metadata/identity` | — | +| `/metadata/./identity` | `/metadata/identity` | — | +| `/metadata/x/../identity` | `/metadata/identity` | — | +| `/metadata%2Fidentity` | `/metadata/identity` | — | +| `/metadata%252Fidentity` | `/metadata%2fidentity` | — (single decode only) | +| `/metadata/%C0%AFidentity` | — | `OverlongUtf8` | +| `/metadata/identity/../../..` | — | `PathUnderflow` | +| `/metadata/identity;jsessionid=abc` | `/metadata/identity` | — | +| `/metadata/identity%3Fapi-version=2018` | — | `EmbeddedQuery` | +| `/metadata/identity%0A` | — | `ControlChar` | + +### A.2 Host vectors (all should map to `Destination::Imds`) + +| Host text | Result | +|-------------------------------|-------------------------------------------------| +| `169.254.169.254` | `Imds` | +| `2852039166` | `Imds` | +| `0xa9fea9fe` | `Imds` | +| `0251.0376.0251.0376` | `Imds` | +| `[::ffff:169.254.169.254]` | `Imds` | +| `[::ffff:a9fe:a9fe]` | `Imds` | +| `user@169.254.169.254` | `CanonError::UserinfoPresent` | +| `169.254.169.254:80@evil.com` | `CanonError::BadHost` | +| `metadata.azure.internal` | `Destination::Unknown { host_text: Some(...) }` | + +Detailed design for direction 2.1 of the GPA innovation plan. Parent doc: [Innovation-Directions.md](Innovation-Directions.md). Source-of-truth files: `proxy_agent/src/proxy/authorization_rules.rs`, `proxy_agent/src/key_keeper/key.rs`, `pentest/linux/DESIGN.md`. diff --git a/doc/plans/Innovation-2.2-typed-policy-cedar.md b/doc/plans/Innovation-2.2-typed-policy-cedar.md new file mode 100644 index 00000000..58fc1a50 --- /dev/null +++ b/doc/plans/Innovation-2.2-typed-policy-cedar.md @@ -0,0 +1,137 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Why Cedar](#why) +3. [3. Entity model](#model) +4. [4. Policy form](#policy) +5. [5. Compile pipeline](#compile) +6. [6. Integration](#integration) +7. [7. Dual-eval](#dual) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 2.2** · **Policy** + +# Detailed Design — Typed Policy Language (Cedar) + +Replace the ad-hoc JSON rule shape with Cedar, a typed, analyzable, verified-evaluator policy language. Existing rules compile down to Cedar at load time; the matcher becomes a thin call into the Cedar evaluator over `CanonicalRequest`-derived entities. + +**Files affected:** new `proxy_agent/src/proxy/policy/` module (Cedar adapter), integrates with 1.4 scopes and 2.1 canonical model. + +> **Prerequisites:** [2.1 Canonical request](Innovation-2.1-canonical-request.md)[1.4 Capability scopes](Innovation-1.4-capability-scopes.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------------|------------|------------------------|----------------| +| **High** analyzable policy | **Medium** | **Low** shadow rollout | **agent only** | + +### Goals + +- Typed actions and entities — no more string-prefix matching surface. +- Formal analysis: "is policy P at least as strict as policy Q?" (Cedar's policy analyzer). +- Stable, versioned grammar; precise diagnostics on bad rules. +- Drop-in path: existing JSON rules continue to load via a compiler. + +## 2. Why Cedar (vs Rego / OPA / Custom DSL) + +| Option | Pro | Con | +|------------|------------------------------------------------------------------------------------------------------|------------------------------------------------------------| +| **Cedar** | Rust-native crate, verified evaluator (Lean), built-in policy analyzer, deterministic, low footprint | Smaller community than OPA | +| OPA / Rego | Huge ecosystem | Heavyweight runtime, Go dependency, less formal guarantees | +| Custom DSL | Tailored | Reinvents analyzer, parser, fuzz suite — long tail of bugs | + +**Choice:** Cedar (`cedar-policy` crate). It runs in-process, has stable serialized form, and supports schema-based static type checking — directly enabling the analyses described in 1.4. + +## 3. Entity Model + + // Cedar schema (simplified) + entity Identity in [Role] = { + name: String, + userName?: String, + groupName?: String, + exeMeasurement?: { kind: String, value: String, enforce: Bool }, + }; + entity Role = { name: String }; + entity Service { }; // Imds, WireServer, HostGa + entity Resource in [Service] = { service: Service, name: String }; + action read,write,invoke,enumerate appliesTo { + principal: [Identity], + resource: [Resource], + context: { + url: String, // canonical URL hash, not raw + scope: String, // from 1.4 capability classifier + canon: { dest: String, segments: [String], query_keys: [String] } + } + }; + +## 4. Policy Form + + // Allow waagent to read goalstate + permit ( + principal == Identity::"walinuxagent", + action == Action::"read", + resource == Resource::"WireServer::goalstate" + ); + + // Anyone may read instance metadata + permit ( + principal, + action == Action::"read", + resource == Resource::"Imds::instance" + ); + + // Forbid identity matching that requires measurement when measurement missing + forbid (principal, action, resource) + when { + principal.exeMeasurement has "enforce" && + principal.exeMeasurement.enforce && + context.canon.dest != "imds" // example forbid condition + }; + +## 5. Compile Pipeline + +JSON rules (legacy v1) │ ▼ legacy_to_cedar // small translator; 1:1 mapping for permit shapes │ ▼ cedar::PolicySet // typed AST │ ▼ schema_validate // reject if a policy references unknown entities │ ▼ ArcSwap\ + +- Compilation happens off the hot path (rule reload thread). +- Validation errors abort the swap; fail-closed: previous policy stays active. + +## 6. Integration with 1.4 and 2.1 + +- **2.1 Canonical model** provides `CanonicalRequest`. The classifier (1.4) reduces it to a `Scope`. +- Cedar context = `{ url: hash, scope, canon: { dest, segments, query_keys } }`. Policies primarily key on `scope`; the rest is available for advanced rules. +- The evaluator returns `Decision::Allow | Deny` plus the matched policy id (for audit). + +## 7. Dual-Evaluation Rollout + +Same mechanism as direction 2.1: + +- `policy.mode = off | shadow | enforce`. +- In shadow, legacy decides; Cedar's verdict is logged with policy-id reasoning. +- Cutover gate: ≥ 14 days zero divergence in production sample. + +## 8. Tests + +- Round-trip: any legacy rule file → Cedar policy set → produced JSON → same decisions on the request corpus. +- Cedar's own policy analyzer used in CI to verify invariants (e.g. no `permit` for `wireserver:*:write` by `principal: any`). +- Fuzz: random Cedar policies + random canonical requests — evaluator must not panic. +- Property test: enforce decisions monotone in policy strictness ("more strict policy never allows more"). + +## 9. Risks + +- **Crate version churn.** Pin to a Cedar release line; vendor source if needed. +- **Customer-authored Cedar (future).** Out of scope for v1 — only auto-translated policies are accepted; advanced customers go through review. +- **Evaluator cost.** Cedar is fast (microseconds), but bench against the legacy matcher and gate p99. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|--------------------------------------------|----------------------------------------------------------------| +| M1 | Cedar schema + translator for legacy rules | Lossless round-trip on test fixtures | +| M2 | Evaluator integrated behind feature flag | Shadow-mode running in CI | +| M3 | Dual-eval in production canary | Zero divergence 14 days | +| M4 | Enforce mode | Legacy matcher marked `#[deprecated]` | +| M5 | Remove legacy | Codebase reduction recorded; analyzer hooked into `gpa policy` | + +Detail design for direction 2.2. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-2.3-versioned-snapshots.md b/doc/plans/Innovation-2.3-versioned-snapshots.md new file mode 100644 index 00000000..d3d0939f --- /dev/null +++ b/doc/plans/Innovation-2.3-versioned-snapshots.md @@ -0,0 +1,123 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Design](#design) +4. [4. API](#api) +5. [5. Reload protocol](#reload) +6. [6. Audit emission](#audit) +7. [7. Integration](#integration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 2.3** · **Concurrency** + +# Detailed Design — Versioned, Per-Request Policy Snapshots + +Wrap the active policy in `ArcSwap` with a monotonic `epoch`. Each incoming request captures the policy snapshot at accept time and uses it for the whole forwarding decision. Closes pentest `D5` (TOCTOU between rule reload and in-flight request) and gives operators a precise audit trail. + +**Files affected:** `proxy_agent/src/key_keeper/key.rs`, `proxy_agent/src/proxy/proxy_authorizer.rs`, `proxy_agent/src/proxy/proxy_server.rs`, `proxy_agent/src/key_keeper/local_rules.rs`. + +> **Prerequisites:** [2.2 Typed policy (Cedar)](Innovation-2.2-typed-policy-cedar.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------|-----------|---------|----------------| +| **Medium** closes D5 | **Small** | **Low** | **agent only** | + +### Goals + +- A request that started under policy *P_n* finishes under *P_n*, even if reload occurs mid-request. +- Audit log records the exact policy `epoch` that authorized each request. +- Reloads are wait-free for readers; no mutex on the hot path. + +## 2. Today's Behavior + +Rules are stored in shared mutable state. A reload can race with an in-flight authorize call — different parts of the decision can read different versions, and there is no *per-request* identifier of which policy version applied. + +## 3. Design + +### 3.1 Types + + pub struct PolicyEpoch(pub u64); + + pub struct PolicySnapshot { + pub epoch: PolicyEpoch, + pub computed: ComputedAuthorizationRules, + pub source_hash: [u8;32], + pub loaded_at: SystemTime, + } + + pub struct PolicyStore { + inner: arc_swap::ArcSwap, + next_epoch: AtomicU64, + } + + impl PolicyStore { + pub fn current(&self) -> Arc; + pub fn install(&self, computed: ComputedAuthorizationRules, source_hash: [u8;32]) + -> PolicyEpoch; + } + +### 3.2 Invariants + +- `epoch` is monotonically increasing across the agent process lifetime; persists across restart by reading the last `epoch` stamped in the `AuthorizationRules_*.json` file and incrementing. +- Installation is fail-closed: if validation fails, no install occurs and previous snapshot stays active. A counter `gpa_policy_install_failed_total` increments. +- Readers never block writers; writers never block readers. + +## 4. Usage + + // Accept site (proxy_server.rs) + let snap = policy_store.current(); // cheap Arc clone + ctx.policy_snapshot = snap; + ctx.policy_epoch = snap.epoch; + + // Authorizer (proxy_authorizer.rs) + let decision = ctx.policy_snapshot.is_allowed(&canon_req, &claims); + logger.attach_field("policy_epoch", ctx.policy_epoch.0); + +## 5. Reload Protocol + +1. Reload thread fetches new rules (remote + local merge per `local_rules.rs`). +2. Compile to `ComputedAuthorizationRules` (and Cedar policy set when 2.2 lands). +3. Validate (schema + structural). On failure: log + telemetry + leave previous in place. +4. `PolicyStore::install` assigns the next epoch and swaps the Arc. +5. Emit a structured event `PolicyInstalled{epoch, source_hash, loaded_at}`. + +## 6. Audit Emission + +- Every connection log entry gains `policy_epoch`. +- `status.json` reports `active_policy_epoch`, `last_failed_install_at`, `last_failed_install_reason`. +- Telemetry: histogram of `request_age_vs_policy_age_seconds` to detect long-lived connections still bound to ancient snapshots (potentially a sign of upstream hang). + +## 7. Integration Points + +- `proxy_agent/src/key_keeper/key.rs` — replace direct sharing with `Arc`. +- `proxy_agent/src/proxy/proxy_server.rs` — capture snapshot on accept and stash on connection context. +- `proxy_agent/src/proxy/proxy_authorizer.rs` — read from context, not global. +- `proxy_agent/src/proxy/proxy_summary.rs` — propagate `policy_epoch` into log entries. +- `proxy_agent/src/key_keeper/local_rules.rs` — fail-closed merge already exists; just route the install through the store. + +## 8. Tests + +- Concurrent test: 64 worker threads issue authorize calls; reload thread installs new policies at random intervals. Assert every decision is consistent (Allow/Deny matches the snapshot's rules) and no thread observes a half-installed state. +- Pentest `D5`: with an in-flight request held by a slow upstream, install a deny policy; the in-flight request still completes with the prior epoch (documented behavior) but no new connections see the old policy. +- Fail-closed: corrupt the rules file → install fails → previous epoch remains active and is reflected in `status.json`. + +## 9. Risks + +- **Long-lived requests retain old policies.** Mitigation: documented behavior; emit warning when request_age \> threshold. +- **Epoch wraparound:** `u64`, no practical issue. +- **Stale snapshot retained by Arc.** Memory only; small (one struct per request in flight). + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|-----------------------------------------|-----------------------------------------------------| +| M1 | Introduce `PolicyStore` + plumb context | All unit tests pass; `policy_epoch` visible in logs | +| M2 | Status + telemetry fields | Operator dashboards updated | +| M3 | Pentest D5 regression test added | Test green; runs in CI | + +Detail design for direction 2.3. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-2.4-differential-testing.md b/doc/plans/Innovation-2.4-differential-testing.md new file mode 100644 index 00000000..4bcd9855 --- /dev/null +++ b/doc/plans/Innovation-2.4-differential-testing.md @@ -0,0 +1,119 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Design](#design) +3. [3. Mutator catalog](#mutators) +4. [4. Runner](#runner) +5. [5. Integration](#integration) +6. [6. Failure handling](#failmode) +7. [7. Perf budget](#perf) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 2.4** · **Self-test** + +# Detailed Design — Differential & Property Testing of Rules + +For each rule loaded, auto-generate "evil twin" requests (case toggles, percent-encoded slashes, IPv6 forms, Unicode confusables). Run the matcher on each variant; any mismatch with the canonical request indicates a latent bypass and blocks the rule from going live. + +**Files affected:** new `proxy_agent/src/proxy/policy/selftest/` module, hooked into `local_rules.rs` reload path and CI. + +> **Prerequisites:** [2.1 Canonical request](Innovation-2.1-canonical-request.md)[2.2 Typed policy (Cedar)](Innovation-2.2-typed-policy-cedar.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|---------------------------------------|-----------|---------|----------------| +| **Medium** proactive bypass detection | **Small** | **Low** | **agent + CI** | + +### Goals + +- Every rule reload runs a self-test that proves the rule is robust to known bypass patterns. +- Same self-test runs in CI on the repository's bundled rule files. +- Fail-closed: a rule that fails self-test is rejected during reload, and the previous policy remains active. + +## 2. Design + +For every rule, the runner derives a small set of synthetic requests: + +1. **Canonical request** matching the rule's intent exactly. +2. **Evil twins** — produced by mutators that should normalize to the same canonical form per 2.1. +3. **Negative twins** — close-but-not-matching requests that should be rejected by the rule. + +The runner asserts: *canonical and evil twins produce the same decision; negative twins produce a different decision.* + +## 3. Mutator Catalog + +| Mutator | Example | Pentest mapping | +|--------------------------------------|-----------------------------------------|------------------------------------| +| Case-toggle | `/Metadata/Identity` | D1 | +| Percent-encoded slash | `/metadata%2Fidentity` | D1 | +| Double-encoding | `%252e%252e` | D1 | +| Trailing dot / whitespace | `/metadata./` | D1 | +| Matrix params | `/metadata;jsessionid=x/identity` | D1 | +| Embedded query via `%3F` | `/metadata/identity%3Fapi-version=2018` | D1 (now rejected by canonicalizer) | +| IPv4 numeric | `http://2852039166/...` | C7 | +| IPv4 hex | `http://0xa9fea9fe/...` | C7 | +| IPv4 octal | `http://0251.0376.0251.0376/...` | C7 | +| IPv4-mapped IPv6 | `http://[::ffff:169.254.169.254]/...` | C7 | +| Unicode confusables in identity name | `"r\u00f6ot"` vs `"root"` | D3 | + +## 4. Runner + + pub struct SelfTestReport { + pub rule_id: String, + pub passed: bool, + pub failures: Vec, + } + + pub struct SelfTestFailure { + pub mutator: &'static str, + pub canonical_decision: Decision, + pub mutated_decision: Decision, + pub mutated_uri: String, + pub reason: &'static str, + } + + pub fn selftest(policy: &CompiledPolicy) -> Vec; + +- Runs over the compiled policy, not the raw JSON, so it tests the actual evaluator path. +- Per-rule budget: ≤ 1 ms for 30 mutators; total budget ≤ 100 ms per reload for typical rule counts. + +## 5. Integration + +- **Reload path** (`local_rules.rs`): selftest runs before `PolicyStore::install`. Failure → install aborted, previous snapshot stays. +- **CI**: `cargo test --features selftest -- --include-ignored` runs against every fixture rule file in `config/`. +- **Operator visibility**: `status.json` exposes `last_selftest_failures`; `gpa-doctor` (direction 6.2) surfaces this prominently. + +## 6. Failure Handling + +- Selftest failures during reload are non-fatal for serving (we keep the prior policy) but block the new one. +- Selftest failures in CI are fatal — block PR merges. Authors fix either the rule or the mutator. +- Each failure includes a *minimum-reproducer URI* for fast triage. + +## 7. Performance + +- Selftest runs off the hot path (rule-reload thread). +- Time-bounded; if budget exceeded, emit a warning and continue (don't deadlock on a pathological rule set). + +## 8. Tests for the selftest itself + +- Inject an intentionally-buggy matcher (e.g. case-sensitive substring) and confirm selftest catches the bypass. +- Inject a "perfect" matcher and confirm selftest reports zero failures. +- Property test: for any rule, mutated canonical request and original canonical request canonicalize to equal forms. + +## 9. Risks + +- **False positives** if a mutator generates a request that legitimately differs semantically. Mitigation: maintain mutators in a curated list, not generic fuzz. +- **Rule writers blocked** by unfamiliar failures. Mitigation: the report includes the reproducer URI and the mutator name; `gpa policy simulate` (6.1) provides a single-step debugger. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|------------------------------------------------|---------------------------------------------| +| M1 | 10 mutators + report type | Runs in CI on bundled rules | +| M2 | Reload-path integration | Install aborted on failure; covered by test | +| M3 | Operator surface (`status.json`, `gpa-doctor`) | Documented in operator guide | + +Detail design for direction 2.4. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-3.1-hash-chained-log.md b/doc/plans/Innovation-3.1-hash-chained-log.md new file mode 100644 index 00000000..582e3588 --- /dev/null +++ b/doc/plans/Innovation-3.1-hash-chained-log.md @@ -0,0 +1,114 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Record format](#format) +4. [4. Chain semantics](#chain) +5. [5. Anchoring](#anchor) +6. [6. Rotation](#rotation) +7. [7. Verifier tool](#verify) +8. [8. Integration](#integration) +9. [9. Tests](#tests) +10. [10. Risks](#risks) +11. [11. Milestones](#milestones) + +**GPA** · **Direction 3.1** · **Audit integrity** + +# Detailed Design — Hash-chained, Append-only Audit Log + +Wrap the connection log with a Merkle/hash chain so any post-hoc tampering (deletion, edit, injection) breaks the chain and is detectable. Optional anchoring to an external transparency log makes the chain non-repudiable. + +**Files affected:** `proxy_agent/src/common/logger` (Sink abstraction), `proxy_agent/src/proxy/proxy_summary.rs`, new `proxy_agent/src/audit/chain.rs`. + +> **Prerequisites:** None — foundational audit layer. Required by [3.2 OTel export](Innovation-3.2-otel-export.md), [3.3 Self-attestation](Innovation-3.3-self-attestation.md), [6.2 GPA doctor](Innovation-6.2-gpa-doctor.md). + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------------|-----------|---------|-----------| +| **Medium** compliance + IR | **Small** | **Low** | **agent** | + +### Goals + +- Tampering with the audit log is detectable; addresses pentest `F2` (log injection) and `F3` (rotation race). +- No central service required for tamper-evidence; anchoring optional. +- Negligible runtime overhead (≤ 1 µs / record). + +## 2. Today + +Connection log is a plain newline-delimited JSON file. A root attacker can edit or delete entries; an attacker who can inject newlines into a process name or URL can forge entries that pass downstream parsers. + +## 3. Record Format + + // One line per record (NDJSON) + { + "seq": 175201, + "ts": "2026-06-01T12:34:56.789Z", + "kind": "decision|policy_install|service|...", + "payload": { ... arbitrary ... }, + "prev_hash": "b3:9f8a...", // hash of record seq-1 (full line bytes) + "hash": "b3:2c1d..." // sha256 of (prev_hash || canonical_json(payload) || ts || seq) + } + +- **Canonical JSON** for the payload to ensure deterministic hashing across runtimes. +- **Length-prefix** embedded in the line (start of line: `16-hex-len `) to defeat newline-injection — parsers ignore content past `len`. +- Every record carries its sequence number; gaps are immediately detected. + +## 4. Chain Semantics + +- The chain is a forward hash list — minimal compute, sufficient for tamper-evidence. +- Periodic "checkpoint" records (every N records or T seconds) include a Merkle root over the latest segment to allow O(log N) inclusion proofs later. +- `prev_hash` at sequence 0 is a fixed sentinel containing service start time, binary hash, and policy epoch at startup. + +## 5. Anchoring (optional) + +- Periodic checkpoint hashes can be: + - Submitted to a Rekor-compatible transparency log. + - Sent to Azure Monitor as a signed custom-log entry, signed by the VM's vTPM AIK if available. + - Mirrored locally to a read-only directory under `/var/log/azure-proxy-agent/checkpoints/` for offline forensic use. +- Anchoring failures do not block log writes — fail-open for availability, log telemetry instead. + +## 6. Rotation (closes F3) + +- Rotation creates a new file `ProxyAgent.Connection.NNN.log`; the first record in NNN+1 is a "rotation" record that includes the final `hash` of NNN. +- Open file via `O_NOFOLLOW | O_CREAT | O_EXCL` to defeat symlink swap. +- Permissions enforced as 0600 root:root via `fchmod` after creation and verified before each append. +- Rotation never overwrites; old files are renamed atomically. + +## 7. Verifier Tool + +`gpa audit verify [--from N] [--to M] [--anchor file]` + +- Walks the chain, validates each record's hash; reports first divergence with sequence numbers and byte offsets. +- `--anchor` validates against a fetched checkpoint file (or rekor inclusion proof). +- Exits non-zero on tamper-detection; suitable for SIEM integration. + +## 8. Integration + +- Logger refactored to a `trait Sink` with implementations: `PlainFileSink` (legacy), `ChainedFileSink`, `SyslogSink`, `TestSink`. +- Choice via config `audit.sink = "chained"`; default off in v1. +- Connection log writer + service log writer share the sink trait so both gain integrity. + +## 9. Tests + +- Append, verify → pass. Edit one byte of payload → verifier reports failure at that sequence. +- Delete a record line → next record's `prev_hash` mismatch detected. +- Newline injection in process name → verifier still parses correctly because length-prefix bounds the record. +- Symlink swap pre-rotation → `O_NOFOLLOW` open fails; alert event written to previous file before exit. +- Pentest `F2`/`F3` reruns: PASS. + +## 10. Risks + +- **Performance:** hashing every record adds ~1 µs; bounded. +- **Recovery after crash:** last partial line skipped on restart; checkpoint emitted noting the gap. +- **Disk full:** chain still self-consistent; rotation policy must avoid pruning without a verified anchor. + +## 11. Milestones + +| M | Deliverable | Exit | +|-----|------------------------------------|--------------------------------------------| +| M1 | Sink trait + plain + chained impls | Logger refactor merged behind feature flag | +| M2 | Verifier CLI | F2/F3 pentest PASS | +| M3 | Optional Rekor anchoring | Anchor docs published | + +Detail design for direction 3.1. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-3.2-otel-export.md b/doc/plans/Innovation-3.2-otel-export.md new file mode 100644 index 00000000..e4ea6d0e --- /dev/null +++ b/doc/plans/Innovation-3.2-otel-export.md @@ -0,0 +1,99 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Schema](#schema) +3. [3. Metrics](#metrics) +4. [4. Traces](#traces) +5. [5. Exporter](#exporter) +6. [6. Perf](#perf) +7. [7. Integration](#integration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 3.2** · **Observability** + +# Detailed Design — OpenTelemetry Export + +Emit standards-based metrics and traces so GPA can be observed by any modern monitoring stack (Azure Monitor, Prometheus, OTel collectors). Closes the gap where today's only signal is a text log. + +**Files affected:** new `proxy_agent/src/telemetry/` module, light hooks in proxy/redirector. + +> **Prerequisites:** [3.1 Hash-chained log](Innovation-3.1-hash-chained-log.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|------------------------------------|-----------|---------|-----------| +| **Medium** SLO + incident response | **Small** | **Low** | **agent** | + +### Goals + +- Production teams can graph allow/deny by rule id and chase regressions without parsing logs. +- Optional OTLP exporter; default off keeps footprint minimal. +- No PII / secrets in metric labels. + +## 2. Resource Attributes + + service.name = "gpa" + service.version = + host.id = + gpa.binary.hash = + gpa.policy.epoch = // updated on reload + gpa.seal.backend = "tpm2|snp|tdx|noop" + +## 3. Metrics + +| Name | Type | Labels | Notes | +|------------------------------|-----------|--------------------|-------------------------------| +| `gpa_requests_total` | counter | `dest`, `decision` | Decision = allow\|deny\|error | +| `gpa_request_latency_us` | histogram | `dest`, `decision` | End-to-end through the agent | +| `gpa_canon_errors_total` | counter | `code` | From direction 2.1 | +| `gpa_policy_install_total` | counter | `result` | success\|failed | +| `gpa_policy_epoch` | gauge | — | Currently active epoch | +| `gpa_ebpf_audit_map_entries` | gauge | — | From direction 4.4 | +| `gpa_pop_verify_total` | counter | `result` | From direction 1.1 | +| `gpa_restart_total` | counter | `reason` | graceful\|crash\|sigterm | + +## 4. Traces + +- One span per inbound request: `gpa.serve_request` with attributes `http.method`, `gpa.dest`, `gpa.decision`, `gpa.policy_epoch`, `gpa.matched_scope`. +- Child span: `gpa.upstream_request` with `net.peer.ip`, `http.status_code`. +- W3C trace context propagation: *do not* propagate inbound trace context to upstream metadata services (avoid leaking client trace ids into fabric). GPA spans share its own trace id rooted at the connection. + +## 5. Exporter + +- Default: **Prometheus exposition over Unix socket** at `/run/azure-proxy-agent/metrics.sock` (so it never goes over TCP). +- Optional: OTLP/gRPC to a configurable endpoint (used by AKS observability pipelines). +- Sampling: traces sampled at 1/100 by default; head-based; configurable. + +## 6. Performance Budget + +- Recording overhead per request ≤ 1 µs in default mode; ≤ 5 µs with trace sampled. +- Exporter flush is asynchronous, bounded queue; backpressure drops oldest with a counter. + +## 7. Integration + +- Use `opentelemetry` + `opentelemetry_sdk` crates; `prometheus` crate for exposition. +- Init in `main.rs` behind `--features otel`; no-op otherwise. +- Hook points: accept, authorize result, upstream call boundary, reload, eBPF map size sampler (every 30 s). + +## 8. Tests + +- Unit: counters/histograms record expected values. +- Integration: spawn agent with metrics socket, hit it with sample requests, assert metrics endpoint output matches. +- Soak: 24 h with metrics on; memory and CPU within budget. + +## 9. Risks + +- **Cardinality explosion** if URL or identity goes into labels. Mitigation: only typed enums in labels; never raw strings from requests. +- **Dependency weight.** Mitigation: feature flag default off. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|-------------------------------|---------------------------------| +| M1 | Metric registry + Prom socket | Local dashboards working | +| M2 | Traces + OTLP | Pilot in one production cluster | + +Detail design for direction 3.2. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-3.3-self-attestation.md b/doc/plans/Innovation-3.3-self-attestation.md new file mode 100644 index 00000000..4f063e15 --- /dev/null +++ b/doc/plans/Innovation-3.3-self-attestation.md @@ -0,0 +1,108 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Endpoint](#endpoint) +3. [3. Payload](#payload) +4. [4. Access control](#access) +5. [5. Signing](#sign) +6. [6. Integration](#integration) +7. [7. Tests](#tests) +8. [8. Risks](#risks) +9. [9. Milestones](#milestones) + +**GPA** · **Direction 3.3** · **Attestation** + +# Detailed Design — Self-Attestation Endpoint + +Expose a read-only endpoint that returns GPA's own measurements: binary hash, loaded eBPF program ids and bytecode hashes, attached cgroup, active policy epoch, sealed-key id, attestation backend. Consumable by Defender for Cloud, Azure Policy, and operator tooling. + +**Files affected:** `proxy_agent/src/proxy/proxy_server.rs` (new route), new `proxy_agent/src/attestation/`. + +> **Prerequisites:** [1.2 vTPM sealing](Innovation-1.2-vtpm-sealing.md)[3.1 Hash-chained log](Innovation-3.1-hash-chained-log.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|-----------------------------------------|-----------|---------|-----------| +| **Medium** compliance + drift detection | **Small** | **Low** | **agent** | + +### Goals + +- Externally verifiable proof of which GPA is running, with which policy, and which kernel-side components are attached. +- Cheap probe with no secrets in the payload. + +## 2. Endpoint + +- HTTP GET `/.well-known/gpa/attestation` on the local listener (`127.0.0.1:3080`). +- Optional `?nonce=BASE64URL(32 bytes)` for freshness. +- Response: `application/json` with a signed `jws` field when an attestation backend is present. + +## 3. Payload Shape + + { + "version": 1, + "service": { + "name": "gpa", + "version": "1.X.Y", + "binary_hash": "sha256:...", + "uptime_s": 12345 + }, + "policy": { + "epoch": 175201, + "source_hash": "sha256:...", + "loaded_at": "2026-06-01T12:30:00Z", + "selftest_passed": true + }, + "ebpf": [ + { "name": "cgroup_connect", "id": 42, "bytecode_hash": "sha256:...", "attach_cgroup": "/sys/fs/cgroup" }, + { "name": "audit_event", "id": 43, "bytecode_hash": "sha256:..." } + ], + "seal": { + "backend": "tpm2|snp|tdx|noop", + "kid": "...", + "counter": 7 + }, + "nonce": "...", + "ts": "2026-06-01T12:34:56Z", + "jws": "eyJhbGc..." // optional, present when sealed + } + +## 4. Access Control + +- Reachable only via the localhost listener; no external exposure. +- Subject to standard GPA AuthZ: by default, any in-VM caller may read; rules can restrict if desired. +- Rate-limited (1 req/s per caller cgroup) to prevent measurement-driven side channels. + +## 5. Signing + +- When a hardware backend (1.2) is present, sign the canonical JSON payload + nonce with the attestation key (TPM AIK, SNP report, TDX QUOTE) and place the proof in `jws`. +- When `noop` backend, omit `jws`; tooling treats the response as unauthenticated diagnostic. +- Nonce is reflected verbatim; binding nonce + measurements prevents replay across calls. + +## 6. Integration + +- `proxy_agent/src/proxy/proxy_server.rs` — new handler before the generic forwarding path. +- Pulls fields from: build-time const (version), `policy_store.current()`, `redirector::loaded_programs()`, `key_keeper::sealing`. +- `gpa-doctor` (direction 6.2) and Azure Policy hook can both consume this. + +## 7. Tests + +- Probe returns expected fields; binary_hash matches actual file hash. +- With `tpm2` backend simulator, JWS validates with the AIK. +- Rate-limit test: 100 rapid requests → some 429s; payload counter stable. +- Mutate binary (in test harness) → next call returns the new hash; external monitor detects drift. + +## 8. Risks + +- **Information disclosure** of internal program ids. Mitigation: ids are not secrets; documented as public attestation surface. +- **Hot-loop callers** can heat up the agent. Mitigation: rate limit + cheap payload caching with nonce-only signing per request. + +## 9. Milestones + +| M | Deliverable | Exit | +|-----|--------------------------|-----------------------------| +| M1 | Unsigned endpoint | Surfaces in `gpa-doctor` | +| M2 | Signed via vTPM backend | JWS verification documented | +| M3 | Defender for Cloud probe | Drift alerts configured | + +Detail design for direction 3.3. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-3.4-supply-chain.md b/doc/plans/Innovation-3.4-supply-chain.md new file mode 100644 index 00000000..8244bb16 --- /dev/null +++ b/doc/plans/Innovation-3.4-supply-chain.md @@ -0,0 +1,91 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. SBOM](#sbom) +3. [3. Reproducible build](#repro) +4. [4. Signing (Sigstore)](#signing) +5. [5. Setup verification](#verify) +6. [6. CI pipeline](#pipeline) +7. [7. Tests](#tests) +8. [8. Risks](#risks) +9. [9. Milestones](#milestones) + +**GPA** · **Direction 3.4** · **Supply chain** + +# Detailed Design — Supply-chain Hardening (SBOM, Reproducible Builds, Sigstore) + +Produce an SBOM, make the build bit-reproducible, sign artifacts with Sigstore, and have `proxy_agent_setup` verify the signature before installing. Closes pentest `H1` (rollback to malicious previous-version archive). + +**Files affected:** CI pipeline, `proxy_agent_setup/`, package build for Linux/Windows. + +> **Prerequisites:** None — build-time / release-pipeline change, independent of agent runtime work. + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|-----------------------------------|------------|---------|-------------------| +| **Medium** compliance + integrity | **Medium** | **Low** | **build + setup** | + +### Goals + +- Auditable list of every transitive crate / system library shipped. +- Builds bit-reproducible across two builders; output hash matches what is signed. +- `proxy_agent_setup` refuses to install an unsigned, downgraded, or tampered archive. + +## 2. SBOM + +- Generate CycloneDX with `cargo-cyclonedx --format json` for each crate in the workspace, merged into a single document. +- Include the eBPF object files and their `clang` + `BTF` versions. +- Ship SBOM alongside the package (`gpa-.sbom.json`); attach to GitHub Release. + +## 3. Reproducible Build + +- Pin toolchain via `rust-toolchain.toml`. +- Vendor dependencies (`cargo vendor`); commit checksum. +- Strip build paths: `RUSTFLAGS="--remap-path-prefix $(pwd)=. -C link-arg=-Wl,--build-id=none"`. +- Pin clang version for eBPF objects. +- CI runs two independent builders; compares sha256 of all output artifacts; fail if not equal. + +## 4. Signing (Sigstore / cosign) + +- **Keyless signing** via `cosign sign --certificate-identity ...` backed by GitHub Actions OIDC token. +- Signed artifacts: the agent binaries, the extension `HandlerManifest`, eBPF objects, SBOM. +- Signatures + Rekor transparency entries are published in the same release. +- Optional in-toto attestation describing the build (commit, builder, dependencies). + +## 5. Setup-side Verification + +- `proxy_agent_setup` ships with the Sigstore root + the expected identity (the GitHub repo / workflow). +- Before any install / replace operation: + 1. Verify cosign signature on the new archive. + 2. Verify the Rekor inclusion proof (offline-verifiable bundle ships alongside the artifact). + 3. Verify the new version is ≥ the previously-installed version recorded in `/var/lib/azure-proxy-agent/installed_version`; refuse downgrades unless an explicit operator override flag is provided. +- Any failure: leave the prior installation in place, write a structured audit event, exit non-zero. + +## 6. CI Pipeline + +PR build: cargo build / test / clippy / fmt cargo-cyclonedx (SBOM) cargo audit (advisory DB) Release build (two builders in parallel): build → diff hashes → must match cosign sign --keyless (each artifact) publish: artifacts + signatures + Rekor entries + SBOM + +## 7. Tests + +- Tamper a release archive → setup verification fails with explicit reason. +- Downgrade attempt → setup refuses; override flag accepted only via documented path. +- Reproducible-build diff job catches an intentionally inserted timestamp. +- Pentest `H1`: malicious rollback archive → REJECTED. + +## 8. Risks + +- **Reproducibility on Windows** is harder due to PDB embedding. Mitigation: strip PDBs from the verified path; archive separately for symbol servers. +- **Sigstore root rotation** requires periodic updates. Mitigation: ship root bundle; refresh on agent update. +- **Operator downgrade workflows** need a documented override; design it so the override itself is signed and audited. + +## 9. Milestones + +| M | Deliverable | Exit | +|-----|------------------------------|-------------------------------------------------| +| M1 | SBOM in CI artifacts | Published with releases | +| M2 | Reproducible build job | Two builders match for two consecutive releases | +| M3 | Cosign signing + Rekor | Setup verifies on install in staging | +| M4 | Downgrade refusal default-on | Pentest H1 PASS | + +Detail design for direction 3.4. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-4.1-sk-lookup-bpf-lsm.md b/doc/plans/Innovation-4.1-sk-lookup-bpf-lsm.md new file mode 100644 index 00000000..eea985c9 --- /dev/null +++ b/doc/plans/Innovation-4.1-sk-lookup-bpf-lsm.md @@ -0,0 +1,117 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Design](#design) +4. [4. sk_lookup program](#sklookup) +5. [5. bpf_lsm hook](#lsm) +6. [6. Loader strategy](#loader) +7. [7. Integration](#integration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 4.1** · **eBPF** + +# Detailed Design — sk_lookup + bpf_lsm Redirect + +Move from `cgroup/connect4` SNAT-style redirect to `sk_lookup` (listener-side steering, preserves original destination) augmented with `bpf_lsm` socket hooks that close netns/cgroup escape paths (pentest `C5`, `C6`, `C7`). + +**Files affected:** `linux-ebpf/` (split into `cgroup_connect.bpf.c`, `sk_lookup.bpf.c`, `lsm.bpf.c`), `proxy_agent/src/redirector/linux/`. + +> **Prerequisites:** [4.2 Core eBPF unification](Innovation-4.2-core-unify-ebpf.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------|------------|-------------------|-----------------------------| +| **High** kills C5–C7 | **Medium** | **Kernel matrix** | **linux-ebpf + redirector** | + +### Goals + +- Original destination IP is preserved end-to-end (no SNAT to localhost) so the agent can authoritatively match on it after netns shenanigans. +- An LSM hook denies connect attempts to fabric IPs from sockets the redirect cannot capture (different netns, unshared cgroup). +- Fallback path retains today's `cgroup/connect4` for older kernels. + +## 2. Today + +A `cgroup/connect4` program rewrites the destination of outbound TCP connect calls from fabric IPs to `127.0.0.1:3080`. Caveats: + +- It's a destination rewrite — the agent has to recover the original IP from a side channel. +- It's attached to the root cgroup, so a workload that escapes to a sibling cgroup hierarchy (pentest C5) or new netns (C6) escapes the redirect. +- Address-encoding bypasses (C7) are mitigated only by string parsing in user space. + +## 3. Design + +client connect(168.63.129.16:80) │ ▼ (1) cgroup/connect4 — kept for back-compat; sets sk_storage.original_dest │ ▼ (2) bpf_lsm socket_connect — DENY if dest is fabric AND sk is outside the agent's view │ kernel routing │ ▼ (3) sk_lookup at agent's listener — selects the agent's accept socket │ and preserves SO_ORIGINAL_DST for the agent to read │ agent accept() + +## 4. sk_lookup Program + + // linux-ebpf/sk_lookup.bpf.c + SEC("sk_lookup") + int gpa_sk_lookup(struct bpf_sk_lookup *ctx) { + __u32 dip = ctx->remote_ip4 ? ctx->local_ip4 : 0; + __u16 dport = ctx->local_port; + if (!is_fabric_dest(dip, dport)) return SK_PASS; + bpf_sk_assign(ctx, &agent_listener_sk, 0); + return SK_PASS; + } + +- The agent registers its listener socket via `BPF_MAP_TYPE_SK_LOOKUP` map; the kernel preserves the original destination tuple, available to the agent via `SO_ORIGINAL_DST`. +- No payload mutation, no SNAT, so the agent reads the real destination IP for AuthZ. +- IPv6 variant attached as separate program. + +## 5. bpf_lsm Hook + + // linux-ebpf/lsm.bpf.c + SEC("lsm/socket_connect") + int BPF_PROG(gpa_block, struct socket *sock, struct sockaddr *addr, int addrlen, int ret) { + if (ret) return ret; + if (!is_fabric_dest_sockaddr(addr)) return 0; + // If this socket's cgroup is not under the GPA-attached cgroup root, + // or sk_lookup is not present for this netns, deny. + if (!cgroup_is_under_root(sock->sk) || !sk_lookup_present_in_netns(sock->sk)) return -EPERM; + return 0; + } + +- This is the "you didn't go through GPA → you can't reach the fabric" rule, enforced at the kernel boundary. +- Compiled as `bpf_lsm`; requires kernel with `CONFIG_BPF_LSM=y` and `lsm=bpf` on kernel cmdline. + +## 6. Loader Strategy + +| Kernel feature | Programs loaded | Behavior | +|------------------------------|-----------------------------------------------------|---------------------------------------------| +| bpf_lsm + sk_lookup (≥ 5.13) | LSM + sk_lookup + cgroup_connect (defense-in-depth) | Best case | +| sk_lookup only | sk_lookup + cgroup_connect | No LSM deny; sk_lookup still preserves dest | +| cgroup_connect only | cgroup_connect (legacy) | Today's behavior | + +Loader probes feature availability at startup using `libbpf` feature probes; logs the chosen mode and exposes it via the attestation endpoint (3.3). + +## 7. Integration + +- `proxy_agent/src/redirector/linux/` — refactor to manage three programs with per-program lifecycle (load, attach, detach, pin under `/sys/fs/bpf/gpa/`). +- `proxy_agent/src/proxy/proxy_server.rs` — read original destination via `SO_ORIGINAL_DST` (IPv4) and `IPV6_RECVORIGDSTADDR` (IPv6). +- `linux-ebpf/` — adopt CO-RE (see 4.2) so we ship one object per program for all supported kernels. + +## 8. Tests + +- Pentest `C5` (unshare cgroup): LSM denies; without LSM, sk_lookup still captures. +- Pentest `C6` (new netns): LSM denies because sk_lookup is not present in the new netns. +- Pentest `C7` (address-encoding): all encodings reach `sk_lookup` at the same destination tuple; canonical model (2.1) then handles host normalization for AuthZ. +- IPv6 path: same checks on link-local fabric equivalents. + +## 9. Risks + +- **Kernel feature matrix** — older distros lack `bpf_lsm`. Mitigation: tiered loader, telemetry exposes which mode is in use. +- **BTF availability** on stripped kernels — vendor BTF in the package as a fallback. +- **Performance** of an extra LSM hook per connect — micro-benchmarked; expected ≤ 200 ns. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|-------------------------------------------|----------------------------------------------| +| M1 | sk_lookup program + loader tier detection | Original dest preserved on supported kernels | +| M2 | bpf_lsm deny hook | C5, C6 pentest PASS on lsm-enabled kernels | +| M3 | IPv6 variants (ties to direction 4.3) | Dual-stack VMs covered | + +Detail design for direction 4.1. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-4.2-core-unify-ebpf.md b/doc/plans/Innovation-4.2-core-unify-ebpf.md new file mode 100644 index 00000000..a2fd30de --- /dev/null +++ b/doc/plans/Innovation-4.2-core-unify-ebpf.md @@ -0,0 +1,103 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. CO-RE design](#design) +4. [4. Shared headers](#shared) +5. [5. Build system](#build) +6. [6. BTF strategy](#btf) +7. [7. Integration](#integration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 4.2** · **eBPF** + +# Detailed Design — Unify Linux/Windows eBPF on CO-RE + +Replace today's per-kernel-version eBPF source duplication with CO-RE (Compile Once, Run Everywhere). Share data structures between Linux (`linux-ebpf/`) and Windows (`ebpf/`), reduce build matrix to one object per program per platform. + +**Files affected:** `linux-ebpf/`, `ebpf/`, `proxy_agent/build.rs`, `proxy_agent/src/redirector/`. + +> **Prerequisites:** None — foundational eBPF layer. Unblocks [4.1](Innovation-4.1-sk-lookup-bpf-lsm.md), [4.3](Innovation-4.3-ipv6-dual-stack.md), [4.4](Innovation-4.4-ebpf-throttling-lru.md), [5.1](Innovation-5.1-aks-container-native.md), [5.3](Innovation-5.3-cross-cloud-port.md). + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|------------------------------------|------------|---------|------------------| +| **Medium** maintainability + reach | **Medium** | **Low** | **eBPF + build** | + +### Goals + +- One eBPF object per program, portable across kernel versions via CO-RE relocations. +- Shared C header for the audit event struct, consumed by both Linux and Windows. +- Lower QA burden — no need to rebuild per kernel. + +## 2. Today + +Linux and Windows have two source trees with similar logic but separate definitions of the audit event struct, depending on platform tooling. Some Linux fields are hardcoded to specific kernel offsets, requiring per-distro builds for older targets. + +## 3. CO-RE Design + +- Adopt **libbpf** and **libbpf-rs** for the Linux side; use `BPF_CORE_READ()` for any kernel-struct dereference so the verifier relocates at load time. +- Use `vmlinux.h` generated from the build host's BTF as the canonical source for kernel-side types; relocations adapt to the target. +- For Windows, eBPF-for-Windows already uses BTF; mirror the same approach so user-space struct layouts align byte-for-byte. + +## 4. Shared Headers + + // shared-ebpf/include/gpa_audit_event.h (consumed by both linux-ebpf and ebpf) + #pragma once + #define GPA_AUDIT_PATH_MAX 256 + struct gpa_audit_event { + __u64 cgroup_id; + __u32 pid; + __u64 pid_starttime_ns; + __u32 uid; + __u32 gid; + __u32 measurement_kind; + __u8 measurement[32]; + char exe_path[GPA_AUDIT_PATH_MAX]; + }; + _Static_assert(sizeof(struct gpa_audit_event) == 320, "stable layout"); + +- Rust bindings generated with `bindgen` in `build.rs`; the struct layout is asserted in CI on both platforms. + +## 5. Build System + +- `proxy_agent/build.rs` invokes `clang -target bpf -O2 -g -c` with `-emit-llvm`+`llc` for each program file. +- Pin `clang` version (15+); pin `llvm`. +- Generated `.bpf.o` files are checked into the artifact pipeline (not the repo). +- One output per program: `cgroup_connect.bpf.o`, `sk_lookup.bpf.o`, `lsm.bpf.o`, `audit_event.bpf.o`. + +## 6. BTF Strategy + +- Prefer kernel-provided `/sys/kernel/btf/vmlinux` at load time. +- For kernels without BTF, ship a small BTF stub generated by `pahole` for the structs we actually use (`struct sock`, `struct task_struct`'s relevant fields). +- Document a minimum kernel of 5.4 (per current README compatibility) with CO-RE relocations rather than per-kernel builds. + +## 7. Integration + +- `proxy_agent/src/redirector/linux/` uses `libbpf-rs` Skel objects generated by `libbpf-cargo`. +- Windows side switches to consume the same header for the user-space event ring buffer. +- Removed: per-distro compile-time forks; replaced with feature probes at load. + +## 8. Tests + +- Cross-kernel matrix CI (5.4, 5.15, 6.1, 6.8) loads each program and runs a smoke test that triggers the audit event. +- Layout assertion test on both Linux and Windows; deliberate field add must update version number. +- Performance: load time \< 100 ms across all kernels. + +## 9. Risks + +- **clang/LLVM bugs** in CO-RE relocation. Mitigation: pin known-good toolchain; fallback to per-kernel build path retained for one release. +- **vmlinux.h size** — large but only at build time; not shipped. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|----------------------------------------------|------------------------------------------------------| +| M1 | libbpf-rs adoption | Existing program runs unchanged on supported kernels | +| M2 | Shared header + cross-platform Rust bindings | Layout assertions green on both OSes | +| M3 | Drop per-kernel builds | CI matrix \< 1/3 of previous count | + +Detail design for direction 4.2. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-4.3-ipv6-dual-stack.md b/doc/plans/Innovation-4.3-ipv6-dual-stack.md new file mode 100644 index 00000000..9d30dd23 --- /dev/null +++ b/doc/plans/Innovation-4.3-ipv6-dual-stack.md @@ -0,0 +1,110 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Design](#design) +4. [4. eBPF v6 programs](#ebpf) +5. [5. Listener](#listener) +6. [6. Canonical Destination v6](#canon) +7. [7. Integration](#integration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 4.3** · **Network** + +# Detailed Design — IPv6 / Dual-stack Support + +Extend the redirect, listener, canonical model, and rule engine to handle IPv6 fabric endpoints uniformly with IPv4. Closes the gap on dual-stack VMs. + +**Files affected:** `linux-ebpf/sk_lookup.bpf.c`, `ebpf/redirect.bpf.c`, `proxy_agent/src/proxy/proxy_server.rs`, `proxy_agent/src/proxy/canonical/destination.rs`. + +> **Prerequisites:** [4.2 Core eBPF unification](Innovation-4.2-core-unify-ebpf.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------------|------------|---------|------------------| +| **Medium** future-proofing | **Medium** | **Low** | **eBPF + agent** | + +### Goals + +- IPv6 fabric link-local addresses (e.g. `fe80::a9fe:a9fe`) caught by eBPF and routed through agent. +- Canonical destination enum unified across families; rule engine sees one `Destination::Imds` regardless of family. +- Defeat IPv4-mapped IPv6 bypasses (pentest C7) at the kernel layer. + +## 2. Today + +Redirect handles IPv4 only. Dual-stack VMs that route fabric over v6 (uncommon today but increasing) bypass the agent. The canonical model in direction 2.1 already plans for v6 typed destinations; this direction wires it through the kernel. + +## 3. Design + +- Add v6 sibling programs: `cgroup_connect6`, `sk_lookup_v6`. +- Listener binds IPv6 socket with `IPV6_V6ONLY=0` dual-stack on Linux, or two sockets where dual-stack is unavailable. +- Canonical `Destination` resolves IPv4-mapped IPv6 (`::ffff:a.b.c.d`) to the v4 destination — there is exactly one `Destination::Imds` regardless of family. +- Per-destination address tables published to BPF programs via a `BPF_MAP_TYPE_HASH` keyed on a 16-byte normalized address. + +## 4. eBPF v6 Programs + + SEC("cgroup/connect6") + int gpa_connect6(struct bpf_sock_addr *ctx) { + struct in6_addr dst; + __builtin_memcpy(&dst, ctx->user_ip6, sizeof(dst)); + if (!is_fabric_dest6(&dst, bpf_ntohs(ctx->user_port))) return 1; + // Redirect: rewrite to agent's v6 listener + set_user_dest_v6(ctx, &agent_v6, agent_port); + return 1; + } + +- `is_fabric_dest6` recognizes the v6 link-local equivalent (typically `fe80::a9fe:a9fe` if used) and IPv4-mapped forms. +- SO_ORIGINAL_DST equivalent for v6 via `IP6T_SO_ORIGINAL_DST`; reachable from user space. + +## 5. Listener + +- Bind `[::1]:3080` in addition to `127.0.0.1:3080` (or single dual-stack socket). +- Original destination read on accept via family-appropriate `getsockopt`. +- Listener exposed via attestation endpoint (3.3) with all bound addresses. + +## 6. Canonical Destination v6 + + impl Destination { + pub fn from_ip(ip: IpAddr, port: u16) -> Destination { + let v4 = match ip { + IpAddr::V4(v) => Some(v), + IpAddr::V6(v) => v.to_ipv4_mapped(), + }; + match (v4, port) { + (Some(Ipv4Addr::new(169,254,169,254)), 80) => Destination::Imds, + (Some(Ipv4Addr::new(168,63,129,16)), 80) => Destination::WireServer, + (Some(Ipv4Addr::new(168,63,129,16)), 32526) => Destination::HostGaPlugin, + _ => Destination::Unknown { /* ... */ }, + } + } + } + +## 7. Integration + +- Loader (4.2) detects v6 enablement on the host and loads v6 programs only when needed. +- PoP token (1.1) `dip` claim is always a 16-byte normalized form so signatures cover both families. +- Telemetry: per-family labels on `gpa_requests_total`. + +## 8. Tests + +- Dual-stack pod test: v4 and v6 requests both reach the agent and produce identical `Destination`. +- Pentest C7 v6 variants: all map to `Destination::Imds` after canonicalization. +- Linkup test for hosts without v6 — programs not loaded; no warnings. + +## 9. Risks + +- **Fabric v6 endpoints not finalized** in some regions — make destinations data-driven via the BPF map so production can update without redeploying eBPF. +- **Dual-stack socket semantics** vary on Windows — keep two sockets there. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|------------------------------|---------------------------------------------| +| M1 | v6 listener + canonical fold | v4 behavior unchanged | +| M2 | connect6 + sk_lookup_v6 | v6 fabric traffic captured in dual-stack VM | +| M3 | Data-driven dest table | Region rollout without rebuild | + +Detail design for direction 4.3. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-4.4-ebpf-throttling-lru.md b/doc/plans/Innovation-4.4-ebpf-throttling-lru.md new file mode 100644 index 00000000..9c1da887 --- /dev/null +++ b/doc/plans/Innovation-4.4-ebpf-throttling-lru.md @@ -0,0 +1,111 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. LRU map](#lru) +4. [4. Token bucket](#bucket) +5. [5. Sizing](#sizing) +6. [6. Integration](#integration) +7. [7. Tests](#tests) +8. [8. Risks](#risks) +9. [9. Milestones](#milestones) + +**GPA** · **Direction 4.4** · **DoS hardening** + +# Detailed Design — Kernel-side Throttling & Audit-map LRU + +Replace the audit hash map with an LRU-evicting map, and add a per-cgroup token bucket in BPF so connection floods are dropped early. Mitigates pentest `G1` (connection flood) and `G3` (audit-map exhaustion). + +**Files affected:** `linux-ebpf/cgroup_connect.bpf.c`, `linux-ebpf/audit_event.bpf.c`, `proxy_agent/src/redirector/`. + +> **Prerequisites:** [4.2 Core eBPF unification](Innovation-4.2-core-unify-ebpf.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|-------------------------|-----------|---------|----------| +| **Medium** availability | **Small** | **Low** | **eBPF** | + +### Goals + +- Audit map cannot be exhausted to evict legitimate entries. +- A noisy cgroup cannot DoS the agent into a fail-open window. +- Throttling visible via the metrics in 3.2. + +## 2. Today + +The audit map is a fixed-size hash map. Once full, new identities cannot be recorded and a legitimate caller may be missing an audit entry at decision time. There is no kernel-side rate limit. + +## 3. LRU Map + + struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); + __type(key, __u64); // cgroup_id + __type(value, struct gpa_audit_event); + __uint(max_entries, AUDIT_MAP_MAX); + } gpa_audit_map SEC(".maps"); + +- Eviction is least-recently-used; a stale cgroup ages out, an active one stays. +- `AUDIT_MAP_MAX` sized from `(expected unique cgroups) * 2`; default 16,384. +- Map size sampled by the agent every 30 s and exported (`gpa_ebpf_audit_map_entries`). + +## 4. Token Bucket + + struct token_bucket { __u64 tokens; __u64 last_refill_ns; }; + struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); + __type(key, __u64); // cgroup_id + __type(value, struct token_bucket); + __uint(max_entries, AUDIT_MAP_MAX); + } gpa_rl SEC(".maps"); + + SEC("cgroup/connect4") + int gpa_rate_limit(struct bpf_sock_addr *ctx) { + __u64 cg = bpf_get_current_cgroup_id(); + struct token_bucket *b = bpf_map_lookup_elem(&gpa_rl, &cg); + if (!b) { /* lazy init */ } + refill(b, bpf_ktime_get_ns()); + if (b->tokens == 0) { + increment_counter(METRIC_RL_DROPPED, cg); + return 0; // deny connect + } + b->tokens--; + return 1; + } + +- Bucket capacity: 100 connects, refill 50/s per cgroup (tunable). +- Dropped connect returns `EACCES` to the caller; not a silent black-hole. +- Per-cgroup, so a single noisy container cannot starve neighbors. + +## 5. Sizing & Tuning + +| Param | Default | Source | +|--------------------------|---------|--------------------------------------------| +| `AUDIT_MAP_MAX` | 16384 | Config; overridable via `--ebpf-audit-max` | +| Token bucket capacity | 100 | Config | +| Token bucket refill rate | 50/s | Config | +| Map sampler interval | 30 s | Config | + +## 6. Integration + +- Redirector loads the new map types and exposes counters to OTel (3.2). +- Telemetry: `gpa_ebpf_audit_map_evictions_total`, `gpa_ebpf_rate_limited_total{cgroup}`. +- `gpa-doctor` warns when eviction rate or RL drop rate is non-zero over 5 minutes. + +## 7. Tests + +- Spawn many short-lived cgroups → map grows but never exceeds `AUDIT_MAP_MAX`; eviction counter increases; legitimate cgroup retains entry due to recency. +- Connect-flood single cgroup → RL drops counter increments; agent CPU stays bounded; service does not crash (pentest `G1`). +- G3 audit-map exhaustion attempt → legitimate caller retains decision attribution. + +## 8. Risks + +- **Bursty workloads** hit RL ceiling. Mitigation: per-config bucket; observable in metrics; doc tuning guidance. +- **LRU not strict FIFO** — acceptable for this use case. + +## 9. Milestones + +| M | Deliverable | Exit | +|-----|-------------------|----------------------------------------| +| M1 | LRU map + sampler | G3 pentest PASS | +| M2 | Token bucket | G1 pentest PASS; agent steady at flood | + +Detail design for direction 4.4. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-5.1-aks-container-native.md b/doc/plans/Innovation-5.1-aks-container-native.md new file mode 100644 index 00000000..4ca6ba4d --- /dev/null +++ b/doc/plans/Innovation-5.1-aks-container-native.md @@ -0,0 +1,177 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Problem](#problem) +3. [3. Pod identity](#identity) +4. [4. Azure Workload Identity primer](#workload-identity) +5. [5. Token issuance](#tokens) +6. [6. Deployment](#deploy) +7. [7. Rule shape](#rules) +8. [8. Integration](#integration) +9. [9. Tests](#tests) +10. [10. Risks](#risks) +11. [11. Milestones](#milestones) + +**GPA** · **Direction 5.1** · **AKS** + +# Detailed Design — Kubernetes / AKS-native Mode + +Run GPA per node as a DaemonSet; map the eBPF-captured `cgroup_id` for each connect to a Kubernetes pod identity. Issue pod-scoped tokens via Azure Workload Identity instead of handing back the node-MI token — closes the "pod steals node MI" class of attack. + +**Prerequisite eBPF change:** the audit map entry must include `cgroup_id` (see [4.2 Core eBPF unification](Innovation-4.2-core-unify-ebpf.md)). Today's `sock_addr_audit_entry` only carries `process_id` — see [linux-ebpf/socket.h](../linux-ebpf/socket.h). + +**Files affected:** new `proxy_agent/src/k8s/` module, deployment manifests, integrates with PoP (1.1). + +> **Prerequisites:** [4.2 Core eBPF unification](Innovation-4.2-core-unify-ebpf.md)[1.4 Capability scopes](Innovation-1.4-capability-scopes.md)[1.1 PoP tokens](Innovation-1.1-pop-tokens.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------------------|-----------|---------------------------|-----------------------| +| **High** new surface, big market | **Large** | **Coordination with AKS** | **agent + ecosystem** | + +### Goals + +- A pod that calls IMDS gets a token scoped to *its* ServiceAccount, not the node identity. +- Operators write rules using familiar K8s identifiers (namespace, ServiceAccount, label selectors). +- Zero application code change for pods that already use Azure Workload Identity SDKs. + +## 2. Problem + +On an AKS node, any pod with hostNetwork or a permissive NetworkPolicy can `curl 169.254.169.254/metadata/identity/oauth2/token` and obtain the node managed identity. This is documented as the most common cluster-credential escalation. Existing mitigations are network-policy and Workload Identity but they are easily misconfigured. + +## 3. Pod Identity Resolution + +**Prerequisite (not yet implemented):** step 1 below requires extending the eBPF `sock_addr_audit_entry` to carry `cgroup_id` populated via `bpf_get_current_cgroup_id()`. Today the struct in [linux-ebpf/socket.h](../linux-ebpf/socket.h) (and the Windows counterpart in [ebpf/socket.h](../ebpf/socket.h)) only stores `process_id`. The field is proposed in the unified schema of [4.2 Core eBPF unification](Innovation-4.2-core-unify-ebpf.md). Until then, the agent must derive cgroup from `/proc//cgroup` via the audit entry's `process_id` (slower, racy for short-lived PIDs). + +1. eBPF audit map provides `cgroup_id` for each connect *(post-4.2)*; pre-4.2 fallback uses `process_id` + `/proc//cgroup`. +2. Agent reads `/proc//cgroup` + CRI socket (`containerd` / `cri-o`) to map cgroup → container ID → pod. +3. Pod metadata (namespace, name, ServiceAccount, labels) is cached from the local kubelet pod-resources API and the `--pod-manifest-path` watch. +4. Cache invalidated when pod sandbox is recreated; cached entries hold for ≤ 60 s after pod deletion to handle in-flight requests. + +## 4. Azure Workload Identity — Primer + +Azure Workload Identity (AWI) is the upstream Kubernetes-native way for pods to authenticate to Entra ID (Azure AD) **without storing secrets**. GPA's AKS mode consumes AWI as the identity source — the projected ServiceAccount token (or the resulting AAD token) becomes the caller identity bound to a pod, replacing host-level IMDS interception as the trust anchor. + +### 4.1 How it works + +1. The AKS cluster publishes a public OIDC discovery document (`/.well-known/openid-configuration` + JWKS). +2. Each pod gets a **projected ServiceAccount JWT** mounted by kubelet, signed by the cluster issuer. +3. The pod (via Azure SDK `DefaultAzureCredential` / `WorkloadIdentityCredential`) exchanges that JWT at Entra ID's `/oauth2/v2.0/token` endpoint using the *federated credential* flow (`client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`). +4. Entra ID validates `iss` (cluster issuer URL) and `sub` (`system:serviceaccount::`) against a **Federated Identity Credential** configured on the target App Registration / User-Assigned Managed Identity, and returns an AAD access token scoped to that workload. + +End-to-end token exchange (baseline AWI, no GPA in the path): + +sequenceDiagram autonumber participant Pod as Pod (Azure SDK) participant Kubelet participant Entra as Entra ID (AAD) participant Azure as Azure Resource +(Key Vault / Storage / ARM) Kubelet-\>\>Pod: Mount projected SA JWT +(iss = cluster OIDC, sub = system:serviceaccount:ns:sa) Pod-\>\>Pod: Read AZURE_FEDERATED_TOKEN_FILE + +AZURE_CLIENT_ID / TENANT_ID (injected env) Pod-\>\>Entra: POST /oauth2/v2.0/token +grant_type=client_credentials +client_assertion_type=jwt-bearer +client_assertion=\ Entra-\>\>Entra: Validate iss + sub against +Federated Identity Credential Entra--\>\>Pod: AAD access token (pod-scoped) Pod-\>\>Azure: API call with Bearer \ Azure--\>\>Pod: 200 OK + +With GPA in AKS-mode (§5) synthesizing the IMDS response for legacy callers: + +sequenceDiagram autonumber participant App as Legacy Pod +(curl 169.254.169.254) participant eBPF as eBPF redirect +(on node) participant GPA as GPA DaemonSet +(node-local) participant CRI as containerd / CRI participant Entra as Entra ID (AAD) App-\>\>eBPF: GET /metadata/identity/oauth2/token eBPF-\>\>GPA: Redirect + cgroup_id, pid GPA-\>\>CRI: Resolve cgroup -\> container -\> pod CRI--\>\>GPA: namespace, ServiceAccount, labels GPA-\>\>GPA: Policy check (§7 grants) +match k8s_pod principal alt allowed GPA-\>\>GPA: Read pod's projected SA JWT +from kubelet token volume GPA-\>\>Entra: jwt-bearer exchange +(SA JWT -\> AAD token) Entra--\>\>GPA: Pod-scoped AAD token GPA--\>\>App: IMDS-shaped JSON +{access_token, expires_in, ...} else denied GPA--\>\>App: 403 + structured audit event end + +### 4.2 Key pieces + +| Component | Role | +|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **AKS OIDC issuer** | Cluster publishes JWKS that Entra ID trusts. | +| **ServiceAccount annotation** | `azure.workload.identity/client-id: ` links SA → Entra app/UAMI. | +| **Federated Identity Credential** | On the Entra app/UAMI: trusts tokens where `iss=` and `sub=system:serviceaccount::`. | +| **Mutating webhook** | Injects `AZURE_*` env vars (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_FEDERATED_TOKEN_FILE`, `AZURE_AUTHORITY_HOST`) and the projected token volume into pods labeled `azure.workload.identity/use: "true"`. | +| **Azure SDK credential** | Performs the JWT-bearer exchange transparently. | + +### 4.3 Why it matters (vs. predecessors) + +- **No secrets** — replaces client-secret-based service principals. +- **Supersedes AAD Pod Identity v1** (deprecated May 2024), which used the NMI/MIC DaemonSets to intercept IMDS — brittle, racy at pod startup, and the very pattern this innovation hardens. +- **Per-pod identity** — each ServiceAccount maps to a distinct Entra identity, enabling least privilege. +- **Cloud-agnostic shape** — same OIDC federation model used by GitHub Actions, GKE, EKS. + +### 4.4 Relevance to GPA + +- GPA *does not replace* AWI — it complements it: pods that already use the AWI SDK keep working unchanged, and GPA additionally enforces a node-side policy so that **even compromised or misconfigured pods cannot fall back to the node MI** via raw IMDS. +- For pods that bypass the SDK and hit `169.254.169.254` directly, GPA can synthesize an IMDS-shaped response backed by the pod's AWI-derived AAD token (see §5) — making AWI the default, transparently. +- The `sub` claim from the projected SA JWT (`system:serviceaccount::`) is the same string GPA's rule engine matches in §7 grants — one identity model end-to-end. + +### 4.5 References + +- [AKS Workload Identity overview](https://learn.microsoft.com/azure/aks/workload-identity-overview) +- [azure-workload-identity project site](https://azure.github.io/azure-workload-identity/) +- [Entra ID workload identity federation](https://learn.microsoft.com/entra/workload-id/workload-identity-federation) + +## 5. Token Issuance + +- Pod's projected ServiceAccount token (OIDC, signed by cluster) is sent to AAD's federated credential endpoint; AAD returns a pod-scoped access token. +- The IMDS-shaped response is synthesized by GPA from this token: same JSON contract as IMDS `/identity/oauth2/token`. +- For non-Workload-Identity pods, behavior is policy-driven: *deny* (default), *node identity* (explicit opt-in), or a fixed allow-list. + +## 6. Deployment + +- **DaemonSet**: one privileged GPA pod per node; mounts host paths for cgroup, kubelet sockets, and the BPF FS. +- **NetworkPolicy** shipped as a sample: blocks all egress to 169.254/16 and 168.63/29 except from the GPA pod's host network. +- **Helm chart** + reference Azure Policy that pins the configuration. + +## 7. Rule Shape + + { + "version": 2, + "grants": [ + { + "principal": { "kind": "k8s_pod", + "namespace": "billing", + "serviceAccount": "frontend" }, + "scopes": ["imds:identity:read"] + }, + { + "principal": { "kind": "k8s_pod", + "namespace": "*", + "labelSelector": "tier=batch" }, + "scopes": ["imds:instance:read"] + } + ] + } + +- Builds on direction 1.4 capability scopes; principal types are pluggable. + +## 8. Integration + +- `proxy_agent/src/k8s/identity_resolver.rs` — cgroup → pod mapping with caching. +- `proxy_agent/src/k8s/token_issuer.rs` — exchanges projected SA token for AAD access token. +- Hooks into the authorizer after canonicalization to enrich `Claims` with pod identity. +- Telemetry: per-pod allow/deny counters with namespace+SA labels (bounded cardinality). + +## 9. Tests + +- Two pods in different namespaces — each gets only its own scoped token. +- Pod with no matching grant → deny + structured audit event. +- Pod sandbox restart → cache reflects new pod identity within a bounded window. +- NetworkPolicy fail-open test: even if NP misconfigured, GPA still authoritatively decides. + +## 10. Risks + +- **CRI sockets vary** across runtimes. Mitigation: pluggable resolver, support containerd + cri-o. +- **Race between connect and pod-metadata cache fill**. Mitigation: brief synchronous lookup on cache miss; bounded by timeout. +- **AKS coordination** on default install and policy library. + +## 11. Milestones + +| M | Deliverable | Exit | +|-----|------------------------------------------|------------------------------------------------| +| M1 | Identity resolver (cgroup → pod) + tests | Demo: per-pod identity in audit log | +| M2 | Token issuer + IMDS contract | Workload Identity SDK passes integration tests | +| M3 | Helm + NetworkPolicy + Azure Policy | Pilot on internal cluster | +| M4 | GA in AKS as opt-in | SLA met for 1 month | + +Detail design for direction 5.1. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-5.2-gate-more-endpoints.md b/doc/plans/Innovation-5.2-gate-more-endpoints.md new file mode 100644 index 00000000..da7053e2 --- /dev/null +++ b/doc/plans/Innovation-5.2-gate-more-endpoints.md @@ -0,0 +1,104 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Destination drivers](#design) +4. [4. KeyVault example](#kv) +5. [5. ARM token example](#arm) +6. [6. Config](#config) +7. [7. Integration](#integration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 5.2** · **New endpoints** + +# Detailed Design — Gate Additional Cloud-Credential Endpoints + +Generalize GPA from "IMDS + WireServer + HostGAPlugin" to a pluggable framework where additional cloud-credential endpoints (KeyVault MSI, ARM token, Storage MI) are governed by the same rule engine. + +**Files affected:** new `proxy_agent/src/destinations/` module, canonical model (2.1), classifier (1.4). + +> **Prerequisites:** [2.1 Canonical request](Innovation-2.1-canonical-request.md)[2.2 Typed policy (Cedar)](Innovation-2.2-typed-policy-cedar.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|---------------------------------|------------|------------------|-----------| +| **High** bigger product surface | **Medium** | **Low** additive | **agent** | + +### Goals + +- One rule language to govern all cloud-credential egress. +- New endpoints add a driver module; no core changes. +- Authoring stays declarative. + +## 2. Today + +Destination IPs are hard-coded; classifier knows only IMDS/WireServer/HostGAPlugin URL shapes. Customers wanting to gate KeyVault calls have no path through GPA. + +## 3. Destination Drivers + + pub trait DestinationDriver: Send + Sync { + fn id(&self) -> &'static str; + fn addresses(&self) -> &[SocketAddrSpec]; // IPs/ports for eBPF redirect map + fn classify(&self, req: &CanonicalRequest) -> Option; + fn signer(&self) -> &dyn RequestSigner; // adds creds before forwarding + fn upstream(&self, req: &CanonicalRequest) -> Upstream; // resolved URL/host + } + +- Built-in drivers: `imds`, `wireserver`, `hostga`. +- New drivers: `keyvault_msi`, `arm_token`, `storage_mi`. +- Drivers register their address specs at startup; eBPF redirect map is populated dynamically. + +## 4. KeyVault MSI Example + +- Destination spec: `*.vault.azure.net:443` (TLS-terminated; SNI used for routing). +- Classifier maps `GET /secrets/?api-version=...` to scope `keyvault:secret:read:`. +- Signer fetches an AAD token using PoP-bound identity (no static client secrets in the agent). +- Rules: `{ identity: "billing-pod", scopes: ["keyvault:secret:read:billing-vault"] }`. + +## 5. ARM Token Example + +- Destination: `management.azure.com:443`. +- Classifier maps verb + resource provider to typed scopes (`arm:Microsoft.Compute/virtualMachines:read`). +- Scopes intentionally align with Azure RBAC action names so policy is reviewable side-by-side with RBAC role definitions. + +## 6. Config + + { + "destinations": { + "enabled": ["imds","wireserver","hostga","keyvault_msi"], + "keyvault_msi": { "vaults": ["billing-vault","app-vault"] } + } + } + +- Enabled set drives which BPF redirect entries are loaded. +- Disabling a driver removes its redirect entries safely. + +## 7. Integration + +- Canonical model (2.1) needs to handle TLS-fronted destinations; for those, the agent terminates TLS using a fabric-provisioned cert (acceptable for the localhost hop). +- PoP (1.1) signing applies uniformly because tokens are minted with audience = driver id. +- OTel (3.2) labels include driver id. + +## 8. Tests + +- Per-driver classification golden vectors. +- Disable driver → no redirect entry → connection bypasses agent → fabric-side AAD rejects (defense-in-depth). +- End-to-end: pod calls KeyVault → agent enforces scope → upstream succeeds. + +## 9. Risks + +- **TLS termination at localhost** requires careful cert handling. Mitigation: certs generated per-boot, pinned by SPKI; never exposed off-host. +- **Endpoint churn** — APIs evolve. Mitigation: driver tables updateable independently of agent core. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|--------------------------------------------|-------------------------------| +| M1 | Driver trait + refactor existing endpoints | No behavior change | +| M2 | KeyVault MSI driver | Pilot customer | +| M3 | ARM token driver | Mapped scopes align with RBAC | + +Detail design for direction 5.2. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-5.3-cross-cloud-port.md b/doc/plans/Innovation-5.3-cross-cloud-port.md new file mode 100644 index 00000000..8737c1be --- /dev/null +++ b/doc/plans/Innovation-5.3-cross-cloud-port.md @@ -0,0 +1,100 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Why cross-cloud](#why) +3. [3. Abstraction](#abstraction) +4. [4. AWS driver](#aws) +5. [5. GCP driver](#gcp) +6. [6. Packaging](#packaging) +7. [7. Integration](#integration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones](#milestones) + +**GPA** · **Direction 5.3** · **Multi-cloud** + +# Detailed Design — Cross-Cloud Port + +Refactor signer + destinations into traits so community-supported drivers can govern AWS IMDSv2 and GCP metadata server traffic with the same eBPF chokepoint and rule engine. Positioning: a *metadata firewall for any cloud*. + +**Files affected:** trait surfaces in `proxy_agent/src/destinations/` and `proxy_agent/src/key_keeper/`. + +> **Prerequisites:** [4.2 Core eBPF unification](Innovation-4.2-core-unify-ebpf.md)[2.1 Canonical request](Innovation-2.1-canonical-request.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------------|------------|------------------|---------------------| +| **Medium** ecosystem reach | **Medium** | **Low** additive | **agent + drivers** | + +### Goals + +- AWS / GCP metadata services can be governed by the same agent. +- Driver authors implement two traits; core remains untouched. +- Existing Azure behavior unchanged. + +## 2. Why Cross-Cloud + +- AWS IMDSv2 and GCP metadata both have the confused-deputy class of bugs that motivated GPA. +- Multi-cloud security teams want one mental model for "what can read instance credentials." +- Architecture (cgroup eBPF + identity-aware proxy) is cloud-neutral; only the signer and destination set are Azure-specific. + +## 3. Abstraction Lines + + pub trait CloudPlatform: Send + Sync { + fn name(&self) -> &'static str; + fn destinations(&self) -> Vec>; + fn local_identity_source(&self) -> Box; // node identity / instance role + } + + pub trait RequestSigner: Send + Sync { + fn sign(&self, req: &mut http::Request, dest: &Destination, caller: &ResolvedIdentity) + -> Result<(), SignError>; + } + +- Azure platform implementation reuses today's logic. +- AWS / GCP implementations provided as separate crates so they can iterate independently. + +## 4. AWS Driver + +- Destinations: `169.254.169.254:80` (IMDS) and instance-profile endpoints. +- Signer: re-mints IMDSv2 session tokens with TTL bound by the caller's policy; rejects IMDSv1 PUT-less requests entirely. +- Caller-scoped tokens optionally bound to pod identity in EKS. + +## 5. GCP Driver + +- Destinations: `metadata.google.internal` (169.254.169.254) and `metadata.google.internal:80`. +- Mandates `Metadata-Flavor: Google` header presence (the standard GCP SSRF guard) and rejects requests without it. +- Authorization scopes derived from URL path (`/instance/service-accounts/default/token` → `gcp:identity:read`). + +## 6. Packaging + +- `gpa-azure`, `gpa-aws`, `gpa-gcp` binaries built from the same workspace with feature flags. +- Default release is `gpa-azure`; AWS/GCP builds maintained by community + signed via the same Sigstore process (3.4). + +## 7. Integration + +- Canonical model (2.1) cloud-neutral; `Destination::Unknown` becomes `Destination::CloudSpecific(&'static str)` for non-fabric endpoints. +- Capability scopes (1.4) namespaced per platform: `aws:sts:assume_role`, `gcp:storage:read`. +- Telemetry (3.2) labels include `cloud`. + +## 8. Tests + +- Conformance tests per driver against documented metadata API shapes. +- Regression: Azure default build behaves byte-identically to today's release. +- SSRF regression: cross-cloud known bypasses (e.g. `Metadata-Flavor` missing on GCP) blocked. + +## 9. Risks + +- **Community ownership** for non-Azure drivers — must clearly mark non-Azure builds as community-maintained. +- **License compatibility** for any cloud-specific SDKs — prefer plain HTTP clients. + +## 10. Milestones + +| M | Deliverable | Exit | +|-----|---------------------------------|-------------------------------------------| +| M1 | Trait refactor; Azure unchanged | All existing tests pass | +| M2 | AWS driver MVP | EC2 instance with IMDSv2 enforcement demo | +| M3 | GCP driver MVP | GCE instance demo | + +Detail design for direction 5.3. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-6.1-policy-simulator.md b/doc/plans/Innovation-6.1-policy-simulator.md new file mode 100644 index 00000000..165ca4c5 --- /dev/null +++ b/doc/plans/Innovation-6.1-policy-simulator.md @@ -0,0 +1,110 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. CLI design](#cli) +4. [4. Library mode](#lib) +5. [5. Output format](#output) +6. [6. Integration](#integration) +7. [7. Tests](#tests) +8. [8. Risks](#risks) +9. [9. Milestones](#milestones) + +**GPA** · **Direction 6.1** · **DX** + +# Detailed Design — Policy Simulator CLI + +A `gpa policy simulate` command that takes a rule file, a request, and a caller identity, and reports the exact decision plus the matched rule and canonicalization trace. Used by operators in CI before rolling rules and by support engineers to reproduce production decisions. + +**Files affected:** new `proxy_agent/src/bin/gpa_policy.rs`, library reuse from `proxy_agent/src/authorization/`. + +> **Prerequisites:** [2.1 Canonical request](Innovation-2.1-canonical-request.md)[2.2 Typed policy (Cedar)](Innovation-2.2-typed-policy-cedar.md)[2.3 Versioned snapshots](Innovation-2.3-versioned-snapshots.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|------------------------------|-----------|---------|---------| +| **Medium** author confidence | **Small** | **Low** | **CLI** | + +### Goals + +- Same engine, same answer — the CLI uses the production authorizer (no reimplementation). +- Round-tripable input: stdin / file / args. +- Reproduces decisions captured in the audit log byte-for-byte. + +## 2. Today + +Rule authors have no offline way to test rule changes against canonical request inputs short of standing up a VM. Production debugging requires reading source. There is no "explain" output. + +## 3. CLI Design + + gpa policy simulate \ + --rules ./rules.json \ + --request ./req.json \ + --caller ./caller.json \ + [--explain] [--json] + +| Flag | Meaning | +|-------------|------------------------------------------------------| +| `--rules` | Rules JSON, same schema as production | +| `--request` | HTTP request shape: method, url, headers, body | +| `--caller` | Caller identity: pid + measurement + cgroup + claims | +| `--explain` | Emit canonicalization steps and matched rule | +| `--json` | Machine-readable output for CI | + +Exit codes: `0` allow, `1` deny, `2` error (malformed input). + +## 4. Library Mode + + // proxy_agent_shared/src/policy_eval.rs + pub fn simulate(rules: &RuleSet, req: &CanonicalRequest, caller: &ResolvedIdentity) + -> SimulationResult { /* ... */ } + + pub struct SimulationResult { + pub decision: Decision, // Allow / Deny + pub matched_rule: Option, + pub trace: Vec, // each canonicalization step + } + +- Same crate consumed by unit tests across the workspace and by the future VS Code extension (6.3). + +## 5. Output Format + + $ gpa policy simulate --rules r.json --request req.json --caller c.json --explain + DECISION: deny + RULE: none matched + TRACE: + url.raw = http://169.254.169.254/metadata/instance?api-version=2021-12-13 + url.host_norm = 169.254.169.254 + destination = imds + path.norm = /metadata/instance + scope = imds:instance:read + caller.id = sha256:abcd... (binary unmeasured) + caller.scopes = [] + reason = caller.scopes did not include imds:instance:read + +## 6. Integration + +- Same canonical types from direction 2.1. +- Reads remote rule format and the local override (`proxy_agent/src/authorization/local_rules.rs`) so simulation mirrors production resolution order. +- Importable from CI: `gpa policy simulate --rules ./new.json --suite ./golden/` applies a directory of test cases. + +## 7. Tests + +- Round-trip: every entry in `doc/audit-samples/` reproduces exactly under simulation. +- CI corpus: golden test cases checked in; PR-blocking if rules diverge from expectations. +- Fuzz: random rules + random requests; cross-check engine vs. simulator. + +## 8. Risks + +- **Drift** between simulator and production. Mitigation: same library; CI gates on equality of decisions. + +## 9. Milestones + +| M | Deliverable | Exit | +|-----|----------------------------|--------------------------------| +| M1 | `simulate()` library + CLI | Reproduces 20 audit samples | +| M2 | Golden test runner | Used in CI | +| M3 | VS Code integration (6.3) | Inline decisions while editing | + +Detail design for direction 6.1. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-6.2-gpa-doctor.md b/doc/plans/Innovation-6.2-gpa-doctor.md new file mode 100644 index 00000000..ebadff07 --- /dev/null +++ b/doc/plans/Innovation-6.2-gpa-doctor.md @@ -0,0 +1,88 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Checks](#checks) +3. [3. Report](#report) +4. [4. Production safety](#safety) +5. [5. Integration](#integration) +6. [6. Tests](#tests) +7. [7. Risks](#risks) +8. [8. Milestones](#milestones) + +**GPA** · **Direction 6.2** · **Operability** + +# Detailed Design — gpa-doctor + +One-command hardening check derived from the pentest suite. Runs read-only probes that mirror specific pentest cases (A1, E1, E4, G4, D4) and emits a coloured green/yellow/red report. Safe to run on production. + +**Files affected:** new `proxy_agent/src/bin/gpa_doctor.rs`; reuses telemetry endpoints. + +> **Prerequisites:** [3.1 Hash-chained log](Innovation-3.1-hash-chained-log.md)[3.2 OTel export](Innovation-3.2-otel-export.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|-----------------------------|-----------|-------------------|---------| +| **High** support deflection | **Small** | **Low** read-only | **CLI** | + +### Goals + +- One command. One report. Zero side effects. +- Each finding cites the pentest case it derives from. +- Run by support engineers and by customers themselves. + +## 2. Checks + +| Check | Maps to | What it does | +|---------------------------------|---------|---------------------------------------------------------------------------------| +| eBPF programs loaded & attached | A1 | Verifies the redirect program is live; tests connect-to-fabric path is captured | +| Loopback bypass | E1 | Probes whether non-agent local processes can reach upstream fabric directly | +| Header smuggling | E4 | Confirms agent strips/rejects suspicious headers known to bypass authorization | +| State file integrity | G4 | Checks owner/mode/SELinux label on `/var/lib/azure/proxyagent/*` | +| Time/clock sanity | D4 | Verifies system clock skew vs WireServer; PoP token expirations | +| Audit log shape | — | Last 1000 entries parse cleanly; no broken hash chain (ties to 3.1) | + +## 3. Report + + $ gpa-doctor + [ OK ] eBPF programs loaded (cgroup_connect4, sk_lookup) [A1] + [ WARN ] Loopback bypass possible: nginx on :8080 has direct route [E1] + [ OK ] Header strip configured [E4] + [ FAIL ] /var/lib/azure/proxyagent/state.json mode is 0644 (want 0640) [G4] + [ OK ] Clock skew 12 ms [D4] + [ OK ] Audit log chain intact (last 1000 entries) + 2 OK · 1 WARN · 1 FAIL · suggested actions printed below + +- Each line ends with the pentest case ID so customers can find the public write-up. +- `--json` mode for monitoring integration. +- Suggestions reference exact `chmod` / config edits. + +## 4. Production Safety + +- All probes are read or self-targeted (we only test our own listener). +- No upstream traffic generated except a single benign IMDS "instance" GET (idempotent). +- Runtime \< 1 second; CPU bounded. +- Refuses to run as a non-root user with a clear message — except for the read-only checks that don't need root. + +## 5. Integration + +- Optional cron/systemd timer publishes the report to Geneva/OTel daily. +- Linked from the README, troubleshooting docs, and CES/CSS playbooks. + +## 6. Tests + +- Each check has a positive and a negative fixture. +- Idempotency: 100 runs produce identical reports given a static system. + +## 7. Risks + +- **False positives** on heterogeneous host configurations. Mitigation: WARN (not FAIL) for ambiguous findings; "more info" link. + +## 8. Milestones + +| M | Deliverable | Exit | +|-----|------------------------------|-------------------------------| +| M1 | Six built-in checks + report | Used by CSS in one ticket | +| M2 | JSON mode + nightly reporter | Telemetry shows fleet posture | + +Detail design for direction 6.2. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-6.3-rule-authoring-ux.md b/doc/plans/Innovation-6.3-rule-authoring-ux.md new file mode 100644 index 00000000..ff5dd648 --- /dev/null +++ b/doc/plans/Innovation-6.3-rule-authoring-ux.md @@ -0,0 +1,101 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. JSON Schema](#schema) +3. [3. VS Code extension](#ext) +4. [4. Diff view](#diff) +5. [5. Integration](#integration) +6. [6. Tests](#tests) +7. [7. Risks](#risks) +8. [8. Milestones](#milestones) + +**GPA** · **Direction 6.3** · **DX** + +# Detailed Design — Rule Authoring UX + +A JSON Schema for the rule format and a VS Code extension that gives autocomplete, validation, hover docs, and a diff view between the remote rule set and the local override (`local_rules.rs`). + +**Files affected:** new `schema/rules.schema.json`; new `tools/vscode-gpa-rules/` extension; uses the simulator from 6.1. + +> **Prerequisites:** [2.2 Typed policy (Cedar)](Innovation-2.2-typed-policy-cedar.md)[2.3 Versioned snapshots](Innovation-2.3-versioned-snapshots.md)[6.1 Policy simulator](Innovation-6.1-policy-simulator.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------------|------------|---------|-------------| +| **Medium** author velocity | **Medium** | **Low** | **tooling** | + +### Goals + +- Schema-driven authoring; no need to memorize field names. +- Real-time decision preview using simulator (6.1). +- Make local-override drift obvious. + +## 2. JSON Schema + + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gpa.azure.com/schema/rules.json", + "type": "object", + "required": ["version","grants"], + "properties": { + "version": { "const": 2 }, + "grants": { + "type": "array", + "items": { "$ref": "#/$defs/grant" } + } + }, + "$defs": { + "grant": { + "type": "object", + "required": ["principal","scopes"], + "properties": { + "principal": { "$ref": "#/$defs/principal" }, + "scopes": { "type": "array", "items": { "pattern": + "^(imds|wireserver|hostga|keyvault|arm):" } }, + "conditions": { "$ref": "#/$defs/conditions" } + } + } + } + } + +- Schema is the source of truth; agent parser is generated from it (or tested against it). +- Published at a stable URL so editors auto-fetch. + +## 3. VS Code Extension + +- Activates on files matching `**/gpa.rules.json` or with the JSON schema header. +- Autocomplete for principal kinds, scope strings, conditions. +- Hover docs explain each scope (e.g. "imds:identity:read — allows the token endpoint, response includes the access token"). +- Status-bar shows simulator verdict for a focused request (loaded from a sibling `.req.json`). +- Quick fix: convert `identity: "*"` to scoped rules using cluster/fleet audit data. + +## 4. Diff View + +- "GPA: Compare Local Override to Remote" command opens a diff between the remote rule set and the compiled-in overrides from `proxy_agent/src/authorization/local_rules.rs`. +- Renders both as canonical JSON for clean diff regardless of source format. +- Flags any local rule that is broader than the remote rule. + +## 5. Integration + +- Extension invokes `gpa policy simulate` via a sidecar process (no remote dependencies). +- Schema versioned alongside the agent; CI generates updated schema on each release. + +## 6. Tests + +- Schema validation matches agent parser on a corpus of known-good + known-bad rule files. +- Extension contract tests using `vscode-test`. + +## 7. Risks + +- **Schema drift** vs. parser. Mitigation: generate parser tests from schema; CI fails on drift. + +## 8. Milestones + +| M | Deliverable | Exit | +|-----|---------------------------------------------|------------------------------------------| +| M1 | JSON Schema + drift CI | Schema used in agent tests | +| M2 | VS Code extension (autocomplete + simulate) | Used by ≥ 3 rule authors | +| M3 | Diff view | Local override drift visible at a glance | + +Detail design for direction 6.3. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-6.4-wasm-rule-sandbox.md b/doc/plans/Innovation-6.4-wasm-rule-sandbox.md new file mode 100644 index 00000000..93c0054f --- /dev/null +++ b/doc/plans/Innovation-6.4-wasm-rule-sandbox.md @@ -0,0 +1,106 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Host ABI](#abi) +3. [3. Limits](#limits) +4. [4. Example](#example) +5. [5. Opt-in](#optin) +6. [6. Integration](#integration) +7. [7. Tests](#tests) +8. [8. Risks](#risks) +9. [9. Milestones](#milestones) + +**GPA** · **Direction 6.4** · **Extensibility** + +# Detailed Design — WASM Rule Sandbox + +An opt-in WebAssembly extension point for organizations that need conditions richer than the declarative rule language. WASM modules are deny-by-default sandboxed: no syscalls, no clocks, no network, no I/O — just compute over the canonical request and claims. + +**Files affected:** new `proxy_agent/src/authorization/wasm/` module; integrates with rule engine (after Cedar from 2.2). + +> **Prerequisites:** [2.2 Typed policy (Cedar)](Innovation-2.2-typed-policy-cedar.md) + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|-------------------------------|------------|-------------------------|-----------| +| **Medium** niche but powerful | **Medium** | **Sandbox correctness** | **agent** | + +### Goals + +- Custom conditions without exposing the agent to arbitrary code. +- Hard, enforced CPU + memory limits. +- Modules are content-addressed; auditable via 3.4 supply-chain pipeline. + +## 2. Host ABI + + // Exported by the WASM module: + // fn decide(req_ptr: i32, req_len: i32, + // claims_ptr: i32, claims_len: i32) -> i32 // 0 = allow, 1 = deny + + // Imported from host (the only imports allowed): + // fn log(ptr: i32, len: i32); // append to a per-decision diag string + // fn abort(); // immediate deny + + // No clocks. No filesystem. No network. No randomness. + +- Inputs and outputs are JSON-encoded canonical request + resolved claims. +- Runtime: [wasmtime](https://github.com/bytecodealliance/wasmtime) with all WASI features disabled. +- Memory cap: 16 MB; fuel cap: 100,000 instructions per call. + +## 3. Limits + +| Limit | Default | Reason | +|----------------------|---------------------|-------------------------| +| Memory | 16 MB | Bounded request size | +| Fuel | 100k instructions | ~1 ms on typical hosts | +| Imports | `log`, `abort` only | Deny-by-default surface | +| Modules per rule set | 16 | Bounded compile time | +| Wall-clock per call | 5 ms hard kill | Tail-latency guard | + +## 4. Example + + // rust crate compiled to wasm32-unknown-unknown + #[no_mangle] + pub extern "C" fn decide(req_ptr: i32, req_len: i32, + claims_ptr: i32, claims_len: i32) -> i32 { + let req: CanonicalRequest = json_read(req_ptr, req_len); + let claims: ResolvedIdentity = json_read(claims_ptr, claims_len); + // Custom rule: only allow IMDS identity reads from pods that exist for > 5 min + if req.destination == "imds" && claims.pod_age_secs.unwrap_or(0) >= 300 { 0 } else { 1 } + } + +The module is referenced from a normal grant: `"condition": { "wasm": "sha256:..."}`. + +## 5. Opt-in + +- Disabled by default. Enabled per-deployment via `--enable-wasm-rules`. +- Module hash must be present in the trust list (Sigstore-signed; see 3.4). +- Per-call telemetry tagged with module hash so noisy modules are findable. + +## 6. Integration + +- After Cedar (2.2) returns "allow with condition C", the engine invokes the WASM condition module. +- Result combined with Cedar's verdict; deny always wins. +- Module compiled once at load, cached in memory. + +## 7. Tests + +- Resource-exhaustion modules (infinite loop, oversize allocation) are bounded as expected. +- Malformed input handling: module returns deny; agent treats abort as deny. +- Sandbox escape attempts (using removed WASI imports) fail to link. + +## 8. Risks + +- **wasmtime CVEs** — pin to LTS; track advisories. +- **Performance regression** if customers push hot paths into WASM. Mitigation: opt-in + telemetry visible. + +## 9. Milestones + +| M | Deliverable | Exit | +|-----|--------------------------------------|----------------------------| +| M1 | Host ABI + limits + reference module | Internal demo | +| M2 | Sigstore-signed module store | Trust list enforced | +| M3 | Pilot | One real customer use-case | + +Detail design for direction 6.4. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-7.1-io-uring-hot-path.md b/doc/plans/Innovation-7.1-io-uring-hot-path.md new file mode 100644 index 00000000..5e91ec03 --- /dev/null +++ b/doc/plans/Innovation-7.1-io-uring-hot-path.md @@ -0,0 +1,84 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Design](#design) +4. [4. Feature flag](#features) +5. [5. Benchmark plan](#bench) +6. [6. Integration](#integration) +7. [7. Tests](#tests) +8. [8. Risks](#risks) +9. [9. Milestones](#milestones) + +**GPA** · **Direction 7.1** · **Perf** + +# Detailed Design — io_uring Hot Path + +Switch the IMDS GET hot path to `tokio-uring` (or `monoio`) behind a feature flag. Cuts syscall overhead for the highest-volume request shape. + +**Files affected:** `proxy_agent/src/proxy/proxy_server.rs`; new runtime selection module. + +> **Prerequisites:** None — performance-only change, independent of identity / policy / audit work. + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|--------------------------|------------|------------------|-----------------| +| **Medium** latency + CPU | **Medium** | **Runtime swap** | **proxy_agent** | + +### Goals + +- p50 latency reduction ≥ 30% on IMDS GET path on modern kernels. +- CPU per million requests reduced ≥ 20%. +- No regression on legacy kernels (feature flag off). + +## 2. Today + +Tokio + epoll on Linux. Every request: `accept`, `read`, `write`, `read`, `write`. For a fast localhost GET this is dominated by syscall overhead and scheduler wake-ups. + +## 3. Design + +- Use `tokio-uring` for accept + read + write on the proxy hot loop; the rest of the agent stays on stock Tokio. +- Single-threaded reactor per CPU; SO_REUSEPORT to spread accept. +- Buffer pool: reusable 4 KB buffers registered with io_uring (zero allocations on hot path). +- For unauthorized requests the agent still falls back to the standard path (cold). + +## 4. Feature Flag + +- Cargo feature `io_uring`; off by default. +- Runtime probe: kernel ≥ 5.15 and unrestricted `io_uring_setup`; otherwise the proxy falls back to the Tokio path with one INFO log line. +- Attestation endpoint (3.3) advertises which path is in use. + +## 5. Benchmark Plan + +| Workload | Metric | Target | +|---------------------|-------------------|------------------------------| +| 1 client × 10k req | p50 / p99 latency | ≥ 30% / ≥ 20% lower | +| 50 clients × 10 min | RPS / CPU | ≥ 30% higher RPS at same CPU | +| 500 clients | Tail latency | p99.9 stable | + +## 6. Integration + +- Authorizer stays unchanged; lives behind an async boundary. +- Telemetry (3.2) emits `gpa_runtime_kind` label. + +## 7. Tests + +- Functional parity test: same input → same response on both runtimes. +- Kernel matrix CI (5.4, 5.15, 6.1, 6.8): flag-on tests skipped on unsupported kernels. +- Fuzz: malformed HTTP requests handled identically. + +## 8. Risks + +- **io_uring CVEs** on older kernels. Mitigation: explicit kernel-version gate; documented minimum. +- **Code split** between two runtimes. Mitigation: keep the hot path tiny; AuthZ remains shared. + +## 9. Milestones + +| M | Deliverable | Exit | +|-----|----------------------------------|-----------------------| +| M1 | Hot path prototype (flag off) | Benchmark numbers | +| M2 | Feature flag + probe + telemetry | Internal opt-in | +| M3 | Default-on for supported kernels | SLOs hold for 1 month | + +Detail design for direction 7.1. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-7.2-zero-copy-splice.md b/doc/plans/Innovation-7.2-zero-copy-splice.md new file mode 100644 index 00000000..1b922932 --- /dev/null +++ b/doc/plans/Innovation-7.2-zero-copy-splice.md @@ -0,0 +1,75 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Design](#design) +4. [4. Header handling](#headers) +5. [5. Integration](#integration) +6. [6. Tests](#tests) +7. [7. Risks](#risks) +8. [8. Milestones](#milestones) + +**GPA** · **Direction 7.2** · **Perf** + +# Detailed Design — Zero-Copy splice(2) after AuthZ Pass + +Once authorization succeeds, splice the client socket directly to the upstream socket via `splice(2)`. The agent stops touching the payload; bytes flow through a kernel pipe without user-space copy. + +**Files affected:** `proxy_agent/src/proxy/proxy_server.rs`, `proxy_agent/src/proxy/upstream.rs`. + +> **Prerequisites:** None — performance-only change, independent of identity / policy / audit work. + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|----------------------------|-----------|---------|-----------------| +| **Medium** bandwidth + CPU | **Small** | **Low** | **proxy_agent** | + +### Goals + +- Zero user-space copies for the response body. +- Reduce CPU by ≥ 15% on bodies \> 16 KB. +- Preserve TLS-terminated paths (5.2) by skipping splice when content must be inspected/transformed. + +## 2. Today + +Each response goes `recv → userland buffer → send`. For large IMDS goal-state pulls or WireServer extensions data this dominates CPU. + +## 3. Design + +client ──accept──\> agent │ ▼ AuthZ pass (headers parsed) │ ▼ open upstream socket │ ▼ splice(client_fd, upstream_fd) for request body │ ▼ splice(upstream_fd, client_fd) for response body │ └── still record byte counts via tee(2) for audit + +- Two kernel pipes per direction; `splice(2)` with `SPLICE_F_MOVE | SPLICE_F_MORE`. +- `tee(2)` teaches an audit ring of message lengths without copying payload. +- Skip splice when: TLS-terminated, payload transformation required, or body \< 4 KB. + +## 4. Header Handling + +- Agent still parses request line + headers in user space (needed for AuthZ + canonical model). +- Once the boundary is found and the verdict is allow, the remaining body and response are spliced. +- Response headers from upstream are parsed and re-emitted under agent control; only the body is spliced. + +## 5. Integration + +- Falls back to copy-loop on non-Linux and when splice is unsupported. +- Telemetry: `gpa_splice_bytes_total` vs `gpa_copy_bytes_total`. + +## 6. Tests + +- Functional parity: identical byte-for-byte response under both paths. +- Large body benchmark: ≥ 15% CPU reduction at 1 MB bodies. +- Short body verification: short paths unaffected (still copy). + +## 7. Risks + +- **Connection lifecycle** bugs are subtle (half-close, RST during splice). Mitigation: explicit unit + integration tests for terminations. +- **Bypassing future hooks** that would want to inspect payload — keep splice optional per destination driver. + +## 8. Milestones + +| M | Deliverable | Exit | +|-----|---------------------------|----------------------------------| +| M1 | Splice for IMDS large GET | CPU target met | +| M2 | Per-driver opt-in | TLS-terminated paths skip splice | + +Detail design for direction 7.2. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-7.3-crate-consolidation.md b/doc/plans/Innovation-7.3-crate-consolidation.md new file mode 100644 index 00000000..5ec483f5 --- /dev/null +++ b/doc/plans/Innovation-7.3-crate-consolidation.md @@ -0,0 +1,192 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Today](#today) +3. [3. Code moves](#moves) +4. [4. musl static](#musl) +5. [5. Bloat budget](#bloat) +6. [6. `signing` feature gate](#signing) +7. [7. Integration](#integration) +8. [8. Tests](#tests) +9. [9. Risks](#risks) +10. [10. Milestones & status](#milestones) + +**GPA** · **Direction 7.3** · **Footprint** · **Status: ✅ done** + +# Detailed Design — Crate Consolidation, musl Static, Bloat Budget + +Move duplicated helpers (logging init, config loader, version probe) into `proxy_agent_shared`. Ship a single static `musl` binary per role with a hard cargo-bloat budget enforced in CI. + +**Files affected:** `proxy_agent_shared/`, `proxy_agent/`, `proxy_agent_extension/`, `proxy_agent_setup/`, CI pipeline. + +> **Prerequisites:** None — internal refactor, independent of feature work. + +## 1. Overview & Goals + +| Impact | Effort | Risk | Scope | +|------------------------------|-----------|---------|---------------| +| **Internal** maintainability | **Small** | **Low** | **workspace** | + +### Status snapshot — ✅ done + +- **Done** — musl static binaries (`x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`) are already produced by [`build-linux.sh`](../../build-linux.sh) driven from [`reusable-build.yml`](../../.github/workflows/reusable-build.yml); Windows MSVC builds go through [`build.cmd`](../../build.cmd) in the same pipeline. +- **Done** — PR 353: logger init + GPA service name moved to `proxy_agent_shared`. +- **Done** — PR 352: `cargo-bloat` budget enforced per-(target, role) on top of those builds; `signing` Cargo feature lets non-signing binaries drop OpenSSL. +- **Out of scope** — *config loader* and *OS/version probe* consolidation. See [§3](#moves) for why: only `proxy_agent` has a JSON config loader (the other two binaries don't load JSON config at all), and the OS/version probe already lives in `proxy_agent_shared`. + +### Goals + +- Single source of truth for cross-cutting helpers. +- One static binary per role: `azure-proxy-agent`, `azure-proxy-agent-ext`, `azure-proxy-agent-setup`. +- Binary size capped in CI — surprise growth blocks merge. + +## 2. Today + +Logger init used to be duplicated across `proxy_agent`, `proxy_agent_extension`, and `proxy_agent_setup` (PR 353 removed that), and the OS service-name constant had silently drifted across `proxy_agent` / `proxy_agent_extension` / `proxy_agent_setup` (PR 353 unified that too). The OS / version probe already lives in `proxy_agent_shared::{current_info, linux, windows}`, and a workspace-wide JSON config loader exists only in `proxy_agent` — the other two binaries don't read a JSON config, so there is no "third copy" to fold in. + +## 3. Code Moves + +| From | To | Status | Notes | +|----------------------------|---------------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| per-crate logger setup | `proxy_agent_shared::logger::init_loggers` | **done** (PR 353) | One helper takes `(log_folder, &[(key, file)], primary_key, max_size, max_count, level)` and constructs the `RollingLogger` map in one place. | +| GPA service / display name | `proxy_agent_shared::constants` | **done** (PR 353) | `PROXY_AGENT_SERVICE_NAME` (`GuestProxyAgent` on Windows, `azure-proxy-agent` on Linux) + `PROXY_AGENT_SERVICE_DISPLAY_NAME` live in one module. | +| OS / version probe | `proxy_agent_shared::{current_info, linux, windows}` | **already done** | Pre-existing. `proxy_agent_extension/src/handler_main.rs` already calls into `proxy_agent_shared::{linux, windows}::get_os_version`; no second copy to fold in. | +| HTTP client helpers | `proxy_agent_shared::hyper_client` | **already done** | `hyper_client` is the only HTTP client; the three binaries all call into it directly. The per-binary "wrapper" assumption from the original plan turned out not to exist. | +| ~~per-crate config loaders~~ | ~~`proxy_agent_shared::config`~~ | **dropped** (not needed) | Only `proxy_agent/src/common/config.rs` reads a JSON config file. `proxy_agent_extension` is driven by the VM-extension HandlerEnvironment + `*.settings` sequence files (a different shape, owned by the extension framework), and `proxy_agent_setup` has no runtime config at all. Nothing to consolidate. | + +### What the logger consolidation looks like + +Before (duplicated across `proxy_agent`, `proxy_agent_extension`, `proxy_agent_setup`): + + let agent_logger = RollingLogger::create_new(log_folder.clone(), "ProxyAgent.log".to_string(), MAX_SIZE, MAX_COUNT); + let connection_logger = RollingLogger::create_new(log_folder.clone(), "ProxyAgent.Connection.log".to_string(), MAX_SIZE, MAX_COUNT); + let mut loggers = HashMap::new(); + loggers.insert(AGENT_LOGGER_KEY.to_string(), agent_logger); + loggers.insert(CONNECTION_LOGGER_KEY.to_string(), connection_logger); + logger_manager::set_loggers(loggers, AGENT_LOGGER_KEY.to_string(), level); + +After (one call from each binary, including the in-tree `logger_manager` tests): + + proxy_agent_shared::logger::init_loggers( + log_folder, + &[ + (logger::AGENT_LOGGER_KEY, "ProxyAgent.log"), + (ConnectionLogger::CONNECTION_LOGGER_KEY, "ProxyAgent.Connection.log"), + ], + logger::AGENT_LOGGER_KEY, + constants::MAX_LOG_FILE_SIZE, + constants::MAX_LOG_FILE_COUNT as u16, + config::get_file_log_level(), + ); + +Net: ~60 lines of boilerplate removed across the three binaries, and the rolling-logger contract (panic if `primary_key` isn't registered) is enforced in one place. + +## 4. musl Static Build + +**Status: done** (pre-existing; not part of PR 352 / 353). Both Linux targets are built by [`build-linux.sh`](../../build-linux.sh) and invoked from the shared [`reusable-build.yml`](../../.github/workflows/reusable-build.yml) workflow (`build-linux-amd64` / `build-linux-arm64`); Windows MSVC builds go through [`build.cmd`](../../build.cmd) in the same file (`build-windows-amd64` / `build-windows-arm64`). The bloat workflow piggy-backs on these and only adds the regression gate on top of them. + +- CI targets: `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl` (selected by the `-Target amd64|arm64` argument in `build-linux.sh`). +- No dynamic libc dependency for the agent role; the setup tool ships from the same musl target. +- Cross-distro install: same binary on RHEL 8/9, Ubuntu 22.04/24.04, Azure Linux. +- eBPF object files shipped separately (loaded by libbpf at runtime), independent of libc. + +## 5. Bloat Budget + +**Status: done (PR 352).** Enforced on every PR via [`.github/workflows/bloat.yml`](../../.github/workflows/bloat.yml) and [`ci/check_bloat.py`](../../ci/check_bloat.py). See [`ci/README.md`](../../ci/README.md) for the full design and override path. + +The gate has two ceilings on purpose: + +- **Absolute ceiling** (`--max-binary-bytes`) catches "total growth" no matter where it came from. +- **Per-crate share ceiling** (`--max-crate-share`, default `0.10` = 10% of `.text`) catches "one bad dependency dominates the binary" even when the total is under the absolute ceiling — the failure message names the offending crate, turning a vague size regression into a specific code-review conversation. + +First-party workspace crates (`azure-proxy-agent`, `ProxyAgentExt`, `proxy_agent_setup`, `proxy_agent_shared`) are exempt from the share gate; the absolute ceiling still bounds them. Per-(target, crate, dependency) ceiling overrides go through `--crate-share-override clap_builder=0.35` so the policy stays narrow: raising the ceiling for `clap` in `proxy_agent_setup` does not also raise it for every other binary. + +### Per-(target, role) ceilings + +A Linux musl binary and a Windows MSVC binary (with `static_vcruntime` + `windows-sys`) have very different baselines. One shared ceiling would either let Windows regress silently or false-flag every Linux PR, so the workflow runs as a matrix: + +| Target | Role binary | Max stripped size | +|------------------------------|-----------------------|-------------------| +| `x86_64-unknown-linux-musl` | `azure-proxy-agent` | 20 MB | +| `x86_64-unknown-linux-musl` | `ProxyAgentExt` | 9 MB | +| `x86_64-unknown-linux-musl` | `proxy_agent_setup` | 6 MB | +| `aarch64-unknown-linux-musl` | `azure-proxy-agent` | 20 MB | +| `aarch64-unknown-linux-musl` | `ProxyAgentExt` | 16 MB | +| `aarch64-unknown-linux-musl` | `proxy_agent_setup` | 11 MB | +| `x86_64-pc-windows-msvc` | `azure-proxy-agent` | 10 MB | +| `x86_64-pc-windows-msvc` | `ProxyAgentExt` | 5 MB | +| `x86_64-pc-windows-msvc` | `proxy_agent_setup` | 4 MB | +| `aarch64-pc-windows-msvc` | `azure-proxy-agent` | 8 MB | +| `aarch64-pc-windows-msvc` | `ProxyAgentExt` | 5 MB | +| `aarch64-pc-windows-msvc` | `proxy_agent_setup` | 4 MB | + +### Running locally + + rustup target add x86_64-unknown-linux-musl + sudo apt-get install -y musl-tools + cargo install cargo-bloat --locked + + cargo bloat --release --crates \ + --target x86_64-unknown-linux-musl \ + -p azure-proxy-agent \ + --message-format json > bloat.json + + python3 ci/check_bloat.py \ + --bloat-json bloat.json \ + --max-binary-bytes 20000000 \ + --max-crate-share 0.10 + +Exit `0` = within budget, `1` = ceiling tripped (prints top contributors and which ceiling), `2` = bad input. + +### Override path + +Bloat regressions are intentional sometimes (new feature, security-driven dep upgrade). Procedure: + +1. Run the commands above locally and copy the report into the PR. +2. Bump the relevant `max_binary_bytes` entry (or add a `crate_share_overrides` line) in `.github/workflows/bloat.yml` **and** update the table in `ci/README.md` in the same PR. Only the regressing row(s). +3. Get two reviewer approvals specifically acknowledging the budget change (`LGTM-bloat` review tag convention). +4. After merge, the new ceiling becomes the baseline. + +Unauthorized bypasses (`--no-verify`, removing the workflow) are not permitted. + +## 6. `signing` feature gate + +**Status: done (PR 352).** `proxy_agent_shared` now exposes an opt-in `signing` Cargo feature: + + # proxy_agent_shared/Cargo.toml + [features] + default = [] + # Enables compute_signature (HMAC-SHA256 via OpenSSL on Linux). + # Binaries that don't sign anything (e.g. proxy_agent_setup) should leave this off. + signing = ["dep:openssl"] + +`openssl` is now an *optional* dep on both musl and gnu Linux targets and is `#[cfg(feature = "signing")]`-gated everywhere it is touched (`linux.rs`, `hyper_client.rs`, `misc_helpers.rs`, `error.rs`). Only `proxy_agent` opts in (`proxy_agent_shared = { path = "...", features = ["signing"] }`); `proxy_agent_setup` and `ProxyAgentExt` drop vendored OpenSSL entirely, which is a multi-MB win on musl (and lets the bloat ceilings for those two binaries land where they did). + +## 7. Integration + +- SBOM (3.4) generated from the static binary's compiled crate graph. +- Attestation endpoint (3.3) reports binary hash and bloat report URL. +- Pkg builds (deb/rpm) pick up the static binary unchanged. + +## 8. Tests + +- Smoke test on each supported distro using the static binary. +- Bloat budget test runs on every PR; documented override path with mandatory two-reviewer approval. +- The in-tree `logger_manager` unit tests now exercise `init_loggers` directly, so the consolidated helper is covered by the existing test suite. + +## 9. Risks + +- **musl perf** for DNS / network can differ. Mitigation: micro-benchmark before/after. +- **Bloat budget** false alarms on benign upgrades. Mitigation: per-(target, crate) overrides in `bloat.yml` + weekly main-branch baseline refresh. +- **`signing` feature drift**: adding a new caller of `compute_signature` from a binary that doesn't opt into `signing` is a compile-time error rather than a runtime surprise — the `#[cfg(feature = "signing")]` gates make the contract explicit. + +## 10. Milestones & status + +| M | Deliverable | Status | Exit | +|-----|--------------------------------------|-------------------------------------------------|-------------------------------------------------------------------| +| M1 | Helper moves to `proxy_agent_shared` | **done** (PR 353 + pre-existing) | Logger setup + GPA service name unified in PR 353; OS/version probe and HTTP client already lived in `proxy_agent_shared`. Config loader intentionally not moved — only one binary has one. | +| M2 | musl static binary in CI | **done** (pre-existing) | Built by `build-linux.sh` via `reusable-build.yml` (amd64 + arm64) | +| M3 | Bloat budget enforced | **done** (PR 352) | Per-(target, role) ceilings active; PRs blocked over ceiling | +| M4 | OpenSSL gated off non-signing roles | **done** (PR 352) | `proxy_agent_shared` `signing` feature; setup + ext build without | + +Detail design for direction 7.3. Parent: [Innovation-Directions.md](Innovation-Directions.md). diff --git a/doc/plans/Innovation-Directions.md b/doc/plans/Innovation-Directions.md new file mode 100644 index 00000000..871dfc44 --- /dev/null +++ b/doc/plans/Innovation-Directions.md @@ -0,0 +1,452 @@ +## Innovation Directions + +1. [1. AuthN/AuthZ Model](#d1) + - [Short-lived PoP tokens](#d1-tokens) + - [vTPM / CVM sealing](#d1-vtpm) + - [Measured identity](#d1-identity) + - [Capability grants](#d1-cap) +2. [2. Rule Engine Modernization](#d2) +3. [3. Observability & Supply Chain](#d3) +4. [4. eBPF / Kernel Hardening](#d4) +5. [5. Threat Coverage Expansion](#d5) +6. [6. Developer & Operator UX](#d6) +7. [7. Performance & Footprint](#d7) +8. [Cross-cutting Roadmap](#roadmap) +9. [**★ Plans & Milestones**](Innovation-Plans-Milestones.md) + +**Azure Guest Proxy Agent** · **Rust + eBPF** · **Security** + +# Innovation Directions — Detailed Designs & Implementation Plans + +Companion document to the repo analysis. Each direction includes goals, design, code-level touch points in the GPA codebase, an incremental implementation plan, test strategy, risks, and success metrics. + +→ See also: [**Consolidated Plans & Milestones**](Innovation-Plans-Milestones.md) — cross-track schedule, dependency map, per-innovation M0–M4 milestones, RACI, risks, and program exit criteria. + +**Reference areas:** `proxy_agent/src/proxy/authorization_rules.rs`, `proxy_agent/src/key_keeper/`, `proxy_agent/src/redirector/`, `proxy_agent_extension/`, `pentest/linux/`. + +## 1. Strengthen the AuthN/AuthZ Model + +| Impact | Effort | Risk | Scope | +|------------------------------------|------------------|-----------------------------|--------------------------| +| **High** closes whole vuln classes | **Medium–Large** | **Wire-compat with fabric** | **agent + fabric coord** | + +Today GPA authenticates with a long-lived HMAC key latched at provisioning time (`proxy_agent/src/key_keeper/key.rs`) and adds `x-ms-azure-signature` to every authorized request. Identity is taken from cgroup + `processFullPath` reported by eBPF audit. Four sub-initiatives raise the floor. + +### 1.1 Short-lived Proof-of-Possession (PoP) tokens + +[Detailed design](Innovation-1.1-pop-tokens.md) + +#### Goal + +- Eliminate the "steal key file → sign forever" path (pentest `B3`, `E5`). +- Bind each token to caller, destination, and time, so replay (`B2`) becomes structurally impossible. + +#### Design + +- Replace the single HMAC over `METHOD || URL || time-tick` with a JWS-like compact token: `header.payload.sig` where payload includes `{aud, sub (caller fingerprint), iat, exp ≤ 30s, nonce, dest_ip, url_hash}`. +- Signature stays HMAC-SHA256 initially (no fabric crypto change), but the *signed claims* shape changes, so the fabric can reject any token without an `exp`. +- Token is minted per request in `proxy_server.rs` right before forwarding; replaces direct header injection. +- Add a *session key* derived from latched key + nonce so the latched key never appears on the wire and never signs raw HTTP. + +#### Code touch points + +- `proxy_agent/src/key_keeper/key.rs` — add `derive_session_key(nonce) -> SessionKey`. +- `proxy_agent/src/proxy/proxy_server.rs` — replace `x-ms-azure-signature` mint path with `mint_pop_token(req, caller, dest)`. +- `proxy_agent_shared` — new module `pop_token` with serde structs + constant-time compare. +- Wire-compat shim: emit both legacy and new headers behind a feature flag `pop_v2` until fabric is ready. + +#### Plan + +1. RFC + threat model doc; align with WireServer/IMDS team on header name and accepted claim set. +2. Implement `pop_token` crate with golden-vector tests. +3. Ship dual-emit behind config flag; telemetry-only (fabric ignores new header). +4. Fabric flips acceptance; deprecate legacy header after one release cycle. + +#### Tests + +- Unit: claim canonicalization, clock-skew tolerance, constant-time verify. +- Pentest re-runs: `B2` replay must fail; `B3` stolen-key + cross-VM must still fail because `sub` binds caller identity verified by fabric+vTPM (see 1.2). +- Fuzz the token parser (`cargo-fuzz`). + +#### Risks + +- Clock drift; mitigate by accepting ±60s and refreshing via fabric time. +- Fabric rollout coupling; mitigate with dual-emit flag. + +### 1.2 vTPM / Confidential-VM attestation binding + +[Detailed design](Innovation-1.2-vtpm-sealing.md) + +#### Goal + +Make a stolen key file useless on another VM, and make key rollback (`E5`) cryptographically infeasible. + +#### Design + +- At provisioning, seal the latched key to vTPM PCRs covering: firmware, bootloader, kernel cmdline, and `azure-proxy-agent` binary measurement (IMA). +- Under CVM (SEV-SNP / TDX), include the attestation report hash. The fabric stores the bound report and only honors signatures whose KID matches. +- On unseal failure (rebooted into a tampered image), GPA enters fail-closed and requests a re-provisioning. + +#### Code touch points + +- New crate `proxy_agent/src/key_keeper/sealing/` with backends: `tpm2.rs` (uses `tss-esapi`), `snp.rs`, `tdx.rs`, `noop.rs`. +- `key.rs` — add `load_sealed()` / `store_sealed()` wrapping current on-disk reads. +- Provisioning flow in `provision.rs` — request fresh attestation, hand to fabric, persist sealed blob. + +#### Plan + +1. Backend detection helper (probe `/dev/tpmrm0`, SEV-SNP MSRs, TDX guest module). +2. Implement `noop` + `tpm2` backends behind feature flag; keep current plain-file path as default. +3. Pilot in selected SKUs; collect attestation latency telemetry. +4. Promote to default for CVM SKUs; keep plain-file as fallback for legacy SKUs. + +#### Tests + +- Reboot with modified kernel cmdline must produce unseal failure → fail-closed. +- Snapshot+restore of `/var/lib/azure-proxy-agent` to a different VM must fail unseal. +- Pentest `E5` rollback: re-introducing an older sealed blob fails monotonic counter check. + +### 1.3 Measured caller identity (replace path-string matching) + +[Detailed design](Innovation-1.3-measured-identity.md) + +#### Goal + +Defeat pentest scenarios `C3` (bind-mount over `/proc/self/exe`) and `D2` (symlink to allowed binary) by matching on *what the code is*, not *where it lives*. + +#### Design + +- Capture binary measurement in-kernel via **IMA** (`ima_file_hash()`) or **fs-verity** root hash; on Windows, use code-integrity / WDAC hash. +- Emit measurement in the eBPF audit event consumed by `redirector::lookup_audit`. +- Extend `Privilege` in `key.rs` with optional `exeHash: Option`; matcher prefers hash over path when present, and rejects when both diverge. + +#### Code touch points + +- `linux-ebpf/ebpf_cgroup.c` — augment audit map value with hash bytes. +- `proxy_agent/src/redirector/linux/` — surface hash field. +- `proxy_agent/src/proxy/authorization_rules.rs` — new matcher predicate; back-compat: if rule lacks hash, fall back to path matching. + +#### Plan + +1. Add hash plumbing end-to-end, audit-only (log mismatches). +2. Author tooling to generate hashes for allow-listed binaries (extension handlers, customer agents). +3. Enable enforcement per rule via `enforceMeasurement: true`. + +### 1.4 Capability-style scoped grants + +[Detailed design](Innovation-1.4-capability-scopes.md) + +#### Goal + +Move from "this path is allowed for this identity" to verifiable scopes (`imds:instance:read`, `wireserver:goalstate:read`, `hostga:extensions:status:write`). + +#### Design + +- Introduce a Cedar/CEL-style policy compiled to an evaluation IR at rule-load time. +- Each request is mapped to a typed `Action` + `Resource` by a URL classifier (one canonical mapping table per endpoint). +- Identity carries a set of granted scopes; decision is `scopes ⊇ required(action)`. + +#### Why it matters + +- Enables static analysis ("does any rule grant unauthenticated WireServer write?") — see direction 2. +- Eliminates SSRF-style URL-encoding bypasses because the classifier normalizes once and operates on the typed action. + +## 2. Modernize the Rule Engine + +| Impact | Effort | Risk | Scope | +|-----------------------------------|------------|--------------------|----------------| +| **High** kills SSRF-bypass family | **Medium** | **Self-contained** | **agent only** | + +Current matcher: lowercased `request.path().starts_with(rule.path)` plus a query-param map (authorization_rules.rs:194, key.rs:223). This is the largest single source of latent AuthZ bypass surface. + +### 2.1 Canonical request model + +[Detailed design](Innovation-2.1-canonical-request.md) + +- Build a single `CanonicalRequest` type produced by *one* normalizer: percent-decode → collapse `.`/`..` → strip `;params` → lowercase host → resolve numeric IP forms (decimal, hex, IPv4-mapped IPv6 — pentest `C7`). +- Use the same normalizer for rule loading and request matching — eliminates rule/request asymmetry. +- Reject requests whose normalization is ambiguous (e.g. invalid UTF-8 in path) — fail-closed. + +### 2.2 Typed policy language + +[Detailed design](Innovation-2.2-typed-policy-cedar.md) + +- Pick one: **Cedar** (Rust-native, fast, analyzable) or **OPA/Rego** (familiar). Recommendation: **Cedar** — has a verified evaluator and supports static analysis. +- Compile JSON rules at load time into a Cedar policy set; keep a legacy adapter so existing rules continue to work. + +  + + // proxy_agent/src/proxy/policy/mod.rs + pub struct CompiledPolicy { /* Cedar policy set + entity store */ } + + impl CompiledPolicy { + pub fn from_legacy(rules: &AuthorizationItem) -> Result { /* ... */ } + pub fn evaluate(&self, req: &CanonicalRequest, caller: &CallerIdentity) -> Decision { /* ... */ } + } + +### 2.3 Versioned snapshots (TOCTOU fix — pentest D5) + +[Detailed design](Innovation-2.3-versioned-snapshots.md) + +- Wrap the active policy in `arc_swap::ArcSwap`. +- Each request captures `(arc, epoch)` at accept time and uses it for the whole forwarding decision. +- Surface `policy_epoch` in the connection log. + +### 2.4 Differential / property testing + +[Detailed design](Innovation-2.4-differential-testing.md) + +- For each rule loaded, auto-generate "evil twins": case toggles, percent-encoded slashes, trailing-slash variants, IPv6 forms, Unicode confusables. +- Run the matcher on each variant; mismatch with the original ⇒ block the rule and alert. +- Integrate into `local_rules.rs` reload path and into CI. + +### Plan + +1. Introduce `CanonicalRequest` + normalizer; add property-tests (`proptest`) and run against the current matcher in shadow mode. +2. Land Cedar compilation behind a feature flag; dual-evaluate (legacy + Cedar), log divergences. +3. Flip enforcement to Cedar once divergence rate is zero for N days in production telemetry. +4. Remove legacy matcher. + +### Metrics + +- Divergence rate (legacy vs Cedar) → must reach 0 before cutover. +- Pentest `D1`/`C7` scenarios reach 100% pass. +- Rule load time \< 50 ms for 1k rules. + +## 3. Observability & Supply-Chain Trust + +| Impact | Effort | Risk | Scope | +|-------------------------------|------------|---------|----------------------------| +| **Medium** + audit/compliance | **Medium** | **Low** | **agent + build pipeline** | + +### 3.1 Hash-chained, append-only audit log + +[Detailed design](Innovation-3.1-hash-chained-log.md) + +- Wrap the existing connection log with a Merkle chain: `entry_n.hash = SHA256(entry_n.payload || entry_{n-1}.hash)`. +- Periodically anchor the tip to a transparency log (rekor-compatible) or to Azure Monitor as a signed sentinel. +- Closes pentest `F2` (log injection) and `F3` (rotation race) — tampering breaks the chain and is detectable. + +### 3.2 OpenTelemetry export + +[Detailed design](Innovation-3.2-otel-export.md) + +- Emit metrics: allow/deny counts by rule id, signer latency, eBPF map occupancy, restart count. +- Emit traces for the proxy hop (accept → authz → upstream → response) with W3C trace context. +- Optional OTLP endpoint; defaults to local Prometheus exposition on a UDS only. + +### 3.3 Self-attestation endpoint + +[Detailed design](Innovation-3.3-self-attestation.md) + +- New read-only endpoint on the local listener: `GET /.well-known/gpa/attestation`. +- Returns: agent version, binary measurement, loaded eBPF prog ids and bytecode hash, attached cgroup, active `policy_epoch`, sealed-key KID, attestation backend in use. +- Consumable by Defender for Cloud, Azure Policy, or operator scripts. + +### 3.4 Supply chain + +[Detailed design](Innovation-3.4-supply-chain.md) + +- **SBOM**: generate CycloneDX during the cargo build (`cargo-cyclonedx`). +- **Reproducible builds**: pin `rust-toolchain.toml`, vendor deps, use `-Clink-arg=-Wl,--build-id=none`; verify via two-builder diff in CI. +- **in-toto / Sigstore**: sign release artifacts; `proxy_agent_setup` verifies signature before installing — closes pentest `H1`. + +### Plan + +1. Refactor logger into a `trait Sink` with a chained-Merkle implementation. +2. Wire OTel behind `--features otel`; default off to keep footprint. +3. Add attestation endpoint (no secrets in payload; just measurements). +4. Pipeline work: SBOM, reproducible build, Sigstore signing, verification in setup binary. + +## 4. eBPF / Kernel Hardening + +| Impact | Effort | Risk | Scope | +|----------------------------------|------------|---------------------------|-----------------------------| +| **High** kernel-layer chokepoint | **Medium** | **Kernel-version matrix** | **linux-ebpf + redirector** | + +### 4.1 Move from cgroup/`connect4` to `bpf_lsm` + `sk_lookup` + +[Detailed design](Innovation-4.1-sk-lookup-bpf-lsm.md) + +- `sk_lookup` redirects on the listening side; the original destination IP is preserved (no SNAT-to-localhost), so we can match on real destination after netns shenanigans (pentest `C5`, `C6`). +- `bpf_lsm` hooks (`socket_connect`) provide a deny path even when a hostile program tries to construct sockets in alternate namespaces. +- Fallback to existing cgroup hook for kernels \< 5.13 (no `sk_lookup`). + +### 4.2 Unify Linux + Windows eBPF on CO-RE + +[Detailed design](Innovation-4.2-core-unify-ebpf.md) + +- Today: separate sources in `ebpf/` (Windows) and `linux-ebpf/` (Linux). +- Adopt libbpf-rs with CO-RE relocations; share the audit-event struct via a common header. +- Ship a single BTF-portable object per platform; drop kernel-version-specific builds. + +### 4.3 IPv6 / dual-stack + +[Detailed design](Innovation-4.3-ipv6-dual-stack.md) + +- Add v6 redirect for IMDS/WireServer link-local equivalents. +- Normalize address family at the audit-event boundary so the rule engine sees a unified `Destination` enum, not raw bytes. + +### 4.4 Kernel-side throttling and audit-map LRU + +[Detailed design](Innovation-4.4-ebpf-throttling-lru.md) + +- Replace the audit hash map with `BPF_MAP_TYPE_LRU_HASH` sized by cgroup count (pentest `G3`). +- Add a token bucket per cgroup in BPF; over-rate connections get an early reject before reaching user space (mitigates `G1`). + +### Code touch points + +- `linux-ebpf/ebpf_cgroup.c` → split into `cgroup_connect.bpf.c`, `sk_lookup.bpf.c`, `lsm.bpf.c`. +- `proxy_agent/src/redirector/linux/` → loader picks the best available program set. +- Build system: introduce `build.rs` step invoking `clang -target bpf` with BTF. + +### Plan + +1. Add CO-RE build, keep behavior identical (no semantic change). +2. Land `sk_lookup` as optional, gated by kernel probe; A/B in pentest harness. +3. Add `bpf_lsm` deny hook; verify with `C5`/`C6` scenarios. +4. Switch audit map to LRU and add token bucket; verify with `G1`/`G3`. +5. IPv6 path last (depends on fabric v6 readiness). + +## 5. Expand the Threat Coverage + +| Impact | Effort | Risk | Scope | +|------------------------------|-----------|-----------------------------|-----------------------| +| **High** new product surface | **Large** | **Cross-team coordination** | **agent + ecosystem** | + +### 5.1 Container-native / AKS mode + +[Detailed design](Innovation-5.1-aks-container-native.md) + +#### Problem + +On AKS nodes, any pod with hostNetwork or a permissive NetworkPolicy can reach node IMDS and steal the node managed identity. This is the well-known "pod-steals-node-credentials" class. GPA already runs as the IMDS chokepoint — the missing piece is *per-pod identity*. + +#### Design + +- Map cgroup id (already captured by eBPF audit) → Kubernetes pod via the kubelet pod-resources API or by reading `/proc//cgroup` + the CRI socket. +- Project pod ServiceAccount → SPIFFE ID; use Azure Workload Identity federation to mint a pod-scoped token instead of handing back the node MI token. +- Rule shape: `{ namespace: "app-*", serviceAccount: "billing", allow: ["imds:identity:read"] }`. + +#### Plan + +1. Ship a `--mode kubernetes` flag and a sidecar/DaemonSet manifest. +2. Integrate with Azure Workload Identity issuer; reuse OIDC trust to AKS cluster. +3. Pilot on internal clusters; publish reference NetworkPolicy that forces all IMDS traffic through GPA. + +### 5.2 Gate other cloud endpoints + +[Detailed design](Innovation-5.2-gate-more-endpoints.md) + +- Generalize destination handling so KeyVault MSI, ARM token endpoint, and Storage MI flows can be authorized through the same rule engine. +- Add pluggable *destination drivers* with: address set, request classifier (URL → typed action), signer. +- Customer-visible: one rule language to govern all cloud-credential egress. + +### 5.3 Cross-cloud port + +[Detailed design](Innovation-5.3-cross-cloud-port.md) + +- Architecture (cgroup eBPF + identity-aware proxy) is cloud-neutral. The signer and destination set are Azure-specific. +- Factor `signer` and `destinations` into traits; ship community drivers for AWS IMDSv2 and GCP metadata. +- Positioning: *a metadata firewall for any cloud*. + +## 6. Developer & Operator Experience + +| Impact | Effort | Risk | Scope | +|-----------------------------------------|------------------|---------|-------------| +| **Medium** adoption + incident response | **Small–Medium** | **Low** | **tooling** | + +### 6.1 Policy simulator / dry-run + +[Detailed design](Innovation-6.1-policy-simulator.md) + +- CLI: `gpa policy simulate --rules rules.json --request 'GET http://169.254.169.254/metadata/identity?api-version=2021-08-01' --caller pid=1234` +- Output: decision, which rule matched, canonicalization steps applied, divergence vs current production rules. +- Library-mode for unit tests so customers can lock down expected behavior. + +### 6.2 `gpa-doctor` + +[Detailed design](Innovation-6.2-gpa-doctor.md) + +- One command runs hardening checks derived from `pentest/linux/`: port exposure (A1), file modes (E1), restart safety (E4), orphan eBPF programs (G4), rule loader sanity (D4). +- Green/yellow/red report + remediation links; safe to run on production VMs. + +### 6.3 Rule authoring UX + +[Detailed design](Innovation-6.3-rule-authoring-ux.md) + +- JSON Schema for the rules file; ship in repo. +- VS Code extension providing schema-driven autocomplete, live validation, and a "rule diff" view for remote vs `local_rules.rs` overrides. +- Portal-side experience reuses the same schema. + +### 6.4 WASM rule sandbox (optional) + +[Detailed design](Innovation-6.4-wasm-rule-sandbox.md) + +- Allow customer-supplied AuthZ in WebAssembly with a tightly scoped host ABI (read normalized request, read claims, return decision; no syscalls, no clocks). +- Useful for advanced customers needing logic that doesn't fit a declarative rule. +- Guardrails: hard CPU/memory limits per invocation; disabled by default. + +## 7. Performance & Footprint + +| Impact | Effort | Risk | Scope | +|---------------------------|------------|---------|----------------| +| **Medium** latency + cost | **Medium** | **Low** | **agent only** | + +### 7.1 io_uring hot path + +[Detailed design](Innovation-7.1-io-uring-hot-path.md) + +- Behind feature flag, replace tokio-default reactor for the listener with `tokio-uring` or `monoio`. +- Targets the IMDS read path (the most frequent request shape). + +### 7.2 Zero-copy forwarding + +[Detailed design](Innovation-7.2-zero-copy-splice.md) + +- After AuthN/AuthZ pass, splice the body between accept socket and upstream socket using `splice(2)` when the request has no body transformation. +- Avoids two user-space copies on the common GET path. + +### 7.3 Crate consolidation — ✅ done + +[Detailed design](Innovation-7.3-crate-consolidation.md) + +- Today: `proxy_agent`, `proxy_agent_extension`, `proxy_agent_setup`, `proxy_agent_shared`. +- Move duplicated helpers (logging, config loading, version probing) into `proxy_agent_shared`. +- Build with musl for a single static binary per role; run `cargo-bloat` in CI with a budget. + +**Status: done.** Logger setup and the GPA service-name constants have moved into `proxy_agent_shared` (PR 353), removing ~60 lines of boilerplate from the three binaries. The OS/version probe and the HTTP client already live in `proxy_agent_shared`; the JSON config loader is intentionally not moved — only `proxy_agent` reads a JSON config (the extension is driven by HandlerEnvironment / sequence files, and the setup tool has no runtime config), so there is no second copy to consolidate. The musl static-binary builds for both `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl` are already produced by `build-linux.sh` from the shared `reusable-build.yml` workflow (Windows MSVC builds go through `build.cmd` in the same file). On top of those, the `cargo-bloat` regression gate is now live in CI as a per-(target, role) matrix with absolute + per-crate-share ceilings (PR 352, see [`ci/README.md`](../../ci/README.md)). A new opt-in `signing` Cargo feature on `proxy_agent_shared` lets `proxy_agent_setup` and `ProxyAgentExt` drop the vendored OpenSSL dep entirely. + +### Metrics + +- p99 added latency per IMDS request ≤ 1 ms (target). +- RSS at idle ≤ 20 MB (target). +- Binary size ≤ 8 MB stripped (target). + +## ★. Cross-Cutting Roadmap + +| Phase | Focus | Items | Exit criteria | +|---------------------------|----------------------------------------|-------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------| +| P1 — Foundations | Safe refactors, no behavior change | 2.1 canonicalizer (shadow), 2.3 ArcSwap epochs, 3.2 OTel skeleton, 7.3 crate consolidation, 4.2 CO-RE build | Zero shadow-mode divergence; CI green on all targets | +| P2 — Hardening | Close pentest-known gaps | 2.2 Cedar dual-eval, 4.1 sk_lookup + bpf_lsm, 4.4 LRU + token bucket, 3.1 hash-chained log, 3.4 Sigstore-verified setup | Pentest categories C5–C7, D1, D4–D5, F2–F3, G1, G3, H1 all PASS | +| P3 — Identity step-change | Defeat key-theft and identity spoofing | 1.1 PoP tokens (dual-emit), 1.2 vTPM/CVM sealing, 1.3 measured identity | Fabric accepts PoP; CVM SKUs default to sealed keys | +| P4 — Surface expansion | New customers, new endpoints | 1.4 capability scopes, 5.1 AKS mode, 5.2 more endpoints, 6.1 simulator, 6.2 `gpa-doctor`, 6.3 schema/VS Code | Pilot AKS customer; first non-IMDS endpoint governed by GPA | +| P5 — Reach | Ecosystem & perf polish | 5.3 cross-cloud drivers, 6.4 WASM sandbox, 7.1 io_uring, 7.2 splice | Latency & size budgets met; community-maintained AWS/GCP drivers | + +**Suggested first PR sequence** + +1. Introduce `CanonicalRequest` + property tests in shadow mode (direction 2.1). +2. Wrap policy in `ArcSwap` with per-request epoch logging (direction 2.3). +3. Add CO-RE build for the eBPF objects without behavior change (direction 4.2). +4. Add hash-chained log sink behind a feature flag (direction 3.1). +5. Begin Cedar policy compilation in dual-evaluation mode (direction 2.2). + +These five are low-risk, independently shippable, and unlock most of the later work. + +**Coordination required** + +- Direction 1.1 (PoP tokens) and 1.2 (vTPM sealing) require WireServer/IMDS fabric-side acceptance changes. +- Direction 5.1 (AKS mode) requires alignment with Azure Workload Identity team. +- Direction 4.1 needs a kernel-version matrix decision (CO-RE fallback path). + +Generated companion to the GPA repo analysis. Cross-references: `proxy_agent/src/proxy/authorization_rules.rs`, `proxy_agent/src/key_keeper/key.rs`, `proxy_agent/src/redirector/`, `pentest/linux/DESIGN.md`. diff --git a/doc/plans/Innovation-Plans-Milestones.md b/doc/plans/Innovation-Plans-Milestones.md new file mode 100644 index 00000000..2cfe919e --- /dev/null +++ b/doc/plans/Innovation-Plans-Milestones.md @@ -0,0 +1,436 @@ +## Sections + +1. [1. Overview](#overview) +2. [2. Phases & KPIs](#phases) +3. [3. 12-Quarter Roadmap](#roadmap) +4. [4. Dependency Map](#deps) +5. [5. Per-Innovation Milestones](#milestones) + - [D1 — AuthN/AuthZ](#d1-plans) + - [D2 — Rule Engine](#d2-plans) + - [D3 — Observability & Supply Chain](#d3-plans) + - [D4 — eBPF / Kernel](#d4-plans) + - [D5 — Threat Coverage](#d5-plans) + - [D6 — Dev/Operator UX](#d6-plans) + - [D7 — Performance](#d7-plans) +6. [6. RACI / Coordination](#raci) +7. [7. Risk Register](#risks) +8. [8. Program Exit Criteria](#exit) + +**GPA** · **Program Plan** · **P1 → P5** · **~12 quarters** + +# Innovation Program — Consolidated Plans & Milestones + +One operational view across all 25 innovations: phase placement, dependencies, per-track milestones (M0–M4), exit gates, owners, and risks. Each detailed design page remains the source of truth for its own scope; this page is the scheduling and coordination contract. + +**Conventions:** milestone numbering is uniform — `M0` design lock, `M1` prototype/shadow, `M2` dual-mode behind flag, `M3` default-on for target SKU, `M4` legacy removed. Week numbers (`W1..W48`) are relative to program start; quarters Q1–Q12 mirror the swimlane. + +## 1. Program Overview + +The roadmap groups the 25 innovations into five sequenced phases that respect prerequisite chains and the cross-team coupling already called out in [Innovation Directions § Roadmap](Innovation-Directions.md#roadmap). Each phase ends with a quantitative gate; later phases cannot start their *default-on* step until earlier phases reach `M3` on the same SKU class. + +| Innovations | Directions | Phases (P1–P5) | Quarters end-to-end | +|-------------|------------|----------------|---------------------| +| **25** | **7** | **5** | **≈12** | + +## 2. Phases & KPIs + +| Phase | Window | Theme | Items | Phase exit KPI | +|-------------------------------|---------|-------------------------------------|-----------------------------------|--------------------------------------------------------------------------------------------------------| +| **P1 — Foundations** | Q1–Q3 | Safe refactors, no behavior change | 2.1, 2.3, 2.4, 3.2, 4.2, 7.3 | Zero shadow-mode divergence across 7 days of prod traffic; CI green on win+linux; binary ≤ 8 MB | +| **P2 — Hardening** | Q3–Q6 | Close pentest-known gaps | 2.2, 3.1, 3.3, 3.4, 4.1, 4.3, 4.4 | Pentest categories C5–C7, D1, D4–D5, F2–F3, G1, G3, H1 → 100% PASS in CI harness | +| **P3 — Identity step-change** | Q5–Q9 | Defeat key-theft and identity spoof | 1.1, 1.2, 1.3 | Fabric accepts PoP-v2 in 100% of regions; CVM SKUs default to sealed keys; pentest B2/B3/C3/D2/E5 PASS | +| **P4 — Surface expansion** | Q7–Q11 | New customers, new endpoints | 1.4, 5.1, 5.2, 6.1, 6.2, 6.3 | ≥ 1 AKS pilot in prod; ≥ 1 non-IMDS endpoint governed; `gpa-doctor` shipped in agent package | +| **P5 — Reach** | Q10–Q12 | Ecosystem & perf polish | 5.3, 6.4, 7.1, 7.2 | p99 added latency ≤ 1 ms; RSS ≤ 20 MB idle; community drivers building in CI | + +## 3. 12-Quarter Swimlane + +| Track | Q1 | Q2 | Q3 | Q4 | Q5 | Q6 | Q7 | Q8 | Q9 | Q10 | Q11 | Q12 | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **D1 AuthN/Z** | — | — | — | — | 1.2 M0–M1 | 1.1 M0–M1 | 1.1 M2 | 1.2 M2 | 1.3 M2 | 1.4 M1 | 1.4 M2 | _M3/4_ | +| **D2 Rules** | 2.1 M1 | 2.3 M2 | 2.4 M2 | 2.2 M1 | 2.2 M2 | 2.2 M3 | _M4 remove_ | — | — | — | — | — | +| **D3 Obs/SC** | — | 3.2 M1 | 3.2 M2 | 3.1 M1 | 3.4 M2 | 3.3 M2 | 3.1 M3 | _M4_ | — | — | — | — | +| **D4 eBPF** | — | 4.2 M1 | 4.2 M2 | 4.1 M1 | 4.4 M2 | 4.1 M2 | 4.3 M2 | 4.1 M3 | — | — | — | — | +| **D5 Threats** | — | — | — | — | — | — | 5.1 M1 | 5.2 M1 | 5.1 M2 | 5.2 M2 | 5.3 M1 | 5.3 M2 | +| **D6 UX** | — | — | 6.3 M1 | — | — | — | 6.1 M2 | 6.2 M2 | 6.3 M2 | 6.4 M1 | 6.4 M2 | — | +| **D7 Perf** | 7.3 M1 | 7.3 M2 | — | — | — | — | — | — | — | 7.1 M2 | 7.2 M2 | _tune_ | + +Legend: **P1** Foundations · **P2** Hardening · **P3** Identity · **P4** Surface · **P5** Reach. +Milestones — `M0` design lock · `M1` prototype/shadow · `M2` dual-mode behind flag · `M3` default-on · `M4` legacy removed. + +Bars use uniform milestone names: `M0` design lock · `M1` prototype/shadow · `M2` dual-mode behind flag · `M3` default-on · `M4` legacy removed. Cells without a bar are intentionally idle for that track that quarter; idle quarters are buffer for risk. + +## 4. Dependency Map + +Hard prerequisites (must reach `M2` on the upstream item before the downstream item can begin `M1`): + +- **1.1 PoP tokens** ← 2.1 CanonicalRequest (`url_hash` uses canonical form) +- **1.2 vTPM sealing** ← 3.3 Self-attestation (KID surfaced for fabric pinning) +- **1.3 Measured identity** ← 4.2 CO-RE eBPF (audit event carries hash bytes) +- **1.4 Capability scopes** ← 2.2 Cedar (scopes encoded as Cedar actions) +- **2.2 Cedar** ← 2.1 CanonicalRequest + 2.3 ArcSwap +- **2.4 Diff testing** ← 2.1 CanonicalRequest +- **3.1 Hash-chained log** ← 3.2 OTel skeleton (shared sink trait) +- **3.3 Self-attestation** ← 4.2 CO-RE (prog id + bytecode hash exposure) +- **3.4 Supply chain** ← 7.3 Crate consolidation (single artifact to sign) +- **4.1 sk_lookup + bpf_lsm** ← 4.2 CO-RE +- **4.3 IPv6 dual-stack** ← 4.1 (unified destination enum) +- **4.4 LRU + throttling** ← 4.2 CO-RE +- **5.1 AKS mode** ← 1.4 Capability scopes + 4.2 CO-RE +- **5.2 More endpoints** ← 2.2 Cedar + 1.4 scopes +- **5.3 Cross-cloud** ← 5.2 (destination driver trait) +- **6.1 Simulator** ← 2.1 + 2.2 (uses compiled policy in library mode) +- **6.2 gpa-doctor** ← 3.3 Self-attestation +- **6.3 Authoring UX** ← 2.2 (schema generated from Cedar) +- **6.4 WASM sandbox** ← 2.2 + 6.1 +- **7.1 io_uring** ← 7.3 Crate consolidation +- **7.2 splice forwarding** ← 1.1 (decision before splice means token mint stays in user-space) + +## 5. Per-Innovation Milestones + +Each entry below uses the same template: `M0 design lock` · `M1 prototype/shadow` · `M2 dual-mode behind flag` · `M3 default-on` · `M4 legacy removed`. Week numbers are relative to program start. + +### Direction 1 — AuthN/AuthZ Model + +#### 1.1 Short-lived PoP tokens — [design](Innovation-1.1-pop-tokens.md) + +##### Deliverables + +- `proxy_agent_shared/src/pop_token/` module with serde + constant-time verify +- `derive_session_key()` in `key_keeper/key.rs` +- Dual-emit behind `--feature pop_v2` in `proxy_server.rs` +- Fabric-side acceptance change (WireServer/IMDS) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-------------------------------------------------------------------|-------------------------------------------| +| M0 | W17 | RFC + threat-model signed off with fabric team | Header name + claim set frozen | +| M1 | W20 | pop_token crate + golden vectors; agent mints into local log only | Fuzz 60 min clean | +| M2 | W26 | Dual-emit in production; fabric ignores, telemetry tracks parity | ≥ 99.99% mint success; clock-skew \< 0.1% | +| M3 | W34 | Fabric flips acceptance; legacy header kept for 1 release | B2 replay pentest FAIL ⇒ blocked | +| M4 | W42 | Legacy `x-ms-azure-signature` removed | 0 legacy headers in 14-day telemetry | + +#### 1.2 vTPM / CVM sealing — [design](Innovation-1.2-vtpm-sealing.md) + +##### Deliverables + +- `key_keeper/sealing/` with backends `noop`, `tpm2`, `snp`, `tdx` +- `load_sealed()` / `store_sealed()` in `key.rs` +- Backend probe in `provision.rs`; fail-closed on unseal failure + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|--------------------------------------------------------|---------------------------------------| +| M0 | W14 | PCR set + monotonic-counter design | Sec review pass | +| M1 | W22 | tpm2 backend on a single SKU; noop fallback | Reboot survives; unseal \< 200 ms p99 | +| M2 | W30 | Pilot CVM SKUs (SEV-SNP first), feature-flagged | E5 rollback pentest blocked | +| M3 | W36 | Default-on for CVM SKUs; legacy plain-file for non-CVM | Snapshot-restore-other-VM FAIL | +| M4 | W48 | Plain-file path deleted on CVM build | 0 plain-file reads in CVM telemetry | + +#### 1.3 Measured caller identity — [design](Innovation-1.3-measured-identity.md) + +##### Deliverables + +- Augment audit map value in `linux-ebpf/ebpf_cgroup.c` with IMA / fs-verity hash +- Surface `exeHash` in `redirector::lookup_audit` +- `enforceMeasurement` matcher in `authorization_rules.rs` +- Hash-generation CLI for allow-listed binaries + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-----------------------------------------------------------------|--------------------------------| +| M0 | W26 | Choose IMA vs fs-verity per distro; pick Windows CI hash source | Spec lock | +| M1 | W30 | End-to-end hash plumbing, audit-only logging | Mismatch rate observable | +| M2 | W36 | Per-rule `enforceMeasurement` opt-in | C3 + D2 pentest FAIL ⇒ blocked | +| M3 | W44 | Default-on for fabric-shipped extension handlers | No false-positives in 14 days | + +#### 1.4 Capability-style scoped grants — [design](Innovation-1.4-capability-scopes.md) + +##### Deliverables + +- URL classifier (one canonical action/resource table per endpoint) +- Scope type carried in `CallerIdentity` +- Cedar action set generated from classifier + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-------------------------------------------------------------------|-------------------------------| +| M0 | W30 | Classifier table reviewed with IMDS + WireServer owners | Action set frozen v1 | +| M1 | W34 | Library mode + simulator integration (6.1) | All current rules round-trip | +| M2 | W40 | Scoped grants accepted in rules, dual-evaluated with path matcher | 0 divergences for 7 days | +| M3 | W46 | Path matcher disabled for scoped rules | Encoding-bypass class blocked | + +### Direction 2 — Rule Engine Modernization + +#### 2.1 CanonicalRequest — [design](Innovation-2.1-canonical-request.md) + +##### Deliverables + +- Single normalizer module used by rule loader and request path +- `proptest` suite (case, %-encoding, dots, IPv4/IPv6, Unicode confusables) +- Shadow comparison harness in `proxy_server.rs` + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|----------------------------------------------------------|----------------------------------| +| M0 | W1 | Normalizer spec frozen with fail-closed cases enumerated | Sec review pass | +| M1 | W3 | Normalizer + property tests; shadow log in prod | ≥ 99.9% match with legacy | +| M2 | W8 | Used by Cedar evaluator (2.2) | 0 ambiguous requests in 7 days | +| M3 | W14 | Legacy normalizer paths removed | CI green; one normalizer in tree | + +#### 2.2 Typed policy (Cedar) — [design](Innovation-2.2-typed-policy-cedar.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-------------------------------------------------------|-------------------------------------| +| M0 | W8 | Cedar adopted; legacy adapter spec | Policy-set schema v1 | +| M1 | W12 | Compile-on-load + dual-eval shadow | Rule load ≤ 50 ms / 1k rules | +| M2 | W18 | Enforcement gated by config flag | Divergence = 0 for 7 prod days | +| M3 | W22 | Cedar is default; legacy adapter still loads old JSON | D1 / C7 pentest 100% PASS | +| M4 | W28 | Legacy matcher code removed | No `starts_with` on path in matcher | + +#### 2.3 Versioned snapshots (ArcSwap) — [design](Innovation-2.3-versioned-snapshots.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-------------------------------------------------------|----------------------------------| +| M1 | W4 | `ArcSwap`, epoch captured per request | D5 TOCTOU pentest FAIL ⇒ blocked | +| M2 | W6 | `policy_epoch` in connection log + OTel metric | Reload latency \< 5 ms p99 | + +#### 2.4 Differential / property testing — [design](Innovation-2.4-differential-testing.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|----------------------------------------|-------------------------------------| +| M1 | W6 | Evil-twin generator + CI gate | 100 variants/rule, 0 false-mismatch | +| M2 | W9 | Plug into `local_rules.rs` reload path | Bad rule = reject + telemetry alert | + +### Direction 3 — Observability & Supply Chain + +#### 3.1 Hash-chained audit log — [design](Innovation-3.1-hash-chained-log.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|--------------------------------------------|----------------------------------| +| M0 | W10 | Sink trait + Merkle chain spec | Recovery from torn write defined | +| M1 | W14 | Chain implementation; tip anchored locally | Tamper detect 100% | +| M2 | W18 | Rekor / Monitor sentinel publisher | F2 + F3 pentest blocked | +| M3 | W26 | Default sink on all SKUs | \< 2% log throughput overhead | + +#### 3.2 OpenTelemetry export — [design](Innovation-3.2-otel-export.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-------------------------------------------|-----------------------------------| +| M1 | W5 | Metrics skeleton on UDS (Prom exposition) | No new heap on hot path | +| M2 | W9 | OTLP exporter behind `--features otel` | Trace W3C ctx propagates upstream | + +#### 3.3 Self-attestation endpoint — [design](Innovation-3.3-self-attestation.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-----------------------------------------|-------------------------| +| M0 | W14 | Payload schema, no-secret contract | Sec review pass | +| M2 | W22 | Endpoint live, consumed by `gpa-doctor` | Defender pulls in pilot | + +#### 3.4 Supply chain (SBOM + repro + Sigstore) — [design](Innovation-3.4-supply-chain.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|---------------------------------------------------------------|---------------------------------| +| M1 | W14 | CycloneDX SBOM emitted; reproducible flags set | Two-builder diff bit-identical | +| M2 | W20 | Sigstore signing; `proxy_agent_setup` verifies before install | H1 supply-chain pentest blocked | + +### Direction 4 — eBPF / Kernel Hardening + +#### 4.1 sk_lookup + bpf_lsm — [design](Innovation-4.1-sk-lookup-bpf-lsm.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|---------------------------------------|--------------------------------| +| M0 | W14 | Kernel-version matrix + fallback plan | Min kernel set frozen | +| M1 | W18 | sk_lookup probe + gated load | A/B match cgroup-connect path | +| M2 | W24 | bpf_lsm deny hook | C5 + C6 pentest FAIL ⇒ blocked | +| M3 | W30 | Default-on for supported kernels | No regression vs cgroup-only | + +#### 4.2 CO-RE unification — [design](Innovation-4.2-core-unify-ebpf.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|----------------------------------------------------|-----------------------------| +| M1 | W5 | libbpf-rs build, shared header, no behavior change | All current tests pass | +| M2 | W9 | Single BTF-portable object per platform | Loads on 3+ kernel versions | + +#### 4.3 IPv6 / dual-stack — [design](Innovation-4.3-ipv6-dual-stack.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|--------------------------------------------------------|----------------------| +| M1 | W22 | Unified `Destination` enum in user-space | v4 path unaffected | +| M2 | W26 | v6 redirect for IMDS/WireServer link-local equivalents | Fabric v6 acceptance | + +#### 4.4 LRU + throttling — [design](Innovation-4.4-ebpf-throttling-lru.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|------------------------------|--------------------| +| M1 | W18 | `BPF_MAP_TYPE_LRU_HASH` swap | G3 pentest blocked | +| M2 | W22 | Per-cgroup token bucket | G1 pentest blocked | + +### Direction 5 — Threat Coverage Expansion + +#### 5.1 AKS / container-native — [design](Innovation-5.1-aks-container-native.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|---------------------------------------------------------------|------------------------------------| +| M0 | W26 | Alignment with Azure Workload Identity team | OIDC trust path agreed | +| M1 | W34 | `--mode kubernetes` DaemonSet manifest, cgroup→pod resolution | SPIFFE ID minted per pod | +| M2 | W40 | Pilot on internal AKS cluster | Pod-steals-node-cred class blocked | + +#### 5.2 Gate more endpoints — [design](Innovation-5.2-gate-more-endpoints.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|------------------------------------------------|-----------------------------------| +| M1 | W34 | Destination-driver trait + KeyVault MSI driver | Same rule lang governs both | +| M2 | W42 | ARM token + Storage MI drivers | ≥ 1 customer enabling beyond IMDS | + +#### 5.3 Cross-cloud port — [design](Innovation-5.3-cross-cloud-port.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|--------------------------------------------------|----------------------------------| +| M1 | W42 | Signer + destination traits factored | AWS IMDSv2 driver compiles in CI | +| M2 | W46 | GCP metadata driver; positioned as cloud-neutral | External contributor PR landed | + +### Direction 6 — Developer & Operator UX + +#### 6.1 Policy simulator — [design](Innovation-6.1-policy-simulator.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|------------------------------------------|---------------------------| +| M1 | W30 | `gpa policy simulate` CLI + library mode | Reproduces prod decisions | +| M2 | W34 | Diff vs production rules; CI helper | Used by ≥ 1 customer test | + +#### 6.2 gpa-doctor — [design](Innovation-6.2-gpa-doctor.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-----------------------------------|------------------------------------------| +| M1 | W32 | Checks A1/E1/E4/G4/D4 implemented | Green-yellow-red report | +| M2 | W38 | Shipped in agent package | Safe on prod; no privileged side-effects | + +#### 6.3 Rule authoring UX — [design](Innovation-6.3-rule-authoring-ux.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|------------------------------------|--------------------------| +| M1 | W9 | JSON Schema in repo | Portal reuses schema | +| M2 | W40 | VS Code extension + rule-diff view | Marketplace listing live | + +#### 6.4 WASM rule sandbox — [design](Innovation-6.4-wasm-rule-sandbox.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|-----------------------------------------|---------------------------| +| M1 | W42 | Tightly scoped host ABI, wasmtime embed | CPU/mem caps enforced | +| M2 | W46 | Opt-in for one preview customer | No syscall escape in fuzz | + +### Direction 7 — Performance & Footprint + +#### 7.1 io_uring hot path — [design](Innovation-7.1-io-uring-hot-path.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|------------------------------------------------|----------------------------| +| M1 | W40 | `tokio-uring` behind feature flag for listener | Bench shows ≥ 20% p99 win | +| M2 | W44 | Default-on for Linux ≥ 5.15 | No regression on small VMs | + +#### 7.2 Zero-copy splice — [design](Innovation-7.2-zero-copy-splice.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|--------------------------------------------------|------------------------------| +| M1 | W42 | Splice path for body-unchanged GET | 2 fewer copies on perf trace | +| M2 | W46 | Default-on; fallback for HTTPS-terminating paths | p99 added latency ≤ 1 ms | + +#### 7.3 Crate consolidation — [design](Innovation-7.3-crate-consolidation.md) + +##### Milestones + +| Milestone | Week | Description | Exit criteria | +|-----------|------|--------------------------------------------------------|------------------------------------| +| M1 | W2 | Shared helpers moved to `proxy_agent_shared` | No duplicated logger / config code | +| M2 | W6 | musl static build per role; `cargo-bloat` budget in CI | Binary ≤ 8 MB stripped | + +## 6. RACI & Coordination + +| Item | Owner (R) | Approver (A) | Consulted (C) | Informed (I) | +|-------------------------|-------------|--------------|-----------------------|--------------------| +| 1.1 PoP tokens | GPA core | GPA TL | WireServer, IMDS | Defender, AzPolicy | +| 1.2 vTPM sealing | GPA core | GPA TL | CVM team, Host OS | Compliance | +| 1.3 Measured identity | GPA core | GPA TL | Linux IMA, Windows CI | Extension teams | +| 1.4 Capability scopes | GPA core | Security PM | IMDS, WireServer | Customers | +| 2.x Rule engine | GPA core | GPA TL | Cedar SIG | Portal | +| 3.1 Hash-chained log | GPA core | Sec review | Rekor / Sigstore | Compliance | +| 3.4 Supply chain | Build owner | Sec review | 1ES pipelines | Release mgmt | +| 4.1 sk_lookup / bpf_lsm | Kernel SIG | GPA TL | Distro vendors | Customers | +| 5.1 AKS mode | GPA core | AKS PM | Workload Identity | Customers | +| 5.3 Cross-cloud | Community | GPA TL | — | External | +| 7.x Performance | GPA core | GPA TL | — | Customers | + +## 7. Risk Register + +| \# | Risk | Phase | Severity | Mitigation | +|-----|------------------------------------------------------|-------|----------|----------------------------------------------------------------------------| +| R1 | Fabric acceptance of PoP-v2 slips a quarter | P3 | High | Dual-emit indefinitely; legacy header gated by remote feature switch | +| R2 | CO-RE breaks on a niche kernel | P1/P2 | Med | Keep classic-BPF path; runtime probe + automatic fallback | +| R3 | Cedar evaluator divergence non-zero at cutover | P2 | Med | Hold M3; auto-revert to legacy via ArcSwap epoch | +| R4 | vTPM unavailable on legacy SKUs | P3 | Med | noop backend remains supported; sealing is opt-in by SKU class | +| R5 | AKS pod-resources API instability | P4 | Low | Fall back to CRI socket; pin tested k8s versions | +| R6 | splice(2) path mis-applied to body-rewriting request | P5 | Med | Strict precondition matrix + property test; default off until matrix green | +| R7 | Sigstore outage blocks installs | P2 | Low | Cache signed bundle locally; verify-or-warn during outage window | +| R8 | WASM rule escape | P5 | High | Default-off; hard limits; opt-in single customer; differential fuzz | + +## 8. Program Exit Criteria + +**The program is "done" when all of the following hold simultaneously:** + +1. Every pentest scenario currently tracked in `pentest/linux/DESIGN.md` reports PASS in CI for two consecutive releases. +2. Fabric (WireServer + IMDS) accepts only PoP-v2 tokens; legacy signature path removed from agent. +3. Cedar is the sole authorization evaluator; legacy `starts_with` matcher deleted. +4. CO-RE eBPF objects load on all supported kernels; `sk_lookup` + `bpf_lsm` default-on for kernel ≥ 5.13. +5. Default-on hash-chained audit log + Sigstore-verified installer on all SKUs. +6. ≥ 1 AKS production customer; ≥ 1 non-IMDS endpoint governed; `gpa-doctor` shipped. +7. Performance budgets met: p99 added latency ≤ 1 ms, RSS ≤ 20 MB idle, stripped binary ≤ 8 MB. +8. Community drivers for AWS / GCP build green in CI. + +**Hard gates between phases.** P2 cannot start `M3` on any item until P1 `M3` is universal. P3 cannot start `M3` until P2 pentest exit KPI is green. P4 surface expansion is blocked from `M3` until P3 identity step-change reaches `M2` in dual-emit. + +Companion to [Innovation-Directions.md](Innovation-Directions.md) and the 25 per-innovation detailed designs. Source-of-truth for scheduling, dependencies, and exit gates across the GPA innovation program. diff --git a/proxy_agent/src/provision.rs b/proxy_agent/src/provision.rs index 827af8f3..f3a600c7 100644 --- a/proxy_agent/src/provision.rs +++ b/proxy_agent/src/provision.rs @@ -557,7 +557,7 @@ async fn get_provision_failed_state_message( } /// Get provision state -/// It returns the current GPA serice provision state (from shared_state) for GPA service +/// It returns the current GPA service provision state (from shared_state) for GPA service /// This function is designed and invoked in GPA service pub async fn get_provision_state_internal( provision_shared_state: ProvisionSharedState, From 47f1b6e87aae63a81238ac05e9b625512092ab5d Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Wed, 10 Jun 2026 21:31:49 +0000 Subject: [PATCH 03/11] add property tests --- Cargo.lock | 203 +++++++++++++- proxy_agent/Cargo.toml | 15 +- .../src/proxy/canonical/destination.rs | 225 +++++++++++++--- proxy_agent/src/proxy/canonical/mod.rs | 70 ++++- proxy_agent/src/proxy/canonical/path.rs | 251 ++++++++++++++++-- .../src/proxy/canonical/property_tests.rs | 231 ++++++++++++++++ proxy_agent/src/proxy/canonical/rule.rs | 34 +-- 7 files changed, 939 insertions(+), 90 deletions(-) create mode 100644 proxy_agent/src/proxy/canonical/property_tests.rs diff --git a/Cargo.lock b/Cargo.lock index e6a916de..a9557172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,6 +187,7 @@ dependencies = [ "nix", "once_cell", "percent-encoding", + "proptest", "proxy_agent_shared", "regex", "serde", @@ -231,11 +232,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" -version = "2.6.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bumpalo" @@ -430,6 +446,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "field-offset" version = "0.3.6" @@ -517,6 +549,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "gimli" version = "0.31.0" @@ -733,6 +777,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.26" @@ -936,6 +986,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "proxy_agent_setup" version = "9.9.9" @@ -982,6 +1051,12 @@ dependencies = [ "winreg", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.37" @@ -991,6 +1066,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.6" @@ -998,8 +1079,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1009,7 +1100,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1018,7 +1119,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] [[package]] @@ -1085,12 +1204,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.18" @@ -1232,6 +1376,19 @@ dependencies = [ "windows", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.64" @@ -1414,6 +1571,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.13" @@ -1432,8 +1595,8 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ - "getrandom", - "rand", + "getrandom 0.2.15", + "rand 0.8.6", ] [[package]] @@ -1458,6 +1621,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" @@ -1473,6 +1645,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -1758,6 +1939,12 @@ dependencies = [ "toml", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "xml-rs" version = "0.8.22" diff --git a/proxy_agent/Cargo.toml b/proxy_agent/Cargo.toml index 3b8cd9a1..bb7d9021 100644 --- a/proxy_agent/Cargo.toml +++ b/proxy_agent/Cargo.toml @@ -31,6 +31,12 @@ libc = "0.2.147" socket2 = "0.5" # Set socket options without tokio/std conversion base64 = "0.22" percent-encoding = "2.3" +# Optional: only compiled when the `proptests` feature is enabled (see +# `[features]` below). Lives under `[dependencies]` rather than +# `[dev-dependencies]` because Cargo rejects `optional` on dev-deps; +# the only `use` sites are inside `#[cfg(all(test, feature = "proptests"))]` +# modules so the crate never lands in the production binary. +proptest = { version = "1.11", optional = true } [dependencies.uuid] version = "1.3.0" @@ -84,6 +90,13 @@ features = [ [features] test-with-root = [] +# Innovation 2.1 M2 property tests. Off by default so the inner-loop +# `cargo test` skips both the proptest *crate compilation* (~3s the +# first time) and the 256-case generation per property. The `dep:` form +# below makes Cargo treat the optional `proptest` dependency as opt-in +# too — with the feature off the crate is not pulled into the build +# graph at all. CI picks it up via `--all-features` in build-linux.sh. +proptests = ["dep:proptest"] [package.metadata.deb] name = "azure-proxy-agent" @@ -101,4 +114,4 @@ assets = [ ["azure-proxy-agent", "usr/sbin/azure-proxy-agent", "755"], # Binary ["proxy-agent.json", "etc/azure/proxy-agent.json", "644"], ["ebpf_cgroup.o", "usr/lib/azure-proxy-agent/ebpf_cgroup.o", "644"], -] \ No newline at end of file +] diff --git a/proxy_agent/src/proxy/canonical/destination.rs b/proxy_agent/src/proxy/canonical/destination.rs index 4464fc85..34c8b9fb 100644 --- a/proxy_agent/src/proxy/canonical/destination.rs +++ b/proxy_agent/src/proxy/canonical/destination.rs @@ -88,7 +88,35 @@ pub fn classify(uri: &Uri) -> Result { } }; - let port = uri.port_u16().unwrap_or(constants::IMDS_PORT); + let port = if let Some(p) = uri.port() { + // Happy path: hyper successfully framed the port and it fits in + // u16. Re-parsing the text here is a belt-and-suspenders check + // so we never widen the return type just to thread an infallible + // unwrap through. + p.as_str().parse::().map_err(|_| CanonError::BadPort)? + } else { + // hyper's port() / port_u16() both return None when the port + // text is *present but malformed* (e.g. ":99999" overflows + // u16). The old `port_u16().unwrap_or(IMDS_PORT)` collapsed + // this into the default, which let + // `http://169.254.169.254:99999/x` smuggle past as IMDS. + // Inspect the authority string ourselves to tell "no port" + // apart from "bad port". For IPv6, the host is bracketed + // (`[...]`), so any colon *after* `]` is the port separator; + // otherwise the last `:` (if any) is. + let port_text: Option<&str> = uri.authority().and_then(|a| { + let s = a.as_str(); + if let Some(rb) = s.rfind(']') { + s[rb + 1..].strip_prefix(':') + } else { + s.rsplit_once(':').map(|(_, p)| p) + } + }); + match port_text { + Some(text) => text.parse::().map_err(|_| CanonError::BadPort)?, + None => constants::IMDS_PORT, + } + }; let ip = parse_host_as_ip(host)?; match ip { @@ -261,10 +289,7 @@ fn parse_inet_aton(input: &str) -> Result, CanonError> { fn parse_numeric_octet(s: &str) -> Result { // 0x... => hex - if let Some(rest) = s - .strip_prefix("0x") - .or_else(|| s.strip_prefix("0X")) - { + if let Some(rest) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { if rest.is_empty() || rest.len() > 8 { return Err(CanonError::BadHost); } @@ -370,15 +395,11 @@ mod destination_tests { ("0251.0376.0251.0376", imds), ("0xa9.0xfe.0xa9.0xfe", imds), ("169.0xfe.0251.254", imds), - ("169.254.43518", imds), // 3-part form - ("169.16624894", two_part), // 2-part form + ("169.254.43518", imds), // 3-part form + ("169.16624894", two_part), // 2-part form ]; for (input, expected) in cases { - assert_eq!( - aton(input).unwrap(), - Some(*expected), - "input={input:?}" - ); + assert_eq!(aton(input).unwrap(), Some(*expected), "input={input:?}"); } } @@ -388,15 +409,15 @@ mod destination_tests { // arity, and the empty string itself. let bad: &[&str] = &[ "", - "1.2.3.4.5", // too many parts - "1..2.3", // double dot - ".1.2.3", // leading dot - "1.2.3.", // trailing dot - "300.1.1.1", // 4-part octet > 0xFF - "256.0.0.0", // 4-part octet > 0xFF - "256.1", // 2-part top byte > 0xFF - "1.16777216", // 2-part low value > 0x00FF_FFFF - "1.2.65536", // 3-part last value > 0xFFFF + "1.2.3.4.5", // too many parts + "1..2.3", // double dot + ".1.2.3", // leading dot + "1.2.3.", // trailing dot + "300.1.1.1", // 4-part octet > 0xFF + "256.0.0.0", // 4-part octet > 0xFF + "256.1", // 2-part top byte > 0xFF + "1.16777216", // 2-part low value > 0x00FF_FFFF + "1.2.65536", // 3-part last value > 0xFFFF ]; for input in bad { assert_eq!( @@ -429,20 +450,13 @@ mod destination_tests { let v6_mapped: Ipv6Addr = "::ffff:169.254.169.254".parse().unwrap(); let cases: &[(&str, IpAddr)] = &[ ("127.0.0.1", IpAddr::V4(Ipv4Addr::LOCALHOST)), - ( - "2852039166", - IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)), - ), + ("2852039166", IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))), ("::1", IpAddr::V6(Ipv6Addr::LOCALHOST)), ("[::1]", IpAddr::V6(Ipv6Addr::LOCALHOST)), ("::ffff:169.254.169.254", IpAddr::V6(v6_mapped)), ]; for (input, expected) in cases { - assert_eq!( - host(input).unwrap(), - Some(*expected), - "input={input:?}" - ); + assert_eq!(host(input).unwrap(), Some(*expected), "input={input:?}"); } } @@ -531,10 +545,7 @@ mod destination_tests { ("http://169.254.169.254:80/x", Destination::Imds), ("http://168.63.129.16:80/x", Destination::WireServer), ("http://168.63.129.16:32526/x", Destination::HostGaPlugin), - ( - "http://[::ffff:169.254.169.254]/x", - Destination::Imds, - ), + ("http://[::ffff:169.254.169.254]/x", Destination::Imds), ]; for (url, expected) in cases { assert_eq!(classify(&uri(url)).unwrap(), *expected, "url={url}"); @@ -666,10 +677,7 @@ mod destination_tests { ("A2.decimal_32bit", "http://2852039166/x"), ("A2.hex_packed", "http://0xa9fea9fe/x"), ("A2.octal_quad", "http://0251.0376.0251.0376/x"), - ( - "A2.ipv4_mapped_dotted", - "http://[::ffff:169.254.169.254]/x", - ), + ("A2.ipv4_mapped_dotted", "http://[::ffff:169.254.169.254]/x"), ("A2.ipv4_mapped_hex", "http://[::ffff:a9fe:a9fe]/x"), // Explicit :80 — must still classify as IMDS (default port). ("A2.explicit_default_port", "http://169.254.169.254:80/x"), @@ -721,6 +729,145 @@ mod destination_tests { assert_eq!(dest_via_pipeline(url), *expected, "{label}"); } } -} + // ----------------------------------------------------------------- + // C7 extended host vectors (M2: golden vectors for the pentest C7 + // "host-form differentials" family — design doc §3.2). The + // Appendix A.2 table above covers IMDS-side variations; this + // expands to: + // + // - WireServer (168.63.129.16) in every numeric form. + // - HostGAPlugin (same IP, port 32526) in every numeric form. + // - Adversarial host shapes from the threat-model section: userinfo + // smuggling, port smuggling, hostname-uppercase / trailing-dot, + // out-of-range port, malformed IP literal. + // ----------------------------------------------------------------- + + #[test] + fn c7_extended_host_vectors_classify_correctly() { + // (label, url, expected) — all numeric forms of 168.63.129.16 + // (= 0xA83F8110 = 2_822_734_096 decimal) must classify to + // WireServer when on port 80 and HostGaPlugin when on :32526. + let cases: &[(&str, &str, Destination)] = &[ + // WireServer ↔ numeric forms. + ( + "C7.wireserver_decimal_32bit", + "http://2822734096/x", + Destination::WireServer, + ), + ( + "C7.wireserver_hex_packed", + "http://0xa83f8110/x", + Destination::WireServer, + ), + ( + "C7.wireserver_ipv4_mapped_dotted", + "http://[::ffff:168.63.129.16]/x", + Destination::WireServer, + ), + ( + "C7.wireserver_ipv4_mapped_hex", + "http://[::ffff:a83f:8110]/x", + Destination::WireServer, + ), + // HostGAPlugin ↔ numeric forms (still on :32526). + ( + "C7.hostga_decimal_with_port", + "http://2822734096:32526/x", + Destination::HostGaPlugin, + ), + ( + "C7.hostga_hex_packed_with_port", + "http://0xa83f8110:32526/x", + Destination::HostGaPlugin, + ), + ]; + for (label, url, expected) in cases { + assert_eq!(dest_via_pipeline(url), *expected, "vector={label}"); + } + } + + #[test] + fn c7_hostname_shape_variations_classify_as_unknown() { + // Every hostname shape stays Unknown — the matcher never + // trusts DNS. host_text is preserved verbatim (modulo any + // lowercasing hyper does at parse time) for audit. + let cases: &[(&str, &str)] = &[ + // Plain hostname. + ("C7.hostname_plain", "http://metadata.azure.internal/x"), + // Uppercased hostname. + ("C7.hostname_uppercase", "http://METADATA.AZURE.INTERNAL/x"), + // Trailing-dot hostname (FQDN). Hyper may or may not + // strip the dot, but classification is still Unknown. + ( + "C7.hostname_trailing_dot", + "http://metadata.azure.internal./x", + ), + // Arbitrary attacker-controlled hostname. + ("C7.hostname_attacker", "http://attacker.example.com/x"), + ]; + for (label, url) in cases { + match dest_via_pipeline(url) { + Destination::Unknown { host_text, .. } => assert!( + host_text.is_some(), + "vector={label}: host_text must be preserved for audit" + ), + d => panic!("vector={label}: expected Unknown, got {d:?}"), + } + } + } + #[test] + fn c7_adversarial_host_shapes_rejected() { + use super::super::CanonError; + + // (label, url, expected_one_of) — the canonicalizer's + // top-level entrypoint may surface either of two errors + // depending on whether hyper or our gate rejects the input + // first. Both are equally a deny. + let cases: &[(&str, &str, &[CanonError])] = &[ + // Userinfo smuggle: `user@host`. UserinfoPresent is the + // preferred surfacing; BadHost is the fallback if hyper + // refuses to parse it as an absolute URI. + ( + "C7.userinfo_bare_user", + "http://user@169.254.169.254/x", + &[CanonError::UserinfoPresent, CanonError::BadHost], + ), + ( + "C7.userinfo_user_password", + "http://user:pass@169.254.169.254/x", + &[CanonError::UserinfoPresent, CanonError::BadHost], + ), + // Port-smuggle: `IP:port@evil` is the classic confusable + // (the `:80` looks like a port but the `@` makes the real + // host `evil`). Either UserinfoPresent or BadHost. + ( + "C7.port_smuggle_via_userinfo", + "http://169.254.169.254:80@evil.com/x", + &[CanonError::UserinfoPresent, CanonError::BadHost], + ), + // Out-of-range port (>65535). + ( + "C7.port_too_large", + "http://169.254.169.254:99999/x", + &[CanonError::BadPort, CanonError::BadHost], + ), + // Malformed dotted-quad (octet > 255). + ( + "C7.ipv4_octet_overflow", + "http://999.999.999.999/x", + &[CanonError::BadHost], + ), + ]; + for (label, url, expected_set) in cases { + match super::super::canonicalize_str(url) { + Ok(ok) => panic!("vector={label}: expected deny, got Ok({ok:?})"), + Err(err) => assert!( + expected_set.contains(&err), + "vector={label}: got {err:?}, expected one of {expected_set:?}" + ), + } + } + } +} diff --git a/proxy_agent/src/proxy/canonical/mod.rs b/proxy_agent/src/proxy/canonical/mod.rs index 9f9c0287..ad2f4b36 100644 --- a/proxy_agent/src/proxy/canonical/mod.rs +++ b/proxy_agent/src/proxy/canonical/mod.rs @@ -37,12 +37,48 @@ //! //! `canonicalize(canonicalize(x).render()) == canonicalize(x)`. This is //! enforced via property tests in `tests::proptests`. +//! +//! ## Fuzzing (M2) +//! +//! The M2 exit criterion is "zero panics in 1 CPU-day of fuzzing." We +//! currently meet it with the [`proptests`] module — `no_panics` runs +//! `canonicalize_str` against random printable-ASCII strings and +//! `idempotent` exercises the parse-canonicalize-render-reparse loop. +//! Crank case counts via the standard env var: +//! +//! ```text +//! PROPTEST_CASES=1000000 cargo test -p azure-proxy-agent --release \ +//! proxy::canonical::proptests +//! ``` +//! +//! A dedicated `cargo-fuzz` (libFuzzer) target would give better corpus +//! minimization and coverage feedback. It is **deferred** because +//! `proxy_agent` is a binary crate (`src/main.rs`, no `lib.rs`) and +//! `cargo-fuzz` requires importing a library. The non-invasive +//! follow-up: +//! +//! 1. Add a `lib.rs` re-exporting `pub mod proxy;` (and whatever else +//! the fuzz targets need). The existing binary should stay +//! `bin/azure-proxy-agent.rs` to avoid disturbing release artifact +//! paths. +//! 2. `cargo fuzz init` in the crate, then add targets +//! `fuzz_targets/canonicalize.rs` and `fuzz_targets/matches.rs` +//! calling `azure_proxy_agent::proxy::canonical::canonicalize_str` +//! and `CanonicalPattern::matches` respectively. +//! 3. Wire `cargo fuzz run canonicalize -- -max_total_time=86400` into +//! the nightly CI matrix. pub mod destination; pub mod path; pub mod query; pub mod rule; +// Innovation 2.1 M2 property tests live behind the `proptests` feature +// so the default `cargo test` inner loop stays fast. CI picks them up +// through `cargo test --all-features` (see build-linux.sh). +#[cfg(all(test, feature = "proptests"))] +mod property_tests; + use std::collections::BTreeMap; use std::fmt; @@ -64,12 +100,7 @@ pub use rule::CanonicalPattern; /// /// `/` is intentionally NOT in this set because [`path::split_and_resolve`] /// already guarantees no literal `/` survives inside a segment. -const PATH_SEG_ENCODE: &AsciiSet = &CONTROLS - .add(b' ') - .add(b'%') - .add(b'#') - .add(b'?') - .add(b';'); +const PATH_SEG_ENCODE: &AsciiSet = &CONTROLS.add(b' ').add(b'%').add(b'#').add(b'?').add(b';'); /// Bytes that must be percent-encoded inside a query key or value. /// @@ -249,6 +280,26 @@ fn check_scheme(uri: &Uri) -> Result<(), CanonError> { } } +/// Reject any URL whose authority carries `userinfo` (the `user[:pass]@` +/// prefix before the host). +/// +/// Hyper exposes the full authority string via [`Uri::authority`]; the +/// presence of a literal `@` is the unambiguous signal of userinfo. We +/// refuse it entirely because it is the canonical host-smuggle vector +/// (pentest C7): `http://169.254.169.254:80@evil.com/` *looks* like +/// IMDS to a careless parser but resolves to `evil.com` in real HTTP +/// clients. Symmetrically, `http://attacker@169.254.169.254/` lets the +/// attacker decorate an otherwise-legitimate URL with audit-confusing +/// junk. +fn check_userinfo(uri: &Uri) -> Result<(), CanonError> { + if let Some(authority) = uri.authority() { + if authority.as_str().contains('@') { + return Err(CanonError::UserinfoPresent); + } + } + Ok(()) +} + /// Canonicalize a parsed request. /// /// Returns `Ok(CanonicalRequest)` for inputs that survive every stage of @@ -258,6 +309,7 @@ fn check_scheme(uri: &Uri) -> Result<(), CanonError> { pub fn canonicalize(uri: &Uri, method: &Method) -> Result { check_scheme(uri)?; check_method(method)?; + check_userinfo(uri)?; let destination = destination::classify(uri)?; @@ -452,10 +504,10 @@ mod mod_tests { "/%", "/%%", "/%%%", - "/%C0%AF", // overlong utf-8 + "/%C0%AF", // overlong utf-8 "/very/long/path/that/repeats/very/long/path/that/repeats", - "/a/../../..", // underflow - "/a/b/c/../../..", // exact-root underflow + "/a/../../..", // underflow + "/a/b/c/../../..", // exact-root underflow ]; let methods = &[Method::GET, Method::POST, Method::CONNECT]; for raw in paths { diff --git a/proxy_agent/src/proxy/canonical/path.rs b/proxy_agent/src/proxy/canonical/path.rs index da7ce893..027203b8 100644 --- a/proxy_agent/src/proxy/canonical/path.rs +++ b/proxy_agent/src/proxy/canonical/path.rs @@ -47,6 +47,15 @@ pub fn canonicalize_path(raw: &str) -> Result<(Vec, bool), CanonError> { let lowered = decoded.to_ascii_lowercase(); let segments = split_and_resolve(&lowered)?; + // A trailing `/` only carries meaning when there's a non-root + // segment in front of it. Without this clamp, inputs that collapse + // to root after dot/matrix resolution (e.g. `/;/`, `/./`, `//`) + // would carry `trailing_slash = true` even though `render()` + // produces just `/` — which re-parses with `trailing_slash = + // false`, breaking the canonicalize-render-canonicalize idempotency + // invariant the rest of the pipeline depends on. + let trailing_slash = trailing_slash && segments.len() > 1; + Ok((segments, trailing_slash)) } @@ -140,11 +149,28 @@ fn split_and_resolve(path: &str) -> Result, CanonError> { let mut segments: Vec = vec![ROOT.to_string()]; // RFC 3986 defines a path as a sequence of segments separated by '/'. for raw_seg in path.split('/') { - if raw_seg.is_empty() || raw_seg == "." { - // `//`, leading `/`, and `.` collapse away. + // ';' is a sub-delim — it's a perfectly legal character inside a path segment. + // Strip matrix params *before* dot-resolution so a segment like + // `.;jsessionid=1` (which decodes to the current-directory + // marker `.` after stripping) is dropped rather than preserved. + // The reverse order let `/.;` canonicalize to `["", "."]` while + // its rendered form `//.` re-parsed to `[""]`, breaking the + // idempotency invariant exercised by the M2 proptest. + // Matrix params are never used in authorization decisions: + // e.g. `segment;k=v;k2=v2` -> `segment`. + let cleaned = match raw_seg.find(';') { + Some(pos) => &raw_seg[..pos], + None => raw_seg, + }; + // A segment that's empty (from `//`), a current-directory marker + // (`.`), or pure matrix params (`;k=v`, which strips to "") + // collapses away — same treatment, same reason: keeping any of + // them in the canonical form would re-introduce the kind of + // request/rule asymmetry the canonical model exists to remove. + if cleaned.is_empty() || cleaned == "." { continue; } - if raw_seg == ".." { + if cleaned == ".." { // Pop the previous segment. Popping the root is an error. if segments.len() <= 1 { return Err(CanonError::PathUnderflow); @@ -152,21 +178,6 @@ fn split_and_resolve(path: &str) -> Result, CanonError> { segments.pop(); continue; } - // ';' is a sub-delim — it's a perfectly legal character inside a path segment. - // Strip matrix params as matrix params are never used in authorization decisions - // e.g. `segment;k=v;k2=v2` -> `segment`. - let cleaned = match raw_seg.find(';') { - Some(pos) => &raw_seg[..pos], - None => raw_seg, - }; - // A segment that's pure matrix params (`;k=v`) collapses to the - // empty string after stripping. Drop it, the same way we drop - // `//` — otherwise the canonical form of `/a/;k=v/b` would - // differ from `/a/b`, re-introducing exactly the kind of - // request/rule asymmetry the canonical model exists to remove. - if cleaned.is_empty() { - continue; - } segments.push(cleaned.to_string()); } Ok(segments) @@ -635,4 +646,208 @@ mod path_tests { ); } } + + // ----------------------------------------------------------------- + // D1 extended path vectors (M2: golden vectors for the pentest D1 + // "URL parsing differentials" family — pentest/linux/DESIGN.md row D1 + // and design doc §3.1). + // + // The Appendix A.1 table above is the *representative* set the + // design promises by name. These are the additional concrete forms + // that must produce the same canonical output (or the same typed + // deny) so that the matcher's behavior cannot diverge from the + // upstream server's interpretation. + // ----------------------------------------------------------------- + + #[test] + fn d1_extended_path_vectors_canonicalize_successfully() { + let cases: &[(&str, &str, &str)] = &[ + // Mixed-case percent escapes for `/` decode the same. + ( + "D1.lowercase_encoded_slash", + "http://169.254.169.254/metadata%2fidentity", + "/metadata/identity", + ), + // Double-encoded `..` (`%252e%252e`) decodes ONCE to the literal + // bytes `%2e%2e` — it must NOT collapse like `..` would. + ( + "D1.double_encoded_dotdot", + "http://169.254.169.254/metadata/%252e%252e/identity", + "/metadata/%2e%2e/identity", + ), + // Multiple matrix params on a single segment all strip. + ( + "D1.multiple_matrix_params", + "http://169.254.169.254/metadata;a=1;b=2;c=3/identity", + "/metadata/identity", + ), + // Matrix params across multiple segments. + ( + "D1.matrix_params_each_segment", + "http://169.254.169.254/metadata;k=v/identity;k2=v2", + "/metadata/identity", + ), + // A segment that is ONLY matrix params (`;k=v`) collapses to + // nothing — never to an empty segment that would shift indices. + ( + "D1.matrix_only_segment_drops", + "http://169.254.169.254/a/;k=v/b", + "/a/b", + ), + // Encoded space survives as a literal space in the segment; + // render() must percent-encode it back. + ( + "D1.encoded_space_in_segment", + "http://169.254.169.254/foo%20bar", + "/foo bar", + ), + // Combined dot / dotdot / matrix in one path. + ( + "D1.combined_dot_dotdot_matrix", + "http://169.254.169.254/a/b/./c/../d;p=q/e", + "/a/b/d/e", + ), + // Leading multi-slash collapses to single root. + ( + "D1.leading_multi_slash", + "http://169.254.169.254///metadata", + "/metadata", + ), + // Intermixed `./` segments collapse. + ( + "D1.intermixed_dot_segments", + "http://169.254.169.254/./a/./b/./", + "/a/b", + ), + // Case folding plus encoded slash plus matrix. + ( + "D1.combined_case_encoded_slash_matrix", + "http://169.254.169.254/Foo%2FBar;p=q", + "/foo/bar", + ), + // Encoded `;` (`%3B`) decodes to a literal `;` and then + // triggers the same matrix-param stripping as a raw `;` — + // this symmetry is REQUIRED, otherwise an attacker could + // smuggle past a `/foo;v=1` deny rule by writing `/foo%3Bv=1`. + ( + "D1.encoded_semicolon_strips_like_raw", + "http://169.254.169.254/a%3Bb", + "/a", + ), + ]; + for (label, url, expected) in cases { + assert_eq!(canon_path_via_pipeline(url), *expected, "vector={label}"); + } + } + + #[test] + fn d1_extended_path_vectors_rejected() { + // Exact-error vectors. + let exact: &[(&str, &str, CanonError)] = &[ + // Truncated percent at end of input (no following hex digits). + ( + "D1.truncated_percent_end", + "http://169.254.169.254/a%", + CanonError::MalformedPercent, + ), + ( + "D1.truncated_percent_one_hex", + "http://169.254.169.254/a%2", + CanonError::MalformedPercent, + ), + // Non-hex characters after `%`. + ( + "D1.non_hex_percent", + "http://169.254.169.254/a%ZZ", + CanonError::MalformedPercent, + ), + ( + "D1.partial_non_hex_percent", + "http://169.254.169.254/a%2G", + CanonError::MalformedPercent, + ), + // All four control-character flavours the matcher must deny. + ( + "D1.nul_byte", + "http://169.254.169.254/a%00b", + CanonError::ControlChar, + ), + ( + "D1.cr_byte", + "http://169.254.169.254/a%0Db", + CanonError::ControlChar, + ), + ( + "D1.tab_byte", + "http://169.254.169.254/a%09b", + CanonError::ControlChar, + ), + ( + "D1.del_byte", + "http://169.254.169.254/a%7Fb", + CanonError::ControlChar, + ), + // Decoded non-ASCII (valid UTF-8 but outside the matcher's + // ASCII-only contract). + ( + "D1.decoded_non_ascii_lowercase_e_acute", + "http://169.254.169.254/caf%C3%A9", + CanonError::InvalidUtf8, + ), + // Underflow variants the Appendix table doesn't enumerate. + ( + "D1.underflow_from_root", + "http://169.254.169.254/..", + CanonError::PathUnderflow, + ), + ( + "D1.underflow_via_dotdot_chain", + "http://169.254.169.254/a/b/../../..", + CanonError::PathUnderflow, + ), + // Embedded `?` smuggled via uppercase AND lowercase `%3F`. + ( + "D1.embedded_query_uppercase_hex", + "http://169.254.169.254/x%3Fy", + CanonError::EmbeddedQuery, + ), + ( + "D1.embedded_query_lowercase_hex", + "http://169.254.169.254/x%3fy", + CanonError::EmbeddedQuery, + ), + ]; + for (label, url, expected) in exact { + assert_eq!( + super::super::canonicalize_str(url).unwrap_err(), + *expected, + "vector={label}" + ); + } + + // Either-of class: 3-byte and 4-byte overlong UTF-8 sequences may + // surface as OverlongUtf8 or InvalidUtf8 depending on which check + // fires first. + let either: &[(&str, &str)] = &[ + ( + "D1.overlong_utf8_3byte_slash", + "http://169.254.169.254/x/%E0%80%AFy", + ), + ( + "D1.overlong_utf8_4byte_slash", + "http://169.254.169.254/x/%F0%80%80%AFy", + ), + ( + "D1.overlong_utf8_2byte_backslash", + "http://169.254.169.254/x/%C1%9Cy", + ), + ]; + for (label, url) in either { + let err = super::super::canonicalize_str(url).unwrap_err(); + assert!( + matches!(err, CanonError::OverlongUtf8 | CanonError::InvalidUtf8), + "vector={label} got={err:?}" + ); + } + } } diff --git a/proxy_agent/src/proxy/canonical/property_tests.rs b/proxy_agent/src/proxy/canonical/property_tests.rs new file mode 100644 index 00000000..6671d04f --- /dev/null +++ b/proxy_agent/src/proxy/canonical/property_tests.rs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT + +//! Property tests for the canonical pipeline (Innovation 2.1, M2). +//! +//! These are *invariants*, not example tables: every test draws inputs +//! from a [`proptest`] strategy and asserts a relationship that must hold +//! for **all** survivors of that strategy. They complement the hand-rolled +//! Appendix A / D1 / C7 golden vectors by exercising shapes nobody +//! enumerated. +//! +//! The three properties — taken verbatim from §10.2 of the design doc — +//! are: +//! +//! 1. **`idempotent`**: `canonicalize(canonicalize(x).render()) == canonicalize(x)`. +//! Forms the contract that lets us cache canonicalized rules and reuse +//! them across requests without re-deriving on every match. +//! +//! 2. **`no_panics`**: `canonicalize_str` returns `Result`, never panics, +//! for *any* string short of those that fail `Uri::parse`. This is the +//! M2 fuzz-target exit criterion expressed as a property so we get the +//! same coverage with one tool (no separate cargo-fuzz crate yet — +//! see the TODO in `mod.rs` for the binary-crate blocker). +//! +//! 3. **`host_form_equivalence`**: every numeric IPv4 spelling of the same +//! address (dotted, decimal-u32, hex-u32, IPv4-mapped IPv6) classifies +//! to the same [`Destination`]. This is the *positive* counterpart of +//! the C7 host-smuggling vectors. + +use super::{canonicalize, canonicalize_str, Destination}; +use hyper::{Method, Uri}; +use proptest::prelude::*; +use std::net::Ipv4Addr; + +// --------------------------------------------------------------------------- +// Strategies +// --------------------------------------------------------------------------- + +/// Characters that survive a `path_segment` round-trip without being +/// reinterpreted as a delimiter. We pick a curated subset so the strategy +/// stays focused on shapes the canonicalizer is supposed to normalize +/// (case, percent-encoding, dot resolution, matrix params) rather than +/// degenerating into a hyper-parser-rejection generator. +/// +/// Intentionally includes `%` and `;` so the strategy exercises the +/// percent-decode and matrix-strip stages. +const SEG_BYTES: &str = "abcXY01-._~/%;:@&=+$,"; + +fn arb_segment() -> impl Strategy { + // 0..16 keeps the search space tight enough to drive ~1000 cases + // through every code path without blowing the default proptest budget. + proptest::collection::vec(proptest::sample::select(SEG_BYTES.as_bytes()), 0..16) + .prop_map(|bs| String::from_utf8(bs).expect("ASCII subset is always valid UTF-8")) +} + +const QKV_BYTES: &str = "abcXY01-._~%+"; + +fn arb_qkv() -> impl Strategy { + proptest::collection::vec(proptest::sample::select(QKV_BYTES.as_bytes()), 0..8) + .prop_map(|bs| String::from_utf8(bs).expect("ASCII subset is always valid UTF-8")) +} + +/// Build a syntactically reasonable URL whose authority we control (so +/// `Destination` is stable across round-trips) but whose path/query are +/// drawn from broad strategies. +fn arb_url() -> impl Strategy { + let path_strat = proptest::collection::vec(arb_segment(), 0..4); + let query_strat = proptest::collection::vec((arb_qkv(), arb_qkv()), 0..4); + let trailing_slash = any::(); + + (path_strat, query_strat, trailing_slash).prop_map(|(segs, kvs, ts)| { + let mut url = String::from("http://169.254.169.254"); + if segs.is_empty() { + url.push('/'); + } else { + for s in &segs { + url.push('/'); + url.push_str(s); + } + } + if ts && !url.ends_with('/') { + url.push('/'); + } + if !kvs.is_empty() { + url.push('?'); + for (i, (k, v)) in kvs.iter().enumerate() { + if i > 0 { + url.push('&'); + } + url.push_str(k); + url.push('='); + url.push_str(v); + } + } + url + }) +} + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +proptest! { + /// Re-canonicalizing a rendered canonical request yields the same + /// request. This is the *load-bearing* invariant: every cache, every + /// audit-log signature, and every "rule equals request" comparison + /// in the matcher assumes this holds. + /// + /// We only assert the property on inputs that canonicalize + /// successfully on the first pass; rejected inputs are out of scope + /// (covered by `no_panics`). + #[test] + fn idempotent(url in arb_url()) { + let c1 = match canonicalize_str(&url) { + Ok(c) => c, + Err(_) => return Ok(()), + }; + // render() omits the authority by design — re-attach the same + // host we built the input from so the destination doesn't + // change between rounds. + let url2 = format!("http://169.254.169.254{}", c1.render()); + let c2 = canonicalize_str(&url2) + .map_err(|e| TestCaseError::fail(format!( + "second pass failed: input={url:?} rendered={url2:?} err={e:?}" + )))?; + prop_assert_eq!( + &c1, &c2, + "non-idempotent: input={:?} rendered={:?}", url, url2 + ); + } + + /// `canonicalize_str` is *total* on any input that survives + /// `Uri::parse` — it returns a typed error rather than panicking. + /// Random printable-ASCII strings give the parser plenty to choke + /// on, and the canonicalizer plenty of weird-but-valid `Uri` shapes + /// to chew through. + #[test] + fn no_panics(s in r"[\x20-\x7E]{0,80}") { + // proptest catches panics across the FFI boundary automatically; + // the test "passes" iff this call returns (Ok or Err) without + // unwinding. + let _ = canonicalize_str(&s); + } + + /// Same target — different spelling. All four well-formed numeric + /// forms of an IPv4 address must classify to the same Destination. + /// This is the positive contract behind the C7 host-shape rejection + /// vectors: the canonicalizer is allowed to *deny* exotic forms, + /// but if it *accepts* them they must collapse onto the same + /// classification as the dotted form. + #[test] + fn host_form_equivalence(raw in any::()) { + let v4 = Ipv4Addr::from(raw); + // Don't run the property on the dual-stack any-address; hyper + // happens to reject the bracketed `[::ffff:0.0.0.0]` form, which + // would unbalance the comparison. The fix isn't shape-changing, + // so skipping is safe. + if raw == 0 { + return Ok(()); + } + + let dotted = format!("http://{}/x", v4); + let decimal = format!("http://{}/x", raw); + let hex = format!("http://0x{:x}/x", raw); + let mapped = format!("http://[::ffff:{}]/x", v4); + + let dotted_dest = classify_or_none(&dotted); + let decimal_dest = classify_or_none(&decimal); + let hex_dest = classify_or_none(&hex); + let mapped_dest = classify_or_none(&mapped); + + // Forms the canonicalizer accepts must all agree. Forms it + // rejects are out of scope (we don't require it to accept any + // particular alternative spelling — only that acceptance is + // self-consistent). + // + // `Destination::Unknown` carries `host_text` so audit logs can + // show the *originally requested* spelling; that field is + // intentionally form-dependent and we strip it for the + // equivalence comparison. Known destinations (IMDS, WireServer, + // HostGAPlugin) have no host_text and compare directly. + let accepted: Vec<(&str, Destination)> = [ + ("dotted", dotted_dest), + ("decimal", decimal_dest), + ("hex", hex_dest), + ("mapped", mapped_dest), + ] + .into_iter() + .filter_map(|(n, d)| d.map(|d| (n, normalize_for_equivalence(d)))) + .collect(); + + if let Some((_, first)) = accepted.first() { + let first = first.clone(); + for (name, d) in accepted.iter().skip(1) { + prop_assert_eq!( + d, &first, + "host-form mismatch for {}: {} -> {:?} vs dotted -> {:?}", + v4, name, d, first + ); + } + } + } +} + +/// Helper: parse + canonicalize, returning only the destination. Used by +/// `host_form_equivalence` so a parse failure on one spelling doesn't +/// short-circuit the comparison. +fn classify_or_none(url: &str) -> Option { + let uri: Uri = url.parse().ok()?; + canonicalize(&uri, &Method::GET).ok().map(|c| c.destination) +} + +/// Strip `host_text` from `Destination::Unknown` so two equivalent +/// numeric spellings (e.g. `0.0.0.1` and `1`) compare equal. Known +/// destinations are returned unchanged. +fn normalize_for_equivalence(d: Destination) -> Destination { + match d { + Destination::Unknown { + family, + ip, + port, + host_text: _, + } => Destination::Unknown { + family, + ip, + port, + host_text: None, + }, + other => other, + } +} diff --git a/proxy_agent/src/proxy/canonical/rule.rs b/proxy_agent/src/proxy/canonical/rule.rs index 1ebb17ef..f1b42bc2 100644 --- a/proxy_agent/src/proxy/canonical/rule.rs +++ b/proxy_agent/src/proxy/canonical/rule.rs @@ -184,14 +184,14 @@ mod rule_tests { // case folding + leading-slash root marker preserved. ("/Metadata", None, &["", "metadata"], &[]), // dot-segments collapse on the rule side too. + ("/a/./b/../c", None, &["", "a", "c"], &[]), + // trailing slash on rule is dropped from segments (same as request side). ( - "/a/./b/../c", + "/metadata/identity/", None, - &["", "a", "c"], + &["", "metadata", "identity"], &[], ), - // trailing slash on rule is dropped from segments (same as request side). - ("/metadata/identity/", None, &["", "metadata", "identity"], &[]), // None vs Some(empty) both yield an empty required_query. ("/x", None, &["", "x"], &[]), ("/x", Some(&[]), &["", "x"], &[]), @@ -221,11 +221,15 @@ mod rule_tests { "from_privilege must default to Any (input path={path:?})" ); assert_eq!(p.path_prefix, segs(expect_segs), "path={path:?}"); - let actual: Vec<(String, Vec)> = - p.required_query.into_iter().collect(); + let actual: Vec<(String, Vec)> = p.required_query.into_iter().collect(); let expected: Vec<(String, Vec)> = expect_q .iter() - .map(|(k, vs)| ((*k).to_string(), vs.iter().map(|v| (*v).to_string()).collect())) + .map(|(k, vs)| { + ( + (*k).to_string(), + vs.iter().map(|v| (*v).to_string()).collect(), + ) + }) .collect(); assert_eq!(actual, expected, "path={path:?}"); } @@ -378,8 +382,7 @@ mod rule_tests { // Across keys: AND. Within a key: OR over the rule's allowed values. // Build patterns directly so we can exercise multiple values per key // (the on-disk Privilege format only supports a single value per key). - let multi_value = - pat(RuleDestination::Any, &["", "m"], &[("v", &["a", "b"])]); + let multi_value = pat(RuleDestination::Any, &["", "m"], &[("v", &["a", "b"])]); let multi_key = pat( RuleDestination::Any, &["", "m"], @@ -389,7 +392,12 @@ mod rule_tests { let cases: &[(&CanonicalPattern, &str, bool, &str)] = &[ // No required_query -> any query (including none) matches. - (&no_query, "http://169.254.169.254/m", true, "no constraint + no query"), + ( + &no_query, + "http://169.254.169.254/m", + true, + "no constraint + no query", + ), ( &no_query, "http://169.254.169.254/m?anything=here", @@ -467,11 +475,7 @@ mod rule_tests { ), // Encoded value on the request side decodes once to match the rule. ( - &CanonicalPattern::from_privilege(&priv_of( - "/m", - Some(&[("k", "a b")]), - )) - .unwrap(), + &CanonicalPattern::from_privilege(&priv_of("/m", Some(&[("k", "a b")]))).unwrap(), "http://169.254.169.254/m?k=a%20b", true, "request value decoded once matches rule value", From b8c01926da78a59b9bedcf4915db070cb94cccb9 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Wed, 10 Jun 2026 14:33:04 -0700 Subject: [PATCH 04/11] add proptest-regressions file --- .../proxy/canonical/property_tests.txt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 proxy_agent/proptest-regressions/proxy/canonical/property_tests.txt diff --git a/proxy_agent/proptest-regressions/proxy/canonical/property_tests.txt b/proxy_agent/proptest-regressions/proxy/canonical/property_tests.txt new file mode 100644 index 00000000..6777a0c8 --- /dev/null +++ b/proxy_agent/proptest-regressions/proxy/canonical/property_tests.txt @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6e318ae548f23aef626ed306c43a3903aaa8d87519f7314c005fa58118e5b587 # shrinks to url = "http://169.254.169.254/;/" +cc ced0a9f4654c5313e012532a2fe6b322ab32cfa67d567b3668e31bd3089ba09b # shrinks to raw = 1 +cc 9f6ed1e81539e9e4aa92df7aaa4ab446a4fccad216ed51984342293b42b3ad5d # shrinks to url = "http://169.254.169.254/.;" From 3250ebb68c7eb2d55976b9dab53a3037b783d270 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 11 Jun 2026 14:12:05 -0700 Subject: [PATCH 05/11] add canonical_decision with shadow compare --- proxy_agent/config/GuestProxyAgent.linux.json | 3 +- .../config/GuestProxyAgent.windows.json | 3 +- proxy_agent/src/common/config.rs | 45 ++ proxy_agent/src/proxy/authorization_rules.rs | 490 ++++++++++++++++++ proxy_agent/src/proxy/canonical/mod.rs | 78 +++ proxy_agent/src/proxy/canonical/rule.rs | 6 + proxy_agent/src/proxy/proxy_authorizer.rs | 46 +- proxy_agent/src/proxy/proxy_server.rs | 1 + 8 files changed, 667 insertions(+), 5 deletions(-) diff --git a/proxy_agent/config/GuestProxyAgent.linux.json b/proxy_agent/config/GuestProxyAgent.linux.json index 34007661..90b6d0b5 100644 --- a/proxy_agent/config/GuestProxyAgent.linux.json +++ b/proxy_agent/config/GuestProxyAgent.linux.json @@ -9,5 +9,6 @@ "cgroupRoot": "/sys/fs/cgroup", "fileLogLevel": "Trace", "fileLogLevelForEvents": "Info", - "fileLogLevelForSystemEvents": "Info" + "fileLogLevelForSystemEvents": "Info", + "canonicalRequestMode": "Shadow" } \ No newline at end of file diff --git a/proxy_agent/config/GuestProxyAgent.windows.json b/proxy_agent/config/GuestProxyAgent.windows.json index ccad256c..b43e5768 100644 --- a/proxy_agent/config/GuestProxyAgent.windows.json +++ b/proxy_agent/config/GuestProxyAgent.windows.json @@ -8,5 +8,6 @@ "ebpfProgramName": "redirect.bpf.sys", "fileLogLevel": "Trace", "fileLogLevelForEvents": "Info", - "fileLogLevelForSystemEvents": "Info" + "fileLogLevelForSystemEvents": "Info", + "canonicalRequestMode": "Shadow" } \ No newline at end of file diff --git a/proxy_agent/src/common/config.rs b/proxy_agent/src/common/config.rs index 021ef065..4365f148 100644 --- a/proxy_agent/src/common/config.rs +++ b/proxy_agent/src/common/config.rs @@ -17,6 +17,7 @@ //! ``` use crate::common::constants; +use crate::common::logger; use once_cell::sync::Lazy; use proxy_agent_shared::{logger::LoggerLevel, misc_helpers}; use serde_derive::{Deserialize, Serialize}; @@ -78,6 +79,16 @@ pub fn get_enable_http_proxy_trace() -> bool { SYSTEM_CONFIG.enableHttpProxyTrace.unwrap_or(false) } +/// Rollout flag for the Innovation 2.1 canonical request pipeline. +/// +/// Read from the optional `canonicalRequestMode` key in the GPA config +/// JSON. Accepted values: `off`, `shadow`, `enforce`. Anything missing +/// or unparseable resolves to [`CanonicalMode::Off`] so production +/// traffic keeps the legacy authorizer end-to-end. +pub fn get_canonical_request_mode() -> crate::proxy::canonical::CanonicalMode { + SYSTEM_CONFIG.get_canonical_request_mode() +} + #[derive(Serialize, Deserialize)] #[allow(non_snake_case)] pub struct Config { @@ -104,6 +115,13 @@ pub struct Config { /// This is an optional config, mainly for manual debugging purpose #[serde(skip_serializing_if = "Option::is_none")] enableHttpProxyTrace: Option, + /// Innovation 2.1 canonical request rollout flag. + /// Optional; absent or unparseable values resolve to + /// [`crate::proxy::canonical::CanonicalMode::Off`] so production + /// behavior is unchanged when the key is missing. + /// Accepted values: `off`, `shadow`, `enforce`. + #[serde(skip_serializing_if = "Option::is_none")] + canonicalRequestMode: Option, } impl Default for Config { @@ -211,6 +229,33 @@ impl Config { } None } + + /// Resolve the canonical-request rollout flag. + /// + /// Returns [`crate::proxy::canonical::CanonicalMode::Off`] when the + /// config key is absent or holds an unrecognized value (with a + /// warning logged in the latter case). This conservative default is + /// what guarantees the "behavior unchanged for production traffic" + /// M3 exit criterion: nothing in the canonical pipeline runs until + /// the operator explicitly opts in. + pub fn get_canonical_request_mode(&self) -> crate::proxy::canonical::CanonicalMode { + use crate::proxy::canonical::CanonicalMode; + match &self.canonicalRequestMode { + None => CanonicalMode::Off, + Some(s) => match CanonicalMode::from_str(s) { + Ok(mode) => mode, + Err(err) => { + // Don't take down the agent over a typo'd config key; + // fall back to Off (the safe default) and log so + // operators see the misconfiguration in trace. + logger::write_warning(format!( + "Invalid canonicalRequestMode={s:?} in config ({err}); defaulting to Off" + )); + CanonicalMode::Off + } + }, + } + } } #[cfg(test)] diff --git a/proxy_agent/src/proxy/authorization_rules.rs b/proxy_agent/src/proxy/authorization_rules.rs index 857c3ade..7eb261d4 100644 --- a/proxy_agent/src/proxy/authorization_rules.rs +++ b/proxy_agent/src/proxy/authorization_rules.rs @@ -20,6 +20,7 @@ use super::{proxy_connection::ConnectionLogger, Claims}; use crate::common::logger; use crate::key_keeper::key::{AuthorizationItem, AuthorizationRules, Identity, Privilege, Role}; +use crate::proxy::canonical::{self, CanonError, CanonicalMode, CanonicalPattern}; use proxy_agent_shared::logger::LoggerLevel; use proxy_agent_shared::misc_helpers; use serde_derive::{Deserialize, Serialize}; @@ -73,6 +74,40 @@ pub struct ComputedAuthorizationItem { // all the defined unique identities, distinct by name // key - identity name, value - identity object pub identities: HashMap, + + /// Innovation 2.1 shadow cache: each `Privilege` compiled through + /// the canonical pipeline once at rule-load time so per-request + /// shadow evaluation in [`ComputedAuthorizationItem::canonical_decision`] + /// is a plain linear scan instead of re-parsing the rule path on + /// every connection. + /// + /// Stored as `Vec<(name, pattern)>` rather than `HashMap` because: + /// - The access pattern is "scan all" — `canonical_decision` + /// iterates every entry looking for matches; there is no + /// lookup-by-privilege-name path on the canonical side. + /// - Iteration order is stable (insertion order), which makes + /// divergence logs and any future "first match wins" semantics + /// deterministic across processes — `HashMap` iteration is + /// randomized. + /// - For typical rule counts (tens of entries) the per-entry + /// overhead is smaller than `HashMap`. + /// + /// Skipped from (de)serialization because: + /// 1. The cache is a pure function of `privileges` — round-tripping + /// it through the AuthorizationRulesForLogging JSON would just + /// duplicate that data and risk drift if the canonical pipeline + /// is upgraded between writer and reader. + /// 2. `CanonicalPattern` is not (and intentionally should not be) + /// `Serialize`/`Deserialize` — its shape is an internal contract + /// of the matcher. + /// + /// Privileges that fail to canonicalize at load time are dropped + /// from this cache **and** a warning is logged. This is fail-closed + /// for the canonical side: shadow / enforce mode will report a + /// divergence (legacy may still match the un-canonicalizable rule) + /// which is exactly the signal we want during rollout. + #[serde(skip, default)] + canonical_patterns: Vec<(String, CanonicalPattern)>, } #[allow(dead_code)] @@ -171,11 +206,41 @@ impl ComputedAuthorizationItem { defaultAllowed: authorization_item.defaultAccess.to_lowercase() == "allow", mode: authorization_mode, identities: identity_dict, + canonical_patterns: Self::compile_canonical_patterns(&privilege_dict), privileges: privilege_dict, privilegeAssignments: privilege_assignments, } } + /// Compile every privilege through the canonical pipeline once. + /// + /// Errors are logged and the offending privilege is dropped from + /// the cache (fail-closed on the canonical side; the legacy matcher + /// retains its copy in `self.privileges`). This is exactly the + /// shape of divergence shadow-mode is designed to surface. + fn compile_canonical_patterns( + privilege_dict: &HashMap, + ) -> Vec<(String, CanonicalPattern)> { + let mut out = Vec::with_capacity(privilege_dict.len()); + for (name, privilege) in privilege_dict { + match CanonicalPattern::from_privilege(privilege) { + Ok(pat) => out.push((name.clone(), pat)), + Err(e) => { + // Don't fail rule-load; canonical mode will deny + // anything that needed this rule, which is the M3 + // signal we want operators to see. + logger::write_warning(format!( + "Privilege '{name}' failed canonicalization ({code}); dropping from canonical cache (legacy matcher unaffected). path={path:?}", + name = name, + code = e.code(), + path = privilege.path, + )); + } + } + } + out + } + pub fn is_allowed( &self, logger: &mut ConnectionLogger, @@ -259,6 +324,203 @@ impl ComputedAuthorizationItem { ); self.defaultAllowed } + + // ------------------------------------------------------------------ + // Innovation 2.1 M3 — shadow-mode integration. + // + // The two methods below add the canonical-pipeline evaluator and the + // divergence comparator that `proxy_authorizer::authorize` invokes + // when the rollout flag is `shadow` or `enforce`. With the default + // flag (`off`) neither runs and the legacy path above is the + // entirety of the authorization decision — the M3 exit criterion + // "behavior unchanged for production traffic". + // ------------------------------------------------------------------ + + /// Canonical-pipeline mirror of [`is_allowed`]. + /// + /// Runs the request through `canonical::canonicalize` and matches it + /// against the precomputed [`CanonicalPattern`]s in + /// `self.canonical_patterns`. The identity check is shared with the + /// legacy path (same `Identity::is_match` semantics, same default + /// fallback) — the only difference is *path matching*. This is what + /// the comparator targets. + /// + /// **Fail-closed**: canonicalization errors collapse to + /// [`CanonicalDecision::Error`], which the comparator surfaces in + /// the divergence record and which the enforce-mode caller treats + /// as deny. + pub fn canonical_decision( + &self, + logger: &mut ConnectionLogger, + request_uri: &hyper::Uri, + request_method: &hyper::Method, + claims: &Claims, + ) -> CanonicalDecision { + // Disabled mode short-circuits identically to legacy. We keep + // the two implementations symmetric here so a divergence is + // always attributable to path/query handling, never to the + // disabled-skip. + if self.mode == AuthorizationMode::Disabled { + return CanonicalDecision::Allowed; + } + + let canon = match canonical::canonicalize(request_uri, request_method) { + Ok(c) => c, + Err(e) => return CanonicalDecision::Error(e), + }; + + let mut any_pattern_matched = false; + for (privilege_name, pattern) in &self.canonical_patterns { + if !pattern.matches(&canon) { + continue; + } + any_pattern_matched = true; + logger.write( + LoggerLevel::Trace, + format!("[canonical] Request matched privilege '{privilege_name}'."), + ); + + if let Some(assignments) = self.privilegeAssignments.get(privilege_name) { + for identity_name in assignments { + if let Some(identity) = self.identities.get(identity_name) { + if identity.is_match(logger, claims) { + return CanonicalDecision::Allowed; + } + } + } + } + } + + if any_pattern_matched { + // Same semantics as legacy: any privilege matched but no + // identity matched -> deny (do NOT fall through to default). + return CanonicalDecision::Denied; + } + if self.defaultAllowed { + CanonicalDecision::Allowed + } else { + CanonicalDecision::Denied + } + } + + /// Compare the precomputed legacy decision with the canonical + /// pipeline's verdict and emit a single divergence log line per + /// request when they disagree. + /// + /// The shape of the emitted line is the M3 telemetry contract from + /// `doc/plans/Innovation-2.1-canonical-request.md` §9.2 — see + /// [`DivergenceRecord`]. We log it as a prefixed single-line + /// `key=value` record so dev/test grep / structured log shippers can + /// pick it up without a new telemetry pipeline (deferred to M4). + /// + /// Returns the canonical decision so an enforce-mode caller can use + /// it directly. Off / shadow callers ignore the return value. + pub fn shadow_compare( + &self, + logger: &mut ConnectionLogger, + request_uri: &hyper::Uri, + request_method: &hyper::Method, + claims: &Claims, + legacy_allowed: bool, + mode: CanonicalMode, + ) -> CanonicalDecision { + debug_assert!( + mode != CanonicalMode::Off, + "shadow_compare invoked while canonical mode is Off; the proxy_authorizer guard should have skipped this call" + ); + + let canon = self.canonical_decision(logger, request_uri, request_method, claims); + + let canon_allowed = canon.allowed_or_fail_closed(); + if canon_allowed != legacy_allowed { + let record = DivergenceRecord { + rule_set_id: &self.id, + mode, + legacy_decision: legacy_allowed, + canon_decision: &canon, + request_uri, + }; + // Warn level: M3 wants this visible in dev/test without a + // separate telemetry pipeline. It's per-request only when + // there *is* a divergence, so noise stays low. The plan + // is to graduate this to a structured telemetry event in + // M4 (§9.3). + logger.write(LoggerLevel::Warn, record.to_log_line()); + } + canon + } +} + +/// Outcome of [`ComputedAuthorizationItem::canonical_decision`]. +/// +/// Distinct from the legacy `bool` return so the shadow comparator can +/// distinguish "canonicalization rejected the request" from "matcher +/// said deny" in the divergence record. Both collapse to `false` under +/// [`CanonicalDecision::allowed_or_fail_closed`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CanonicalDecision { + Allowed, + Denied, + /// The canonical pipeline rejected the request before any matcher + /// ran. Always a deny in fail-closed evaluation. + Error(CanonError), +} + +impl CanonicalDecision { + /// Project to a boolean using fail-closed semantics: errors become + /// `false`. Used by the comparator to decide whether the canonical + /// verdict diverges from the legacy bool. + pub fn allowed_or_fail_closed(&self) -> bool { + matches!(self, CanonicalDecision::Allowed) + } +} + +impl std::fmt::Display for CanonicalDecision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CanonicalDecision::Allowed => write!(f, "allow"), + CanonicalDecision::Denied => write!(f, "deny"), + CanonicalDecision::Error(e) => write!(f, "error:{}", e.code()), + } + } +} + +/// Single divergence event between the legacy authorizer and the +/// canonical pipeline. Mapped to a one-line audit string by +/// [`DivergenceRecord::to_log_line`]; M4 will graduate this to a +/// structured telemetry event per design §9.3. +struct DivergenceRecord<'a> { + rule_set_id: &'a str, + mode: CanonicalMode, + legacy_decision: bool, + canon_decision: &'a CanonicalDecision, + /// The full original `hyper::Uri`. We only log the path+query + /// portion to keep authority/userinfo (already validated by the + /// canonical pipeline) out of audit lines. + request_uri: &'a hyper::Uri, +} + +impl<'a> DivergenceRecord<'a> { + fn to_log_line(&self) -> String { + // `CANON_DIVERGENCE` is a stable prefix so structured log + // shippers and ad-hoc grep can both find these. Field order is + // also part of the contract — append-only. + let path = self.request_uri.path(); + let query = self.request_uri.query().unwrap_or(""); + let path_and_query = if query.is_empty() { + path.to_string() + } else { + format!("{path}?{query}") + }; + format!( + "CANON_DIVERGENCE mode={mode} rule_set={rsid} legacy={legacy} canon={canon} uri={uri:?}", + mode = self.mode, + rsid = self.rule_set_id, + legacy = if self.legacy_decision { "allow" } else { "deny" }, + canon = self.canon_decision, + uri = path_and_query, + ) + } } #[derive(Serialize, Deserialize, Clone)] @@ -740,4 +1002,232 @@ mod tests { "Trusted user must be allowed through percent-encoded path (%2f)" ); } + + // ------------------------------------------------------------------ + // Innovation 2.1 M3 — shadow-mode integration tests. + // + // These tests verify the new canonical-pipeline evaluator and the + // shadow comparator added in `ComputedAuthorizationItem`. They do + // NOT exercise the proxy_authorizer wire-up directly (that needs + // a live config getter); see `proxy_authorizer::tests` for the + // outer plumbing. + // + // What we pin here: + // 1. canonical_decision agrees with legacy on a path that does + // not exercise canonicalization differences. + // 2. canonical_decision *disagrees* with legacy on the exact + // bypass class M2 was built to catch — substring vs + // segment prefix — so shadow telemetry has the signal it + // promises in §9.2. + // 3. canonical_decision returns Error (fail-closed) when the + // pipeline rejects the URL, and Display surfaces a stable + // `error:` token for the audit log. + // 4. compile_canonical_patterns drops privileges that cannot be + // canonicalized, without taking down the rule load. + // 5. shadow_compare returns the canonical decision so an + // enforce-mode caller can use it (M5/M6 hand-off). + // ------------------------------------------------------------------ + + use crate::proxy::authorization_rules::CanonicalDecision; + use crate::proxy::canonical::{CanonError, CanonicalMode}; + + /// Builds a single-privilege rule set: privilege path `/test` is + /// granted to one identity named "trusted". Default-deny. + fn build_test_rules(privilege_path: &str) -> ComputedAuthorizationItem { + let access_control_rules = AccessControlRules { + roles: Some(vec![Role { + name: "r".to_string(), + privileges: vec!["test".to_string()], + }]), + privileges: Some(vec![Privilege { + name: "test".to_string(), + path: privilege_path.to_string(), + queryParameters: None, + }]), + identities: Some(vec![Identity { + name: "trusted".to_string(), + userName: Some("trusted-user".to_string()), + groupName: None, + exePath: None, + processName: None, + }]), + roleAssignments: Some(vec![RoleAssignment { + role: "r".to_string(), + identities: vec!["trusted".to_string()], + }]), + }; + let authorization_item = AuthorizationItem { + defaultAccess: "deny".to_string(), + mode: "enforce".to_string(), + rules: Some(access_control_rules), + id: "test-rs".to_string(), + }; + ComputedAuthorizationItem::from_authorization_item(authorization_item) + } + + fn trusted_claims() -> Claims { + Claims { + userId: 0, + userName: "trusted-user".to_string(), + userGroups: vec![], + processId: 0, + processFullPath: PathBuf::from("p"), + clientIp: "0".to_string(), + clientPort: 0, + processName: OsString::from("p"), + processCmdLine: "p".to_string(), + runAsElevated: true, + } + } + + #[test] + fn canonical_decision_agrees_with_legacy_on_clean_paths() { + // Sanity: when nothing in the URL exercises a canonical-vs- + // substring difference, the two evaluators must agree. If this + // ever flips, every shadow log line becomes noise and we lose + // the M3 signal. So this is a regression net for both sides. + let rules = build_test_rules("/test"); + let claims = trusted_claims(); + let mut log = ConnectionLogger::new(0, 0); + + let cases: &[(&str, bool)] = &[ + // (uri, expected_allowed) + ("http://169.254.169.254/test/x", true), + ("http://169.254.169.254/test", true), + // not under /test -> default deny + ("http://169.254.169.254/other", false), + ]; + for (uri_str, expected) in cases { + let uri = hyper::Uri::from_str(uri_str).unwrap(); + let legacy = rules.is_allowed(&mut log, uri.clone(), claims.clone()); + let canon = rules.canonical_decision(&mut log, &uri, &hyper::Method::GET, &claims); + assert_eq!(legacy, *expected, "legacy verdict for {uri_str}"); + assert_eq!( + canon.allowed_or_fail_closed(), + *expected, + "canonical verdict for {uri_str}" + ); + assert_eq!( + legacy, + canon.allowed_or_fail_closed(), + "shadow drift for {uri_str}" + ); + } + } + + #[test] + fn canonical_decision_diverges_on_substring_vs_segment_prefix() { + // THE M3 motivating divergence. Legacy `Privilege::is_match` + // uses `starts_with` -> `/test-evil/x` is "under /test" by + // substring. Canonical does segment-by-segment prefix + // matching -> `/test-evil/x` is NOT under `/test`. Shadow mode + // exists to report this exact class. + let rules = build_test_rules("/test"); + let claims = trusted_claims(); + let mut log = ConnectionLogger::new(0, 0); + + let uri = hyper::Uri::from_str("http://169.254.169.254/test-evil/anything").unwrap(); + let legacy = rules.is_allowed(&mut log, uri.clone(), claims.clone()); + let canon = rules.canonical_decision(&mut log, &uri, &hyper::Method::GET, &claims); + + // Legacy is buggy here (allows the SSRF-shaped path). + assert!( + legacy, + "legacy substring match should still allow /test-evil; if this fails, legacy was fixed and this test should be retired" + ); + // Canonical correctly denies. + assert_eq!( + canon, + CanonicalDecision::Denied, + "canonical segment match must deny /test-evil" + ); + // The shape we'll feed to telemetry: + assert_ne!( + legacy, + canon.allowed_or_fail_closed(), + "this is the divergence shadow mode must surface" + ); + } + + #[test] + fn canonical_decision_returns_error_for_userinfo_url() { + // Userinfo in the authority -> canonical rejects at the + // pipeline entry with UserinfoPresent. The decision must + // collapse to fail-closed and Display must surface the stable + // error code so audit logs can grep for it. + let rules = build_test_rules("/test"); + let claims = trusted_claims(); + let mut log = ConnectionLogger::new(0, 0); + + // Build via hyper to avoid relying on whether `from_str` + // accepts the userinfo form. + let uri: hyper::Uri = match "http://attacker@169.254.169.254/test".parse() { + Ok(u) => u, + Err(_) => { + // Hyper may refuse to parse this -> nothing to test. + eprintln!("hyper refused to parse userinfo URL; skipping"); + return; + } + }; + let canon = rules.canonical_decision(&mut log, &uri, &hyper::Method::GET, &claims); + assert_eq!( + canon, + CanonicalDecision::Error(CanonError::UserinfoPresent), + "canonical must surface UserinfoPresent (got {canon:?})" + ); + assert!(!canon.allowed_or_fail_closed(), "errors must fail-closed"); + // The audit-log token shape is part of the §9.2 telemetry + // contract — keep it stable across refactors. + assert_eq!(canon.to_string(), "error:CANON_USERINFO"); + } + + #[test] + fn shadow_compare_returns_canonical_decision_for_enforce_handoff() { + // shadow_compare's return value is the M5/M6 hand-off: the + // enforce-mode caller will use it as the decision once the + // shadow window proves zero divergences. We pin its + // semantics here so a future enforce wiring doesn't get a + // surprise. + let rules = build_test_rules("/test"); + let claims = trusted_claims(); + let mut log = ConnectionLogger::new(0, 0); + + // Divergent case: legacy=true (substring), canon=Denied. + let uri = hyper::Uri::from_str("http://169.254.169.254/test-evil/anything").unwrap(); + let legacy_allowed = true; + let canon = rules.shadow_compare( + &mut log, + &uri, + &hyper::Method::GET, + &claims, + legacy_allowed, + CanonicalMode::Shadow, + ); + assert_eq!( + canon, + CanonicalDecision::Denied, + "shadow_compare must return the canonical verdict" + ); + } + + #[test] + fn compile_canonical_patterns_drops_uncanonicalizable_rules() { + // A privilege whose path can't be canonicalized must NOT + // brick the whole rule-load — the legacy matcher still has + // its copy and the canonical cache simply skips it. This is + // exactly the asymmetry shadow mode is designed to surface. + // + // We use an overlong %C0%AF (canonically `/`) which the + // pipeline rejects with OverlongUtf8. + let rules = build_test_rules("/x%C0%AFy"); + + // Legacy still sees the privilege: + assert_eq!(rules.privileges.len(), 1); + // Canonical cache dropped it: + assert_eq!( + rules.canonical_patterns.len(), + 0, + "uncanonicalizable privilege must be skipped from canonical cache" + ); + } } diff --git a/proxy_agent/src/proxy/canonical/mod.rs b/proxy_agent/src/proxy/canonical/mod.rs index ad2f4b36..70db9e68 100644 --- a/proxy_agent/src/proxy/canonical/mod.rs +++ b/proxy_agent/src/proxy/canonical/mod.rs @@ -333,6 +333,51 @@ pub fn canonicalize_str(url: &str) -> Result { canonicalize(&uri, &Method::GET) } +/// Rollout flag controlling whether the canonical pipeline shadows or +/// replaces the legacy authorizer. +/// +/// See `doc/plans/Innovation-2.1-canonical-request.md` §9 (Shadow-Mode +/// Rollout). Defaults to [`CanonicalMode::Off`] so production traffic +/// keeps the pre-canonical behavior bit-for-bit. +/// +/// - [`Off`](CanonicalMode::Off): legacy decides; canonical not invoked. +/// - [`Shadow`](CanonicalMode::Shadow): legacy decides; canonical runs +/// in parallel and divergences are logged as telemetry. **Behavior +/// unchanged** — this is the M3 default for dev/test. +/// - [`Enforce`](CanonicalMode::Enforce): canonical decides; legacy is +/// still computed for divergence telemetry. Wired but only intended +/// for use once shadow-mode reports zero divergences (M5/M6). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CanonicalMode { + #[default] + Off, + Shadow, + Enforce, +} + +impl std::fmt::Display for CanonicalMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CanonicalMode::Off => write!(f, "off"), + CanonicalMode::Shadow => write!(f, "shadow"), + CanonicalMode::Enforce => write!(f, "enforce"), + } + } +} + +impl std::str::FromStr for CanonicalMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "off" | "" => Ok(CanonicalMode::Off), + "shadow" => Ok(CanonicalMode::Shadow), + "enforce" => Ok(CanonicalMode::Enforce), + other => Err(format!("Invalid CanonicalMode: {other}")), + } + } +} + #[cfg(test)] mod mod_tests { //! Cross-cutting tests for the canonical pipeline. @@ -541,4 +586,37 @@ mod mod_tests { assert_eq!(err.code(), *expected_code, "variant={err:?}"); } } + + #[test] + fn canonical_mode_parses_and_defaults_off() { + use std::str::FromStr; + + // Empty / missing config string defaults to Off so production + // traffic is unchanged when the config key is absent. + assert_eq!(CanonicalMode::default(), CanonicalMode::Off); + assert_eq!(CanonicalMode::from_str("").unwrap(), CanonicalMode::Off); + + // Accepted spellings — case- and whitespace-insensitive so + // operators can write "Shadow" or " enforce " without surprises. + let cases: &[(&str, CanonicalMode)] = &[ + ("off", CanonicalMode::Off), + ("Off", CanonicalMode::Off), + ("shadow", CanonicalMode::Shadow), + ("SHADOW", CanonicalMode::Shadow), + (" shadow ", CanonicalMode::Shadow), + ("enforce", CanonicalMode::Enforce), + ]; + for (input, expected) in cases { + assert_eq!( + CanonicalMode::from_str(input).unwrap(), + *expected, + "input={input:?}" + ); + } + + // Unknown strings reject — better to fail loud at config load + // than silently fall through to Off and lie in telemetry. + assert!(CanonicalMode::from_str("audit").is_err()); + assert!(CanonicalMode::from_str("on").is_err()); + } } diff --git a/proxy_agent/src/proxy/canonical/rule.rs b/proxy_agent/src/proxy/canonical/rule.rs index f1b42bc2..590e411a 100644 --- a/proxy_agent/src/proxy/canonical/rule.rs +++ b/proxy_agent/src/proxy/canonical/rule.rs @@ -124,6 +124,12 @@ impl CanonicalPattern { } #[cfg(test)] +// The table-driven tests below intentionally carry deep nested-tuple +// literal types like `&[(&str, Option<&[(&str, &str)]>, &[&str], &[(&str, &[&str])])]`. +// That nesting IS the readability point — each column lines up with a +// (input, expected) axis the test is exercising — and factoring it out +// into a `type` alias hurts at-a-glance reading more than it helps. +#[allow(clippy::type_complexity)] mod rule_tests { use std::collections::HashMap; diff --git a/proxy_agent/src/proxy/proxy_authorizer.rs b/proxy_agent/src/proxy/proxy_authorizer.rs index 23d54dc5..ad9bfc15 100644 --- a/proxy_agent/src/proxy/proxy_authorizer.rs +++ b/proxy_agent/src/proxy/proxy_authorizer.rs @@ -21,8 +21,9 @@ use super::authorization_rules::{AuthorizationMode, ComputedAuthorizationItem}; use super::proxy_connection::ConnectionLogger; +use crate::proxy::canonical::CanonicalMode; use crate::shared_state::access_control_wrapper::AccessControlSharedState; -use crate::{common::constants, common::result::Result, proxy::Claims}; +use crate::{common::config, common::constants, common::result::Result, proxy::Claims}; use proxy_agent_shared::logger::LoggerLevel; #[derive(PartialEq)] @@ -232,15 +233,54 @@ pub fn authorize( port: u16, logger: &mut ConnectionLogger, request_uri: hyper::Uri, + request_method: hyper::Method, claims: Claims, access_control_rules: Option, ) -> AuthorizeResult { - let auth = get_authorizer(ip, port, claims); + let auth = get_authorizer(ip, port, claims.clone()); logger.write( LoggerLevel::Trace, format!("Got auth: {}", auth.to_string()), ); - auth.authorize(logger, request_uri, access_control_rules) + let legacy_result = auth.authorize(logger, request_uri.clone(), access_control_rules.clone()); + + // Innovation 2.1 M3 — shadow-mode integration. + // + // Only walks the canonical pipeline when the operator opts in via + // the `canonicalRequestMode` config key. With the default (`off`) + // there is **zero** additional work versus the pre-M3 control + // flow — that's what guarantees the "behavior unchanged for + // production traffic" exit criterion. + // + // Why we re-evaluate `is_allowed` here instead of recovering it + // from `legacy_result`: the wrapper above mixes in audit-mode + // softening and `runAsElevated` gating, neither of which the + // canonical matcher knows about. Comparing the wrapper output to + // the canonical matcher would produce phantom divergences. So we + // call the rules-only predicate twice in shadow/enforce — once + // through the Authorizer, once explicitly for the shadow + // comparison — accepting one extra matcher invocation per request + // only in non-production modes. + let mode = config::get_canonical_request_mode(); + if mode != CanonicalMode::Off { + if let Some(rules) = access_control_rules.as_ref() { + let legacy_allowed = rules.is_allowed(logger, request_uri.clone(), claims.clone()); + let _canon = rules.shadow_compare( + logger, + &request_uri, + &request_method, + &claims, + legacy_allowed, + mode, + ); + // Enforce-mode *behavior* is deliberately deferred to + // M5/M6 — see the design doc §9.3. In M3 Enforce only + // surfaces telemetry; it does NOT yet replace the legacy + // decision. A one-shot warning above (during config load) + // tells operators the same. + } + } + legacy_result } #[cfg(test)] diff --git a/proxy_agent/src/proxy/proxy_server.rs b/proxy_agent/src/proxy/proxy_server.rs index 5d1dc5ea..9cd2258d 100644 --- a/proxy_agent/src/proxy/proxy_server.rs +++ b/proxy_agent/src/proxy/proxy_server.rs @@ -501,6 +501,7 @@ impl ProxyServer { port, http_connection_context.get_logger_mut_ref(), request.uri().clone(), + request.method().clone(), claims.clone(), access_control_rules, ); From 21704fd477241674cb8dbe08c259652ef74368f3 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 11 Jun 2026 21:28:23 +0000 Subject: [PATCH 06/11] Update check-spelling metadata --- .github/actions/spelling/expect.txt | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 113389e7..22611f9b 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -9,7 +9,9 @@ abe addrlen addrpair advapi +aef AFidentity +AFy aks almalinux ATL @@ -21,6 +23,7 @@ autocrlf autonumber AWI aya +AZaz AZUREPUBLICCLOUD azuretools backcompat @@ -44,12 +47,15 @@ buildshell byos bytecodealliance callouts +canonicalizable canonicalizer cbl ccbdee ccbf +ced CEL CES +cfa cgroups cgroupv cgtop @@ -106,6 +112,7 @@ dllmain dministrator dnf dodce +dotdot dotnet DPAPI dport @@ -146,7 +153,8 @@ extconfig fafbfc failmode Fapi -Fbar +fbar +fccad fde fea ffcecb @@ -156,6 +164,7 @@ ffff FFFFFFFF fffi ffi +FFu fidentity FIXEDFILEINFO Fmanagement @@ -179,11 +188,13 @@ guestproxyagentmsis guiddef handleapi hdr +hexdigit hklm hlist HMACs homoglyph hostga +hostgaplugin hostingenvironmentconfig HTAB httpwg @@ -232,6 +243,7 @@ kpi kprobe ktime kusto +kvs lbl lgrui libbpf @@ -358,6 +370,7 @@ proxyagentvalidation pscustomobject ptrace pwstr +qkv raci radamsa Razr @@ -372,6 +385,7 @@ registrykey reimplementation rekor relativeurl +reparse Reprovisioning resf reuseport @@ -386,6 +400,7 @@ rolename rootdir rpmbuild RPMS +rsid rstr RTMR rul @@ -439,6 +454,7 @@ stdbool stdint stdoutput stduser +strat subsecond substatus Substatuses @@ -486,11 +502,14 @@ tshark TSV Tsv tsv +typo'd UAMI UBR UBRSTRING udev +uncanonicalizable unistd +unk unmark unparseable updateable @@ -554,6 +573,7 @@ xbf xcopy XDP xef +xfe xfsprogs xsi xxxx @@ -564,3 +584,4 @@ Zeroizing zipsas zureuser zypper +zzz From f859b5c10e695c442e61dca88e75df1b9d4706cd Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 16 Jun 2026 12:35:00 -0700 Subject: [PATCH 07/11] Update the pen tests for linux --- doc/plans/Innovation-2.1-canonical-request.md | 1 + pentest/linux/phase4_rules_fuzz/url_diff.py | 97 +++++++++++-- pentest/linux/phase4b_local_rules/run.py | 61 ++++++++ pentest/linux/test_catalog.py | 6 + pentest/windows/Phase4-RulesFuzz.ps1 | 102 +++++++++++-- pentest/windows/Phase4b-LocalRules.ps1 | 28 ++++ pentest/windows/TestCatalog.psm1 | 1 + proxy_agent/src/proxy/canonical/mod.rs | 15 ++ proxy_agent/src/proxy/canonical/path.rs | 28 +++- proxy_agent/src/proxy/canonical/query.rs | 49 ++++++- proxy_agent/src/proxy/canonical/rule.rs | 2 - proxy_agent/src/proxy/proxy_connection.rs | 136 +++++++++++++++++- proxy_agent/src/proxy/proxy_server.rs | 8 +- 13 files changed, 488 insertions(+), 46 deletions(-) diff --git a/doc/plans/Innovation-2.1-canonical-request.md b/doc/plans/Innovation-2.1-canonical-request.md index 7cfb50d0..8e83bf52 100644 --- a/doc/plans/Innovation-2.1-canonical-request.md +++ b/doc/plans/Innovation-2.1-canonical-request.md @@ -284,6 +284,7 @@ hyper::Uri │ ▼ parse_scheme_method (must be http; reject https/ws/...; metho | `MalformedPercent` | Truncated / non-hex `%XX` | Deny; 400 | `CANON_PCT` | | `OverlongUtf8` | Classic IDS-bypass payload | Deny; 400 | `CANON_OVERLONG` | | `InvalidUtf8` | Random bytes or wrong codec | Deny; 400 | `CANON_UTF8` | +| `NonAscii` | Unicode confusable / homoglyph attack in **path** (query allows non-ASCII values) | Deny; 400 | `CANON_NON_ASCII` | | `ControlChar` | CRLF injection attempt | Deny; 400 | `CANON_CTRL` | | `PathUnderflow` | Too many `..` | Deny; 400 | `CANON_UNDERFLOW` | | `EmbeddedQuery` | `%3F` smuggling | Deny; 400 | `CANON_EMBQ` | diff --git a/pentest/linux/phase4_rules_fuzz/url_diff.py b/pentest/linux/phase4_rules_fuzz/url_diff.py index 5ad3c92a..4d817bd6 100755 --- a/pentest/linux/phase4_rules_fuzz/url_diff.py +++ b/pentest/linux/phase4_rules_fuzz/url_diff.py @@ -13,7 +13,7 @@ Loopback fabric only; safe to run from a normal user account. """ from __future__ import annotations -import argparse, datetime, http.client, os, sys, urllib.parse +import argparse, datetime, http.client, os, re, socket, sys, time, urllib.parse IMDS_HOST = "169.254.169.254" IMDS_PORT = 80 @@ -22,19 +22,35 @@ CANONICAL = "/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" VARIANTS = { - "canonical": CANONICAL, - "uppercase_pct": "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/", - "lowercase_pct": "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/", - "double_encoded": "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/", - "trailing_dot_path": "/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/", - "dot_segments": "/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", - "param_injection": "/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/", - "case_path": "/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/", - "extra_slashes": "//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/", - "fragment": "/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x", - "unicode_dotless": "/metadata/\u0131dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", + "canonical": CANONICAL, + "uppercase_pct": "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "lowercase_pct": "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "double_encoded": "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "trailing_dot_path": "/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/", + "dot_segments": "/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", + "dot_segments_encoded": "/metadata/%2E/identity/%2E%2E/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", + "param_injection": "/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/", + "case_path": "/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/", + "extra_slashes": "//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/", + "fragment": "/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x", + "unicode_dotless": "/metadata/\u0131dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", } +# Variants whose effectiveness depends on the raw bytes reaching GPA verbatim. +# Python's http.client.HTTPConnection currently passes the request-target +# through to the wire without collapsing `./`, `../`, repeated slashes, or +# decoding `%2E` — but this is an undocumented quirk of the CPython +# implementation, not a contract. .NET's System.Uri normalizes all of these +# before the request leaves the box; if a future Python release follows suit +# (or a sysadmin adds a transparent proxy that rewrites paths), the +# differential test silently turns into a no-op. Sending these as a literal +# HTTP/1.1 request line via a raw socket guarantees the bytes hit GPA +# verbatim and matches what the Windows harness does (Phase4-RulesFuzz.ps1 +# Send-VariantRaw). The eBPF cgroup redirector still intercepts outbound +# traffic to IMDS:80 at the kernel layer, so the raw connection is picked +# up by GPA the same as http.client. +RAW_VARIANTS = frozenset({"dot_segments", "dot_segments_encoded"}) + def send(path: str) -> tuple[int, str]: try: @@ -48,6 +64,54 @@ def send(path: str) -> tuple[int, str]: return -1, f"{type(exc).__name__}: {exc}" +def send_raw(path: str) -> tuple[int, str]: + """Write a literal HTTP/1.1 request line over a raw socket, bypassing any + URL parsing/canonicalization that http.client or a future Python release + might perform. The request bytes contain `path` verbatim.""" + req_text = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {IMDS_HOST}\r\n" + "Metadata: true\r\n" + "Connection: close\r\n" + "Accept: */*\r\n" + "\r\n" + ) + sock: socket.socket | None = None + try: + sock = socket.create_connection((IMDS_HOST, IMDS_PORT), timeout=8) + sock.sendall(req_text.encode("ascii")) + sock.settimeout(8) + chunks: list[bytes] = [] + total = 0 + deadline = time.monotonic() + 8.0 + while total < 4096 and time.monotonic() < deadline: + try: + buf = sock.recv(min(4096 - total, 1024)) + except socket.timeout: + break + if not buf: + break + chunks.append(buf) + total += len(buf) + text = b"".join(chunks).decode("latin-1", errors="replace") + status_line = text.split("\r\n", 1)[0] if text else "" + m = re.match(r"^HTTP/1\.[01]\s+(\d{3})", status_line) + code = int(m.group(1)) if m else -1 + body_head = "" + idx = text.find("\r\n\r\n") + if 0 <= idx and idx + 4 < len(text): + body_head = text[idx + 4 : idx + 84].replace("\n", " ").replace("\t", " ") + return code, body_head + except Exception as exc: # pragma: no cover + return -1, f"RawSocket {type(exc).__name__}: {exc}" + finally: + if sock is not None: + try: + sock.close() + except Exception: + pass + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--out", default=os.environ.get( @@ -63,12 +127,17 @@ def main() -> int: rows = [] diff_count = 0 for name, path in VARIANTS.items(): - status, head = send(path) + if name in RAW_VARIANTS: + status, head = send_raw(path) + sender = "raw" + else: + status, head = send(path) + sender = "net" diff = "DIFF" if status != baseline_status else "same" if diff == "DIFF": diff_count += 1 rows.append((name, status, diff, urllib.parse.quote(path, safe="/?&=:%")[:120], head[:60])) - print(f" {name:<18} status={status:<4} {diff}") + print(f" {name:<22} [{sender}] status={status:<4} {diff}") with open(args.out, "a", encoding="utf-8") as fh: ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 7c111ce8..4f2c9b28 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -631,6 +631,67 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) + # S20: explicit fail-closed of the IMDS managed-identity token endpoint. + # defaultAccess=deny + mode=enforce + empty privileges means EVERY path — + # including every URL-equivalence variant of /metadata/identity/oauth2/ + # token (mixed case, %2F-encoded slash, double-encoded, trailing dot, + # extra slashes, fragment, matrix param, Unicode confusable) — must fall + # through to the deny default. With shadow-mode (M3) enabled this is the + # rule set under which the canonical authorizer can surface + # CANON_DIVERGENCE log lines: any variant where the legacy substring + # matcher and the canonical normalized-path matcher disagree shows up as + # `legacy=allow canon=deny` (or vice versa) in the GPA service log even + # though the on-wire response stays 403. Any 200 response here is a true + # bypass. + # `token_extra_slashes` may also surface as 404 once the pre-canonical + # traversal guard (path_has_traversal) catches `//` before the rule + # engine runs — both 403 and 404 mean the bypass attempt failed. + scenarios.append(Scenario( + sid=f"{pfx}-S20-deny-token-path", + target=target, + description="enforce + deny + no privileges; every URL-equivalence " + "variant of the IMDS token path must 403 (or 404 when " + "GPA traversal-guard fires)", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s20", + "rules": {}, + }, + probes=[ + Probe("token_canonical", + "/metadata/identity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_uppercase_pct", + "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_lowercase_pct", + "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_double_encoded", + "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_trailing_dot_path", + "/metadata/identity/oauth2/token./?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_param_injection", + "/metadata/identity/oauth2/token;x=1?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_case_path", + "/Metadata/Identity/OAuth2/Token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_extra_slashes", + "//metadata///identity//oauth2//token?api-version=2018-02-01" + "&resource=https://management.azure.com/", (403, 404)), + Probe("token_fragment", + "/metadata/identity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/#x", 403), + Probe("token_unicode_dotless", + "/metadata/\u0131dentity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + ], + )) + return scenarios diff --git a/pentest/linux/test_catalog.py b/pentest/linux/test_catalog.py index 70524c7f..cf127e29 100644 --- a/pentest/linux/test_catalog.py +++ b/pentest/linux/test_catalog.py @@ -294,6 +294,12 @@ class TestInfo(TypedDict, total=False): "design": "Identity `userName` uses Cyrillic о (U+043E) in place of ASCII 'o'. The matcher must compare bytes, not Unicode-normalized forms — 200 would mean Unicode normalization is happening in identity comparison and could be used to bypass authZ.", "automation": "Rewrites the harness identity's userName with Cyrillic о substitutions; expects 403.", }, + "IMDS-S20-deny-token-path": { + "title": "Fail-closed deny of the IMDS managed-identity token endpoint across URL-equivalence variants", + "design": "defaultAccess=deny + mode=enforce + empty privileges. Every URL-equivalence variant of /metadata/identity/oauth2/token (mixed case, %2F/%2f/%252F-encoded slash, trailing dot path, extra slashes, fragment, matrix param, Unicode confusable like U+0131) must 403 — none of them should reach the upstream. With shadow-mode (M3) enabled this is the rule set under which the canonical authorizer surfaces `CANON_DIVERGENCE` log lines for any variant where the legacy substring matcher and the canonical normalized-path matcher disagree (`legacy=allow canon=deny` or vice versa) even though the on-wire response stays 403. Any 200 is a true bypass.", + "automation": "Writes `defaultAccess=deny, mode=enforce, rules={}`; probes 10 equivalent forms of the token path. `token_extra_slashes` accepts (403, 404) since the pre-canonical traversal guard (path_has_traversal) may fire on `//` before the rule engine runs — both outcomes mean the bypass failed.", + "fix": "A 200 response here means a URL canonicalization gap before AuthZ. Audit the request path through `authorization_rules.rs` → enable canonical-mode enforce (M5 sunset of the legacy substring matcher) — by design every variant in this scenario normalizes to /metadata/identity/oauth2/token, which falls through to defaultAccess=deny.", + }, # ---------------- WireServer ---------------- "WS-S1-disabled-allow": { diff --git a/pentest/windows/Phase4-RulesFuzz.ps1 b/pentest/windows/Phase4-RulesFuzz.ps1 index bf8b068f..abe88970 100644 --- a/pentest/windows/Phase4-RulesFuzz.ps1 +++ b/pentest/windows/Phase4-RulesFuzz.ps1 @@ -20,19 +20,34 @@ if (-not $Out) { $Out = $UrlDiffTsv } $canonical = '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' $variants = [ordered]@{ - 'canonical' = $canonical - 'uppercase_pct' = '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/' - 'lowercase_pct' = '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/' - 'double_encoded' = '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/' - 'trailing_dot_path' = '/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/' - 'dot_segments' = '/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' - 'param_injection' = '/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/' - 'case_path' = '/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/' - 'extra_slashes' = '//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/' - 'fragment' = '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x' - 'unicode_dotless' = "/metadata/$([char]0x131)dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" + 'canonical' = $canonical + 'uppercase_pct' = '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'lowercase_pct' = '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'double_encoded' = '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'trailing_dot_path' = '/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/' + 'dot_segments' = '/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' + 'dot_segments_encoded' = '/metadata/%2E/identity/%2E%2E/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' + 'param_injection' = '/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/' + 'case_path' = '/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/' + 'extra_slashes' = '//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/' + 'fragment' = '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x' + 'unicode_dotless' = "/metadata/$([char]0x131)dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" } +# Variants whose effectiveness depends on the raw bytes reaching GPA verbatim. +# System.Net.HttpWebRequest constructs a System.Uri that canonicalizes `./` +# and `../` and decodes `%2E` BEFORE the request leaves the box, so without +# raw-socket sending these variants would be silently rewritten to the +# canonical path and never actually exercise GPA's traversal guard nor the +# canonical-vs-legacy shadow comparator. Sending them as a literal HTTP/1.1 +# request line via TcpClient bypasses the .NET URI parser entirely; the WFP +# redirector still intercepts the outbound connection to ImdsIp:80 the same +# way it does for HttpWebRequest, so the bytes hit GPA verbatim. +$rawVariants = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) +[void]$rawVariants.Add('dot_segments') +[void]$rawVariants.Add('dot_segments_encoded') + function Send-Variant { param([string] $Path) # Use HttpWebRequest so we can pass paths with raw %-sequences without @@ -60,6 +75,61 @@ function Send-Variant { } } +function Send-VariantRaw { + param([string] $Path) + # Write a literal HTTP/1.1 request line to a TcpClient stream so that + # System.Uri / HttpWebRequest cannot canonicalize `./`, `../`, `%2E`, + # or repeated slashes before send. The WFP redirector intercepts + # outbound traffic to ImdsIp:80 at the kernel layer, so TcpClient is + # picked up by GPA the same as HttpWebRequest. + $client = New-Object System.Net.Sockets.TcpClient + try { + $client.SendTimeout = 8000 + $client.ReceiveTimeout = 8000 + $client.Connect($ImdsIp, 80) + $stream = $client.GetStream() + # Build the raw request bytes manually so the path is sent verbatim. + $reqText = "GET $Path HTTP/1.1`r`nHost: $ImdsIp`r`nMetadata: true`r`nConnection: close`r`nAccept: */*`r`n`r`n" + $reqBytes = [System.Text.Encoding]::ASCII.GetBytes($reqText) + $stream.Write($reqBytes, 0, $reqBytes.Length) + $stream.Flush() + + # Read up to 4 KB — enough for the status line + headers + a body head. + $buf = New-Object byte[] 4096 + $total = 0 + $deadline = [DateTime]::UtcNow.AddSeconds(8) + while ($total -lt $buf.Length -and [DateTime]::UtcNow -lt $deadline) { + if (-not $stream.DataAvailable) { + Start-Sleep -Milliseconds 50 + continue + } + $n = $stream.Read($buf, $total, $buf.Length - $total) + if ($n -le 0) { break } + $total += $n + } + $text = [System.Text.Encoding]::ASCII.GetString($buf, 0, $total) + $statusLine = ($text -split "`r`n", 2)[0] + if ($statusLine -match '^HTTP/1\.[01]\s+(\d{3})') { + $code = [int]$Matches[1] + } else { + $code = -1 + } + # Body head = everything after the first blank line, lightly cleaned. + $headBody = '' + $idx = $text.IndexOf("`r`n`r`n") + if ($idx -ge 0 -and $idx + 4 -lt $text.Length) { + $headBody = $text.Substring($idx + 4) + } + $head = ($headBody.Substring(0, [Math]::Min(80, $headBody.Length))) -replace "[`r`n`t]", ' ' + return [pscustomobject]@{ Code = $code; Head = $head } + } catch { + return [pscustomobject]@{ Code = -1; Head = ("RawSocket: $($_.Exception.Message)" -replace "[`r`n`t]", ' ') } + } finally { + if ($client.Connected) { $client.Close() } + $client.Dispose() + } +} + $baseline = Send-Variant $canonical Write-Host ("baseline ({0}) for canonical path" -f $baseline.Code) @@ -67,10 +137,16 @@ $ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $rows = @() $diffs = 0 foreach ($k in $variants.Keys) { - $r = Send-Variant $variants[$k] + if ($rawVariants.Contains($k)) { + $r = Send-VariantRaw $variants[$k] + $sender = 'raw' + } else { + $r = Send-Variant $variants[$k] + $sender = 'net' + } $verdict = if ($r.Code -ne $baseline.Code) { 'DIFF' } else { 'same' } if ($verdict -eq 'DIFF') { $diffs++ } - Write-Host (" {0,-18} status={1,-4} {2}" -f $k, $r.Code, $verdict) + Write-Host (" {0,-22} [{1}] status={2,-4} {3}" -f $k, $sender, $r.Code, $verdict) $rows += "$ts`t$k`t$($r.Code)`t$verdict`t$($variants[$k])`t$($r.Head)" } diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index ea101470..464aaa61 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -479,6 +479,34 @@ function Build-ImdsScenarios { Write-Finding "$pfx-S19-identity-homoglyph" INFO ("skipped: '{0}' has no character with a Cyrillic visual look-alike in our table" -f $Identity.userName) } + # S20 — explicit fail-closed of the IMDS managed-identity token endpoint. + # defaultAccess=deny + mode=enforce + empty privileges means EVERY path + # — including every URL-equivalence variant of /metadata/identity/oauth2/ + # token (mixed case, %2F-encoded slash, double-encoded, dot segments, + # extra slashes, fragment, matrix param, Unicode confusable) — must fall + # through to the deny default. With shadow-mode (M3) enabled this is the + # rule set under which the canonical authorizer can surface + # CANON_DIVERGENCE log lines: any variant where the legacy substring + # matcher and the canonical normalized-path matcher disagree shows up as + # `legacy=allow canon=deny` (or vice versa) in the GPA service log even + # though the on-wire response stays 403. Any 200 response here is a true + # bypass. + $scn += New-Scenario -Sid "$pfx-S20-deny-token-path" -TargetName imds ` + -Description 'enforce + deny + no privileges; every URL-equivalence variant of the IMDS token path must 403 (or 404 when GPA traversal-guard fires)' ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s20'; rules = @{} } ` + -Probes @( + [Probe]::new('token_canonical', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_uppercase_pct', '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_lowercase_pct', '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_double_encoded', '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_trailing_dot_path', '/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_param_injection', '/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_case_path', '/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_extra_slashes', '//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_fragment', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x', 403), + [Probe]::new('token_unicode_dotless', "/metadata/$([char]0x131)dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", 403) + ) + return $scn } diff --git a/pentest/windows/TestCatalog.psm1 b/pentest/windows/TestCatalog.psm1 index a67e430f..c6f07e3c 100644 --- a/pentest/windows/TestCatalog.psm1 +++ b/pentest/windows/TestCatalog.psm1 @@ -210,6 +210,7 @@ $script:Phase4b = @{ 'IMDS-S17-name-collision-shielded' = @{ Title='Local rule names already containing LocalFileRules_ prefix'; Design='Tests that `prefix_local_rule_names` rewrites both names AND cross-references consistently even when the admin pre-includes the prefix — so the rule still binds. 403 on /metadata/instance would mean the prefixer mishandled the doubled prefix.' } 'IMDS-S18-dangling-references'= @{ Title='Dangling role→privilege reference must reject'; Design='Validator must reject; agent fail-closes to defaultAccess=deny. 200 means dangling-ref validation regressed.' } 'IMDS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match ASCII user'; Design='Identity userName uses a Cyrillic look-alike (e.g. а=U+0430, е=U+0435, о=U+043E, …) in place of one character of the current user''s ASCII name. The two strings render identically in most fonts but are NOT byte-equal. Matcher must compare bytes, not Unicode-normalized forms — 200 would mean Unicode normalization is happening in identity comparison and could be used to bypass authZ. Skipped (INFO) when the current user has no character with a Cyrillic look-alike in the harness table.' } + 'IMDS-S20-deny-token-path' = @{ Title='Fail-closed deny of the IMDS managed-identity token endpoint across URL-equivalence variants'; Design='defaultAccess=deny + mode=enforce + empty privileges. Every URL-equivalence variant of /metadata/identity/oauth2/token (mixed case, %2F/%2f/%252F-encoded slash, dot segments, extra slashes, fragment, matrix param, Unicode confusable like U+0131) must 403 — none of them should reach the upstream. With shadow-mode (M3) enabled this is the rule set under which the canonical authorizer can surface `CANON_DIVERGENCE` log lines for variants where the legacy substring-matcher and canonical normalized-path matcher disagree. Any 200 here is a true bypass.' } 'WS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow (WireServer)'; Design='Baseline 200s for goalstate and versions.' } 'WS-S2-enforce-deny-empty' = @{ Title='Fail-closed enforce+deny (WireServer)'; Design='All WireServer calls 403.' } 'WS-S3-audit-deny-empty' = @{ Title='Local `mode` field must be ignored (WireServer regression guard)'; Design='Writes mode=audit + defaultAccess=deny + empty rules to WireServer_Rules.json. PRE confirms remote mode=enforce, so probes must 403. A 200 would mean the agent honored `mode` from the local file.' } diff --git a/proxy_agent/src/proxy/canonical/mod.rs b/proxy_agent/src/proxy/canonical/mod.rs index 70db9e68..384c0cad 100644 --- a/proxy_agent/src/proxy/canonical/mod.rs +++ b/proxy_agent/src/proxy/canonical/mod.rs @@ -215,6 +215,19 @@ pub enum CanonError { OverlongUtf8, #[error("invalid UTF-8 in path/query")] InvalidUtf8, + /// Decoded **path** bytes were well-formed UTF-8 but contained at + /// least one non-ASCII codepoint. Separated from + /// [`CanonError::InvalidUtf8`] so audit logs distinguish *encoding + /// corruption* (random bytes, wrong codec) from *Unicode-confusable + /// attacks* (e.g. U+0131 dotless-i looks like ASCII `i`, fullwidth + /// solidus U+FF0F looks like ASCII `/`, Cyrillic homoglyphs) where the + /// attacker hand-crafts perfectly valid UTF-8 specifically to fool + /// ASCII-only string comparisons. The two classes have very different + /// triage paths, so they get different stable codes. Only the path + /// pipeline raises this — the query pipeline allows non-ASCII values + /// (ARM ids may contain Unicode). + #[error("non-ASCII codepoint in path")] + NonAscii, #[error("control character in path/query")] ControlChar, #[error("path traversal past root")] @@ -237,6 +250,7 @@ impl CanonError { CanonError::MalformedPercent => "CANON_PCT", CanonError::OverlongUtf8 => "CANON_OVERLONG", CanonError::InvalidUtf8 => "CANON_UTF8", + CanonError::NonAscii => "CANON_NON_ASCII", CanonError::ControlChar => "CANON_CTRL", CanonError::PathUnderflow => "CANON_UNDERFLOW", CanonError::EmbeddedQuery => "CANON_EMBQ", @@ -576,6 +590,7 @@ mod mod_tests { (CanonError::MalformedPercent, "CANON_PCT"), (CanonError::OverlongUtf8, "CANON_OVERLONG"), (CanonError::InvalidUtf8, "CANON_UTF8"), + (CanonError::NonAscii, "CANON_NON_ASCII"), (CanonError::ControlChar, "CANON_CTRL"), (CanonError::PathUnderflow, "CANON_UNDERFLOW"), (CanonError::EmbeddedQuery, "CANON_EMBQ"), diff --git a/proxy_agent/src/proxy/canonical/path.rs b/proxy_agent/src/proxy/canonical/path.rs index 027203b8..5b165e84 100644 --- a/proxy_agent/src/proxy/canonical/path.rs +++ b/proxy_agent/src/proxy/canonical/path.rs @@ -136,10 +136,17 @@ fn reject_control_chars(s: &str) -> Result<(), CanonError> { } fn reject_non_ascii(s: &str) -> Result<(), CanonError> { + // Well-formed-UTF-8 non-ASCII gets its own dedicated error class so + // it shows up in audit logs as `canon=error:CANON_NON_ASCII`, + // distinguishable from genuine encoding corruption (`CANON_UTF8`, + // `CANON_OVERLONG`). The Unicode-confusable attack class + // (U+0131 dotless-i, fullwidth solidus, Cyrillic homoglyphs) + // surfaces exclusively here, making the family greppable in audit + // logs without false positives from random-byte fuzz. if s.is_ascii() { Ok(()) } else { - Err(CanonError::InvalidUtf8) + Err(CanonError::NonAscii) } } @@ -267,8 +274,10 @@ mod path_tests { ("/x%0A", CanonError::ControlChar), ("/x%0D", CanonError::ControlChar), ("/x%7F", CanonError::ControlChar), - // Non-ASCII after decode: U+4E2D `中` in UTF-8 - ("/x%E4%B8%AD", CanonError::InvalidUtf8), + // Non-ASCII after decode: U+4E2D `中` in UTF-8 — well-formed, + // so it surfaces as NonAscii (Unicode-confusable / homoglyph + // attack class), not InvalidUtf8 (encoding corruption). + ("/x%E4%B8%AD", CanonError::NonAscii), // Embedded `?` from %3F smuggling ( "/metadata/identity%3Fapi-version=2018", @@ -480,11 +489,14 @@ mod path_tests { assert!(reject_non_ascii(s).is_ok(), "input={s:?}"); } // Any non-ASCII character (1, 2, 3, or 4 UTF-8 bytes wide) must - // be rejected. + // be rejected with the NonAscii code — distinct from InvalidUtf8 + // (random bytes) and OverlongUtf8 (IDS-bypass overlongs) so audit + // logs can `grep CANON_NON_ASCII` for the homoglyph attack class + // without picking up generic encoding-failure noise. for s in ["é", "中", "🙂", "/abc/中文/x"] { assert_eq!( reject_non_ascii(s).unwrap_err(), - CanonError::InvalidUtf8, + CanonError::NonAscii, "input={s:?}" ); } @@ -788,11 +800,13 @@ mod path_tests { CanonError::ControlChar, ), // Decoded non-ASCII (valid UTF-8 but outside the matcher's - // ASCII-only contract). + // ASCII-only contract). Distinct from encoding-corruption + // (`InvalidUtf8`) and overlong-bypass (`OverlongUtf8`) + // because this is the Unicode-confusable attack family. ( "D1.decoded_non_ascii_lowercase_e_acute", "http://169.254.169.254/caf%C3%A9", - CanonError::InvalidUtf8, + CanonError::NonAscii, ), // Underflow variants the Appendix table doesn't enumerate. ( diff --git a/proxy_agent/src/proxy/canonical/query.rs b/proxy_agent/src/proxy/canonical/query.rs index 8de3eef8..1540ffd2 100644 --- a/proxy_agent/src/proxy/canonical/query.rs +++ b/proxy_agent/src/proxy/canonical/query.rs @@ -7,8 +7,13 @@ //! become part of the value). //! - Single percent-decode of both key and value. //! - Lowercase the key (case-insensitive matching). -//! - Reject control characters and non-ASCII in both key and value -//! (same rationale as for the path). +//! - Reject control characters and malformed UTF-8 in both key and value. +//! Unlike the path pipeline, well-formed non-ASCII UTF-8 is **allowed** +//! here: query *values* legitimately carry it (e.g. an IMDS +//! `msi_res_id` / `resource` ARM id whose resource-group name contains +//! Unicode letters, which Azure naming rules permit). The path stays +//! ASCII-only because IMDS / WireServer / HGAP paths never contain +//! non-ASCII and a confusable path segment could slip past a deny rule. //! - Fold into a `BTreeMap>`: deterministic key //! ordering, insertion order preserved within a key. @@ -72,15 +77,19 @@ fn decode_query_component(raw: &str) -> Result { } } } + // `String::from_utf8` still rejects malformed byte sequences + // (`InvalidUtf8`), so smuggling via broken encodings is closed. We + // deliberately do NOT reject well-formed non-ASCII here the way the + // path pipeline does: query values legitimately carry Unicode (e.g. + // an ARM `msi_res_id` whose resource-group name has Unicode letters), + // and a confusable in a query value cannot slip past a path-prefix + // deny rule. Control characters stay forbidden in both halves. let s = String::from_utf8(out).map_err(|_| CanonError::InvalidUtf8)?; for b in s.bytes() { if b < 0x20 || b == 0x7F { return Err(CanonError::ControlChar); } } - if !s.is_ascii() { - return Err(CanonError::InvalidUtf8); - } Ok(s) } @@ -167,4 +176,34 @@ mod query_tests { CanonError::ControlChar ); } + + #[test] + fn non_ascii_allowed_in_query_value_and_key() { + // Unlike the path pipeline, well-formed non-ASCII UTF-8 is + // accepted in the query so legitimate ARM ids (e.g. an + // `msi_res_id` whose resource-group name has Unicode letters) are + // not rejected. The decoded codepoints survive verbatim. + // Value side: U+4E2D `中`. + let q = canonicalize_query("k=%E4%B8%AD").unwrap(); + assert_eq!(q.get("k"), Some(&vec!["\u{4e2d}".to_string()])); + // Key side: U+00E9 `é` (only A–Z is ASCII-lowercased, so the + // non-ASCII key is preserved as-is). + let q = canonicalize_query("caf%C3%A9=1").unwrap(); + assert_eq!(q.get("caf\u{e9}"), Some(&vec!["1".to_string()])); + // Both halves non-ASCII. + let q = canonicalize_query("%C3%A9=%C3%A9").unwrap(); + assert_eq!(q.get("\u{e9}"), Some(&vec!["\u{e9}".to_string()])); + } + + #[test] + fn malformed_utf8_still_reports_invalid_utf8() { + // Lone continuation byte (0x80 with no lead byte) is genuine + // encoding corruption, not a homoglyph attack. It must stay on + // `InvalidUtf8` / `CANON_UTF8` so the two audit-log classes + // remain separable. + assert_eq!( + canonicalize_query("k=%80").unwrap_err(), + CanonError::InvalidUtf8 + ); + } } diff --git a/proxy_agent/src/proxy/canonical/rule.rs b/proxy_agent/src/proxy/canonical/rule.rs index 590e411a..edbdd106 100644 --- a/proxy_agent/src/proxy/canonical/rule.rs +++ b/proxy_agent/src/proxy/canonical/rule.rs @@ -256,8 +256,6 @@ mod rule_tests { ("/x", Some(&[("k", "%ZZ")])), // Control byte in rule query key. ("/x", Some(&[("k\x01", "v")])), - // Non-ASCII in rule query value. - ("/x", Some(&[("k", "café")])), ]; for (path, qp) in bad { let r = CanonicalPattern::from_privilege(&priv_of(path, *qp)); diff --git a/proxy_agent/src/proxy/proxy_connection.rs b/proxy_agent/src/proxy/proxy_connection.rs index a810c77a..dd6611bd 100644 --- a/proxy_agent/src/proxy/proxy_connection.rs +++ b/proxy_agent/src/proxy/proxy_connection.rs @@ -282,8 +282,19 @@ impl HttpConnectionContext { hyper_client::should_skip_sig(&self.method, &self.url) } + /// Pre-canonical defense-in-depth guard. Returns `true` if the request + /// path contains a pattern commonly used to bypass prefix-based + /// authorization rules. See [`path_has_traversal`] for the exact set. + /// This is checked in `handle_new_http_request` before any rule lookup + /// so suspicious paths short-circuit to 404 without ever reaching the + /// matcher or the upstream. + /// + /// Once the canonical pipeline graduates to enforce mode (M5), the + /// `PathUnderflow` / slash-collapsing / encoded-dot handling in + /// `crate::proxy::canonical::path` subsumes this check entirely and + /// this method can be removed. pub fn contains_traversal_characters(&self) -> bool { - self.url.path().contains("..") + path_has_traversal(self.url.path()) } pub fn log(&mut self, logger_level: LoggerLevel, message: String) { @@ -393,3 +404,126 @@ impl Clone for ConnectionLogger { } } } + +/// Returns `true` if `raw_path` (the path component of a request URI in +/// its on-wire, *not yet percent-decoded* form) contains a pattern that +/// is commonly used to bypass prefix-based authorization rules. +/// +/// Caught by this function (rejected with 404 by the caller): +/// * `..` — literal dot-dot anywhere in the path +/// * `%2E%2E`, `%2e%2e`, and mixed-case (`%2E%2e`, `%2e%2E`) — caught +/// after a single percent-decode pass +/// * `.%2e`, `%2E.`, etc. — same single-decode pass collapses them +/// into a literal `..` +/// * `//` — two or more consecutive slashes (multi-slash bypass). +/// Legacy prefix-matching treats `/foo//bar` as a distinct path +/// from `/foo/bar`; reject the raw form so attackers cannot pivot +/// past a privilege whose path string lacks the extra slash. +/// +/// INTENTIONALLY NOT rejected here (the canonical pipeline in +/// [`crate::proxy::canonical::path`] handles these and will subsume this +/// guard entirely once it graduates to enforce mode in M5): +/// * single `.` segments — valid in real paths like `/.well-known/*` +/// * `;` matrix parameters — sub-delim that is legal inside a segment +/// * non-ASCII / Unicode-confusable bytes — caught by `reject_non_ascii` +/// * embedded `?` after percent-decode — caught by canonical's EmbQ check +/// * trailing-slash variation — clamped by canonical's normalizer +/// +/// Decoding uses `decode_utf8_lossy`: any invalid-UTF-8 percent +/// sequences are replaced by U+FFFD (which does not contain `..`), and +/// truly malformed sequences are passed through unchanged. This keeps +/// the check fail-open for the rare valid non-ASCII path while staying +/// safe for the traversal cases that matter. +fn path_has_traversal(raw_path: &str) -> bool { + if raw_path.contains("//") { + return true; + } + let decoded = percent_encoding::percent_decode_str(raw_path).decode_utf8_lossy(); + decoded.contains("..") +} + +#[cfg(test)] +mod tests { + use super::path_has_traversal; + + #[test] + fn clean_paths_are_not_traversal() { + for p in [ + "/", + "/metadata/instance", + "/metadata/identity/oauth2/token", + "/machine/?comp=goalstate", + "/.well-known/foo", + "/metadata/instance?api-version=2021-02-01", + ] { + assert!( + !path_has_traversal(p), + "expected clean path, got traversal: {p:?}" + ); + } + } + + #[test] + fn literal_dot_dot_is_traversal() { + for p in [ + "/foo/../bar", + "/metadata/./identity/../identity/oauth2/token", + "/..", + "../etc/passwd", + ] { + assert!(path_has_traversal(p), "expected traversal: {p:?}"); + } + } + + #[test] + fn percent_encoded_dot_dot_is_traversal() { + // Uppercase, lowercase, and mixed-case percent encodings must all + // be caught — they all decode to a literal `..` in a single pass. + for p in [ + "/foo/%2E%2E/bar", + "/foo/%2e%2e/bar", + "/foo/%2E%2e/bar", + "/foo/%2e%2E/bar", + "/foo/.%2e/bar", + "/foo/%2E./bar", + "/metadata/%2E/identity/%2E%2E/identity/oauth2/token", + ] { + assert!( + path_has_traversal(p), + "expected percent-encoded traversal: {p:?}" + ); + } + } + + #[test] + fn multi_slash_is_traversal() { + for p in [ + "//metadata/instance", + "/metadata//instance", + "/metadata///identity//oauth2//token", + "//", + ] { + assert!( + path_has_traversal(p), + "expected multi-slash traversal: {p:?}" + ); + } + } + + #[test] + fn single_dot_and_matrix_params_are_not_traversal_here() { + // Single `.` segments and matrix params (`;`) are not traversal + // markers and must pass through this pre-canonical guard. The + // canonical pipeline applies its own normalization in M5. + for p in [ + "/metadata/./instance", + "/metadata/instance;jsessionid=abc", + "/.well-known/openid-configuration", + ] { + assert!( + !path_has_traversal(p), + "expected non-traversal (deferred to canonical): {p:?}" + ); + } + } +} diff --git a/proxy_agent/src/proxy/proxy_server.rs b/proxy_agent/src/proxy/proxy_server.rs index 9cd2258d..84b55d33 100644 --- a/proxy_agent/src/proxy/proxy_server.rs +++ b/proxy_agent/src/proxy/proxy_server.rs @@ -404,15 +404,15 @@ impl ProxyServer { } if http_connection_context.contains_traversal_characters() { - // If the proxied request contains traversal characters, we will return 404 Not Found to avoid potential security issues. + // If the proxied request contains traversal characters, we will return 403 Forbidden to avoid potential security issues. self.log_connection_summary( &mut http_connection_context, - StatusCode::NOT_FOUND, + StatusCode::FORBIDDEN, false, - "Traversal characters found in the request, return NOT_FOUND!".to_string(), + "Traversal characters found in the request, return FORBIDDEN!".to_string(), ) .await; - return Ok(Self::closed_response(StatusCode::NOT_FOUND)); + return Ok(Self::closed_response(StatusCode::FORBIDDEN)); } if http_connection_context.url == provision::provision_query::PROVISION_URL_PATH { From 5d15f88e013d4cf50953aad0874069c50ee6e6cc Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 16 Jun 2026 12:49:20 -0700 Subject: [PATCH 08/11] test with traversal characters must response with Forbidden. --- proxy_agent/src/proxy/proxy_server.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proxy_agent/src/proxy/proxy_server.rs b/proxy_agent/src/proxy/proxy_server.rs index 84b55d33..be8fc2e7 100644 --- a/proxy_agent/src/proxy/proxy_server.rs +++ b/proxy_agent/src/proxy/proxy_server.rs @@ -1176,9 +1176,9 @@ mod tests { .await .unwrap(); assert_eq!( - http::StatusCode::NOT_FOUND, + http::StatusCode::FORBIDDEN, response.status(), - "response.status must be NOT_FOUND." + "response.status must be FORBIDDEN." ); // test large request body From 9ed7069e2abc4b53fcadf75070fc717ce6ef0f4e Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 16 Jun 2026 13:43:42 -0700 Subject: [PATCH 09/11] fix linux token_unicode_dotless pen test --- pentest/linux/phase4b_local_rules/run.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 4f2c9b28..6131c309 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -170,6 +170,26 @@ def get_current_user_identity() -> dict: } +def _wire_encode_path(path: str) -> str: + """Percent-encode non-ASCII path bytes as UTF-8 before send. + + Python's http.client transmits the request line via request.encode('ascii') + and raises UnicodeEncodeError on any raw non-ASCII char (e.g. the U+0131 + dotless-i confusable in token_unicode_dotless). That exception is caught + below as a false -1 — the request never reaches GPA, so we'd never observe + the real authorization decision. Browsers and .NET's System.Uri percent- + encode non-ASCII path bytes as UTF-8 before sending; mirror that so the + confusable arrives on the wire as %C4%B1 and GPA's 403 is actually exercised. + + Only non-ASCII codepoints are rewritten; ASCII (including existing %XX, + '/', '?', ';', '&', '=') passes through verbatim so every other probe is + byte-for-byte unchanged on the wire.""" + return "".join( + ch if ord(ch) < 0x80 else "".join(f"%{b:02X}" for b in ch.encode("utf-8")) + for ch in path + ) + + def send(target: Target, method: str, path: str, headers: Optional[dict] = None, timeout: float = 8.0) -> int: h: dict[str, str] = {} @@ -181,7 +201,7 @@ def send(target: Target, method: str, path: str, h.update(headers) try: conn = http.client.HTTPConnection(target.host, target.port, timeout=timeout) - conn.request(method, path, headers=h) + conn.request(method, _wire_encode_path(path), headers=h) resp = conn.getresponse() resp.read(64) conn.close() From dcf4e4cb201ed1c6f9476c001091b87f47e86451 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 16 Jun 2026 16:33:52 -0700 Subject: [PATCH 10/11] update with spell --- cspell.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cspell.json b/cspell.json index 52429808..fd1f639a 100644 --- a/cspell.json +++ b/cspell.json @@ -645,6 +645,12 @@ "Skus", "VMSS", "zeroize", + "greppable", + "HGAP", + "hostgaplugin", + "rsid", + "strat", + "uncanonicalizable", "Аdministrator", "аzureuser", "rооt" From fb5f8f03699f7f0db8265b640e0bff8e6733934d Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 18 Jun 2026 09:31:58 -0700 Subject: [PATCH 11/11] fix comments --- proxy_agent/src/proxy/proxy_connection.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy_agent/src/proxy/proxy_connection.rs b/proxy_agent/src/proxy/proxy_connection.rs index dd6611bd..20fe40db 100644 --- a/proxy_agent/src/proxy/proxy_connection.rs +++ b/proxy_agent/src/proxy/proxy_connection.rs @@ -286,7 +286,7 @@ impl HttpConnectionContext { /// path contains a pattern commonly used to bypass prefix-based /// authorization rules. See [`path_has_traversal`] for the exact set. /// This is checked in `handle_new_http_request` before any rule lookup - /// so suspicious paths short-circuit to 404 without ever reaching the + /// so suspicious paths short-circuit to 403 without ever reaching the /// matcher or the upstream. /// /// Once the canonical pipeline graduates to enforce mode (M5), the