From 158cd103eeec7b8ecb541d372a64acc932237e0d Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:54:21 -0700 Subject: [PATCH] wip: forward proxy fix, mapper allowed_ips, TUI polish, CTF rewrite --- architecture/policy-advisor.md | 8 +- architecture/sandbox.md | 4 +- architecture/security-policy.md | 2 +- crates/navigator-sandbox/src/lib.rs | 4 +- crates/navigator-sandbox/src/proxy.rs | 244 +++++---- crates/navigator-server/src/grpc.rs | 2 +- .../src/mechanistic_mapper.rs | 171 ++++++- crates/navigator-tui/src/app.rs | 6 + crates/navigator-tui/src/ui/sandbox_detail.rs | 17 + crates/navigator-tui/src/ui/sandboxes.rs | 5 +- examples/policy-advisor/README.md | 66 ++- examples/policy-advisor/ctf.py | 481 ++++++++++-------- 12 files changed, 666 insertions(+), 344 deletions(-) diff --git a/architecture/policy-advisor.md b/architecture/policy-advisor.md index 7080ecb9c..bbf5a8114 100644 --- a/architecture/policy-advisor.md +++ b/architecture/policy-advisor.md @@ -29,7 +29,7 @@ The `DenialAggregator` (`crates/navigator-sandbox/src/denial_aggregator.rs`) run 2. Deduplicates events by `(host, port, binary)` key with running counters 3. Periodically flushes accumulated summaries to the gateway via `SubmitPolicyAnalysis` gRPC -The flush interval defaults to 30 seconds (configurable via `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS`). +The flush interval defaults to 10 seconds (configurable via `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS`). ### Denial Event Sources @@ -132,7 +132,7 @@ When a chunk is approved, the server: 5. Supersedes older policy versions 6. Notifies watchers (triggers sandbox policy poll) -The sandbox picks up the new policy on its next poll cycle (default 30 seconds) and hot-reloads the OPA engine. +The sandbox picks up the new policy on its next poll cycle (default 10 seconds) and hot-reloads the OPA engine. ## User Interfaces @@ -175,8 +175,8 @@ Navigation: `[j/k]` to select chunks, `[p]` to return to policy, `[l]` for logs. | Environment Variable | Default | Description | |---------------------|---------|-------------| -| `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS` | `30` | How often the aggregator flushes to the gateway | -| `OPENSHELL_POLICY_POLL_INTERVAL_SECS` | `30` | How often the sandbox polls for policy updates | +| `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS` | `10` | How often the aggregator flushes to the gateway | +| `OPENSHELL_POLICY_POLL_INTERVAL_SECS` | `10` | How often the sandbox polls for policy updates | ## Future Work (Issue #205) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 87d8d2382..9c6dc931b 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -319,7 +319,7 @@ sequenceDiagram GW-->>PL: policy + version + hash PL->>PL: Store initial version - loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 30) + loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 10) PL->>GW: GetSandboxPolicy(sandbox_id) GW-->>PL: policy + version + hash alt version > current_version @@ -389,7 +389,7 @@ Proto messages involved: | Initial version fetch fails | Log warning, retry on next interval (poll loop continues) | | `reload_from_proto()` fails (L7 validation error) | Log warning, keep last-known-good engine, report FAILED status | | Status report RPC fails | Log warning, poll loop continues unaffected | -| Poll interval env var unparseable | Fall back to default (30 seconds) | +| Poll interval env var unparseable | Fall back to default (10 seconds) | ## Linux Enforcement diff --git a/architecture/security-policy.md b/architecture/security-policy.md index d98720d2a..fda0adc96 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -181,7 +181,7 @@ In gRPC mode, the sandbox spawns a background task that periodically polls the g | Parameter | Default | Override | |-----------|---------|----------| -| Poll interval | 30 seconds | `OPENSHELL_POLICY_POLL_INTERVAL_SECS` environment variable | +| Poll interval | 10 seconds | `OPENSHELL_POLICY_POLL_INTERVAL_SECS` environment variable | The poll loop: diff --git a/crates/navigator-sandbox/src/lib.rs b/crates/navigator-sandbox/src/lib.rs index 11bf820a4..5ed53b7ea 100644 --- a/crates/navigator-sandbox/src/lib.rs +++ b/crates/navigator-sandbox/src/lib.rs @@ -534,7 +534,7 @@ pub async fn run_sandbox( let poll_interval_secs: u64 = std::env::var("OPENSHELL_POLICY_POLL_INTERVAL_SECS") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(30); + .unwrap_or(10); tokio::spawn(async move { if let Err(e) = @@ -553,7 +553,7 @@ pub async fn run_sandbox( let flush_interval_secs: u64 = std::env::var("OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(30); + .unwrap_or(10); let aggregator = denial_aggregator::DenialAggregator::new(rx, flush_interval_secs); diff --git a/crates/navigator-sandbox/src/proxy.rs b/crates/navigator-sandbox/src/proxy.rs index cecac48c7..1480e5e0b 100644 --- a/crates/navigator-sandbox/src/proxy.rs +++ b/crates/navigator-sandbox/src/proxy.rs @@ -1484,9 +1484,10 @@ fn rewrite_forward_request(raw: &[u8], used: usize, path: &str) -> Vec { /// Handle a plain HTTP forward proxy request (non-CONNECT). /// -/// Restricted to private IP endpoints with explicit `allowed_ips` policy. -/// Rewrites the absolute-form request to origin-form, connects upstream, -/// and relays the response using `copy_bidirectional` for streaming support. +/// Public IPs are allowed through when the endpoint passes OPA evaluation. +/// Private IPs require explicit `allowed_ips` on the endpoint config (SSRF +/// override). Rewrites the absolute-form request to origin-form, connects +/// upstream, and relays the response using `copy_bidirectional` for streaming. async fn handle_forward_proxy( method: &str, target_uri: &str, @@ -1603,50 +1604,67 @@ async fn handle_forward_proxy( }; let policy_str = matched_policy.as_deref().unwrap_or("-"); - // 5. Require allowed_ips (forward proxy only works with explicit SSRF override) + // 5. DNS resolution + SSRF defence (mirrors the CONNECT path logic). + // - If allowed_ips is set: validate resolved IPs against the allowlist + // (this is the SSRF override for private IP destinations). + // - If allowed_ips is empty: reject internal IPs, allow public IPs through. let raw_allowed_ips = query_allowed_ips(&opa_engine, &decision, &host_lc, port); - if raw_allowed_ips.is_empty() { - info!( - src_addr = %peer_addr.ip(), - src_port = peer_addr.port(), - proxy_addr = %local_addr, - dst_host = %host_lc, - dst_port = port, - method = %method, - path = %path, - binary = %binary_str, - binary_pid = %pid_str, - ancestors = %ancestors_str, - cmdline = %cmdline_str, - action = "deny", - engine = "opa", - policy = %policy_str, - reason = "forward proxy requires allowed_ips on endpoint", - "FORWARD", - ); - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - "forward proxy requires allowed_ips on endpoint", - "forward", - ); - respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; - return Ok(()); - } - // 6. Resolve DNS and validate against allowed_ips - let addrs = match parse_allowed_ips(&raw_allowed_ips) { - Ok(nets) => match resolve_and_check_allowed_ips(&host, port, &nets).await { - Ok(addrs) => addrs, + let addrs = if !raw_allowed_ips.is_empty() { + // allowed_ips mode: validate resolved IPs against CIDR allowlist. + match parse_allowed_ips(&raw_allowed_ips) { + Ok(nets) => match resolve_and_check_allowed_ips(&host, port, &nets).await { + Ok(addrs) => addrs, + Err(reason) => { + warn!( + dst_host = %host_lc, + dst_port = port, + reason = %reason, + "FORWARD blocked: allowed_ips check failed" + ); + emit_denial_simple( + denial_tx, + &host_lc, + port, + &binary_str, + &decision, + &reason, + "ssrf", + ); + respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; + return Ok(()); + } + }, Err(reason) => { warn!( dst_host = %host_lc, dst_port = port, reason = %reason, - "FORWARD blocked: allowed_ips check failed" + "FORWARD blocked: invalid allowed_ips in policy" + ); + emit_denial_simple( + denial_tx, + &host_lc, + port, + &binary_str, + &decision, + &reason, + "ssrf", + ); + respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; + return Ok(()); + } + } + } else { + // No allowed_ips: reject internal IPs, allow public IPs through. + match resolve_and_reject_internal(&host, port).await { + Ok(addrs) => addrs, + Err(reason) => { + warn!( + dst_host = %host_lc, + dst_port = port, + reason = %reason, + "FORWARD blocked: internal IP without allowed_ips" ); emit_denial_simple( denial_tx, @@ -1660,53 +1678,10 @@ async fn handle_forward_proxy( respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; return Ok(()); } - }, - Err(reason) => { - warn!( - dst_host = %host_lc, - dst_port = port, - reason = %reason, - "FORWARD blocked: invalid allowed_ips in policy" - ); - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "ssrf", - ); - respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; - return Ok(()); } }; - // 7. Private-IP gate: forward proxy only to RFC 1918 private addresses - if !addrs.iter().all(|a| is_internal_ip(a.ip())) { - info!( - src_addr = %peer_addr.ip(), - src_port = peer_addr.port(), - proxy_addr = %local_addr, - dst_host = %host_lc, - dst_port = port, - method = %method, - path = %path, - binary = %binary_str, - binary_pid = %pid_str, - ancestors = %ancestors_str, - cmdline = %cmdline_str, - action = "deny", - engine = "opa", - policy = %policy_str, - reason = "forward proxy restricted to private IP endpoints", - "FORWARD", - ); - respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; - return Ok(()); - } - - // 8. Connect upstream + // 6. Connect upstream let mut upstream = match TcpStream::connect(addrs.as_slice()).await { Ok(s) => s, Err(e) => { @@ -1741,11 +1716,11 @@ async fn handle_forward_proxy( "FORWARD", ); - // 9. Rewrite request and forward to upstream + // 7. Rewrite request and forward to upstream let rewritten = rewrite_forward_request(buf, used, &path); upstream.write_all(&rewritten).await.into_diagnostic()?; - // 10. Relay remaining traffic bidirectionally (supports streaming) + // 8. Relay remaining traffic bidirectionally (supports streaming) let _ = tokio::io::copy_bidirectional(client, &mut upstream) .await .into_diagnostic()?; @@ -2410,4 +2385,101 @@ mod tests { // Should not add a second Via header assert!(!result_str.contains("Via: 1.1 navigator-sandbox")); } + + // --- Forward proxy SSRF defence tests --- + // + // The forward proxy handler uses the same SSRF logic as the CONNECT path: + // - No allowed_ips: resolve_and_reject_internal blocks private IPs, allows public. + // - With allowed_ips: resolve_and_check_allowed_ips validates against allowlist. + // + // These tests document that contract for the forward proxy path specifically. + + #[tokio::test] + async fn test_forward_public_ip_allowed_without_allowed_ips() { + // Public IPs (e.g. dns.google -> 8.8.8.8) should pass through + // resolve_and_reject_internal without needing allowed_ips. + let result = resolve_and_reject_internal("dns.google", 80).await; + assert!( + result.is_ok(), + "Public IP should be allowed without allowed_ips: {result:?}" + ); + let addrs = result.unwrap(); + assert!(!addrs.is_empty(), "Should resolve to at least one address"); + // All resolved addresses should be public. + for addr in &addrs { + assert!( + !is_internal_ip(addr.ip()), + "dns.google should resolve to public IPs, got {}", + addr.ip() + ); + } + } + + #[tokio::test] + async fn test_forward_private_ip_rejected_without_allowed_ips() { + // Private IP literals should be rejected by resolve_and_reject_internal. + let result = resolve_and_reject_internal("10.0.0.1", 80).await; + assert!( + result.is_err(), + "Private IP should be rejected without allowed_ips" + ); + let err = result.unwrap_err(); + assert!( + err.contains("internal address"), + "expected 'internal address' in error: {err}" + ); + } + + #[tokio::test] + async fn test_forward_private_ip_accepted_with_allowed_ips() { + // Private IP with matching allowed_ips should pass through. + let nets = parse_allowed_ips(&["10.0.0.0/8".to_string()]).unwrap(); + let result = resolve_and_check_allowed_ips("10.0.0.1", 80, &nets).await; + assert!( + result.is_ok(), + "Private IP with matching allowed_ips should be accepted: {result:?}" + ); + } + + #[tokio::test] + async fn test_forward_private_ip_rejected_with_wrong_allowed_ips() { + // Private IP not in allowed_ips should be rejected. + let nets = parse_allowed_ips(&["192.168.0.0/16".to_string()]).unwrap(); + let result = resolve_and_check_allowed_ips("10.0.0.1", 80, &nets).await; + assert!( + result.is_err(), + "Private IP not in allowed_ips should be rejected" + ); + let err = result.unwrap_err(); + assert!( + err.contains("not in allowed_ips"), + "expected 'not in allowed_ips' in error: {err}" + ); + } + + #[tokio::test] + async fn test_forward_loopback_always_blocked_even_with_allowed_ips() { + // Loopback addresses are always blocked, even if in allowed_ips. + let nets = parse_allowed_ips(&["127.0.0.0/8".to_string()]).unwrap(); + let result = resolve_and_check_allowed_ips("127.0.0.1", 80, &nets).await; + assert!(result.is_err(), "Loopback should be always blocked"); + let err = result.unwrap_err(); + assert!( + err.contains("always-blocked"), + "expected 'always-blocked' in error: {err}" + ); + } + + #[tokio::test] + async fn test_forward_link_local_always_blocked_even_with_allowed_ips() { + // Link-local / cloud metadata addresses are always blocked. + let nets = parse_allowed_ips(&["169.254.0.0/16".to_string()]).unwrap(); + let result = resolve_and_check_allowed_ips("169.254.169.254", 80, &nets).await; + assert!(result.is_err(), "Link-local should be always blocked"); + let err = result.unwrap_err(); + assert!( + err.contains("always-blocked"), + "expected 'always-blocked' in error: {err}" + ); + } } diff --git a/crates/navigator-server/src/grpc.rs b/crates/navigator-server/src/grpc.rs index 1fe5f936c..7bb0a5f2f 100644 --- a/crates/navigator-server/src/grpc.rs +++ b/crates/navigator-server/src/grpc.rs @@ -1424,7 +1424,7 @@ impl Navigator for NavigatorService { .map(|c| c.rule_name.clone()) .collect(); - crate::mechanistic_mapper::generate_proposals(&req.summaries, &existing_names) + crate::mechanistic_mapper::generate_proposals(&req.summaries, &existing_names).await } else { req.proposed_chunks.clone() }; diff --git a/crates/navigator-server/src/mechanistic_mapper.rs b/crates/navigator-server/src/mechanistic_mapper.rs index e33234365..ad336e91c 100644 --- a/crates/navigator-server/src/mechanistic_mapper.rs +++ b/crates/navigator-server/src/mechanistic_mapper.rs @@ -16,6 +16,7 @@ use navigator_core::proto::{ DenialSummary, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, }; use std::collections::HashMap; +use std::net::IpAddr; /// Well-known ports that get higher confidence scores. const WELL_KNOWN_PORTS: &[(u16, &str)] = &[ @@ -45,8 +46,12 @@ const WELL_KNOWN_PORTS: &[(u16, &str)] = &[ /// `PolicyChunk` with a `NetworkPolicyRule` allowing that endpoint for the /// observed binaries. /// +/// When a host resolves to a private IP (RFC 1918, loopback, link-local), +/// the proposed endpoint includes `allowed_ips` so the proxy's SSRF override +/// accepts the connection. Public IPs do not need `allowed_ips`. +/// /// Returns an empty vec if there are no actionable denials. -pub fn generate_proposals( +pub async fn generate_proposals( summaries: &[DenialSummary], existing_rule_names: &[String], ) -> Vec { @@ -97,6 +102,11 @@ pub fn generate_proposals( } } + // Resolve the host and check if any IP is private. When a host + // resolves to private IP space, the proxy requires `allowed_ips` as an + // explicit SSRF override. Public IPs don't need this. + let allowed_ips = resolve_allowed_ips_if_private(host, *port).await; + // Build proposed NetworkPolicyRule. let l7_rules = build_l7_rules(&l7_methods); let endpoint = if has_l7 && !l7_rules.is_empty() { @@ -107,12 +117,14 @@ pub fn generate_proposals( tls: "terminate".to_string(), enforcement: "enforce".to_string(), rules: l7_rules, + allowed_ips: allowed_ips.clone(), ..Default::default() } } else { NetworkEndpoint { host: host.clone(), port: *port, + allowed_ips: allowed_ips.clone(), ..Default::default() } }; @@ -154,18 +166,27 @@ pub fn generate_proposals( .map(|(_, name)| format!(" ({name})")) .unwrap_or_default(); + let private_ip_note = if !allowed_ips.is_empty() { + format!( + " Host resolves to private IP ({}); allowed_ips included for SSRF override.", + allowed_ips.join(", ") + ) + } else { + String::new() + }; + let rationale = if has_l7 && !l7_methods.is_empty() { let paths: Vec = l7_methods.keys().map(|(m, p)| format!("{m} {p}")).collect(); format!( "Allow {binary_list} to connect to {host}:{port}{port_name} \ with L7 inspection. Observed {total_count} denied connection(s). \ - Allowed paths: {}.", + Allowed paths: {}.{private_ip_note}", paths.join(", ") ) } else { format!( "Allow {binary_list} to connect to {host}:{port}{port_name}. \ - Observed {total_count} denied connection(s)." + Observed {total_count} denied connection(s).{private_ip_note}" ) }; @@ -395,6 +416,77 @@ fn short_binary_name(path: &str) -> String { path.rsplit('/').next().unwrap_or(path).to_string() } +/// Check if an IP address is in private/internal space. +/// +/// Matches the same ranges as the proxy's `is_internal_ip`: loopback, +/// RFC 1918 private, link-local (IPv4), plus loopback, link-local, and +/// ULA (IPv6). IPv4-mapped IPv6 addresses are unwrapped and checked. +fn is_internal_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), + IpAddr::V6(v6) => { + if v6.is_loopback() { + return true; + } + // fe80::/10 — IPv6 link-local + if (v6.segments()[0] & 0xffc0) == 0xfe80 { + return true; + } + // fc00::/7 — IPv6 unique local addresses (ULA) + if (v6.segments()[0] & 0xfe00) == 0xfc00 { + return true; + } + // Check IPv4-mapped IPv6 (::ffff:x.x.x.x) + if let Some(v4) = v6.to_ipv4_mapped() { + return v4.is_loopback() || v4.is_private() || v4.is_link_local(); + } + false + } + } +} + +/// Resolve a hostname and return the IPs as `allowed_ips` strings only if any +/// resolved address is in private IP space. +/// +/// When a host resolves entirely to public IPs, the proxy doesn't need +/// `allowed_ips` — it passes public traffic through after the OPA check. +/// When any resolved IP is private/internal, the proxy requires `allowed_ips` +/// as an explicit SSRF override, so the mapper includes them. +/// +/// Returns an empty vec for public hosts or on DNS resolution failure. +async fn resolve_allowed_ips_if_private(host: &str, port: u32) -> Vec { + let addr = format!("{host}:{port}"); + let addrs = match tokio::net::lookup_host(&addr).await { + Ok(addrs) => addrs.collect::>(), + Err(e) => { + tracing::warn!(host, port, error = %e, "DNS resolution failed for allowed_ips check"); + return Vec::new(); + } + }; + + if addrs.is_empty() { + tracing::warn!(host, port, "DNS resolution returned no addresses"); + return Vec::new(); + } + + let has_private = addrs.iter().any(|a| is_internal_ip(a.ip())); + if !has_private { + return Vec::new(); + } + + // Host has private IPs — include all resolved IPs in allowed_ips. + let mut ips: Vec = addrs.iter().map(|a| a.ip().to_string()).collect(); + ips.sort(); + ips.dedup(); + tracing::debug!( + host, + port, + ?ips, + "Host resolves to private IP; adding allowed_ips" + ); + ips +} + #[cfg(test)] mod tests { use super::*; @@ -430,14 +522,14 @@ mod tests { assert!(notes.contains("SSRF")); } - #[test] - fn test_generate_proposals_empty() { - let proposals = generate_proposals(&[], &[]); + #[tokio::test] + async fn test_generate_proposals_empty() { + let proposals = generate_proposals(&[], &[]).await; assert!(proposals.is_empty()); } - #[test] - fn test_generate_proposals_basic() { + #[tokio::test] + async fn test_generate_proposals_basic() { let summaries = vec![DenialSummary { sandbox_id: "test".to_string(), host: "api.example.com".to_string(), @@ -458,7 +550,7 @@ mod tests { l7_inspection_active: false, }]; - let proposals = generate_proposals(&summaries, &[]); + let proposals = generate_proposals(&summaries, &[]).await; assert_eq!(proposals.len(), 1); assert_eq!(proposals[0].rule_name, "allow_api_example_com_443"); assert!(proposals[0].proposed_rule.is_some()); @@ -473,10 +565,16 @@ mod tests { // No L7 fields when no samples provided. assert!(rule.endpoints[0].protocol.is_empty()); assert!(rule.endpoints[0].rules.is_empty()); + + // Public host should NOT have allowed_ips. + assert!( + rule.endpoints[0].allowed_ips.is_empty(), + "Public host should not get allowed_ips" + ); } - #[test] - fn test_generate_proposals_with_l7_samples() { + #[tokio::test] + async fn test_generate_proposals_with_l7_samples() { use navigator_core::proto::L7RequestSample; let summaries = vec![DenialSummary { @@ -512,7 +610,7 @@ mod tests { l7_inspection_active: true, }]; - let proposals = generate_proposals(&summaries, &[]); + let proposals = generate_proposals(&summaries, &[]).await; assert_eq!(proposals.len(), 1); let rule = proposals[0].proposed_rule.as_ref().unwrap(); @@ -540,6 +638,55 @@ mod tests { assert!(proposals[0].rationale.contains("L7")); } + // -- is_internal_ip tests ------------------------------------------------- + + #[test] + fn test_is_internal_ip_private_v4() { + use std::net::Ipv4Addr; + // RFC 1918 ranges + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(10, 110, 50, 3)))); + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))); + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255)))); + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))); + } + + #[test] + fn test_is_internal_ip_loopback_and_link_local() { + use std::net::Ipv4Addr; + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new( + 169, 254, 169, 254 + )))); + } + + #[test] + fn test_is_internal_ip_public_v4() { + use std::net::Ipv4Addr; + assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); + assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(208, 95, 112, 1)))); + assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)))); + } + + #[test] + fn test_is_internal_ip_v6() { + use std::net::Ipv6Addr; + // Loopback + assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::LOCALHOST))); + // Link-local fe80::1 + assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::new( + 0xfe80, 0, 0, 0, 0, 0, 0, 1 + )))); + // ULA fd00::1 + assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::new( + 0xfd00, 0, 0, 0, 0, 0, 0, 1 + )))); + // Public 2001:db8::1 + assert!(!is_internal_ip(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 + )))); + } + #[test] fn test_generalise_path() { // Exact path preserved. diff --git a/crates/navigator-tui/src/app.rs b/crates/navigator-tui/src/app.rs index 0c3d3eb17..6d0eb3742 100644 --- a/crates/navigator-tui/src/app.rs +++ b/crates/navigator-tui/src/app.rs @@ -902,6 +902,12 @@ impl App { self.sandbox_log_scroll = 0; self.log_cursor = 0; } + KeyCode::Char('r') => { + self.focus = Focus::SandboxDraft; + } + KeyCode::Char('p') => { + self.focus = Focus::SandboxPolicy; + } _ => {} } } diff --git a/crates/navigator-tui/src/ui/sandbox_detail.rs b/crates/navigator-tui/src/ui/sandbox_detail.rs index fbbdf3dcc..b119da0e1 100644 --- a/crates/navigator-tui/src/ui/sandbox_detail.rs +++ b/crates/navigator-tui/src/ui/sandbox_detail.rs @@ -100,6 +100,23 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let mut lines = vec![Line::from(""), row1, row2, row3, row4]; + // Show pending policy requests prompt. + if pending_count > 0 { + lines.push(Line::from(vec![ + Span::styled(" ", styles::TEXT), + Span::styled( + format!( + "{pending_count} pending policy request{}", + if pending_count == 1 { "" } else { "s" } + ), + styles::ACCENT, + ), + Span::styled(" — press ", styles::MUTED), + Span::styled("[r]", styles::KEY_HINT), + Span::styled(" to review", styles::MUTED), + ])); + } + // Delete confirmation in title area (same pattern as provider delete). if app.confirm_delete { lines.push(Line::from("")); diff --git a/crates/navigator-tui/src/ui/sandboxes.rs b/crates/navigator-tui/src/ui/sandboxes.rs index 4f1fa44a5..2354046e4 100644 --- a/crates/navigator-tui/src/ui/sandboxes.rs +++ b/crates/navigator-tui/src/ui/sandboxes.rs @@ -51,7 +51,10 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { if draft_count > 0 { name_spans.push(Span::raw(" ")); name_spans.push(Span::styled( - format!(" {draft_count} pending "), + format!( + " {draft_count} policy request{} pending ", + if draft_count == 1 { "" } else { "s" } + ), styles::BADGE, )); } diff --git a/examples/policy-advisor/README.md b/examples/policy-advisor/README.md index 479f7ba6b..5092e2c58 100644 --- a/examples/policy-advisor/README.md +++ b/examples/policy-advisor/README.md @@ -5,21 +5,22 @@ A capture-the-flag challenge that walks you through OpenShell's policy recommendation pipeline. You start with a sandbox that only allows traffic to -`api.anthropic.com`. A Python script tries to reach 7 public endpoints -- and -fails. The sandbox proxy detects each denial, aggregates it, and sends it to -the gateway where the mechanistic mapper turns it into a concrete -`NetworkPolicyRule` recommendation. You approve those recommendations one by -one in the TUI, and the script progresses through each gate. +`api.anthropic.com`. A Python script tries to reach 6 endpoints -- and fails. +The sandbox proxy detects each denial, aggregates it, and sends it to the +gateway where the mechanistic mapper turns it into a concrete +`NetworkPolicyRule` recommendation. You approve those recommendations in the +TUI, and the script progresses through each gate. ## How It Works 1. **Script makes a request** -- the sandbox proxy blocks it and emits a `DenialEvent`. -2. **DenialAggregator batches events** -- every ~30 seconds it flushes denial +2. **DenialAggregator batches events** -- every ~10 seconds it flushes denial summaries to the gateway via `SubmitPolicyAnalysis`. 3. **Mechanistic mapper generates proposals** -- each unique `(host, port)` pair becomes a `PolicyChunk` with a proposed `NetworkPolicyRule`, confidence - score, and rationale. + score, and rationale. If the host resolves to a private IP, the mapper + includes `allowed_ips` for the SSRF override. 4. **TUI shows recommendations** -- navigate to the sandbox's draft panel to see pending proposals. 5. **You approve** -- the approved rule merges into the active sandbox policy @@ -31,20 +32,26 @@ one in the TUI, and the script progresses through each gate. | File | Description | |---|---| | `sandbox-policy.yaml` | Restrictive policy that only allows `api.anthropic.com:443` | -| `ctf.py` | Python script with 7 sequential network gates to public services | +| `ctf.py` | Python script with 6 network gates | | `README.md` | This walkthrough | ## Gates | # | Name | Target | Notes | |---|---|---|---| -| 1 | The Ping | `httpbin.org:443` | HTTPS echo service | -| 2 | The Oracle | `api.github.com:443` | GitHub zen quotes | -| 3 | The Jester | `icanhazdadjoke.com:443` | Random dad joke | -| 4 | The Scribe | `jsonplaceholder.typicode.com:443` | POST request (not just GETs) | -| 5 | The Sphinx | `catfact.ninja:443` | Cat facts | -| 6 | The Cartographer | `ip-api.com:80` | Plain HTTP on port 80 (forward-proxy path) | -| 7 | The Guardian | `dog.ceo:443` | Random dog breed | +| 1 | The Ping | `httpbin.org:443` | HTTPS (CONNECT tunnel path) | +| 2 | The Cartographer | `ip-api.com:80` | Plain HTTP (forward proxy path) | +| 3 | The Oracle | `api.github.com:443` | Concurrent with 4 and 5 | +| 4 | The Jester | `icanhazdadjoke.com:443` | Concurrent with 3 and 5 | +| 5 | The Sphinx | `catfact.ninja:443` | Concurrent with 3 and 4 | +| 6 | The Vault | `gitlab-master.nvidia.com:443` | Internal IP -- mapper adds `allowed_ips` | + +Gates 1 and 2 run sequentially so you can observe the single-approval flow. +Gates 3-5 fire concurrently so all three denials arrive together -- use +`[A]` (approve all) in the TUI to unlock them in one shot. +Gate 6 targets a host that resolves to a private IP. The mechanistic mapper +detects this and includes `allowed_ips` in the proposed rule so the proxy's +SSRF override allows the connection. ## Quick Start @@ -88,31 +95,34 @@ seconds. ### 3. Approve recommendations in the TUI -Switch to the TUI terminal. Within ~30 seconds you should see the sandbox +Switch to the TUI terminal. Within ~10 seconds you should see the sandbox reporting denial activity. 1. **Select the sandbox** -- use arrow keys to highlight `advisor-ctf` and press `Enter`. -2. **Open the policy view** -- press `p`. -3. **Open the draft recommendations panel** -- press `r`. -4. **Approve a recommendation** -- highlight the pending chunk for +2. **Open the draft recommendations panel** -- press `r`. +3. **Approve a recommendation** -- highlight the pending chunk for `httpbin.org:443` and press `a` to approve it. -The policy update propagates to the sandbox immediately. On the next retry +The policy update propagates to the sandbox within seconds. On the next retry the script passes Gate 1 and moves on to Gate 2. -Repeat for each gate. You can also press `A` to approve all pending -recommendations at once if you want to skip ahead. +When Gates 3-5 start, all three denials arrive together. Press `A` to approve +all pending recommendations at once. + +Gate 6 requires `allowed_ips` because `gitlab-master.nvidia.com` resolves to a +private IP. The mapper detects this automatically and includes the resolved IPs +in the proposed rule. ### 4. Win -Once all 7 gates are unlocked the script prints a victory banner. +Once all 6 gates are unlocked the script prints a victory banner. ## Tips - **Dry run** -- run `python3 ctf.py --dry-run` to see the gate list without making any network requests. -- **Flush interval** -- the denial aggregator flushes every 30 seconds by +- **Flush interval** -- the denial aggregator flushes every 10 seconds by default. Set `OPENSHELL_DENIAL_FLUSH_INTERVAL_SECS=5` in the sandbox environment for faster feedback during the demo. - **CLI alternative** -- you can approve drafts from the CLI instead of the @@ -122,9 +132,11 @@ Once all 7 gates are unlocked the script prints a victory banner. openshell draft approve advisor-ctf --chunk-id ID # approve one openshell draft approve-all advisor-ctf # approve all ``` -- **Gate 6 is different** -- it uses plain HTTP on port 80, which exercises - the forward-proxy deny path instead of the CONNECT tunnel used by HTTPS. - This generates a separate recommendation with port 80. +- **Gate 2 is different** -- it uses plain HTTP on port 80, which exercises + the forward proxy path instead of the CONNECT tunnel used by HTTPS. +- **Gate 6 is different** -- it targets a host that resolves to a private IP. + The mapper automatically adds `allowed_ips` so the proxy's SSRF override + permits the connection. ## Cleanup diff --git a/examples/policy-advisor/ctf.py b/examples/policy-advisor/ctf.py index 051fc456d..e860e02e5 100644 --- a/examples/policy-advisor/ctf.py +++ b/examples/policy-advisor/ctf.py @@ -9,10 +9,9 @@ A capture-the-flag challenge that exercises OpenShell's policy recommendation pipeline. Run this inside a sandbox with the restrictive policy, then use the TUI to approve mechanistic recommendations and unlock each gate. -Every gate makes a network call to a public service. The sandbox proxy blocks -the call, the DenialAggregator reports it to the gateway, and the mechanistic -mapper generates a policy recommendation. Approve it in the TUI and the next -retry succeeds. +Gate 1 tests the basic HTTPS flow (CONNECT tunnel). +Gate 2 tests plain HTTP (forward proxy path). +Gates 3-5 fire concurrently to test batch approval. Usage: python3 ctf.py # run all gates @@ -24,13 +23,14 @@ from __future__ import annotations import json import socket import sys +import threading import time import urllib.error import urllib.request from datetime import datetime -# ── Terminal formatting ────────────────────────────────────────────────────── +# -- Terminal formatting ------------------------------------------------------- GREEN = "\033[92m" RED = "\033[91m" @@ -62,17 +62,10 @@ def log(level: str, msg: str, **kv: object) -> None: print(f" {DIM}{ts}{RESET} {c}{level:4}{RESET} {msg} {extra}", flush=True) -# ── Gate definitions ───────────────────────────────────────────────────────── -# -# Each gate targets a different public endpoint. The variety exercises the -# mechanistic mapper across hosts, ports, and HTTP methods. -# -# Gates 1-5 : HTTPS on port 443 (CONNECT tunnel path) -# Gate 6 : plain HTTP on port 80 (forward-proxy path) -# Gate 7 : HTTPS on port 443 (final gate) +# -- Gate definitions ---------------------------------------------------------- GATES: list[dict] = [ - # ── Gate 1 ─────────────────────────────────────────────────── + # -- Gate 1: HTTPS on 443 (CONNECT tunnel) -------------------------------- { "num": 1, "name": "The Ping", @@ -82,78 +75,12 @@ GATES: list[dict] = [ "method": "GET", "headers": {"Accept": "application/json"}, "body": None, - "hint": "A simple echo to prove you're alive.", + "hint": "A simple HTTPS echo to prove the CONNECT tunnel works.", "extract": lambda d: f"origin = {json.loads(d).get('origin', '?')}", }, - # ── Gate 2 ─────────────────────────────────────────────────── + # -- Gate 2: plain HTTP on 80 (forward proxy) ----------------------------- { "num": 2, - "name": "The Oracle", - "host": "api.github.com", - "port": 443, - "url": "https://api.github.com/zen", - "method": "GET", - "headers": { - "User-Agent": "openshell-ctf", - "Accept": "application/vnd.github+json", - }, - "body": None, - "hint": "Ancient wisdom from the code forge.", - "extract": lambda d: d.strip()[:80], - }, - # ── Gate 3 ─────────────────────────────────────────────────── - { - "num": 3, - "name": "The Jester", - "host": "icanhazdadjoke.com", - "port": 443, - "url": "https://icanhazdadjoke.com/", - "method": "GET", - "headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"}, - "body": None, - "hint": "Laughter unlocks the third seal.", - "extract": lambda d: json.loads(d).get("joke", "?")[:120], - }, - # ── Gate 4 ─────────────────────────────────────────────────── - { - "num": 4, - "name": "The Scribe", - "host": "jsonplaceholder.typicode.com", - "port": 443, - "url": "https://jsonplaceholder.typicode.com/posts", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "Accept": "application/json", - }, - "body": json.dumps( - { - "title": "CTF Gate 4", - "body": "The scribe records your passage.", - "userId": 1, - } - ), - "hint": "Leave your mark -- POST, don't just GET.", - "extract": lambda d: f"created post id = {json.loads(d).get('id', '?')}", - }, - # ── Gate 5 ─────────────────────────────────────────────────── - { - "num": 5, - "name": "The Sphinx", - "host": "catfact.ninja", - "port": 443, - "url": "https://catfact.ninja/fact", - "method": "GET", - "headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"}, - "body": None, - "hint": "Answer the Sphinx's riddle.", - "extract": lambda d: json.loads(d).get("fact", "?")[:120], - }, - # ── Gate 6 ─────────────────────────────────────────────────── - # Plain HTTP on port 80 -- exercises the forward-proxy deny path - # instead of the CONNECT tunnel path used by HTTPS. - { - "num": 6, "name": "The Cartographer", "host": "ip-api.com", "port": 80, @@ -168,34 +95,63 @@ GATES: list[dict] = [ else json.loads(d).get("message", "?") ), }, - # ── Gate 7 ─────────────────────────────────────────────────── + # -- Gates 3-5: concurrent HTTPS requests (batch approval) ---------------- { - "num": 7, - "name": "The Guardian", - "host": "dog.ceo", + "num": 3, + "name": "The Oracle", + "host": "api.github.com", "port": 443, - "url": "https://dog.ceo/api/breeds/image/random", + "url": "https://api.github.com/zen", "method": "GET", - "headers": {"Accept": "application/json"}, + "headers": { + "User-Agent": "openshell-ctf", + "Accept": "application/vnd.github+json", + }, "body": None, - "hint": "The final guardian reveals its true form.", - "extract": lambda d: _extract_breed(json.loads(d).get("message", "")), + "hint": "Ancient wisdom from the code forge.", + "extract": lambda d: d.strip()[:80], + }, + { + "num": 4, + "name": "The Jester", + "host": "icanhazdadjoke.com", + "port": 443, + "url": "https://icanhazdadjoke.com/", + "method": "GET", + "headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"}, + "body": None, + "hint": "Laughter unlocks the fourth seal.", + "extract": lambda d: json.loads(d).get("joke", "?")[:120], + }, + { + "num": 5, + "name": "The Sphinx", + "host": "catfact.ninja", + "port": 443, + "url": "https://catfact.ninja/fact", + "method": "GET", + "headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"}, + "body": None, + "hint": "Answer the Sphinx's riddle.", + "extract": lambda d: json.loads(d).get("fact", "?")[:120], + }, + # -- Gate 6: HTTPS to internal IP (allowed_ips SSRF override) ------------- + { + "num": 6, + "name": "The Vault", + "host": "gitlab-master.nvidia.com", + "port": 443, + "url": "https://gitlab-master.nvidia.com/", + "method": "GET", + "headers": {"User-Agent": "openshell-ctf"}, + "body": None, + "hint": "Reach behind the firewall -- this host resolves to a private IP.", + "extract": lambda d: f"page length = {len(d)} bytes", }, ] -def _extract_breed(url: str) -> str: - """Pull the breed name out of a dog.ceo image URL.""" - # URL pattern: https://images.dog.ceo/breeds//image.jpg - parts = url.split("/") - try: - idx = parts.index("breeds") - return parts[idx + 1].replace("-", " ").title() - except (ValueError, IndexError): - return url - - -# ── Network request logic ──────────────────────────────────────────────────── +# -- Network request logic ---------------------------------------------------- def _is_proxy_block(exc: Exception) -> bool: @@ -231,7 +187,6 @@ def attempt_gate(gate: dict) -> tuple[str, str]: except urllib.error.HTTPError as exc: if exc.code == 403: return "blocked", "blocked by sandbox proxy (403)" - # A real HTTP error from the upstream service — not retryable. return "error", f"HTTP {exc.code} from {gate['host']}" except urllib.error.URLError as exc: @@ -251,46 +206,47 @@ def attempt_gate(gate: dict) -> tuple[str, str]: return "error", f"unexpected error ({exc})" -# ── Banner / victory ───────────────────────────────────────────────────────── +# -- Banner / victory ---------------------------------------------------------- BANNER = f""" {CYAN}{BOLD}\ - ╔════════════════════════════════════════════════════════════╗ - ║ POLICY ADVISOR CTF -- MECHANISTIC MODE ║ - ╠════════════════════════════════════════════════════════════╣ - ║ ║ - ║ Your sandbox blocks all traffic except api.anthropic.com ║ - ║ 7 gates stand between you and victory. ║ - ║ ║ - ║ Each gate needs a network call to a public service. ║ - ║ Approve the policy recommendations in the TUI to ║ - ║ unlock each gate, one by one. ║ - ║ ║ - ╚════════════════════════════════════════════════════════════╝\ + +============================================================+ + | POLICY ADVISOR CTF -- MECHANISTIC MODE | + +============================================================+ + | | + | Your sandbox blocks all traffic except api.anthropic.com | + | 6 gates stand between you and victory. | + | | + | Gate 1 HTTPS endpoint (CONNECT tunnel) | + | Gate 2 HTTP endpoint (forward proxy) | + | Gate 3-5 Concurrent requests (batch approval) | + | Gate 6 Internal IP endpoint (allowed_ips SSRF override) | + | | + +============================================================+\ {RESET} """ VICTORY = f""" {GREEN}{BOLD}\ - ╔════════════════════════════════════════════════════════════╗ - ║ ║ - ║ * ALL 7 GATES UNLOCKED * ║ - ║ ║ - ║ You've mastered mechanistic policy recommendations. ║ - ║ ║ - ║ Each denied connection was detected by the sandbox ║ - ║ proxy, aggregated into a denial summary, transported ║ - ║ to the gateway, and mapped into a NetworkPolicyRule ║ - ║ for your approval. ║ - ║ ║ - ║ Next up: AI-assisted mode (issue #205). ║ - ║ ║ - ╚════════════════════════════════════════════════════════════╝\ + +============================================================+ + | | + | * ALL 6 GATES UNLOCKED * | + | | + | You've mastered mechanistic policy recommendations. | + | | + | Each denied connection was detected by the sandbox | + | proxy, aggregated into a denial summary, transported | + | to the gateway, and mapped into a NetworkPolicyRule | + | for your approval. | + | | + | Next up: AI-assisted mode (issue #205). | + | | + +============================================================+\ {RESET} """ -# ── Dry-run ────────────────────────────────────────────────────────────────── +# -- Dry-run ------------------------------------------------------------------- def dry_run() -> None: @@ -300,17 +256,179 @@ def dry_run() -> None: print() for g in GATES: proto = "HTTPS" if g["port"] == 443 else "HTTP" + concurrent = " (concurrent)" if g["num"] >= 3 else "" print( f" {CYAN}Gate {g['num']}{RESET} " f"{BOLD}{g['name']}{RESET} " - f"{DIM}{g['host']}:{g['port']} {g['method']} {proto}{RESET}" + f"{DIM}{g['host']}:{g['port']} {g['method']} {proto}{concurrent}{RESET}" ) print(f" {DIM}{g['hint']}{RESET}") print() log("INFO", "Run without --dry-run inside a sandbox to start the challenge") -# ── Main CTF loop ──────────────────────────────────────────────────────────── +# -- Sequential gate runner ---------------------------------------------------- + + +def run_gate(gate: dict) -> bool: + """Run a single gate with retry loop. Returns True on success.""" + num = gate["num"] + total = len(GATES) + + print(f" {CYAN}{BOLD}{'=' * 60}{RESET}") + print(f" {CYAN}{BOLD} GATE {num}/{total}: {gate['name'].upper()}{RESET}") + proto = "https" if gate["port"] == 443 else "http" + print( + f" {DIM} {gate['hint']}{RESET}\n" + f" {DIM} target: {proto}://{gate['host']}:{gate['port']} " + f"({gate['method']}){RESET}" + ) + print(f" {CYAN}{BOLD}{'=' * 60}{RESET}") + print() + + for attempt in range(1, MAX_RETRIES + 1): + log( + "GATE", + f"Gate {num} attempt #{attempt}", + host=gate["host"], + port=gate["port"], + ) + + status, result = attempt_gate(gate) + + if status == "pass": + log("PASS", f"Gate {num} UNLOCKED") + log("FLAG", result) + print() + return True + + if status == "error": + log("WARN", f"Gate {num} skipped: {result}") + log("INFO", "This is an upstream error, not a proxy block") + print() + return True + + log("FAIL", f"Gate {num}: {result}") + + if attempt == 1: + log("WARN", "Approve the recommendation in the TUI to proceed") + log( + "INFO", + "TUI: select sandbox -> [r] drafts -> [a] approve", + ) + + for remaining in range(RETRY_INTERVAL, 0, -1): + print( + f"\r {DIM} retrying in {remaining:>2}s ...{RESET}", + end="", + flush=True, + ) + time.sleep(1) + print("\r" + " " * 50 + "\r", end="", flush=True) + + log("FAIL", f"Gate {num} timed out after {MAX_RETRIES} attempts") + return False + + +# -- Concurrent gate runner ---------------------------------------------------- + + +def run_gates_concurrent(gates: list[dict]) -> int: + """Run multiple gates concurrently, retrying until all pass. + + Returns the number of gates that passed. + """ + nums = ", ".join(str(g["num"]) for g in gates) + total = len(GATES) + + print(f" {CYAN}{BOLD}{'=' * 60}{RESET}") + print(f" {CYAN}{BOLD} GATES {nums} (CONCURRENT):{RESET}") + for g in gates: + proto = "https" if g["port"] == 443 else "http" + print( + f" {DIM} Gate {g['num']}: {g['name']} " + f"{proto}://{g['host']}:{g['port']}{RESET}" + ) + print(f" {CYAN}{BOLD}{'=' * 60}{RESET}") + print() + + log( + "INFO", + f"Firing {len(gates)} requests concurrently -- approve them all at once", + ) + log("INFO", "Tip: press [A] in the TUI draft panel to approve all pending") + print() + + remaining_gates = list(gates) + + for attempt in range(1, MAX_RETRIES + 1): + log( + "GATE", + f"Concurrent attempt #{attempt}", + pending=len(remaining_gates), + ) + + # Fire all remaining gates concurrently. + results: dict[int, tuple[str, str]] = {} + threads: list[threading.Thread] = [] + + def _attempt(g: dict) -> None: + results[g["num"]] = attempt_gate(g) + + for g in remaining_gates: + t = threading.Thread(target=_attempt, args=(g,)) + threads.append(t) + t.start() + + for t in threads: + t.join(timeout=30) + + # Process results. + still_blocked = [] + for g in remaining_gates: + num = g["num"] + status, result = results.get(num, ("blocked", "no result")) + + if status == "pass": + log("PASS", f"Gate {num}/{total} UNLOCKED ({g['name']})") + log("FLAG", result) + elif status == "error": + log("WARN", f"Gate {num}/{total} skipped: {result}") + else: + log("FAIL", f"Gate {num}/{total}: {result}") + still_blocked.append(g) + + passed = len(remaining_gates) - len(still_blocked) + if passed > 0: + print() + + if not still_blocked: + return len(gates) + + remaining_gates = still_blocked + + if attempt == 1: + log("WARN", "Approve the recommendations in the TUI to proceed") + log( + "INFO", + "TUI: select sandbox -> [r] drafts -> [A] approve all", + ) + + for secs in range(RETRY_INTERVAL, 0, -1): + print( + f"\r {DIM} retrying in {secs:>2}s ...{RESET}", + end="", + flush=True, + ) + time.sleep(1) + print("\r" + " " * 50 + "\r", end="", flush=True) + + failed = len(remaining_gates) + log("FAIL", f"{failed} gate(s) timed out after {MAX_RETRIES} attempts") + return len(gates) - failed + + +# -- Main CTF loop ------------------------------------------------------------- def run_ctf() -> int: @@ -326,88 +444,35 @@ def run_ctf() -> int: completed = 0 - for gate in GATES: - num = gate["num"] - total = len(GATES) + # Gate 1: single HTTPS endpoint + if not run_gate(GATES[0]): + return 1 + completed += 1 - # Gate header - print(f" {CYAN}{BOLD}{'─' * 60}{RESET}") - print(f" {CYAN}{BOLD} GATE {num}/{total}: {gate['name'].upper()}{RESET}") - proto = "https" if gate["port"] == 443 else "http" - print( - f" {DIM} {gate['hint']}{RESET}\n" - f" {DIM} target: {proto}://{gate['host']}:{gate['port']} " - f"({gate['method']}){RESET}" - ) - print(f" {CYAN}{BOLD}{'─' * 60}{RESET}") - print() + # Gate 2: single HTTP endpoint + if not run_gate(GATES[1]): + return 1 + completed += 1 - # Retry loop - for attempt in range(1, MAX_RETRIES + 1): - log( - "GATE", - f"Gate {num} attempt #{attempt}", - host=gate["host"], - port=gate["port"], - ) + # Gates 3-5: concurrent + completed += run_gates_concurrent(GATES[2:5]) - status, result = attempt_gate(gate) + # Gate 6: internal IP endpoint (allowed_ips SSRF override) + if not run_gate(GATES[5]): + return 1 + completed += 1 - if status == "pass": - log("PASS", f"Gate {num} UNLOCKED") - log("FLAG", result) - completed += 1 - print() - break + # Done + if completed >= len(GATES): + print(VICTORY) + log("INFO", "CTF complete", gates_passed=f"{completed}/{len(GATES)}") + return 0 - if status == "error": - # Real upstream failure — not a proxy block. Skip the gate - # so the demo doesn't stall on a flaky third-party service. - log("WARN", f"Gate {num} skipped: {result}") - log("INFO", "This is an upstream error, not a proxy block") - completed += 1 - print() - break - - # status == "blocked" — proxy denied the connection; retryable. - log("FAIL", f"Gate {num}: {result}") - - if attempt == 1: - log("WARN", "Approve the recommendation in the TUI to proceed") - log( - "INFO", - "TUI: select sandbox -> [p] policy -> [r] drafts -> [a] approve", - ) - - # Countdown before retry - for remaining in range(RETRY_INTERVAL, 0, -1): - print( - f"\r {DIM} retrying in {remaining:>2}s ...{RESET}", - end="", - flush=True, - ) - time.sleep(1) - # Clear the countdown line - print("\r" + " " * 50 + "\r", end="", flush=True) - - else: - log( - "FAIL", - f"Gate {num} timed out after {MAX_RETRIES} attempts", - ) - log( - "WARN", - "Check that the gateway is running and the TUI is connected.", - ) - return 1 - - # All gates passed - print(VICTORY) - log("INFO", "CTF complete", gates_passed=f"{completed}/{len(GATES)}") - return 0 + log("WARN", f"CTF incomplete: {completed}/{len(GATES)} gates passed") + return 1 -# ── Entry point ────────────────────────────────────────────────────────────── +# -- Entry point --------------------------------------------------------------- if __name__ == "__main__": try: