a73x

3f877c9e

docs: add exfiltration detection implementation plan

a73x   2026-03-29 16:14


diff --git a/docs/superpowers/plans/2026-03-29-exfil-detection.md b/docs/superpowers/plans/2026-03-29-exfil-detection.md
new file mode 100644
index 0000000..9945f3c
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-29-exfil-detection.md
@@ -0,0 +1,1436 @@
# Exfiltration Detection Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend nono-proxy with MITM TLS interception and request body scanning to detect and block exfiltration of sensitive data (SSH keys, passwords, API tokens, etc.).

**Architecture:** Auto-generated CA for MITM TLS interception on CONNECT requests. A scanner package reads regex rules from a YAML config file and checks outbound request bodies. Findings block the request with 403. The nono wrapper script trusts the CA inside the sandbox.

**Tech Stack:** Go stdlib (`crypto/x509`, `crypto/tls`, `crypto/ecdsa`, `crypto/elliptic`), `gopkg.in/yaml.v3`

---

## File Structure

| File | Action | Responsibility |
|------|--------|----------------|
| `ca/ca.go` | Create | Generate/load CA keypair, generate per-host leaf certs |
| `ca/ca_test.go` | Create | Tests for CA generation and leaf cert signing |
| `scanner/scanner.go` | Create | Load rules from YAML, compile regexes, scan bytes for matches |
| `scanner/scanner_test.go` | Create | Tests for rule loading and pattern matching |
| `proxy/proxy.go` | Modify | Add MITM CONNECT handling, integrate scanner into request flow |
| `proxy/proxy_test.go` | Modify | Add tests for MITM and body scanning integration |
| `cmd/nono-proxy/main.go` | Modify | Load CA, init scanner, write default rules.yaml, pass to proxy |
| `nono` | Modify | Bind-mount CA cert, set SSL_CERT_FILE and NODE_EXTRA_CA_CERTS |
| `go.mod` | Modify | Add `gopkg.in/yaml.v3` dependency |

---

### Task 1: CA — Generate and Load CA Certificate

**Files:**
- Create: `ca/ca.go`
- Create: `ca/ca_test.go`

- [ ] **Step 1: Write failing test for CA generation**

```go
// ca/ca_test.go
package ca_test

import (
	"crypto/x509"
	"encoding/pem"
	"os"
	"path/filepath"
	"testing"

	"nono/ca"
)

func TestLoadOrCreateCA_GeneratesNewCA(t *testing.T) {
	dir := t.TempDir()

	caCert, caKey, err := ca.LoadOrCreate(dir)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if caCert == nil {
		t.Fatal("expected CA cert, got nil")
	}
	if caKey == nil {
		t.Fatal("expected CA key, got nil")
	}
	if !caCert.IsCA {
		t.Error("expected cert to be a CA")
	}
	if caCert.Subject.CommonName != "Nono Proxy CA" {
		t.Errorf("expected CN 'Nono Proxy CA', got %q", caCert.Subject.CommonName)
	}

	// Files should exist on disk
	if _, err := os.Stat(filepath.Join(dir, "ca.pem")); err != nil {
		t.Errorf("ca.pem not written: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, "ca.key")); err != nil {
		t.Errorf("ca.key not written: %v", err)
	}
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `go test ./ca/ -v -run TestLoadOrCreateCA_GeneratesNewCA`
Expected: FAIL — package `ca` does not exist

- [ ] **Step 3: Write minimal implementation**

```go
// ca/ca.go
package ca

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"math/big"
	"os"
	"path/filepath"
	"time"
)

// LoadOrCreate loads a CA cert+key from dir, or generates a new one if missing.
func LoadOrCreate(dir string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
	certPath := filepath.Join(dir, "ca.pem")
	keyPath := filepath.Join(dir, "ca.key")

	certPEM, certErr := os.ReadFile(certPath)
	keyPEM, keyErr := os.ReadFile(keyPath)

	if certErr == nil && keyErr == nil {
		return parse(certPEM, keyPEM)
	}

	return generate(certPath, keyPath)
}

func parse(certPEM, keyPEM []byte) (*x509.Certificate, *ecdsa.PrivateKey, error) {
	block, _ := pem.Decode(certPEM)
	cert, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		return nil, nil, err
	}

	keyBlock, _ := pem.Decode(keyPEM)
	key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
	if err != nil {
		return nil, nil, err
	}

	return cert, key, nil
}

func generate(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, nil, err
	}

	serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
	if err != nil {
		return nil, nil, err
	}

	template := &x509.Certificate{
		SerialNumber: serial,
		Subject:      pkix.Name{CommonName: "Nono Proxy CA"},
		NotBefore:    time.Now(),
		NotAfter:     time.Now().Add(10 * 365 * 24 * time.Hour),
		KeyUsage:     x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
		BasicConstraintsValid: true,
		IsCA:                  true,
	}

	certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
	if err != nil {
		return nil, nil, err
	}

	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
	if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
		return nil, nil, err
	}

	keyDER, err := x509.MarshalECPrivateKey(key)
	if err != nil {
		return nil, nil, err
	}
	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
	if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
		return nil, nil, err
	}

	cert, err := x509.ParseCertificate(certDER)
	if err != nil {
		return nil, nil, err
	}

	return cert, key, nil
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `go test ./ca/ -v -run TestLoadOrCreateCA_GeneratesNewCA`
Expected: PASS

- [ ] **Step 5: Write failing test for loading existing CA**

```go
// ca/ca_test.go (append)
func TestLoadOrCreateCA_LoadsExistingCA(t *testing.T) {
	dir := t.TempDir()

	// Generate first
	cert1, _, err := ca.LoadOrCreate(dir)
	if err != nil {
		t.Fatalf("generate: %v", err)
	}

	// Load second time
	cert2, _, err := ca.LoadOrCreate(dir)
	if err != nil {
		t.Fatalf("load: %v", err)
	}

	if !cert1.Equal(cert2) {
		t.Error("expected same cert on reload")
	}
}
```

- [ ] **Step 6: Run test to verify it passes** (implementation already handles this)

Run: `go test ./ca/ -v -run TestLoadOrCreateCA_LoadsExistingCA`
Expected: PASS

- [ ] **Step 7: Commit**

```bash
git add ca/
git commit -m "feat: add CA certificate generation and loading"
```

---

### Task 2: CA — Generate Leaf Certificates

**Files:**
- Modify: `ca/ca.go`
- Modify: `ca/ca_test.go`

- [ ] **Step 1: Write failing test for leaf cert generation**

```go
// ca/ca_test.go (append)
func TestGenerateLeafCert(t *testing.T) {
	dir := t.TempDir()
	caCert, caKey, err := ca.LoadOrCreate(dir)
	if err != nil {
		t.Fatalf("CA setup: %v", err)
	}

	tlsCert, err := ca.GenerateLeaf("example.com", caCert, caKey)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
	if err != nil {
		t.Fatalf("parse leaf: %v", err)
	}

	if leaf.Subject.CommonName != "example.com" {
		t.Errorf("expected CN 'example.com', got %q", leaf.Subject.CommonName)
	}

	// Verify the leaf is signed by the CA
	pool := x509.NewCertPool()
	pool.AddCert(caCert)
	if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool}); err != nil {
		t.Errorf("leaf cert not signed by CA: %v", err)
	}
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `go test ./ca/ -v -run TestGenerateLeafCert`
Expected: FAIL — `ca.GenerateLeaf` undefined

- [ ] **Step 3: Write minimal implementation**

```go
// ca/ca.go (append)
import "crypto/tls"

// GenerateLeaf creates a TLS certificate for host, signed by the CA.
func GenerateLeaf(host string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey) (tls.Certificate, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return tls.Certificate{}, err
	}

	serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
	if err != nil {
		return tls.Certificate{}, err
	}

	template := &x509.Certificate{
		SerialNumber: serial,
		Subject:      pkix.Name{CommonName: host},
		DNSNames:     []string{host},
		NotBefore:    time.Now(),
		NotAfter:     time.Now().Add(24 * time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}

	certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
	if err != nil {
		return tls.Certificate{}, err
	}

	return tls.Certificate{
		Certificate: [][]byte{certDER},
		PrivateKey:  key,
	}, nil
}
```

Note: The `crypto/tls` import needs to be added to the existing import block in `ca/ca.go`.

- [ ] **Step 4: Run test to verify it passes**

Run: `go test ./ca/ -v -run TestGenerateLeafCert`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add ca/
git commit -m "feat: add leaf certificate generation for MITM"
```

---

### Task 3: Scanner — Load Rules from YAML

**Files:**
- Create: `scanner/scanner.go`
- Create: `scanner/scanner_test.go`

- [ ] **Step 1: Add yaml dependency**

Run: `go get gopkg.in/yaml.v3`

- [ ] **Step 2: Write failing test for loading rules**

```go
// scanner/scanner_test.go
package scanner_test

import (
	"os"
	"path/filepath"
	"testing"

	"nono/scanner"
)

func TestNewScanner_LoadsRulesFromYAML(t *testing.T) {
	dir := t.TempDir()
	rulesPath := filepath.Join(dir, "rules.yaml")
	os.WriteFile(rulesPath, []byte(`rules:
  - name: ssh-key
    pattern: "-----BEGIN RSA PRIVATE KEY-----"
  - name: aws-key
    pattern: "AKIA[0-9A-Z]{16}"
`), 0644)

	s, err := scanner.New(rulesPath)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if s.RuleCount() != 2 {
		t.Errorf("expected 2 rules, got %d", s.RuleCount())
	}
}

func TestNewScanner_RejectsInvalidRegex(t *testing.T) {
	dir := t.TempDir()
	rulesPath := filepath.Join(dir, "rules.yaml")
	os.WriteFile(rulesPath, []byte(`rules:
  - name: bad-rule
    pattern: "[invalid"
`), 0644)

	_, err := scanner.New(rulesPath)
	if err == nil {
		t.Fatal("expected error for invalid regex")
	}
}
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `go test ./scanner/ -v`
Expected: FAIL — package `scanner` does not exist

- [ ] **Step 4: Write minimal implementation**

```go
// scanner/scanner.go
package scanner

import (
	"fmt"
	"os"
	"regexp"

	"gopkg.in/yaml.v3"
)

type ruleConfig struct {
	Name    string `yaml:"name"`
	Pattern string `yaml:"pattern"`
}

type rulesFile struct {
	Rules []ruleConfig `yaml:"rules"`
}

type compiledRule struct {
	name    string
	pattern *regexp.Regexp
}

// Finding represents a scanner match.
type Finding struct {
	Rule  string
	Match string
}

// Scanner checks byte slices against a set of regex rules.
type Scanner struct {
	rules []compiledRule
}

// New loads rules from a YAML file and compiles their regexes.
func New(path string) (*Scanner, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	var rf rulesFile
	if err := yaml.Unmarshal(data, &rf); err != nil {
		return nil, err
	}

	var rules []compiledRule
	for _, rc := range rf.Rules {
		re, err := regexp.Compile(rc.Pattern)
		if err != nil {
			return nil, fmt.Errorf("rule %q: %w", rc.Name, err)
		}
		rules = append(rules, compiledRule{name: rc.Name, pattern: re})
	}

	return &Scanner{rules: rules}, nil
}

// RuleCount returns the number of loaded rules.
func (s *Scanner) RuleCount() int {
	return len(s.rules)
}
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `go test ./scanner/ -v`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add scanner/ go.mod go.sum
git commit -m "feat: add scanner package with YAML rule loading"
```

---

### Task 4: Scanner — Scan for Sensitive Patterns

**Files:**
- Modify: `scanner/scanner.go`
- Modify: `scanner/scanner_test.go`

- [ ] **Step 1: Write failing tests for scanning**

```go
// scanner/scanner_test.go (append)

func writeRules(t *testing.T, content string) string {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "rules.yaml")
	os.WriteFile(path, []byte(content), 0644)
	return path
}

func TestScan_DetectsSSHPrivateKey(t *testing.T) {
	path := writeRules(t, `rules:
  - name: ssh-private-key
    pattern: "-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----"
`)
	s, _ := scanner.New(path)

	findings := s.Scan([]byte("some data\n-----BEGIN RSA PRIVATE KEY-----\nMIIE..."))
	if len(findings) != 1 {
		t.Fatalf("expected 1 finding, got %d", len(findings))
	}
	if findings[0].Rule != "ssh-private-key" {
		t.Errorf("expected rule 'ssh-private-key', got %q", findings[0].Rule)
	}
}

func TestScan_DetectsAWSKey(t *testing.T) {
	path := writeRules(t, `rules:
  - name: aws-access-key
    pattern: "AKIA[0-9A-Z]{16}"
`)
	s, _ := scanner.New(path)

	findings := s.Scan([]byte(`{"key": "AKIAIOSFODNN7EXAMPLE"}`))
	if len(findings) != 1 {
		t.Fatalf("expected 1 finding, got %d", len(findings))
	}
	if findings[0].Rule != "aws-access-key" {
		t.Errorf("expected rule 'aws-access-key', got %q", findings[0].Rule)
	}
}

func TestScan_ReturnsMultipleFindings(t *testing.T) {
	path := writeRules(t, `rules:
  - name: ssh-private-key
    pattern: "-----BEGIN RSA PRIVATE KEY-----"
  - name: aws-access-key
    pattern: "AKIA[0-9A-Z]{16}"
`)
	s, _ := scanner.New(path)

	body := []byte("-----BEGIN RSA PRIVATE KEY-----\nkey\nAKIAIOSFODNN7EXAMPLE")
	findings := s.Scan(body)
	if len(findings) != 2 {
		t.Fatalf("expected 2 findings, got %d", len(findings))
	}
}

func TestScan_ReturnsEmptyForCleanBody(t *testing.T) {
	path := writeRules(t, `rules:
  - name: ssh-private-key
    pattern: "-----BEGIN RSA PRIVATE KEY-----"
`)
	s, _ := scanner.New(path)

	findings := s.Scan([]byte("just some normal POST data"))
	if len(findings) != 0 {
		t.Errorf("expected 0 findings, got %d", len(findings))
	}
}

func TestScan_TruncatesMatchSnippet(t *testing.T) {
	path := writeRules(t, `rules:
  - name: ssh-private-key
    pattern: "-----BEGIN RSA PRIVATE KEY-----"
`)
	s, _ := scanner.New(path)

	findings := s.Scan([]byte("-----BEGIN RSA PRIVATE KEY-----"))
	if len(findings) != 1 {
		t.Fatalf("expected 1 finding, got %d", len(findings))
	}
	if len(findings[0].Match) > 40 {
		t.Errorf("expected match snippet to be truncated, got %d chars", len(findings[0].Match))
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./scanner/ -v -run "TestScan_"`
Expected: FAIL — `s.Scan` undefined

- [ ] **Step 3: Write minimal implementation**

```go
// scanner/scanner.go (append to Scanner methods)

// Scan checks body against all rules and returns any findings.
func (s *Scanner) Scan(body []byte) []Finding {
	var findings []Finding
	for _, rule := range s.rules {
		match := rule.pattern.Find(body)
		if match != nil {
			snippet := string(match)
			if len(snippet) > 40 {
				snippet = snippet[:40] + "..."
			}
			findings = append(findings, Finding{
				Rule:  rule.name,
				Match: snippet,
			})
		}
	}
	return findings
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./scanner/ -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add scanner/
git commit -m "feat: add request body scanning with regex rules"
```

---

### Task 5: Scanner — Default Rules File

**Files:**
- Modify: `scanner/scanner.go`
- Modify: `scanner/scanner_test.go`

- [ ] **Step 1: Write failing test for writing default rules**

```go
// scanner/scanner_test.go (append)

func TestWriteDefaultRules_CreatesFile(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "rules.yaml")

	err := scanner.WriteDefaultRules(path)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// Should be loadable
	s, err := scanner.New(path)
	if err != nil {
		t.Fatalf("failed to load default rules: %v", err)
	}

	if s.RuleCount() < 9 {
		t.Errorf("expected at least 9 default rules, got %d", s.RuleCount())
	}
}

func TestWriteDefaultRules_DoesNotOverwrite(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "rules.yaml")

	os.WriteFile(path, []byte(`rules:
  - name: custom
    pattern: "custom"
`), 0644)

	err := scanner.WriteDefaultRules(path)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	s, _ := scanner.New(path)
	if s.RuleCount() != 1 {
		t.Errorf("expected 1 rule (not overwritten), got %d", s.RuleCount())
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./scanner/ -v -run "TestWriteDefaultRules"`
Expected: FAIL — `scanner.WriteDefaultRules` undefined

- [ ] **Step 3: Write minimal implementation**

```go
// scanner/scanner.go (append)

const defaultRulesYAML = `rules:
  - name: ssh-private-key
    pattern: "-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----"
  - name: pgp-private-key
    pattern: "-----BEGIN PGP PRIVATE KEY BLOCK-----"
  - name: basic-auth
    pattern: "Authorization:\\s*Basic\\s+"
  - name: bearer-token
    pattern: "Authorization:\\s*Bearer\\s+"
  - name: aws-access-key
    pattern: "AKIA[0-9A-Z]{16}"
  - name: github-token
    pattern: "gh[ps]_[A-Za-z0-9_]{36,}"
  - name: openai-key
    pattern: "sk-[A-Za-z0-9]{32,}"
  - name: password-field
    pattern: "(password=|\"password\":\\s*\")"
  - name: env-file
    pattern: "(?m)^[A-Z_]+=.+\\n[A-Z_]+=.+\\n[A-Z_]+=.+"
`

// WriteDefaultRules writes the default rules file if it does not exist.
func WriteDefaultRules(path string) error {
	if _, err := os.Stat(path); err == nil {
		return nil // already exists
	}
	return os.WriteFile(path, []byte(defaultRulesYAML), 0644)
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./scanner/ -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add scanner/
git commit -m "feat: add default scanner rules with write-if-missing"
```

---

### Task 6: Proxy — Integrate Scanner into HTTP Handling

**Files:**
- Modify: `proxy/proxy.go`
- Modify: `proxy/proxy_test.go`

- [ ] **Step 1: Write failing test for body scanning on HTTP requests**

```go
// proxy/proxy_test.go (append)

func writeTestRules(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "rules.yaml")
	os.WriteFile(path, []byte(`rules:
  - name: ssh-private-key
    pattern: "-----BEGIN RSA PRIVATE KEY-----"
  - name: aws-access-key
    pattern: "AKIA[0-9A-Z]{16}"
`), 0644)
	return path
}

func TestShouldBlockRequestWithSSHKey(t *testing.T) {
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	defer backend.Close()

	backendURL, _ := url.Parse(backend.URL)
	hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
	os.WriteFile(hostsFile, []byte(backendURL.Hostname()+"\n"), 0644)

	rulesPath := writeTestRules(t)
	p := proxy.New(hostsFile, proxy.WithRules(rulesPath))
	srv := httptest.NewServer(p)
	defer srv.Close()

	client := newProxyClient(t, srv.URL)

	body := strings.NewReader("data=-----BEGIN RSA PRIVATE KEY-----\nMIIE...")
	resp, err := client.Post(backend.URL+"/upload", "text/plain", body)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusForbidden {
		t.Errorf("expected 403, got %d", resp.StatusCode)
	}
}

func TestShouldAllowCleanRequest(t *testing.T) {
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	defer backend.Close()

	backendURL, _ := url.Parse(backend.URL)
	hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
	os.WriteFile(hostsFile, []byte(backendURL.Hostname()+"\n"), 0644)

	rulesPath := writeTestRules(t)
	p := proxy.New(hostsFile, proxy.WithRules(rulesPath))
	srv := httptest.NewServer(p)
	defer srv.Close()

	client := newProxyClient(t, srv.URL)

	body := strings.NewReader("just normal data")
	resp, err := client.Post(backend.URL+"/upload", "text/plain", body)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./proxy/ -v -run "TestShouldBlock|TestShouldAllowClean"`
Expected: FAIL — `proxy.WithRules` undefined

- [ ] **Step 3: Update Proxy to accept scanner via functional options**

```go
// proxy/proxy.go — replace the Proxy struct and New function

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"strings"

	"nono/scanner"
)

type Proxy struct {
	hostsFile string
	scanner   *scanner.Scanner
}

type Option func(*Proxy)

func WithRules(rulesPath string) Option {
	return func(p *Proxy) {
		s, err := scanner.New(rulesPath)
		if err != nil {
			log.Printf("WARNING: failed to load scanner rules: %v", err)
			return
		}
		p.scanner = s
	}
}

func New(hostsFile string, opts ...Option) *Proxy {
	p := &Proxy{hostsFile: hostsFile}
	for _, opt := range opts {
		opt(p)
	}
	return p
}
```

Update `handleHTTP` to scan the request body before forwarding:

```go
func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
	if findings := p.scanRequest(r); len(findings) > 0 {
		names := make([]string, len(findings))
		for i, f := range findings {
			names[i] = f.Rule
		}
		log.Printf("BLOCKED %s %s %v", r.Method, r.Host, names)
		http.Error(w, fmt.Sprintf("request blocked: contains sensitive data (%s)", strings.Join(names, ", ")), http.StatusForbidden)
		return
	}

	r.RequestURI = ""
	resp, err := http.DefaultTransport.RoundTrip(r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()

	for k, vv := range resp.Header {
		for _, v := range vv {
			w.Header().Add(k, v)
		}
	}
	w.WriteHeader(resp.StatusCode)
	io.Copy(w, resp.Body)
}

func (p *Proxy) scanRequest(r *http.Request) []scanner.Finding {
	if p.scanner == nil || r.Body == nil {
		return nil
	}

	body, err := io.ReadAll(r.Body)
	r.Body.Close()
	if err != nil {
		return nil
	}

	// Also scan headers (for Authorization)
	var headerBuf bytes.Buffer
	for k, vv := range r.Header {
		for _, v := range vv {
			fmt.Fprintf(&headerBuf, "%s: %s\n", k, v)
		}
	}

	r.Body = io.NopCloser(bytes.NewReader(body))

	combined := append(headerBuf.Bytes(), body...)
	return p.scanner.Scan(combined)
}
```

- [ ] **Step 4: Update existing tests to use new `New()` signature**

The existing tests call `proxy.New(hostsFile)` — this still works since `opts` is variadic. No changes needed.

- [ ] **Step 5: Run all tests**

Run: `go test ./proxy/ -v`
Expected: PASS (all existing + new tests)

- [ ] **Step 6: Commit**

```bash
git add proxy/ scanner/
git commit -m "feat: integrate scanner into HTTP request handling"
```

---

### Task 7: Proxy — MITM CONNECT Handling

**Files:**
- Modify: `proxy/proxy.go`
- Modify: `proxy/proxy_test.go`

- [ ] **Step 1: Write failing test for MITM CONNECT with body scanning**

```go
// proxy/proxy_test.go (append)

import (
	"crypto/tls"
	"crypto/x509"

	nca "nono/ca"
)

func TestShouldBlockHTTPSRequestWithAWSKey(t *testing.T) {
	// Backend HTTPS server
	backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	defer backend.Close()

	backendURL, _ := url.Parse(backend.URL)
	host := backendURL.Hostname()

	hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
	os.WriteFile(hostsFile, []byte(host+"\n"), 0644)

	caDir := t.TempDir()
	caCert, caKey, err := nca.LoadOrCreate(caDir)
	if err != nil {
		t.Fatalf("CA setup: %v", err)
	}

	rulesPath := writeTestRules(t)
	p := proxy.New(hostsFile,
		proxy.WithRules(rulesPath),
		proxy.WithCA(caCert, caKey),
		proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}),
	)
	srv := httptest.NewServer(p)
	defer srv.Close()

	// Client that trusts the nono CA
	caPool := x509.NewCertPool()
	caPool.AddCert(caCert)

	proxyURL, _ := url.Parse(srv.URL)
	client := &http.Client{
		Transport: &http.Transport{
			Proxy: http.ProxyURL(proxyURL),
			TLSClientConfig: &tls.Config{
				RootCAs: caPool,
			},
		},
	}

	body := strings.NewReader("key=AKIAIOSFODNN7EXAMPLE")
	resp, err := client.Post(backend.URL+"/upload", "text/plain", body)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusForbidden {
		t.Errorf("expected 403, got %d", resp.StatusCode)
	}
}

func TestShouldAllowCleanHTTPSRequest(t *testing.T) {
	backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	}))
	defer backend.Close()

	backendURL, _ := url.Parse(backend.URL)
	host := backendURL.Hostname()

	hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
	os.WriteFile(hostsFile, []byte(host+"\n"), 0644)

	caDir := t.TempDir()
	caCert, caKey, err := nca.LoadOrCreate(caDir)
	if err != nil {
		t.Fatalf("CA setup: %v", err)
	}

	rulesPath := writeTestRules(t)
	p := proxy.New(hostsFile,
		proxy.WithRules(rulesPath),
		proxy.WithCA(caCert, caKey),
		proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}),
	)
	srv := httptest.NewServer(p)
	defer srv.Close()

	caPool := x509.NewCertPool()
	caPool.AddCert(caCert)

	proxyURL, _ := url.Parse(srv.URL)
	client := &http.Client{
		Transport: &http.Transport{
			Proxy: http.ProxyURL(proxyURL),
			TLSClientConfig: &tls.Config{
				RootCAs: caPool,
			},
		},
	}

	resp, err := client.Post(backend.URL+"/data", "text/plain", strings.NewReader("clean"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./proxy/ -v -run "TestShouldBlockHTTPS|TestShouldAllowCleanHTTPS"`
Expected: FAIL — `proxy.WithCA` and `proxy.WithUpstreamTLS` undefined

- [ ] **Step 3: Add CA fields and MITM handleConnect**

Add options to `proxy.go`:

```go
import (
	"crypto/ecdsa"
	"crypto/tls"
	"crypto/x509"
	"sync"

	nca "nono/ca"
)

// Add to Proxy struct:
type Proxy struct {
	hostsFile   string
	scanner     *scanner.Scanner
	caCert      *x509.Certificate
	caKey       *ecdsa.PrivateKey
	certCache   map[string]*tls.Certificate
	certMu      sync.Mutex
	upstreamTLS *tls.Config
}

func WithCA(cert *x509.Certificate, key *ecdsa.PrivateKey) Option {
	return func(p *Proxy) {
		p.caCert = cert
		p.caKey = key
		p.certCache = make(map[string]*tls.Certificate)
	}
}

func WithUpstreamTLS(cfg *tls.Config) Option {
	return func(p *Proxy) {
		p.upstreamTLS = cfg
	}
}
```

Replace `handleConnect`:

```go
func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
	// If no CA configured, fall back to blind tunnel
	if p.caCert == nil {
		p.handleConnectTunnel(w, r)
		return
	}

	p.handleConnectMITM(w, r)
}

// handleConnectTunnel is the original blind-tunnel behavior.
func (p *Proxy) handleConnectTunnel(w http.ResponseWriter, r *http.Request) {
	targetConn, err := net.Dial("tcp", r.Host)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}

	hj, ok := w.(http.Hijacker)
	if !ok {
		http.Error(w, "hijacking not supported", http.StatusInternalServerError)
		return
	}

	clientConn, _, err := hj.Hijack()
	if err != nil {
		targetConn.Close()
		return
	}

	clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))

	go io.Copy(targetConn, clientConn)
	io.Copy(clientConn, targetConn)

	clientConn.Close()
	targetConn.Close()
}

func (p *Proxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) {
	host := extractHost(r.Host)

	hj, ok := w.(http.Hijacker)
	if !ok {
		http.Error(w, "hijacking not supported", http.StatusInternalServerError)
		return
	}

	clientConn, _, err := hj.Hijack()
	if err != nil {
		return
	}

	clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))

	// Get or create leaf cert for this host
	leafCert, err := p.getOrCreateLeaf(host)
	if err != nil {
		log.Printf("MITM cert error for %s: %v", host, err)
		clientConn.Close()
		return
	}

	// TLS handshake with client
	tlsClientConn := tls.Server(clientConn, &tls.Config{
		Certificates: []tls.Certificate{*leafCert},
	})
	if err := tlsClientConn.Handshake(); err != nil {
		log.Printf("MITM client handshake error: %v", err)
		clientConn.Close()
		return
	}

	// Read HTTP request from the TLS connection
	bufReader := bufio.NewReader(tlsClientConn)
	innerReq, err := http.ReadRequest(bufReader)
	if err != nil {
		tlsClientConn.Close()
		return
	}

	// Scan the request
	if findings := p.scanRequest(innerReq); len(findings) > 0 {
		names := make([]string, len(findings))
		for i, f := range findings {
			names[i] = f.Rule
		}
		log.Printf("BLOCKED %s %s %v", innerReq.Method, host, names)
		resp := &http.Response{
			StatusCode: http.StatusForbidden,
			Proto:      "HTTP/1.1",
			ProtoMajor: 1,
			ProtoMinor: 1,
			Header:     make(http.Header),
			Body: io.NopCloser(strings.NewReader(
				fmt.Sprintf("request blocked: contains sensitive data (%s)", strings.Join(names, ", ")),
			)),
		}
		resp.Header.Set("Content-Type", "text/plain")
		resp.Write(tlsClientConn)
		tlsClientConn.Close()
		return
	}

	// Connect to the real target
	upstreamTLSCfg := &tls.Config{ServerName: host}
	if p.upstreamTLS != nil {
		upstreamTLSCfg = p.upstreamTLS.Clone()
		upstreamTLSCfg.ServerName = host
	}
	targetConn, err := tls.Dial("tcp", r.Host, upstreamTLSCfg)
	if err != nil {
		log.Printf("MITM upstream dial error: %v", err)
		tlsClientConn.Close()
		return
	}

	// Forward the request to the target
	innerReq.URL.Scheme = "https"
	innerReq.URL.Host = r.Host
	innerReq.RequestURI = ""
	if err := innerReq.Write(targetConn); err != nil {
		targetConn.Close()
		tlsClientConn.Close()
		return
	}

	// Relay the response back
	targetBuf := bufio.NewReader(targetConn)
	resp, err := http.ReadResponse(targetBuf, innerReq)
	if err != nil {
		targetConn.Close()
		tlsClientConn.Close()
		return
	}

	resp.Write(tlsClientConn)
	resp.Body.Close()

	tlsClientConn.Close()
	targetConn.Close()
}

func (p *Proxy) getOrCreateLeaf(host string) (*tls.Certificate, error) {
	p.certMu.Lock()
	defer p.certMu.Unlock()

	if cert, ok := p.certCache[host]; ok {
		return cert, nil
	}

	cert, err := nca.GenerateLeaf(host, p.caCert, p.caKey)
	if err != nil {
		return nil, err
	}

	p.certCache[host] = &cert
	return &cert, nil
}
```

- [ ] **Step 4: Run all tests**

Run: `go test ./proxy/ -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add proxy/
git commit -m "feat: add MITM CONNECT handling with body scanning"
```

---

### Task 8: Main — Wire Up CA, Scanner, and Default Rules

**Files:**
- Modify: `cmd/nono-proxy/main.go`

- [ ] **Step 1: Update main to load CA and scanner**

```go
// cmd/nono-proxy/main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"

	"nono/ca"
	"nono/proxy"
	"nono/scanner"
)

func main() {
	store := storePath()

	if len(os.Args) > 1 && os.Args[1] == "allow" {
		if len(os.Args) < 3 {
			fmt.Fprintln(os.Stderr, "usage: nono-proxy allow <host>")
			os.Exit(1)
		}
		hostsFile := filepath.Join(store, "approved_hosts")
		if err := proxy.Allow(hostsFile, os.Args[2]); err != nil {
			log.Fatalf("failed to allow host: %v", err)
		}
		fmt.Printf("allowed %s\n", os.Args[2])
		return
	}

	addr := ":9854"
	os.MkdirAll(store, 0755)

	hostsFile := filepath.Join(store, "approved_hosts")
	rulesPath := filepath.Join(store, "rules.yaml")

	// Write default rules if missing
	if err := scanner.WriteDefaultRules(rulesPath); err != nil {
		log.Fatalf("failed to write default rules: %v", err)
	}

	// Load or generate CA
	caCert, caKey, err := ca.LoadOrCreate(store)
	if err != nil {
		log.Fatalf("failed to load/create CA: %v", err)
	}
	log.Printf("CA cert: %s/ca.pem", store)

	opts := []proxy.Option{
		proxy.WithRules(rulesPath),
		proxy.WithCA(caCert, caKey),
	}

	p := proxy.New(hostsFile, opts...)
	log.Printf("nono-proxy listening on %s (hosts: %s, rules: %s)", addr, hostsFile, rulesPath)
	log.Fatal(http.ListenAndServe(addr, p))
}

func storePath() string {
	store := os.Getenv("NONO_STORE")
	if store == "" {
		home, _ := os.UserHomeDir()
		store = filepath.Join(home, ".local", "share", "nono")
	}
	return store
}
```

- [ ] **Step 2: Verify it builds**

Run: `go build ./cmd/nono-proxy/`
Expected: SUCCESS

- [ ] **Step 3: Commit**

```bash
git add cmd/nono-proxy/main.go
git commit -m "feat: wire up CA and scanner in nono-proxy main"
```

---

### Task 9: Nono Script — Trust CA in Sandbox

**Files:**
- Modify: `nono`

- [ ] **Step 1: Add CA cert bind-mount and env vars to the nono script**

After the proxy detection block (line ~69), add CA cert trust:

```bash
# CA cert for MITM (set if ca.pem exists)
CA_CERT="$STORE/ca.pem"
if [[ -f "$CA_CERT" ]]; then
    args+=(
        --ro-bind "$CA_CERT" "$CA_CERT"
        --setenv SSL_CERT_FILE "$CA_CERT"
        --setenv NODE_EXTRA_CA_CERTS "$CA_CERT"
    )
fi
```

- [ ] **Step 2: Manually verify**

Run: `./nono echo "test"` (in a sandbox with CA present)
Expected: Should execute without error, `SSL_CERT_FILE` should be set

- [ ] **Step 3: Commit**

```bash
git add nono
git commit -m "feat: trust nono CA cert inside sandbox"
```

---

### Task 10: Run Full Test Suite and Verify

- [ ] **Step 1: Run all tests**

Run: `go test ./... -v`
Expected: ALL PASS

- [ ] **Step 2: Run with race detector**

Run: `go test -race ./...`
Expected: No race conditions

- [ ] **Step 3: Build and smoke test**

```bash
make build
./nono-proxy &
# In another terminal: test a blocked request
curl -x http://localhost:9854 http://httpbin.org/post -d "-----BEGIN RSA PRIVATE KEY-----"
# Expected: 403 forbidden
kill %1
```

- [ ] **Step 4: Final commit if any cleanup needed**

```bash
git add -A
git commit -m "chore: final cleanup after exfil detection integration"
```