Skip to content

Commit 4fa1661

Browse files
committed
feat(sandbox): log connection attempts that bypass proxy path
Add iptables LOG + REJECT rules inside the sandbox network namespace to detect and diagnose direct connection attempts that bypass the HTTP CONNECT proxy. This provides two improvements: 1. Fast-fail UX: applications get immediate ECONNREFUSED instead of a 30-second timeout when they bypass the proxy 2. Diagnostics: a /dev/kmsg monitor emits structured BYPASS_DETECT tracing events with destination, protocol, process identity, and actionable hints Both TCP and UDP bypass attempts are covered (UDP catches DNS bypass). The feature degrades gracefully if iptables or /dev/kmsg are unavailable. Closes #268
1 parent 29f72f3 commit 4fa1661

File tree

7 files changed

+813
-18
lines changed

7 files changed

+813
-18
lines changed

architecture/sandbox-custom-containers.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ The `openshell-sandbox` supervisor adapts to arbitrary environments:
9898

9999
- **Log file fallback**: Attempts to open `/var/log/openshell.log` for append; silently falls back to stdout-only logging if the path is not writable.
100100
- **Command resolution**: Executes the command from CLI args, then the `OPENSHELL_SANDBOX_COMMAND` env var (set to `sleep infinity` by the server), then `/bin/bash` as a last resort.
101-
- **Network namespace**: Requires successful namespace creation for proxy isolation; startup fails in proxy mode if required capabilities (`CAP_NET_ADMIN`, `CAP_SYS_ADMIN`) or `iproute2` are unavailable.
101+
- **Network namespace**: Requires successful namespace creation for proxy isolation; startup fails in proxy mode if required capabilities (`CAP_NET_ADMIN`, `CAP_SYS_ADMIN`) or `iproute2` are unavailable. If the `iptables` package is present, the supervisor installs OUTPUT chain rules (LOG + REJECT) inside the namespace to provide fast-fail behavior (immediate `ECONNREFUSED` instead of a 30-second timeout) and diagnostic logging when processes attempt direct connections that bypass the HTTP CONNECT proxy. If `iptables` is absent, the supervisor logs a warning and continues — core network isolation still works via routing.
102102

103103
## Design Decisions
104104

@@ -114,6 +114,7 @@ The `openshell-sandbox` supervisor adapts to arbitrary environments:
114114
| Clear `run_as_user/group` for custom images | Prevents startup failure when the image lacks the default `sandbox` user |
115115
| Non-fatal log file init | `/var/log/openshell.log` may be unwritable in arbitrary images; falls back to stdout |
116116
| `docker save` / `ctr import` for push | Avoids requiring a registry for local dev; images land directly in the k3s containerd store |
117+
| Optional `iptables` for bypass detection | Core network isolation works via routing alone (`iproute2`); `iptables` only adds fast-fail (`ECONNREFUSED`) and diagnostic LOG entries. Making it optional avoids hard failures in minimal images that lack `iptables` while giving better UX when it is available. |
117118

118119
## Limitations
119120

architecture/sandbox.md

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ All paths are relative to `crates/openshell-sandbox/src/`.
1919
| `identity.rs` | `BinaryIdentityCache` -- SHA256 trust-on-first-use binary integrity |
2020
| `procfs.rs` | `/proc` filesystem reading for TCP peer identity resolution and ancestor chain walking |
2121
| `grpc_client.rs` | gRPC client for fetching policy, provider environment, inference route bundles, policy polling/status reporting, proposal submission, and log push (`CachedOpenShellClient`) |
22-
| `denial_aggregator.rs` | `DenialAggregator` background task -- receives `DenialEvent`s from the proxy, deduplicates by `(host, port, binary)`, drains on flush interval |
22+
| `denial_aggregator.rs` | `DenialAggregator` background task -- receives `DenialEvent`s from the proxy and bypass monitor, deduplicates by `(host, port, binary)`, drains on flush interval |
2323
| `mechanistic_mapper.rs` | Deterministic policy recommendation generator -- converts denial summaries to `PolicyChunk` proposals with confidence scores, rationale, and SSRF/private-IP detection |
2424
| `sandbox/mod.rs` | Platform abstraction -- dispatches to Linux or no-op |
2525
| `sandbox/linux/mod.rs` | Linux composition: Landlock then seccomp |
2626
| `sandbox/linux/landlock.rs` | Filesystem isolation via Landlock LSM (ABI V1) |
2727
| `sandbox/linux/seccomp.rs` | Syscall filtering via BPF on `SYS_socket` |
28-
| `sandbox/linux/netns.rs` | Network namespace creation, veth pair setup, cleanup on drop |
28+
| `bypass_monitor.rs` | Background `/dev/kmsg` reader for iptables bypass detection events |
29+
| `sandbox/linux/netns.rs` | Network namespace creation, veth pair setup, bypass detection iptables rules, cleanup on drop |
2930
| `l7/mod.rs` | L7 types (`L7Protocol`, `TlsMode`, `EnforcementMode`, `L7EndpointConfig`), config parsing, validation, access preset expansion |
3031
| `l7/inference.rs` | Inference API pattern detection (`detect_inference_pattern()`), HTTP request/response parsing and formatting for intercepted inference connections |
3132
| `l7/tls.rs` | Ephemeral CA generation (`SandboxCa`), per-hostname leaf cert cache (`CertCache`), TLS termination/connection helpers |
@@ -55,10 +56,12 @@ flowchart TD
5556
H --> I{Proxy mode?}
5657
I -- Yes --> J[Generate ephemeral CA + write TLS files]
5758
J --> K[Create network namespace]
58-
K --> K2[Build InferenceContext]
59+
K --> K1[Install bypass detection rules]
60+
K1 --> K2[Build InferenceContext]
5961
K2 --> L[Start HTTP CONNECT proxy]
6062
I -- No --> M[Skip proxy setup]
61-
L --> N{SSH enabled?}
63+
L --> L2[Spawn bypass monitor]
64+
L2 --> N{SSH enabled?}
6265
M --> N
6366
N -- Yes --> O[Spawn SSH server task]
6467
N -- No --> P[Spawn child process]
@@ -96,7 +99,8 @@ flowchart TD
9699
6. **Network namespace** (Linux, proxy mode only):
97100
- `NetworkNamespace::create()` builds the veth pair and namespace
98101
- Opens `/var/run/netns/sandbox-{uuid}` as an FD for later `setns()`
99-
- On failure: return a fatal startup error (fail-closed)
102+
- `install_bypass_rules(proxy_port)` installs iptables OUTPUT chain rules for bypass detection (fast-fail UX + diagnostic logging). See [Bypass detection](#bypass-detection).
103+
- On failure: return a fatal startup error (fail-closed). Bypass rule failure is non-fatal (logged as warning).
100104

101105
7. **Proxy startup** (proxy mode only):
102106
- Validate that OPA engine and identity cache are present
@@ -475,15 +479,123 @@ Each step has rollback on failure -- if any `ip` command fails, previously creat
475479
2. Delete the host-side veth (`ip link delete veth-h-{id}`) -- this automatically removes the peer
476480
3. Delete the namespace (`ip netns delete sandbox-{id}`)
477481

482+
#### Bypass detection
483+
484+
**Files:** `crates/openshell-sandbox/src/sandbox/linux/netns.rs` (`install_bypass_rules()`), `crates/openshell-sandbox/src/bypass_monitor.rs`
485+
486+
The network namespace routes all sandbox traffic through the veth pair, but a misconfigured process that ignores proxy environment variables can still attempt direct connections to the veth gateway IP or other addresses. Bypass detection catches these attempts, providing two benefits: immediate connection failure (fast-fail UX) instead of a 30-second TCP timeout, and structured diagnostic logging that identifies the offending process.
487+
488+
##### iptables rules
489+
490+
`install_bypass_rules()` installs OUTPUT chain rules inside the sandbox network namespace using `iptables` (IPv4) and `ip6tables` (IPv6, best-effort). Rules are installed via `ip netns exec {namespace} iptables ...`. The rules are evaluated in order:
491+
492+
| # | Rule | Target | Purpose |
493+
|---|------|--------|---------|
494+
| 1 | `-d {host_ip}/32 -p tcp --dport {proxy_port}` | `ACCEPT` | Allow traffic to the proxy |
495+
| 2 | `-o lo` | `ACCEPT` | Allow loopback traffic |
496+
| 3 | `-m conntrack --ctstate ESTABLISHED,RELATED` | `ACCEPT` | Allow response packets for established connections |
497+
| 4 | `-p tcp --syn -m limit --limit 5/sec --limit-burst 10 --log-prefix "openshell:bypass:{ns}:"` | `LOG` | Log TCP SYN bypass attempts (rate-limited) |
498+
| 5 | `-p tcp` | `REJECT --reject-with icmp-port-unreachable` | Reject TCP bypass attempts (fast-fail) |
499+
| 6 | `-p udp -m limit --limit 5/sec --limit-burst 10 --log-prefix "openshell:bypass:{ns}:"` | `LOG` | Log UDP bypass attempts, including DNS (rate-limited) |
500+
| 7 | `-p udp` | `REJECT --reject-with icmp-port-unreachable` | Reject UDP bypass attempts (fast-fail) |
501+
502+
The LOG rules use the `--log-uid` flag to include the UID of the process that initiated the connection. The log prefix `openshell:bypass:{namespace_name}:` enables the bypass monitor to filter `/dev/kmsg` for events belonging to a specific sandbox.
503+
504+
The proxy port defaults to `3128` unless the policy specifies a different `http_addr`. IPv6 rules mirror the IPv4 rules via `ip6tables`; IPv6 rule installation failure is non-fatal (logged as warning) since IPv4 is the primary path.
505+
506+
**Graceful degradation:** If iptables is not available (checked via `which iptables`), a warning is logged and rule installation is skipped entirely. The network namespace still provides isolation via routing — processes can only reach the proxy's IP, but without bypass rules they get a timeout rather than an immediate rejection. LOG rule failure is also non-fatal — if the `xt_LOG` kernel module is not loaded, the REJECT rules are still installed for fast-fail behavior.
507+
508+
##### /dev/kmsg monitor
509+
510+
`bypass_monitor::spawn()` starts a background tokio task (via `spawn_blocking`) that reads kernel log messages from `/dev/kmsg`. The monitor:
511+
512+
1. Opens `/dev/kmsg` in read mode and seeks to end (skips historical messages)
513+
2. Reads lines via `BufReader`, filtering for the namespace-specific prefix `openshell:bypass:{namespace_name}:`
514+
3. Parses iptables LOG format via `parse_kmsg_line()`, extracting `DST`, `DPT`, `SPT`, `PROTO`, and `UID` fields
515+
4. Resolves process identity for TCP events via `procfs::resolve_tcp_peer_identity()` (best-effort — requires a valid entrypoint PID and non-zero source port)
516+
5. Emits a structured `tracing::warn!()` event with the tag `BYPASS_DETECT`
517+
6. Sends a `DenialEvent` to the denial aggregator channel (if available)
518+
519+
The `BypassEvent` struct holds the parsed fields:
520+
521+
```rust
522+
pub struct BypassEvent {
523+
pub dst_addr: String, // Destination IP address
524+
pub dst_port: u16, // Destination port
525+
pub src_port: u16, // Source port (for process identity resolution)
526+
pub proto: String, // "tcp" or "udp"
527+
pub uid: Option<u32>, // UID from --log-uid (if present)
528+
}
529+
```
530+
531+
##### BYPASS_DETECT tracing event
532+
533+
Each detected bypass attempt emits a `warn!()` log line with the following structured fields:
534+
535+
| Field | Type | Description |
536+
|-------|------|-------------|
537+
| `dst_addr` | string | Destination IP address |
538+
| `dst_port` | u16 | Destination port |
539+
| `proto` | string | `"tcp"` or `"udp"` |
540+
| `binary` | string | Binary path of the offending process (or `"-"` if unresolved) |
541+
| `binary_pid` | string | PID of the offending process (or `"-"`) |
542+
| `ancestors` | string | Ancestor chain (e.g., `"/usr/bin/bash -> /usr/bin/node"`) or `"-"` |
543+
| `action` | string | Always `"reject"` |
544+
| `reason` | string | `"direct connection bypassed HTTP CONNECT proxy"` |
545+
| `hint` | string | Context-specific remediation hint (see below) |
546+
547+
The `hint` field provides actionable guidance:
548+
549+
| Condition | Hint |
550+
|-----------|------|
551+
| UDP + port 53 | `"DNS queries should route through the sandbox proxy; check resolver configuration"` |
552+
| UDP (other) | `"UDP traffic must route through the sandbox proxy"` |
553+
| TCP | `"ensure process honors HTTP_PROXY/HTTPS_PROXY; for Node.js set NODE_USE_ENV_PROXY=1"` |
554+
555+
Process identity resolution is best-effort and TCP-only. For UDP events or when the entrypoint PID is not yet set (PID == 0), the binary, PID, and ancestors fields are reported as `"-"`.
556+
557+
##### DenialEvent integration
558+
559+
Each bypass event sends a `DenialEvent` to the denial aggregator with `denial_stage: "bypass"`. This integrates bypass detections into the same deduplication, aggregation, and policy proposal pipeline as proxy-level denials. The `DenialEvent` fields:
560+
561+
| Field | Value |
562+
|-------|-------|
563+
| `host` | Destination IP address |
564+
| `port` | Destination port |
565+
| `binary` | Binary path (or `"-"`) |
566+
| `ancestors` | Ancestor chain parsed from `" -> "` separator |
567+
| `deny_reason` | `"direct connection bypassed HTTP CONNECT proxy"` |
568+
| `denial_stage` | `"bypass"` |
569+
| `l7_method` | `None` |
570+
| `l7_path` | `None` |
571+
572+
The denial aggregator deduplicates bypass events by the same `(host, port, binary)` key used for proxy denials, and flushes them to the gateway via `SubmitPolicyAnalysis` on the same interval.
573+
574+
##### Lifecycle wiring
575+
576+
The bypass detection subsystem is wired in `crates/openshell-sandbox/src/lib.rs`:
577+
578+
1. After `NetworkNamespace::create()` succeeds, `install_bypass_rules(proxy_port)` is called. Failure is non-fatal (logged as warning).
579+
2. The proxy's denial channel sender (`denial_tx`) is cloned as `bypass_denial_tx` before being passed to the proxy.
580+
3. After proxy startup, `bypass_monitor::spawn()` is called with the namespace name, entrypoint PID, and `bypass_denial_tx`. Returns `Option<JoinHandle>``None` if `/dev/kmsg` is unavailable.
581+
582+
The monitor runs for the lifetime of the sandbox. It exits when `/dev/kmsg` reaches EOF (process termination) or encounters an unrecoverable read error.
583+
584+
**Graceful degradation:** If `/dev/kmsg` cannot be opened (e.g., restricted container environment without access to the kernel ring buffer), the monitor logs a one-time warning and returns `None`. The iptables REJECT rules still provide fast-fail UX — the monitor only adds diagnostic visibility.
585+
586+
##### Dependencies
587+
588+
Bypass detection requires the `iptables` package for rule installation (in addition to `iproute2` for namespace management). If iptables is not installed, bypass detection degrades to routing-only isolation. The `/dev/kmsg` device is required for the monitor but not for the REJECT rules.
589+
478590
#### Required capabilities
479591

480592
| Capability | Purpose |
481593
|------------|---------|
482594
| `CAP_SYS_ADMIN` | Creating network namespaces, `setns()` |
483-
| `CAP_NET_ADMIN` | Creating veth pairs, assigning IPs, configuring routes |
595+
| `CAP_NET_ADMIN` | Creating veth pairs, assigning IPs, configuring routes, installing iptables bypass detection rules |
484596
| `CAP_SYS_PTRACE` | Proxy reading `/proc/<pid>/fd/` and `/proc/<pid>/exe` for processes running as a different user |
485597

486-
The `iproute2` package must be installed (provides the `ip` command).
598+
The `iproute2` package must be installed (provides the `ip` command). The `iptables` package is required for bypass detection rules; if absent, the namespace still provides routing-based isolation but without fast-fail rejection or diagnostic logging for bypass attempts.
487599

488600
If namespace creation fails (e.g., missing capabilities), startup fails in `Proxy` mode. This preserves fail-closed behavior: either network namespace isolation is active, or the sandbox does not run.
489601

@@ -1087,6 +1199,12 @@ The sandbox uses `miette` for error reporting and `thiserror` for typed errors.
10871199
| Landlock failure + `HardRequirement` | Fatal |
10881200
| Seccomp failure | Fatal |
10891201
| Network namespace creation failure | Fatal in `Proxy` mode (sandbox startup aborts) |
1202+
| Bypass detection: iptables not available | Warn + skip rule installation (routing-only isolation) |
1203+
| Bypass detection: IPv4 rule installation failure | Warn + returned as error (non-fatal at call site) |
1204+
| Bypass detection: IPv6 rule installation failure | Warn + continue (IPv4 rules are the primary path) |
1205+
| Bypass detection: LOG rule installation failure | Warn + continue (REJECT rules still installed for fast-fail) |
1206+
| Bypass detection: `/dev/kmsg` not available | Warn + monitor not started (REJECT rules still provide fast-fail) |
1207+
| Bypass detection: `/dev/kmsg` read error (EPIPE/EIO) | Debug log + continue reading (kernel ring buffer overrun) |
10901208
| Ephemeral CA generation failure | Warn + TLS termination disabled (L7 inspection on TLS endpoints will not work) |
10911209
| CA file write failure | Warn + TLS termination disabled |
10921210
| OPA engine Mutex lock poisoned | Error on the individual evaluation |
@@ -1125,8 +1243,9 @@ Dual-output logging is configured in `main.rs`:
11251243

11261244
Key structured log events:
11271245
- `CONNECT`: One per proxy CONNECT request (for non-`inference.local` targets) with full identity context. Inference interception failures produce a separate `info!()` log with `action=deny` and the denial reason.
1246+
- `BYPASS_DETECT`: One per detected direct connection attempt that bypassed the HTTP CONNECT proxy. Includes destination, protocol, process identity (best-effort), and remediation hint. Emitted at `warn` level.
11281247
- `L7_REQUEST`: One per L7-inspected request with method, path, and decision
1129-
- Sandbox lifecycle events: process start, exit, namespace creation/cleanup
1248+
- Sandbox lifecycle events: process start, exit, namespace creation/cleanup, bypass rule installation
11301249
- Policy reload events: new version detected, reload success/failure, status report outcomes
11311250

11321251
## Log Streaming
@@ -1359,6 +1478,7 @@ Platform-specific code is abstracted through `crates/openshell-sandbox/src/sandb
13591478
| Landlock | Applied via `landlock` crate (ABI V1) | Warning + no-op |
13601479
| Seccomp | Applied via `seccompiler` crate | No-op |
13611480
| Network namespace | Full veth pair isolation | Not available |
1481+
| Bypass detection | iptables rules + `/dev/kmsg` monitor | Not available (no netns) |
13621482
| `/proc` identity binding | Full support | `evaluate_opa_tcp()` always denies |
13631483
| Proxy | Functional (binds to veth IP or loopback) | Functional (loopback only, no identity binding) |
13641484
| SSH server | Full support (with netns for shell processes) | Functional (no netns isolation for shell processes) |

0 commit comments

Comments
 (0)