8ac98377
docs: add exfiltration detection design spec
a73x 2026-03-29 16:00
Commit message
docs/superpowers/specs/2026-03-29-exfil-detection-design.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,106 @@ | |||
| 1 | # Exfiltration Detection for nono-proxy | ||
| 2 | |||
| 3 | ## Problem | ||
| 4 | |||
| 5 | The nono proxy currently does host-based allowlisting only. A sandboxed process can still exfiltrate sensitive data (SSH keys, passwords, API tokens) to an approved host. We need content inspection to detect and block this. | ||
| 6 | |||
| 7 | ## Approach | ||
| 8 | |||
| 9 | Inline MITM in the existing proxy. Extend the single `nono-proxy` binary to do TLS interception on CONNECT requests, enabling full request body scanning for both HTTP and HTTPS traffic. | ||
| 10 | |||
| 11 | ## CA Certificate Management | ||
| 12 | |||
| 13 | On first startup, `nono-proxy` checks for `~/.local/share/nono/ca.key` and `~/.local/share/nono/ca.pem`. If missing, it generates: | ||
| 14 | |||
| 15 | - An ECDSA P-256 CA private key | ||
| 16 | - A self-signed CA certificate ("Nono Proxy CA", 10-year validity) | ||
| 17 | |||
| 18 | Saved to disk and reused on subsequent runs. Per-host leaf certificates are generated on-the-fly at CONNECT time, signed by this CA, and cached in-memory (keyed by hostname) for the process lifetime. | ||
| 19 | |||
| 20 | The `nono` wrapper script is updated to: | ||
| 21 | |||
| 22 | - Bind-mount `ca.pem` into the sandbox (read-only) | ||
| 23 | - Set `SSL_CERT_FILE` and `NODE_EXTRA_CA_CERTS` so tools inside the sandbox trust it | ||
| 24 | |||
| 25 | ## MITM CONNECT Handling | ||
| 26 | |||
| 27 | The current `handleConnect` does a blind TCP tunnel. The new flow: | ||
| 28 | |||
| 29 | 1. Hijack the client connection, send `200 Connection Established` | ||
| 30 | 2. Generate (or fetch from cache) a leaf cert for the requested hostname, signed by the nono CA | ||
| 31 | 3. Wrap the client connection in a `tls.Server` using that leaf cert | ||
| 32 | 4. Establish a real `tls.Client` connection to the target host | ||
| 33 | 5. Read HTTP requests from the client-side TLS connection, run them through the scanner, and if clean, forward to the target | ||
| 34 | 6. Relay the response back to the client | ||
| 35 | |||
| 36 | For non-HTTP protocols over CONNECT (e.g. WebSockets upgrade after initial HTTP), forward the upgraded connection as a raw tunnel after the initial request passes scanning. | ||
| 37 | |||
| 38 | ## Request Body Scanner | ||
| 39 | |||
| 40 | A `scanner` package with a `Scan(body []byte) []Finding` function. Each `Finding` has a `Rule` name and a `Match` snippet (truncated for logging, not the full secret). | ||
| 41 | |||
| 42 | ### Default Rules | ||
| 43 | |||
| 44 | | Rule | Pattern | | ||
| 45 | |------|---------| | ||
| 46 | | `ssh-private-key` | `-----BEGIN (OPENSSH\|RSA\|DSA\|EC\|ED25519) PRIVATE KEY-----` | | ||
| 47 | | `pgp-private-key` | `-----BEGIN PGP PRIVATE KEY BLOCK-----` | | ||
| 48 | | `basic-auth` | `Authorization: Basic` header | | ||
| 49 | | `bearer-token` | `Authorization: Bearer` header | | ||
| 50 | | `aws-access-key` | `AKIA[0-9A-Z]{16}` | | ||
| 51 | | `github-token` | `gh[ps]_[A-Za-z0-9_]{36,}` | | ||
| 52 | | `openai-key` | `sk-[A-Za-z0-9]{32,}` | | ||
| 53 | | `password-field` | `password=` or `"password":` in body | | ||
| 54 | | `env-file` | 3+ consecutive lines matching `[A-Z_]+=.+` | | ||
| 55 | |||
| 56 | ### Configurable Rules | ||
| 57 | |||
| 58 | Rules are loaded from `~/.local/share/nono/rules.yaml`. On first run, `nono-proxy` writes a default file with the built-in rules if one doesn't exist. Users can add, remove, or modify rules. | ||
| 59 | |||
| 60 | Format: | ||
| 61 | |||
| 62 | ```yaml | ||
| 63 | rules: | ||
| 64 | - name: ssh-private-key | ||
| 65 | pattern: "-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----" | ||
| 66 | - name: github-token | ||
| 67 | pattern: "gh[ps]_[A-Za-z0-9_]{36,}" | ||
| 68 | ``` | ||
| 69 | |||
| 70 | Each rule is a name + regex pattern. The scanner compiles them at startup and returns an error if any pattern is invalid. | ||
| 71 | |||
| 72 | ### Behavior | ||
| 73 | |||
| 74 | - Scans outbound request bodies only (not responses) | ||
| 75 | - Reads the full request body via `io.ReadAll`, scans, and if clean replays via `bytes.Reader` | ||
| 76 | - On match: logs `BLOCKED <method> <host> [rule1, rule2]`, returns 403 with message like `"request blocked: contains sensitive data (ssh-private-key)"` | ||
| 77 | |||
| 78 | ## Request Flow | ||
| 79 | |||
| 80 | ``` | ||
| 81 | Client in sandbox | ||
| 82 | -> plain HTTP or CONNECT to nono-proxy (port 9854) | ||
| 83 | -> host allowlist check (existing logic, unchanged) | ||
| 84 | -> if CONNECT: MITM TLS termination, read inner HTTP request | ||
| 85 | -> read request body, run scanner rules | ||
| 86 | -> if findings: log BLOCKED, return 403 | ||
| 87 | -> if clean: forward to target, relay response | ||
| 88 | ``` | ||
| 89 | |||
| 90 | ## Code Changes | ||
| 91 | |||
| 92 | - `proxy/proxy.go` — `Proxy` struct gains `caKey`/`caCert` fields and `certCache map[string]*tls.Certificate`. `New()` takes a CA path in addition to the hosts file. `handleConnect` replaced with MITM flow. `handleHTTP` gets scanner check before forwarding. | ||
| 93 | - `scanner/` — new package with `Rule`, `Finding`, `Scanner` (loads rules from YAML, compiles regexes, exposes `Scan([]byte) []Finding`) | ||
| 94 | - `ca/` — new package with `LoadOrCreateCA(dir string)` and `GenerateLeafCert(host string, ca)` functions | ||
| 95 | - `cmd/nono-proxy/main.go` — loads CA at startup, passes to `proxy.New()`, writes default `rules.yaml` if missing | ||
| 96 | - `nono` script — adds `--ro-bind` for `ca.pem`, sets `SSL_CERT_FILE` and `NODE_EXTRA_CA_CERTS` | ||
| 97 | |||
| 98 | ## New Dependencies | ||
| 99 | |||
| 100 | - `gopkg.in/yaml.v3` for rules config | ||
| 101 | - Everything else is stdlib (`crypto/x509`, `crypto/tls`, `crypto/ecdsa`) | ||
| 102 | |||
| 103 | ## Scan Direction | ||
| 104 | |||
| 105 | - Request bodies only (outbound exfiltration detection) | ||
| 106 | - No size cap — large uploads are themselves suspicious | ||