mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-26 06:52:57 +08:00
wip: forward proxy fix, mapper allowed_ips, TUI polish, CTF rewrite
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1484,9 +1484,10 @@ fn rewrite_forward_request(raw: &[u8], used: usize, path: &str) -> Vec<u8> {
|
||||
|
||||
/// 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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<PolicyChunk> {
|
||||
@@ -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<String> = 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<String> {
|
||||
let addr = format!("{host}:{port}");
|
||||
let addrs = match tokio::net::lookup_host(&addr).await {
|
||||
Ok(addrs) => addrs.collect::<Vec<_>>(),
|
||||
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<String> = 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(""));
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+273
-208
@@ -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/<breed>/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:
|
||||
|
||||
Reference in New Issue
Block a user