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" ```