Skip to content

Commit d3e1b31

Browse files
authored
feat(sandbox): log connection attempts that bypass proxy path (#326)
* 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 * chore: track .python-version to pin Python 3.13.12 for uv The sandbox base image runs Python 3.13. A stale venv on 3.12 causes all exec_python E2E tests to fail because cloudpickle bytecode is not compatible across minor versions. * fix(cluster): preserve hostGatewayIP across fast deploys The fast deploy's helm upgrade was missing the hostGatewayIP value that the bootstrap entrypoint injects into the HelmChart CR. This caused host.openshell.internal hostAliases to be lost from the gateway pod and sandbox pods after any fast deploy, breaking host gateway routing. Read the IP from the HelmChart CR and pass it through to helm upgrade. * wip: fix iptables path resolution, use dmesg for kmsg, add CAP_SYSLOG * fix(sandbox): restore NetworkNamespace Drop impl, remove dead kmsg code The Drop impl for NetworkNamespace was accidentally deleted during the bypass detection refactor, which would cause network namespaces and veth interfaces to leak on every sandbox shutdown. Also removes dead kmsg volume/mount code (bypass monitor uses dmesg instead of direct /dev/kmsg access) and removes an accidentally committed session transcript file.
1 parent 2e65bc4 commit d3e1b31

File tree

11 files changed

+988
-24
lines changed

11 files changed

+988
-24
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ python/*.data
5656
venv/
5757
ENV/
5858
env/
59-
.python-version
59+
# .python-version is tracked — pins uv to match the sandbox base image Python
6060

6161
# Installer logs
6262
pip-log.txt

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13.12

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)