From 68ee9b9944fc995a6cd9f5264926846f177faaec Mon Sep 17 00:00:00 2001 From: Hai Nguyen Date: Tue, 21 Jul 2026 13:56:17 +0700 Subject: [PATCH] feat(supervisor): deliver bypass detection logs via NFLOG and drop CAP_SYSLOG Bypass detection previously tailed the kernel ring buffer via dmesg, which requires CAP_SYSLOG in the initial user namespace and therefore silently degrades in user-namespaced pods (hostUsers: false). Bind an nfnetlink_log socket inside the workload netns instead and generate "log group " nftables rules; delivery is netns-scoped and covered by namespaced CAP_NET_ADMIN. The printk + dmesg path remains an automatic fallback for kernels without nfnetlink_log, and CAP_SYSLOG is removed from the Kubernetes, Docker, and Podman driver capability sets. Refs #2382 Signed-off-by: Hai Nguyen --- crates/openshell-driver-docker/src/lib.rs | 1 - .../openshell-driver-kubernetes/src/driver.rs | 18 +- crates/openshell-driver-podman/README.md | 5 +- .../openshell-driver-podman/src/container.rs | 11 +- .../src/bypass_monitor/mod.rs | 292 ++++--- .../src/bypass_monitor/nflog.rs | 805 ++++++++++++++++++ .../src/netns/mod.rs | 114 ++- .../src/netns/nft_ruleset.rs | 186 +++- .../openshell-supervisor-process/src/run.rs | 12 +- docs/kubernetes/topology.mdx | 2 +- e2e/rust/tests/user_namespaces.rs | 6 +- 11 files changed, 1272 insertions(+), 180 deletions(-) create mode 100644 crates/openshell-supervisor-process/src/bypass_monitor/nflog.rs diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 2f89c2229e..0d941a23c0 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -2355,7 +2355,6 @@ fn build_container_create_body_with_gpu_devices( "SYS_ADMIN".to_string(), "NET_ADMIN".to_string(), "SYS_PTRACE".to_string(), - "SYSLOG".to_string(), ]), // The sandbox supervisor needs to bind-mount `/run/netns`, // mark it shared, and create per-process network namespaces. diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 5dba71adcc..016fb17440 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -2534,7 +2534,10 @@ fn sandbox_template_to_k8s_with_validated_config( container.insert("env".to_string(), serde_json::Value::Array(env)); - let mut capabilities: Vec<&str> = vec!["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYSLOG"]; + // Bypass-detection log entries arrive over an NFLOG socket (covered by + // NET_ADMIN), so CAP_SYSLOG is not needed. With user namespaces every + // remaining capability is scoped to the pod's user namespace. + let mut capabilities: Vec<&str> = vec!["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE"]; if use_user_namespaces { // In a user namespace the bounding set is reset. SETUID/SETGID are // needed for the supervisor to drop privileges to the sandbox user. @@ -3874,7 +3877,7 @@ mod tests { "image": "custom-image:latest", "securityContext": { "capabilities": { - "add": ["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYSLOG"] + "add": ["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE"] } } }] @@ -5179,8 +5182,12 @@ mod tests { let caps = pod_template["spec"]["containers"][0]["securityContext"]["capabilities"]["add"] .as_array() .unwrap(); - assert_eq!(caps.len(), 4); + assert_eq!(caps.len(), 3); assert!(!caps.contains(&serde_json::json!("SETUID"))); + assert!( + !caps.contains(&serde_json::json!("SYSLOG")), + "CAP_SYSLOG must not be requested; bypass detection uses NFLOG" + ); } #[test] @@ -5202,11 +5209,10 @@ mod tests { assert!(caps.contains(&serde_json::json!("SYS_ADMIN"))); assert!(caps.contains(&serde_json::json!("NET_ADMIN"))); assert!(caps.contains(&serde_json::json!("SYS_PTRACE"))); - assert!(caps.contains(&serde_json::json!("SYSLOG"))); assert!(caps.contains(&serde_json::json!("SETUID"))); assert!(caps.contains(&serde_json::json!("SETGID"))); assert!(caps.contains(&serde_json::json!("DAC_READ_SEARCH"))); - assert_eq!(caps.len(), 7); + assert_eq!(caps.len(), 6); } #[test] @@ -5280,7 +5286,7 @@ mod tests { .unwrap(); assert_eq!( caps.len(), - 4, + 3, "extra capabilities must not be added when user namespaces are disabled" ); } diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index c6a619f3d1..b2cdf537d9 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -42,7 +42,7 @@ The container spec in `container.rs` sets these security-critical fields: |---|---|---| | `user` | `0:0` | The supervisor needs root inside the container for namespace creation, proxy setup, Landlock, seccomp, and filesystem preparation. | | `cap_drop` | Selected unneeded defaults | Podman's default capability set is already restricted. The driver drops capabilities the supervisor does not need. | -| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `SYSLOG`, `DAC_READ_SEARCH`, `SETPCAP` | Grants supervisor-only capabilities required for namespace setup, process identity, bypass diagnostics, and child bounding-set cleanup. | +| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `DAC_READ_SEARCH`, `SETPCAP` | Grants supervisor-only capabilities required for namespace setup, process identity, bypass diagnostics, and child bounding-set cleanup. | | `no_new_privileges` | `true` | Prevents privilege escalation after exec. | | `seccomp_profile_path` | `unconfined` | The supervisor installs its own policy-aware BPF filter. A container-level profile can block Landlock/seccomp syscalls during setup. | | `mounts` | Private tmpfs at `/run/netns` | Lets the supervisor create named network namespaces in rootless Podman. | @@ -96,9 +96,8 @@ openshell sandbox create \ | Capability | Purpose | |---|---| | `SYS_ADMIN` | seccomp filter installation, namespace creation, and Landlock setup. | -| `NET_ADMIN` | Network namespace veth setup, IP address assignment, routes, and nftables. | +| `NET_ADMIN` | Network namespace veth setup, IP address assignment, routes, nftables, and the NFLOG socket that receives bypass-detection log entries. | | `SYS_PTRACE` | Reading `/proc//exe` and walking process ancestry for binary identity. | -| `SYSLOG` | Reading `/dev/kmsg` for bypass-detection diagnostics. | | `DAC_READ_SEARCH` | Reading `/proc//fd/` across UIDs so the proxy can resolve the binary responsible for a connection. | | `SETPCAP` | Clearing the restricted child process capability bounding set before exec. | diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index d38a3b9eb1..0fecd9e3fc 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -932,12 +932,12 @@ pub fn build_container_spec_with_token_and_gpu_devices( cap_add: vec![ // seccomp filter installation, namespace creation, Landlock setup. "SYS_ADMIN".into(), - // Network namespace veth setup, IP/route configuration. + // Network namespace veth setup, IP/route configuration. Also + // covers the NFLOG socket that delivers bypass-detection log + // entries, so CAP_SYSLOG is not needed. "NET_ADMIN".into(), // Reading /proc//exe and ancestor walk for process identity in policy. "SYS_PTRACE".into(), - // Reading /dev/kmsg for bypass-detection diagnostics. - "SYSLOG".into(), // Reading /proc//fd/ across UIDs for process identity resolution. // In rootless Podman the supervisor runs as UID 0 inside a user namespace // while sandbox processes run as the sandbox user. The kernel's @@ -1469,7 +1469,10 @@ mod tests { assert!(added.contains(&"SYS_ADMIN"), "missing SYS_ADMIN"); assert!(added.contains(&"NET_ADMIN"), "missing NET_ADMIN"); assert!(added.contains(&"SYS_PTRACE"), "missing SYS_PTRACE"); - assert!(added.contains(&"SYSLOG"), "missing SYSLOG"); + assert!( + !added.contains(&"SYSLOG"), + "CAP_SYSLOG must not be requested; bypass detection uses NFLOG" + ); assert!( added.contains(&"DAC_READ_SEARCH"), "missing DAC_READ_SEARCH" diff --git a/crates/openshell-supervisor-process/src/bypass_monitor/mod.rs b/crates/openshell-supervisor-process/src/bypass_monitor/mod.rs index 7d09d36f09..66fe0767c2 100644 --- a/crates/openshell-supervisor-process/src/bypass_monitor/mod.rs +++ b/crates/openshell-supervisor-process/src/bypass_monitor/mod.rs @@ -1,21 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Bypass detection monitor — reads kernel log messages from `/dev/kmsg` to -//! detect and report direct connection attempts that bypass the HTTP CONNECT -//! proxy. +//! Bypass detection monitor — reports direct connection attempts that bypass +//! the HTTP CONNECT proxy. //! //! When the sandbox network namespace has nftables log rules installed (see -//! `NetworkNamespace::install_bypass_rules`), the kernel writes a log line for -//! each dropped packet. This module reads those messages, parses the nftables -//! LOG format, and emits structured tracing events + denial aggregator entries. +//! `NetworkNamespace::install_bypass_rules`), each rejected packet produces a +//! log entry. This module consumes those entries and emits structured tracing +//! events + denial aggregator entries. +//! +//! Two delivery backends are supported, preferred in order: +//! +//! 1. **NFLOG** (`log group N` rules + an `nfnetlink_log` socket bound inside +//! the sandbox netns). Needs only `CAP_NET_ADMIN` over the netns owner, so +//! it works in user-namespaced pods. See [`nflog`]. +//! 2. **Kernel ring buffer** (`log` rules + `dmesg --follow`). Needs +//! `CAP_SYSLOG` in the initial user namespace; kept as a fallback for +//! kernels without `nfnetlink_log`. //! //! ## Graceful degradation //! -//! If `/dev/kmsg` cannot be opened (e.g., restricted container environment), -//! the monitor logs a one-time warning and returns. The nftables reject rules -//! still provide fast-fail UX — the monitor only adds diagnostic visibility. +//! If neither backend is available the monitor logs a one-time warning and +//! returns. The nftables reject rules still provide fast-fail UX — the +//! monitor only adds diagnostic visibility. +pub mod nflog; mod procfs; use openshell_core::activity::{ActivitySender, try_record_activity}; @@ -104,24 +113,90 @@ fn hint_for_event(event: &BypassEvent) -> &'static str { } } +/// Shared reporting sink for bypass events, independent of the log backend. +struct BypassReporter { + entrypoint_pid: Arc, + denial_tx: Option>, + activity_tx: Option, +} + /// Spawn the bypass monitor as a background tokio task. /// -/// Uses `dmesg --follow` to tail the kernel ring buffer for nftables log -/// entries matching the given namespace. Falls back gracefully if `dmesg` -/// is not available. -/// -/// We use `dmesg` rather than reading `/dev/kmsg` directly because the -/// container runtime's device cgroup policy blocks direct `/dev/kmsg` access -/// even with `CAP_SYSLOG`. The `dmesg` command reads via the `syslog(2)` -/// syscall which is permitted with `CAP_SYSLOG`. +/// Prefers reading nftables log entries from the NFLOG socket bound during +/// namespace setup (see `NetworkNamespace::install_bypass_rules`). When no +/// socket is available it falls back to tailing the kernel ring buffer with +/// `dmesg --follow`, which requires `CAP_SYSLOG` in the initial user +/// namespace. /// -/// Returns a `JoinHandle` if the monitor was started, or `None` if `dmesg` -/// is not available. +/// Returns a `JoinHandle` if a monitor was started, or `None` when no +/// backend is available. pub fn spawn( namespace_name: String, entrypoint_pid: Arc, denial_tx: Option>, activity_tx: Option, + nflog_socket: Option, +) -> Option> { + let namespace_prefix = format!("openshell:bypass:{namespace_name}:"); + let reporter = BypassReporter { + entrypoint_pid, + denial_tx, + activity_tx, + }; + + if let Some(socket) = nflog_socket { + debug!( + namespace = %namespace_name, + group = socket.group(), + "Starting bypass detection monitor via NFLOG" + ); + return Some(tokio::task::spawn_blocking(move || { + run_nflog_loop(&socket, &namespace_prefix, &reporter); + })); + } + + spawn_dmesg_monitor(namespace_name, namespace_prefix, reporter) +} + +/// Blocking receive loop over the NFLOG socket. +fn run_nflog_loop(socket: &nflog::NflogSocket, namespace_prefix: &str, reporter: &BypassReporter) { + let mut buf = vec![0u8; nflog::RECV_BUFFER_LEN]; + loop { + match socket.recv(&mut buf) { + Ok(len) => { + for event in nflog::parse_datagram(&buf[..len], socket.group(), namespace_prefix) { + reporter.report(&event); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + // The kernel dropped entries because the socket buffer overran. + // Detection continues with the next datagram. + Err(e) if e.raw_os_error() == Some(libc::ENOBUFS) => { + debug!(error = %e, "NFLOG socket overrun; kernel dropped bypass log entries"); + } + Err(e) => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .severity(SeverityId::Low) + .message(format!("NFLOG bypass monitor stopped: {e}")) + .build(); + ocsf_emit!(event); + return; + } + } + } +} + +/// Spawn the ring-buffer fallback monitor. +/// +/// We use `dmesg` rather than reading `/dev/kmsg` directly because the +/// container runtime's device cgroup policy blocks direct `/dev/kmsg` access +/// even with `CAP_SYSLOG`. The `dmesg` command reads via the `syslog(2)` +/// syscall which is permitted with `CAP_SYSLOG`. +fn spawn_dmesg_monitor( + namespace_name: String, + namespace_prefix: String, + reporter: BypassReporter, ) -> Option> { use std::io::BufRead; use std::process::{Command, Stdio}; @@ -146,7 +221,6 @@ pub fn spawn( return None; } - let namespace_prefix = format!("openshell:bypass:{namespace_name}:"); debug!( namespace = %namespace_name, "Starting bypass detection monitor via dmesg --follow" @@ -197,93 +271,7 @@ pub fn spawn( let Some(event) = parse_kmsg_line(&line, &namespace_prefix) else { continue; }; - - // Attempt process identity resolution (best-effort, TCP only). - let pid = entrypoint_pid.load(Ordering::Acquire); - let (binary, binary_pid, ancestors) = - if event.proto == "tcp" && event.src_port > 0 && pid > 0 { - resolve_process_identity(pid, event.src_port) - } else { - ("-".to_string(), "-".to_string(), "-".to_string()) - }; - - let hint = hint_for_event(&event); - let reason = "direct connection bypassed HTTP CONNECT proxy"; - - // Dual-emit: Network Activity [4001] + Detection Finding [2004] - { - let dst_ep = if let Ok(ip) = event.dst_addr.parse::() { - Endpoint::from_ip(ip, event.dst_port) - } else { - Endpoint::from_domain(&event.dst_addr, event.dst_port) - }; - - let net_event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Refuse) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(SeverityId::Medium) - .dst_endpoint(dst_ep.clone()) - .actor_process(Process::from_bypass(&binary, &binary_pid, &ancestors)) - .firewall_rule("bypass-detect", "nftables") - .observation_point(3) - .message(format!( - "BYPASS_DETECT {}:{} proto={} binary={binary} action=reject reason={reason}", - event.dst_addr, event.dst_port, event.proto, - )) - .build(); - ocsf_emit!(net_event); - - let finding_event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Open) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(SeverityId::Medium) - .is_alert(true) - .confidence(ConfidenceId::High) - .finding_info( - FindingInfo::new("bypass-detect", "Proxy Bypass Detected") - .with_desc(reason), - ) - .remediation(hint) - .evidence_pairs(&[ - ("dst_addr", &event.dst_addr), - ("dst_port", &event.dst_port.to_string()), - ("proto", &event.proto), - ("binary", &binary), - ("binary_pid", &binary_pid), - ("ancestors", &ancestors), - ]) - .message(format!( - "BYPASS_DETECT {}:{} proto={} binary={binary} hint={hint}", - event.dst_addr, event.dst_port, event.proto, - )) - .build(); - ocsf_emit!(finding_event); - } - - // Send to denial aggregator if available. - if let Some(ref tx) = denial_tx { - let ancestors_vec: Vec = if ancestors == "-" { - vec![] - } else { - ancestors.split(" -> ").map(String::from).collect() - }; - - let _ = tx.send(DenialEvent { - host: event.dst_addr.clone(), - port: event.dst_port, - binary: binary.clone(), - ancestors: ancestors_vec, - deny_reason: "direct connection bypassed HTTP CONNECT proxy".to_string(), - denial_stage: "bypass".to_string(), - l7_method: None, - l7_path: None, - }); - } - if let Some(ref tx) = activity_tx { - let _ = try_record_activity(tx, true, "bypass"); - } + reporter.report(&event); } // Clean up the dmesg child process. @@ -295,6 +283,96 @@ pub fn spawn( Some(handle) } +impl BypassReporter { + /// Emit OCSF events and denial/activity records for one bypass attempt. + fn report(&self, event: &BypassEvent) { + // Attempt process identity resolution (best-effort, TCP only). + let pid = self.entrypoint_pid.load(Ordering::Acquire); + let (binary, binary_pid, ancestors) = + if event.proto == "tcp" && event.src_port > 0 && pid > 0 { + resolve_process_identity(pid, event.src_port) + } else { + ("-".to_string(), "-".to_string(), "-".to_string()) + }; + + let hint = hint_for_event(event); + let reason = "direct connection bypassed HTTP CONNECT proxy"; + + // Dual-emit: Network Activity [4001] + Detection Finding [2004] + { + let dst_ep = event.dst_addr.parse::().map_or_else( + |_| Endpoint::from_domain(&event.dst_addr, event.dst_port), + |ip| Endpoint::from_ip(ip, event.dst_port), + ); + + let net_event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Refuse) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .dst_endpoint(dst_ep) + .actor_process(Process::from_bypass(&binary, &binary_pid, &ancestors)) + .firewall_rule("bypass-detect", "nftables") + .observation_point(3) + .message(format!( + "BYPASS_DETECT {}:{} proto={} binary={binary} action=reject reason={reason}", + event.dst_addr, event.dst_port, event.proto, + )) + .build(); + ocsf_emit!(net_event); + + let finding_event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .is_alert(true) + .confidence(ConfidenceId::High) + .finding_info( + FindingInfo::new("bypass-detect", "Proxy Bypass Detected").with_desc(reason), + ) + .remediation(hint) + .evidence_pairs(&[ + ("dst_addr", &event.dst_addr), + ("dst_port", &event.dst_port.to_string()), + ("proto", &event.proto), + ("binary", &binary), + ("binary_pid", &binary_pid), + ("ancestors", &ancestors), + ]) + .message(format!( + "BYPASS_DETECT {}:{} proto={} binary={binary} hint={hint}", + event.dst_addr, event.dst_port, event.proto, + )) + .build(); + ocsf_emit!(finding_event); + } + + // Send to denial aggregator if available. + if let Some(ref tx) = self.denial_tx { + let ancestors_vec: Vec = if ancestors == "-" { + vec![] + } else { + ancestors.split(" -> ").map(String::from).collect() + }; + + let _ = tx.send(DenialEvent { + host: event.dst_addr.clone(), + port: event.dst_port, + binary, + ancestors: ancestors_vec, + deny_reason: "direct connection bypassed HTTP CONNECT proxy".to_string(), + denial_stage: "bypass".to_string(), + l7_method: None, + l7_path: None, + }); + } + if let Some(ref tx) = self.activity_tx { + let _ = try_record_activity(tx, true, "bypass"); + } + } +} + /// Resolve process identity from a TCP source port. /// /// Returns `(binary_path, pid, ancestors)` as display strings. diff --git a/crates/openshell-supervisor-process/src/bypass_monitor/nflog.rs b/crates/openshell-supervisor-process/src/bypass_monitor/nflog.rs new file mode 100644 index 0000000000..896629d78e --- /dev/null +++ b/crates/openshell-supervisor-process/src/bypass_monitor/nflog.rs @@ -0,0 +1,805 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal `nfnetlink_log` (NFLOG) client for bypass detection. +//! +//! Receives nftables `log group ` entries over an `AF_NETLINK` / +//! `NETLINK_NETFILTER` socket instead of the kernel ring buffer. NFLOG +//! delivery is scoped to the network namespace the socket was created in and +//! requires only `CAP_NET_ADMIN` over that namespace's owning user namespace, +//! so it works in user-namespaced pods (`hostUsers: false`) where reading the +//! ring buffer via `syslog(2)` would need `CAP_SYSLOG` in the initial user +//! namespace. +//! +//! The wire protocol is stable kernel ABI (`linux/netfilter/nfnetlink_log.h`). +//! Only the small subset needed here is implemented: group bind, copy-mode +//! config, and packet-message parsing. Message construction and parsing are +//! pure functions with unit tests; only the socket syscalls are unsafe FFI. + +use super::BypassEvent; +use std::io; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use tracing::debug; + +/// NFLOG group used for sandbox bypass log rules. +/// +/// Arbitrary but must match between the nftables `log group ` rules and +/// the listener socket. The group namespace is per-netns, so collisions with +/// other tools cannot occur inside the dedicated sandbox namespace. +pub const BYPASS_NFLOG_GROUP: u16 = 1; + +/// Bytes of each logged packet copied to userspace. Large enough for any +/// IPv4/IPv6 header plus L4 ports; the monitor only reads addresses, ports, +/// and protocol. +const COPY_RANGE_BYTES: u32 = 256; + +/// Receive buffer for netlink datagrams. The kernel's default per-instance +/// buffer (`NFULNL_NLBUFSIZ_DEFAULT`) is 4096; double it so batched messages +/// are never truncated. +pub const RECV_BUFFER_LEN: usize = 8192; + +/// Timeout for config-request acks during socket setup. Prevents a hang when +/// talking to a kernel that accepts the message but never acks. +const CONFIG_ACK_TIMEOUT_SECS: u64 = 2; + +// --- Netlink / nfnetlink constants (linux/netlink.h, linux/netfilter/nfnetlink.h, +// linux/netfilter/nfnetlink_log.h). Stable kernel ABI. --- + +const NFNL_SUBSYS_ULOG: u16 = 4; +const NFULNL_MSG_PACKET: u16 = 0; +const NFULNL_MSG_CONFIG: u16 = 1; + +const NFULNL_CFG_CMD_BIND: u8 = 1; +const NFULNL_COPY_PACKET: u8 = 2; + +const NFULA_CFG_CMD: u16 = 1; +const NFULA_CFG_MODE: u16 = 2; + +const NFULA_PAYLOAD: u16 = 9; +const NFULA_PREFIX: u16 = 10; +const NFULA_UID: u16 = 11; + +const NFNETLINK_V0: u8 = 0; +/// `nfgenmsg` family for group config messages (`AF_UNSPEC`). +const NFGEN_FAMILY_UNSPEC: u8 = 0; + +/// Mask for the attribute type field (`NLA_TYPE_MASK`): strips the +/// `NLA_F_NESTED` / `NLA_F_NET_BYTEORDER` flag bits. +const NLA_TYPE_MASK: u16 = 0x3fff; + +const NLMSG_HDR_LEN: usize = 16; +const NFGENMSG_LEN: usize = 4; +const NLATTR_HDR_LEN: usize = 4; +/// Netlink message and attribute payloads align to 4 bytes (`NLMSG_ALIGNTO`). +const NL_ALIGN: usize = 4; + +/// `NLMSG_ERROR` message type: carries acks (code 0) and errors (-errno). +const NLMSG_TYPE_ERROR: u16 = 2; +/// `NLM_F_REQUEST` header flag. +const NLM_F_REQUEST: u16 = 1; +/// `NLM_F_ACK` header flag: ask the kernel to confirm the request. +const NLM_F_ACK: u16 = 4; + +/// `nlmsg_type` for NFLOG packet messages: subsystem in the high byte. +const NFLOG_PACKET_MSG_TYPE: u16 = (NFNL_SUBSYS_ULOG << 8) | NFULNL_MSG_PACKET; +const NFLOG_CONFIG_MSG_TYPE: u16 = (NFNL_SUBSYS_ULOG << 8) | NFULNL_MSG_CONFIG; + +const IPV4_MIN_HDR_LEN: usize = 20; +const IPV6_HDR_LEN: usize = 40; +const L4_PORTS_LEN: usize = 4; +const IPPROTO_TCP_NUM: u8 = 6; +const IPPROTO_UDP_NUM: u8 = 17; + +/// An NFLOG listener socket bound to a group inside a network namespace. +/// +/// The netns association is fixed at `socket(2)` time, so the socket keeps +/// receiving from the sandbox namespace regardless of which namespace the +/// reading thread is in. +#[derive(Debug)] +pub struct NflogSocket { + fd: OwnedFd, + group: u16, +} + +impl NflogSocket { + /// Open and configure an NFLOG socket inside the network namespace + /// referred to by `ns_fd`. + /// + /// Spawns a short-lived thread that enters the namespace via `setns`, + /// creates the socket, binds the group, and sets packet copy mode. The + /// thread exits immediately after; using a dedicated thread avoids + /// changing the namespace of any long-lived thread. + /// + /// # Errors + /// + /// Returns an error when `setns` fails, the socket cannot be created + /// (e.g. seccomp or missing `nfnetlink_log`), or the kernel rejects the + /// group bind (e.g. missing `CAP_NET_ADMIN` over the netns owner). + pub fn open_in_netns(ns_fd: RawFd, group: u16) -> io::Result { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = (|| -> io::Result { + // SAFETY: setns on a dedicated thread that exits right after; + // no thread-pool namespace contamination. + // libc/syscall FFI requires unsafe + #[allow(unsafe_code)] + let rc = unsafe { libc::setns(ns_fd, libc::CLONE_NEWNET) }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + Self::open_in_current_netns(group) + })(); + let _ = tx.send(result); + }); + rx.recv() + .map_err(|_| io::Error::other("nflog netns setup thread panicked"))? + } + + /// Open and configure an NFLOG socket in the current network namespace. + /// + /// # Errors + /// + /// Returns an error when socket creation, bind, or group configuration + /// fails. + // sa_family_t / socklen_t narrowing casts of fixed libc constants. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + pub fn open_in_current_netns(group: u16) -> io::Result { + // libc/syscall FFI requires unsafe + #[allow(unsafe_code)] + let raw_fd = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_RAW | libc::SOCK_CLOEXEC, + libc::NETLINK_NETFILTER, + ) + }; + if raw_fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: raw_fd was just returned by socket() and is owned here. + // libc/syscall FFI requires unsafe + #[allow(unsafe_code)] + let fd = unsafe { OwnedFd::from_raw_fd(raw_fd) }; + + // SAFETY: sockaddr_nl is plain-old-data; zeroed then initialized. + // libc/syscall FFI requires unsafe + #[allow(unsafe_code)] + unsafe { + let mut addr: libc::sockaddr_nl = std::mem::zeroed(); + addr.nl_family = libc::AF_NETLINK as libc::sa_family_t; + let rc = libc::bind( + fd.as_raw_fd(), + std::ptr::addr_of!(addr).cast::(), + size_of::() as libc::socklen_t, + ); + if rc != 0 { + return Err(io::Error::last_os_error()); + } + } + + let socket = Self { fd, group }; + socket.set_recv_timeout(CONFIG_ACK_TIMEOUT_SECS)?; + + // Bind the group first, then set copy mode. Sent as two acked + // requests, mirroring libnetfilter_log. Group-targeted `log group N` + // rules deliver without a protocol-family bind, so PF_BIND is not + // needed. + let mut seq: u32 = 0; + seq += 1; + socket.config_request(seq, &build_bind_group_msg(seq, group))?; + seq += 1; + socket.config_request(seq, &build_copy_mode_msg(seq, group, COPY_RANGE_BYTES))?; + + // Back to fully blocking reads for the monitor loop. + socket.set_recv_timeout(0)?; + debug!(group, "NFLOG socket configured"); + Ok(socket) + } + + /// The NFLOG group this socket is bound to. + #[must_use] + pub const fn group(&self) -> u16 { + self.group + } + + /// Receive one netlink datagram. Blocks until data arrives. + /// + /// # Errors + /// + /// Returns the underlying socket error; `ENOBUFS` indicates the kernel + /// dropped messages under load and reading may continue. + pub fn recv(&self, buf: &mut [u8]) -> io::Result { + // libc/syscall FFI requires unsafe + #[allow(unsafe_code)] + let n = unsafe { + libc::recv( + self.fd.as_raw_fd(), + buf.as_mut_ptr().cast::(), + buf.len(), + 0, + ) + }; + if n < 0 { + return Err(io::Error::last_os_error()); + } + // n is non-negative here, so the cast cannot lose the sign. + #[allow(clippy::cast_sign_loss)] + let received = n as usize; + Ok(received) + } + + /// Send a config request and wait for its netlink ack. + fn config_request(&self, seq: u32, msg: &[u8]) -> io::Result<()> { + // libc/syscall FFI requires unsafe + #[allow(unsafe_code)] + let n = unsafe { + libc::send( + self.fd.as_raw_fd(), + msg.as_ptr().cast::(), + msg.len(), + 0, + ) + }; + if n < 0 { + return Err(io::Error::last_os_error()); + } + + let mut buf = [0u8; RECV_BUFFER_LEN]; + let received = self.recv(&mut buf)?; + parse_config_ack(&buf[..received], seq) + } + + /// Set `SO_RCVTIMEO`; zero seconds means blocking (no timeout). + // timeval / socklen_t narrowing casts of small fixed values. + #[allow(clippy::cast_possible_truncation)] + fn set_recv_timeout(&self, secs: u64) -> io::Result<()> { + #[allow(clippy::cast_possible_wrap)] + let tv = libc::timeval { + tv_sec: secs as libc::time_t, + tv_usec: 0, + }; + // libc/syscall FFI requires unsafe + #[allow(unsafe_code)] + let rc = unsafe { + libc::setsockopt( + self.fd.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_RCVTIMEO, + std::ptr::addr_of!(tv).cast::(), + size_of::() as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } +} + +const fn align_nl(len: usize) -> usize { + len.div_ceil(NL_ALIGN) * NL_ALIGN +} + +/// Append one netlink attribute (header + payload + alignment padding). +// Attribute payloads here are a handful of bytes; the u16 length cannot truncate. +#[allow(clippy::cast_possible_truncation)] +fn push_attr(buf: &mut Vec, attr_type: u16, payload: &[u8]) { + let attr_len = (NLATTR_HDR_LEN + payload.len()) as u16; + buf.extend_from_slice(&attr_len.to_ne_bytes()); + buf.extend_from_slice(&attr_type.to_ne_bytes()); + buf.extend_from_slice(payload); + buf.resize(align_nl(buf.len()), 0); +} + +/// Build an `NFULNL_MSG_CONFIG` request: nlmsghdr + nfgenmsg + attributes. +// Config messages are tens of bytes; the u32 length cannot truncate. +#[allow(clippy::cast_possible_truncation)] +fn build_config_msg(seq: u32, group: u16, attrs: &[(u16, Vec)]) -> Vec { + let mut buf = Vec::with_capacity(64); + // nlmsghdr; nlmsg_len patched at the end. + buf.extend_from_slice(&0u32.to_ne_bytes()); + buf.extend_from_slice(&NFLOG_CONFIG_MSG_TYPE.to_ne_bytes()); + buf.extend_from_slice(&(NLM_F_REQUEST | NLM_F_ACK).to_ne_bytes()); + buf.extend_from_slice(&seq.to_ne_bytes()); + buf.extend_from_slice(&0u32.to_ne_bytes()); // nlmsg_pid: kernel fills sender + + // nfgenmsg { family, version, res_id (big-endian group) } + buf.push(NFGEN_FAMILY_UNSPEC); + buf.push(NFNETLINK_V0); + buf.extend_from_slice(&group.to_be_bytes()); + + for (attr_type, payload) in attrs { + push_attr(&mut buf, *attr_type, payload); + } + + let total = buf.len() as u32; + buf[..4].copy_from_slice(&total.to_ne_bytes()); + buf +} + +/// Config message binding this socket to an NFLOG group. +fn build_bind_group_msg(seq: u32, group: u16) -> Vec { + // struct nfulnl_msg_config_cmd { __u8 command; } + build_config_msg(seq, group, &[(NFULA_CFG_CMD, vec![NFULNL_CFG_CMD_BIND])]) +} + +/// Config message setting packet copy mode and range for the group. +fn build_copy_mode_msg(seq: u32, group: u16, copy_range: u32) -> Vec { + // struct nfulnl_msg_config_mode { __be32 copy_range; __u8 copy_mode; __u8 _pad; } + let mut mode = Vec::with_capacity(6); + mode.extend_from_slice(©_range.to_be_bytes()); + mode.push(NFULNL_COPY_PACKET); + mode.push(0); + build_config_msg(seq, group, &[(NFULA_CFG_MODE, mode)]) +} + +/// Parse the netlink ack for a config request with sequence `expected_seq`. +/// +/// An `NLMSG_ERROR` message with error code 0 is a positive ack; a negative +/// code is `-errno` from the kernel. +fn parse_config_ack(datagram: &[u8], expected_seq: u32) -> io::Result<()> { + for (header, payload) in NetlinkMessages::new(datagram) { + if header.msg_type != NLMSG_TYPE_ERROR || header.seq != expected_seq { + continue; + } + if payload.len() < 4 { + return Err(io::Error::other("truncated netlink ack")); + } + let code = i32::from_ne_bytes([payload[0], payload[1], payload[2], payload[3]]); + if code == 0 { + return Ok(()); + } + return Err(io::Error::from_raw_os_error(-code)); + } + Err(io::Error::other( + "no netlink ack received for NFLOG config request", + )) +} + +/// Parsed netlink message header fields used by this module. +struct NlMsgHeader { + msg_type: u16, + seq: u32, +} + +/// Iterator over the netlink messages in one datagram. +struct NetlinkMessages<'a> { + data: &'a [u8], + offset: usize, +} + +impl<'a> NetlinkMessages<'a> { + const fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } +} + +impl<'a> Iterator for NetlinkMessages<'a> { + type Item = (NlMsgHeader, &'a [u8]); + + fn next(&mut self) -> Option { + let remaining = &self.data[self.offset.min(self.data.len())..]; + if remaining.len() < NLMSG_HDR_LEN { + return None; + } + let msg_len = + u32::from_ne_bytes([remaining[0], remaining[1], remaining[2], remaining[3]]) as usize; + if msg_len < NLMSG_HDR_LEN || msg_len > remaining.len() { + return None; + } + let header = NlMsgHeader { + msg_type: u16::from_ne_bytes([remaining[4], remaining[5]]), + seq: u32::from_ne_bytes([remaining[8], remaining[9], remaining[10], remaining[11]]), + }; + let payload = &remaining[NLMSG_HDR_LEN..msg_len]; + self.offset += align_nl(msg_len); + Some((header, payload)) + } +} + +/// Iterator over netlink attributes in a message payload. +struct NlAttrs<'a> { + data: &'a [u8], + offset: usize, +} + +impl<'a> NlAttrs<'a> { + const fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } +} + +impl<'a> Iterator for NlAttrs<'a> { + type Item = (u16, &'a [u8]); + + fn next(&mut self) -> Option { + let remaining = &self.data[self.offset.min(self.data.len())..]; + if remaining.len() < NLATTR_HDR_LEN { + return None; + } + let attr_len = u16::from_ne_bytes([remaining[0], remaining[1]]) as usize; + if attr_len < NLATTR_HDR_LEN || attr_len > remaining.len() { + return None; + } + let attr_type = u16::from_ne_bytes([remaining[2], remaining[3]]) & NLA_TYPE_MASK; + let payload = &remaining[NLATTR_HDR_LEN..attr_len]; + self.offset += align_nl(attr_len); + Some((attr_type, payload)) + } +} + +/// Parse every NFLOG packet message in a datagram into bypass events. +/// +/// Filters on the NFLOG `group` (nfgenmsg `res_id`) and on the log rule prefix +/// so entries from unrelated rules are ignored, mirroring the namespace +/// prefix check in the ring-buffer path. Malformed messages are skipped, not +/// errors: the socket only receives from rules the supervisor installed. +#[must_use] +pub fn parse_datagram(datagram: &[u8], group: u16, expected_prefix: &str) -> Vec { + let mut events = Vec::new(); + for (header, payload) in NetlinkMessages::new(datagram) { + if header.msg_type != NFLOG_PACKET_MSG_TYPE || payload.len() < NFGENMSG_LEN { + continue; + } + let res_id = u16::from_be_bytes([payload[2], payload[3]]); + if res_id != group { + continue; + } + if let Some(event) = parse_packet_attrs(&payload[NFGENMSG_LEN..], expected_prefix) { + events.push(event); + } + } + events +} + +/// Extract a bypass event from one packet message's attributes. +fn parse_packet_attrs(attrs: &[u8], expected_prefix: &str) -> Option { + let mut prefix: Option<&str> = None; + let mut uid: Option = None; + let mut ip_payload: Option<&[u8]> = None; + + for (attr_type, payload) in NlAttrs::new(attrs) { + match attr_type { + NFULA_PREFIX => { + let trimmed = payload.strip_suffix(&[0]).unwrap_or(payload); + prefix = std::str::from_utf8(trimmed).ok(); + } + NFULA_UID if payload.len() >= 4 => { + uid = Some(u32::from_be_bytes([ + payload[0], payload[1], payload[2], payload[3], + ])); + } + NFULA_PAYLOAD => ip_payload = Some(payload), + _ => {} + } + } + + if prefix != Some(expected_prefix) { + return None; + } + let (dst_addr, dst_port, src_port, proto) = parse_ip_packet(ip_payload?)?; + Some(BypassEvent { + dst_addr, + dst_port, + src_port, + proto, + uid, + }) +} + +/// Parse destination address, ports, and protocol from a raw IP packet. +/// +/// Handles IPv4 (any header length) and IPv6 (fixed header; the bypass rules +/// only log plain TCP SYN and UDP, so extension-header chains are not +/// expected). Returns `None` for anything else rather than guessing. +fn parse_ip_packet(packet: &[u8]) -> Option<(String, u16, u16, String)> { + let version = packet.first()? >> 4; + match version { + 4 => { + if packet.len() < IPV4_MIN_HDR_LEN { + return None; + } + let header_len = usize::from(packet[0] & 0x0f) * 4; + if header_len < IPV4_MIN_HDR_LEN || packet.len() < header_len + L4_PORTS_LEN { + return None; + } + let proto = l4_proto_name(packet[9])?; + let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]); + let (src_port, dst_port) = parse_l4_ports(&packet[header_len..]); + Some((dst.to_string(), dst_port, src_port, proto)) + } + 6 => { + if packet.len() < IPV6_HDR_LEN + L4_PORTS_LEN { + return None; + } + let proto = l4_proto_name(packet[6])?; + let dst_octets: [u8; 16] = packet[24..40].try_into().ok()?; + let dst = Ipv6Addr::from(dst_octets); + let (src_port, dst_port) = parse_l4_ports(&packet[IPV6_HDR_LEN..]); + Some((dst.to_string(), dst_port, src_port, proto)) + } + _ => None, + } +} + +fn l4_proto_name(proto: u8) -> Option { + match proto { + IPPROTO_TCP_NUM => Some("tcp".to_string()), + IPPROTO_UDP_NUM => Some("udp".to_string()), + _ => None, + } +} + +/// Source and destination ports share the same offsets in TCP and UDP. +fn parse_l4_ports(l4: &[u8]) -> (u16, u16) { + ( + u16::from_be_bytes([l4[0], l4[1]]), + u16::from_be_bytes([l4[2], l4[3]]), + ) +} + +#[cfg(test)] +// Test fixtures cast fixed libc constants and small buffer lengths. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +mod tests { + use super::*; + + const TEST_PREFIX: &str = "openshell:bypass:sandbox-abcd1234:"; + + /// Build a synthetic NFLOG packet message datagram like the kernel emits. + fn build_packet_msg( + group: u16, + family: u8, + prefix: &str, + uid: Option, + ip_payload: &[u8], + ) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&0u32.to_ne_bytes()); + buf.extend_from_slice(&NFLOG_PACKET_MSG_TYPE.to_ne_bytes()); + buf.extend_from_slice(&0u16.to_ne_bytes()); + buf.extend_from_slice(&0u32.to_ne_bytes()); // seq + buf.extend_from_slice(&0u32.to_ne_bytes()); // pid + buf.push(family); + buf.push(NFNETLINK_V0); + buf.extend_from_slice(&group.to_be_bytes()); + + let mut prefix_z = prefix.as_bytes().to_vec(); + prefix_z.push(0); + push_attr(&mut buf, NFULA_PREFIX, &prefix_z); + if let Some(uid) = uid { + push_attr(&mut buf, NFULA_UID, &uid.to_be_bytes()); + } + push_attr(&mut buf, NFULA_PAYLOAD, ip_payload); + + let total = buf.len() as u32; + buf[..4].copy_from_slice(&total.to_ne_bytes()); + buf + } + + /// Minimal IPv4 packet: src 10.200.0.2 -> dst, with TCP/UDP ports. + fn build_ipv4_packet(proto: u8, dst: [u8; 4], src_port: u16, dst_port: u16) -> Vec { + let mut pkt = vec![0u8; IPV4_MIN_HDR_LEN + 8]; + pkt[0] = 0x45; // version 4, IHL 5 + pkt[9] = proto; + pkt[12..16].copy_from_slice(&[10, 200, 0, 2]); + pkt[16..20].copy_from_slice(&dst); + pkt[20..22].copy_from_slice(&src_port.to_be_bytes()); + pkt[22..24].copy_from_slice(&dst_port.to_be_bytes()); + pkt + } + + fn build_ipv6_packet(proto: u8, dst: [u8; 16], src_port: u16, dst_port: u16) -> Vec { + let mut pkt = vec![0u8; IPV6_HDR_LEN + 8]; + pkt[0] = 0x60; // version 6 + pkt[6] = proto; + pkt[24..40].copy_from_slice(&dst); + pkt[40..42].copy_from_slice(&src_port.to_be_bytes()); + pkt[42..44].copy_from_slice(&dst_port.to_be_bytes()); + pkt + } + + #[test] + fn parses_ipv4_tcp_bypass_event() { + let pkt = build_ipv4_packet(IPPROTO_TCP_NUM, [93, 184, 216, 34], 48012, 443); + let datagram = build_packet_msg( + BYPASS_NFLOG_GROUP, + libc::AF_INET as u8, + TEST_PREFIX, + Some(1000), + &pkt, + ); + + let events = parse_datagram(&datagram, BYPASS_NFLOG_GROUP, TEST_PREFIX); + assert_eq!(events.len(), 1); + assert_eq!(events[0].dst_addr, "93.184.216.34"); + assert_eq!(events[0].dst_port, 443); + assert_eq!(events[0].src_port, 48012); + assert_eq!(events[0].proto, "tcp"); + assert_eq!(events[0].uid, Some(1000)); + } + + #[test] + fn parses_ipv4_udp_dns_bypass_event() { + let pkt = build_ipv4_packet(IPPROTO_UDP_NUM, [8, 8, 8, 8], 53421, 53); + let datagram = build_packet_msg( + BYPASS_NFLOG_GROUP, + libc::AF_INET as u8, + TEST_PREFIX, + Some(1000), + &pkt, + ); + + let events = parse_datagram(&datagram, BYPASS_NFLOG_GROUP, TEST_PREFIX); + assert_eq!(events.len(), 1); + assert_eq!(events[0].dst_addr, "8.8.8.8"); + assert_eq!(events[0].dst_port, 53); + assert_eq!(events[0].proto, "udp"); + } + + #[test] + fn parses_ipv6_tcp_bypass_event() { + let dst = [ + 0x20, 0x01, 0x48, 0x60, 0x48, 0x60, 0, 0, 0, 0, 0, 0, 0, 0, 0x88, 0x88, + ]; + let pkt = build_ipv6_packet(IPPROTO_TCP_NUM, dst, 55555, 443); + let datagram = build_packet_msg( + BYPASS_NFLOG_GROUP, + libc::AF_INET6 as u8, + TEST_PREFIX, + None, + &pkt, + ); + + let events = parse_datagram(&datagram, BYPASS_NFLOG_GROUP, TEST_PREFIX); + assert_eq!(events.len(), 1); + assert_eq!(events[0].dst_addr, "2001:4860:4860::8888"); + assert_eq!(events[0].dst_port, 443); + assert_eq!(events[0].uid, None); + } + + #[test] + fn wrong_prefix_is_ignored() { + let pkt = build_ipv4_packet(IPPROTO_TCP_NUM, [1, 2, 3, 4], 1111, 80); + let datagram = build_packet_msg( + BYPASS_NFLOG_GROUP, + libc::AF_INET as u8, + "openshell:bypass:sandbox-other:", + None, + &pkt, + ); + assert!(parse_datagram(&datagram, BYPASS_NFLOG_GROUP, TEST_PREFIX).is_empty()); + } + + #[test] + fn wrong_group_is_ignored() { + let pkt = build_ipv4_packet(IPPROTO_TCP_NUM, [1, 2, 3, 4], 1111, 80); + let datagram = build_packet_msg( + BYPASS_NFLOG_GROUP + 1, + libc::AF_INET as u8, + TEST_PREFIX, + None, + &pkt, + ); + assert!(parse_datagram(&datagram, BYPASS_NFLOG_GROUP, TEST_PREFIX).is_empty()); + } + + #[test] + fn multiple_messages_in_one_datagram() { + let tcp = build_ipv4_packet(IPPROTO_TCP_NUM, [1, 1, 1, 1], 1000, 443); + let udp = build_ipv4_packet(IPPROTO_UDP_NUM, [8, 8, 4, 4], 2000, 53); + let mut datagram = build_packet_msg( + BYPASS_NFLOG_GROUP, + libc::AF_INET as u8, + TEST_PREFIX, + None, + &tcp, + ); + datagram.extend(build_packet_msg( + BYPASS_NFLOG_GROUP, + libc::AF_INET as u8, + TEST_PREFIX, + None, + &udp, + )); + + let events = parse_datagram(&datagram, BYPASS_NFLOG_GROUP, TEST_PREFIX); + assert_eq!(events.len(), 2); + assert_eq!(events[0].proto, "tcp"); + assert_eq!(events[1].proto, "udp"); + } + + #[test] + fn truncated_datagram_does_not_panic() { + let pkt = build_ipv4_packet(IPPROTO_TCP_NUM, [1, 2, 3, 4], 1111, 80); + let datagram = build_packet_msg( + BYPASS_NFLOG_GROUP, + libc::AF_INET as u8, + TEST_PREFIX, + None, + &pkt, + ); + for len in 0..datagram.len() { + let _ = parse_datagram(&datagram[..len], BYPASS_NFLOG_GROUP, TEST_PREFIX); + } + } + + #[test] + fn non_tcp_udp_protocol_is_skipped() { + // ICMP (protocol 1) should not produce an event. + let pkt = build_ipv4_packet(1, [1, 2, 3, 4], 0, 0); + let datagram = build_packet_msg( + BYPASS_NFLOG_GROUP, + libc::AF_INET as u8, + TEST_PREFIX, + None, + &pkt, + ); + assert!(parse_datagram(&datagram, BYPASS_NFLOG_GROUP, TEST_PREFIX).is_empty()); + } + + #[test] + fn bind_group_msg_layout() { + let msg = build_bind_group_msg(7, 1); + // nlmsg_len covers the whole message. + assert_eq!( + u32::from_ne_bytes([msg[0], msg[1], msg[2], msg[3]]) as usize, + msg.len() + ); + assert_eq!(u16::from_ne_bytes([msg[4], msg[5]]), NFLOG_CONFIG_MSG_TYPE); + assert_eq!( + u16::from_ne_bytes([msg[6], msg[7]]), + NLM_F_REQUEST | NLM_F_ACK + ); + assert_eq!(u32::from_ne_bytes([msg[8], msg[9], msg[10], msg[11]]), 7); + // nfgenmsg res_id is the group in big-endian. + assert_eq!(u16::from_be_bytes([msg[18], msg[19]]), 1); + // First attribute is NFULA_CFG_CMD with the BIND command byte. + assert_eq!(u16::from_ne_bytes([msg[22], msg[23]]), NFULA_CFG_CMD); + assert_eq!(msg[24], NFULNL_CFG_CMD_BIND); + } + + #[test] + fn copy_mode_msg_layout() { + let msg = build_copy_mode_msg(8, 1, COPY_RANGE_BYTES); + assert_eq!(u16::from_ne_bytes([msg[22], msg[23]]), NFULA_CFG_MODE); + // copy_range is big-endian, followed by the copy mode byte. + assert_eq!( + u32::from_be_bytes([msg[24], msg[25], msg[26], msg[27]]), + COPY_RANGE_BYTES + ); + assert_eq!(msg[28], NFULNL_COPY_PACKET); + } + + #[test] + fn config_ack_success() { + let ack = build_error_msg(9, 0); + assert!(parse_config_ack(&ack, 9).is_ok()); + } + + #[test] + fn config_ack_eperm() { + let ack = build_error_msg(9, -libc::EPERM); + let err = parse_config_ack(&ack, 9).unwrap_err(); + assert_eq!(err.raw_os_error(), Some(libc::EPERM)); + } + + #[test] + fn config_ack_missing() { + assert!(parse_config_ack(&[], 9).is_err()); + } + + fn build_error_msg(seq: u32, code: i32) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&0u32.to_ne_bytes()); + buf.extend_from_slice(&NLMSG_TYPE_ERROR.to_ne_bytes()); + buf.extend_from_slice(&0u16.to_ne_bytes()); + buf.extend_from_slice(&seq.to_ne_bytes()); + buf.extend_from_slice(&0u32.to_ne_bytes()); + buf.extend_from_slice(&code.to_ne_bytes()); + let total = buf.len() as u32; + buf[..4].copy_from_slice(&total.to_ne_bytes()); + buf + } +} diff --git a/crates/openshell-supervisor-process/src/netns/mod.rs b/crates/openshell-supervisor-process/src/netns/mod.rs index 44e9470931..6c3665a216 100644 --- a/crates/openshell-supervisor-process/src/netns/mod.rs +++ b/crates/openshell-supervisor-process/src/netns/mod.rs @@ -46,6 +46,9 @@ pub struct NetworkNamespace { sandbox_ip: IpAddr, /// File descriptor for the namespace (for setns) ns_fd: Option, + /// NFLOG listener bound during `install_bypass_rules`, held until the + /// bypass monitor takes it. `None` when the ring-buffer fallback is used. + nflog_socket: std::sync::Mutex>, } impl NetworkNamespace { @@ -184,6 +187,7 @@ impl NetworkNamespace { host_ip, sandbox_ip, ns_fd, + nflog_socket: std::sync::Mutex::new(None), }) } @@ -255,8 +259,9 @@ impl NetworkNamespace { /// This provides two benefits: /// - **Fast-fail UX**: applications get immediate ECONNREFUSED instead of /// a 30-second timeout when they bypass the proxy - /// - **Diagnostics**: nftables LOG entries are picked up by the bypass - /// monitor to emit structured tracing events + /// - **Diagnostics**: nftables log entries (NFLOG, or the kernel ring + /// buffer as fallback) are picked up by the bypass monitor to emit + /// structured tracing events /// /// Degrades gracefully if `nft` is not available — the namespace /// still provides isolation via routing, just without fast-fail and @@ -280,13 +285,25 @@ impl NetworkNamespace { let host_ip_str = self.host_ip.to_string(); let log_prefix = format!("openshell:bypass:{}:", &self.name); - // The kernel's nf_log_syslog module suppresses log output from - // non-init network namespaces by default. Enable it so the bypass - // monitor can see log entries from the sandbox namespace. - enable_nf_log_all_netns(); + // Prefer NFLOG delivery: it is scoped to the sandbox netns and + // reading it needs only CAP_NET_ADMIN over the namespace owner. The + // kernel ring buffer fallback requires CAP_SYSLOG in the initial + // user namespace, which user-namespaced pods cannot use. + let log_backend = self.bind_nflog_backend(); + + if matches!(log_backend, nft_ruleset::LogBackend::Ringbuffer) { + // The kernel's nf_log_syslog module suppresses log output from + // non-init network namespaces by default. Enable it so the bypass + // monitor can see log entries from the sandbox namespace. + enable_nf_log_all_netns(); + } + let log_spec = nft_ruleset::LogSpec { + prefix: &log_prefix, + backend: log_backend, + }; let commands = - nft_ruleset::generate_bypass_commands(&host_ip_str, proxy_port, Some(&log_prefix)); + nft_ruleset::generate_bypass_commands(&host_ip_str, proxy_port, Some(&log_spec)); if let Err(e) = run_nft_commands_netns(&self.name, &nft_path, &commands) { openshell_ocsf::ocsf_emit!( @@ -318,6 +335,74 @@ impl NetworkNamespace { Ok(()) } + /// Bind an NFLOG listener inside the namespace and return the backend + /// the bypass log rules should target. + /// + /// On success the socket is parked on the namespace handle until the + /// bypass monitor takes it via [`Self::take_nflog_socket`]. Any failure + /// selects the ring-buffer backend so behavior degrades to the previous + /// `dmesg`-based monitoring. + fn bind_nflog_backend(&self) -> nft_ruleset::LogBackend { + use crate::bypass_monitor::nflog::{BYPASS_NFLOG_GROUP, NflogSocket}; + use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit}; + + let Some(ns_fd) = self.ns_fd else { + debug!( + namespace = %self.name, + "No namespace fd available; using ring-buffer bypass logging" + ); + return nft_ruleset::LogBackend::Ringbuffer; + }; + + match NflogSocket::open_in_netns(ns_fd, BYPASS_NFLOG_GROUP) { + Ok(socket) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "nflog") + .message(format!( + "Bypass log backend: NFLOG group {BYPASS_NFLOG_GROUP} [ns:{}]", + self.name + )) + .build() + ); + if let Ok(mut slot) = self.nflog_socket.lock() { + *slot = Some(socket); + } + nft_ruleset::LogBackend::Nflog { + group: BYPASS_NFLOG_GROUP, + } + } + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .state(StateId::Enabled, "ringbuffer") + .message(format!( + "NFLOG unavailable ({e}); bypass logging falls back to the \ + kernel ring buffer, which requires CAP_SYSLOG to read [ns:{}]", + self.name + )) + .build() + ); + nft_ruleset::LogBackend::Ringbuffer + } + } + } + + /// Take the NFLOG socket bound during rule installation, if any. + /// + /// The bypass monitor consumes it; `None` selects the `dmesg` fallback. + #[must_use] + pub fn take_nflog_socket(&self) -> Option { + self.nflog_socket + .lock() + .ok() + .and_then(|mut slot| slot.take()) + } + /// Bind a TCP listener inside this network namespace on a dedicated thread. /// /// Spawns a short-lived OS thread that enters the namespace via `setns`, @@ -398,8 +483,9 @@ impl Drop for NetworkNamespace { /// rules. Returns `None` when the policy is not in proxy mode. /// /// The namespace is shared infrastructure: the proxy binds to its host-side -/// veth IP and reads /dev/kmsg from inside it for bypass detection, while -/// the workload child and SSH sessions enter it via `setns()`. +/// veth IP and receives bypass-detection log entries from it (NFLOG, or the +/// kernel ring buffer as fallback), while the workload child and SSH +/// sessions enter it via `setns()`. /// /// # Errors /// @@ -479,8 +565,14 @@ fn install_sidecar_nft_bypass_rules(proxy_uid: u32) -> Result<()> { "trusted nft helper not found; sidecar network enforcement requires nftables" ) })?; - let log_prefix = Some("openshell:sidecar-bypass:"); - let commands = nft_ruleset::generate_sidecar_bypass_commands(proxy_uid, log_prefix); + // The sidecar topology has no bypass log listener today, so the rules + // keep the ring-buffer backend; entries remain greppable via dmesg on + // hosts that permit it. + let log_spec = nft_ruleset::LogSpec { + prefix: "openshell:sidecar-bypass:", + backend: nft_ruleset::LogBackend::Ringbuffer, + }; + let commands = nft_ruleset::generate_sidecar_bypass_commands(proxy_uid, Some(&log_spec)); run_nft_commands_current_namespace(&nft_cmd, &commands) } diff --git a/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs b/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs index 25b4549ab5..c256f6a40f 100644 --- a/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs +++ b/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs @@ -22,6 +22,29 @@ pub struct NftCommand { pub required: bool, } +/// Delivery backend for bypass log rules. +pub enum LogBackend { + /// `log ... flags skuid` — kernel ring buffer via printk + /// (`nf_log_syslog`). Reading entries requires `CAP_SYSLOG` in the + /// initial user namespace. + Ringbuffer, + /// `log ... group ` — `nfnetlink_log` delivery to a netlink socket + /// bound inside the same network namespace. Reading needs only + /// `CAP_NET_ADMIN` over the namespace owner. + Nflog { + /// NFLOG group number matching the listener socket. + group: u16, + }, +} + +/// Log rule specification: entry prefix plus delivery backend. +pub struct LogSpec<'a> { + /// Prefix attached to each log entry, used by the monitor to filter. + pub prefix: &'a str, + /// How log entries reach the bypass monitor. + pub backend: LogBackend, +} + /// Generate nft commands for sandbox network bypass enforcement. /// /// Creates an `inet` family table (handles both IPv4 and IPv6) with rules that: @@ -30,13 +53,14 @@ pub struct NftCommand { /// 3. Accept established/related connections (optional; requires `nf_conntrack`) /// 4. Reject TCP and UDP bypass attempts (both IPv4 and IPv6) /// -/// If `log_prefix` is provided, log rules are inserted before each reject rule -/// so that bypass attempts are recorded in the kernel ring buffer before being -/// rejected. Log rules are always non-required since they need `nf_log` support. +/// If `log` is provided, log rules are inserted before each reject rule so +/// that bypass attempts are recorded (via the ring buffer or NFLOG, per the +/// spec's backend) before being rejected. Log rules are always non-required +/// since they need kernel logging support. pub fn generate_bypass_commands( host_ip: &str, proxy_port: u16, - log_prefix: Option<&str>, + log: Option<&LogSpec<'_>>, ) -> Vec { let table = "openshell_bypass"; let mut cmds = vec![ @@ -92,14 +116,8 @@ pub fn generate_bypass_commands( ), ]; - if let Some(prefix) = log_prefix { - cmds.push(nft_cmd( - false, - &[ - "add", "rule", "inet", table, "output", "tcp", "flags", "syn", "limit", "rate", - "5/second", "burst", "10", "packets", "log", "prefix", prefix, "flags", "skuid", - ], - )); + if let Some(log) = log { + cmds.push(tcp_log_rule(table, log)); } cmds.push(nft_cmd( @@ -145,14 +163,8 @@ pub fn generate_bypass_commands( ], )); - if let Some(prefix) = log_prefix { - cmds.push(nft_cmd( - false, - &[ - "add", "rule", "inet", table, "output", "meta", "l4proto", "udp", "limit", "rate", - "5/second", "burst", "10", "packets", "log", "prefix", prefix, "flags", "skuid", - ], - )); + if let Some(log) = log { + cmds.push(udp_log_rule(table, log)); } cmds.push(nft_cmd( @@ -201,6 +213,50 @@ pub fn generate_bypass_commands( cmds } +/// Rate-limited log rule for TCP connection attempts (SYN packets). +fn tcp_log_rule(table: &str, log: &LogSpec<'_>) -> NftCommand { + log_rule( + &[ + "add", "rule", "inet", table, "output", "tcp", "flags", "syn", "limit", "rate", + "5/second", "burst", "10", "packets", + ], + log, + ) +} + +/// Rate-limited log rule for UDP packets. +fn udp_log_rule(table: &str, log: &LogSpec<'_>) -> NftCommand { + log_rule( + &[ + "add", "rule", "inet", table, "output", "meta", "l4proto", "udp", "limit", "rate", + "5/second", "burst", "10", "packets", + ], + log, + ) +} + +/// Append the backend-specific `log` statement to a rule. +/// +/// The ring buffer backend adds `flags skuid` so the workload UID appears in +/// the text entry; NFLOG entries carry the UID as a netlink attribute +/// automatically, and nft rejects `flags` combined with `group`. +fn log_rule(base: &[&str], log: &LogSpec<'_>) -> NftCommand { + let mut args: Vec = base.iter().map(|s| (*s).to_string()).collect(); + args.extend([ + "log".to_string(), + "prefix".to_string(), + log.prefix.to_string(), + ]); + match log.backend { + LogBackend::Ringbuffer => args.extend(["flags".to_string(), "skuid".to_string()]), + LogBackend::Nflog { group } => args.extend(["group".to_string(), group.to_string()]), + } + NftCommand { + args, + required: false, + } +} + /// Generate nft commands for Kubernetes sidecar enforcement. /// /// The network sidecar and the process supervisor share a pod network @@ -211,7 +267,7 @@ pub fn generate_bypass_commands( /// the sidecar policy fence. pub fn generate_sidecar_bypass_commands( proxy_uid: u32, - log_prefix: Option<&str>, + log: Option<&LogSpec<'_>>, ) -> Vec { let table = "openshell_sidecar_bypass"; let uid_str = proxy_uid.to_string(); @@ -257,14 +313,8 @@ pub fn generate_sidecar_bypass_commands( ), ]; - if let Some(prefix) = log_prefix { - cmds.push(nft_cmd( - false, - &[ - "add", "rule", "inet", table, "output", "tcp", "flags", "syn", "limit", "rate", - "5/second", "burst", "10", "packets", "log", "prefix", prefix, "flags", "skuid", - ], - )); + if let Some(log) = log { + cmds.push(tcp_log_rule(table, log)); } cmds.push(nft_cmd( @@ -310,14 +360,8 @@ pub fn generate_sidecar_bypass_commands( ], )); - if let Some(prefix) = log_prefix { - cmds.push(nft_cmd( - false, - &[ - "add", "rule", "inet", table, "output", "meta", "l4proto", "udp", "limit", "rate", - "5/second", "burst", "10", "packets", "log", "prefix", prefix, "flags", "skuid", - ], - )); + if let Some(log) = log { + cmds.push(udp_log_rule(table, log)); } cmds.push(nft_cmd( @@ -385,6 +429,20 @@ mod tests { cmds.iter().map(cmd_str).collect::>().join("\n") } + const fn ringbuffer_spec(prefix: &str) -> LogSpec<'_> { + LogSpec { + prefix, + backend: LogBackend::Ringbuffer, + } + } + + const fn nflog_spec(prefix: &str, group: u16) -> LogSpec<'_> { + LogSpec { + prefix, + backend: LogBackend::Nflog { group }, + } + } + #[test] fn generates_bypass_commands_with_proxy_rule() { let cmds = generate_bypass_commands("10.0.2.2", 8080, None); @@ -449,7 +507,11 @@ mod tests { #[test] fn log_commands_contain_prefix_for_tcp_and_udp() { - let cmds = generate_bypass_commands("10.0.2.2", 8080, Some("openshell:bypass:test:")); + let cmds = generate_bypass_commands( + "10.0.2.2", + 8080, + Some(&ringbuffer_spec("openshell:bypass:test:")), + ); let text = all_strs(&cmds); let count = text.matches("log prefix openshell:bypass:test:").count(); assert_eq!(count, 2, "need log rules for both TCP and UDP"); @@ -457,9 +519,46 @@ mod tests { assert!(text.contains("meta l4proto udp limit rate 5/second burst 10 packets")); } + #[test] + fn ringbuffer_log_rules_use_skuid_not_group() { + let cmds = generate_bypass_commands( + "10.0.2.2", + 8080, + Some(&ringbuffer_spec("openshell:bypass:test:")), + ); + let text = all_strs(&cmds); + assert_eq!(text.matches("flags skuid").count(), 2); + assert!( + !text.contains(" group "), + "ring buffer rules must not use NFLOG groups" + ); + } + + #[test] + fn nflog_log_rules_use_group_not_skuid() { + let cmds = generate_bypass_commands( + "10.0.2.2", + 8080, + Some(&nflog_spec("openshell:bypass:test:", 1)), + ); + let text = all_strs(&cmds); + let count = text + .matches("log prefix openshell:bypass:test: group 1") + .count(); + assert_eq!(count, 2, "need NFLOG rules for both TCP and UDP"); + assert!( + !text.contains("skuid"), + "nft rejects log flags combined with group; UID arrives as an NFLOG attribute" + ); + } + #[test] fn log_rules_appear_before_reject_rules() { - let cmds = generate_bypass_commands("10.0.2.2", 8080, Some("openshell:bypass:test:")); + let cmds = generate_bypass_commands( + "10.0.2.2", + 8080, + Some(&ringbuffer_spec("openshell:bypass:test:")), + ); let text = all_strs(&cmds); let tcp_log_pos = text.find("tcp flags syn").unwrap(); let tcp_reject_pos = text @@ -495,7 +594,11 @@ mod tests { #[test] fn log_rules_are_not_required() { - let cmds = generate_bypass_commands("10.0.2.2", 8080, Some("openshell:bypass:test:")); + let cmds = generate_bypass_commands( + "10.0.2.2", + 8080, + Some(&ringbuffer_spec("openshell:bypass:test:")), + ); for cmd in &cmds { if cmd_str(cmd).contains("log prefix") { assert!( @@ -517,7 +620,8 @@ mod tests { #[test] fn sidecar_commands_reject_tcp_and_udp_egress() { - let cmds = generate_sidecar_bypass_commands(0, Some("openshell:sidecar:test:")); + let cmds = + generate_sidecar_bypass_commands(0, Some(&ringbuffer_spec("openshell:sidecar:test:"))); let text = all_strs(&cmds); assert!(text.contains("meta nfproto ipv4 meta l4proto tcp reject")); assert!(text.contains("meta nfproto ipv6 meta l4proto tcp reject")); diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index 5a06d97a8f..9bdbb1a0fb 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -107,11 +107,12 @@ pub async fn run_process( // and the SSH listener and entrypoint child have not been exposed yet. crate::sandbox::apply_supervisor_startup_hardening()?; - // Spawn the bypass detection monitor. It tails dmesg for nftables LOG - // entries fired by rules installed on the workload's network namespace - // and reports direct connection attempts that would have bypassed the - // proxy. Spawn it before the entrypoint child so the first packets are - // not missed. Best-effort: returns None when dmesg is unavailable. + // Spawn the bypass detection monitor. It reads nftables log entries + // fired by rules installed on the workload's network namespace (via the + // NFLOG socket bound during namespace setup, or dmesg as fallback) and + // reports direct connection attempts that would have bypassed the proxy. + // Spawn it before the entrypoint child so the first packets are not + // missed. Best-effort: returns None when no log backend is available. #[cfg(target_os = "linux")] let _bypass_handle = netns.and_then(|ns| { crate::bypass_monitor::spawn( @@ -119,6 +120,7 @@ pub async fn run_process( entrypoint_pid.clone(), bypass_denial_tx, bypass_activity_tx, + ns.take_nflog_socket(), ) }); diff --git a/docs/kubernetes/topology.mdx b/docs/kubernetes/topology.mdx index 5bbb18e1ef..355e1aaac6 100644 --- a/docs/kubernetes/topology.mdx +++ b/docs/kubernetes/topology.mdx @@ -29,7 +29,7 @@ The long-running container permissions differ by topology: | Topology | Pod or container | UID/GID | Privilege escalation | Capabilities | Result | |---|---|---|---|---|---| -| `combined` | Agent container, which also runs the supervisor | Not forced by topology | Not explicitly disabled by the driver | Adds `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, and `SYSLOG`; adds `SETUID`, `SETGID`, and `DAC_READ_SEARCH` when user namespaces are enabled | Full supervisor controls run in the agent container. | +| `combined` | Agent container, which also runs the supervisor | Not forced by topology | Not explicitly disabled by the driver | Adds `SYS_ADMIN`, `NET_ADMIN`, and `SYS_PTRACE`; adds `SETUID`, `SETGID`, and `DAC_READ_SEARCH` when user namespaces are enabled | Full supervisor controls run in the agent container. Bypass-detection log entries arrive over an NFLOG socket covered by `NET_ADMIN`. | | `sidecar` | Agent container, process-only supervisor (`network-only`) | `sandbox_uid:sandbox_gid` | `false` | Drops `ALL` | Agent and workload run without added Linux capabilities. | | `sidecar` | Network supervisor sidecar, binary-aware mode (default) | `0:sandbox_gid` | `false` | Drops `ALL`; adds `SYS_PTRACE` and `DAC_READ_SEARCH` | Root sidecar inspects cross-UID workload `/proc` entries. The nftables fence exempts UID 0, so do not inject other root containers into these pods. | | `sidecar` | Network supervisor sidecar, endpoint/L7-only mode | `proxyUid:sandbox_gid` | `false` | Drops `ALL` | Non-root sidecar enforces endpoint and L7 policy without matching `policy.binaries`. | diff --git a/e2e/rust/tests/user_namespaces.rs b/e2e/rust/tests/user_namespaces.rs index 53f2c6ec1f..36280e6ec5 100644 --- a/e2e/rust/tests/user_namespaces.rs +++ b/e2e/rust/tests/user_namespaces.rs @@ -243,10 +243,14 @@ async fn sandbox_pod_spec_has_user_namespace_fields() { "sandbox pod must include {cap} in capabilities when user namespaces are enabled, got: {caps_val}" ); } - for cap in ["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYSLOG"] { + for cap in ["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE"] { assert!( caps_val.contains(cap), "sandbox pod must include {cap} in capabilities, got: {caps_val}" ); } + assert!( + !caps_val.contains("SYSLOG"), + "sandbox pod must not request CAP_SYSLOG; bypass detection uses NFLOG, got: {caps_val}" + ); }