3f877c9e
docs: add exfiltration detection implementation plan
a73x 2026-03-29 16:14
Commit message
docs/superpowers/plans/2026-03-29-exfil-detection.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1436 @@ | |||
| 1 | # Exfiltration Detection Implementation Plan | ||
| 2 | |||
| 3 | > **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. | ||
| 4 | |||
| 5 | **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.). | ||
| 6 | |||
| 7 | **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. | ||
| 8 | |||
| 9 | **Tech Stack:** Go stdlib (`crypto/x509`, `crypto/tls`, `crypto/ecdsa`, `crypto/elliptic`), `gopkg.in/yaml.v3` | ||
| 10 | |||
| 11 | --- | ||
| 12 | |||
| 13 | ## File Structure | ||
| 14 | |||
| 15 | | File | Action | Responsibility | | ||
| 16 | |------|--------|----------------| | ||
| 17 | | `ca/ca.go` | Create | Generate/load CA keypair, generate per-host leaf certs | | ||
| 18 | | `ca/ca_test.go` | Create | Tests for CA generation and leaf cert signing | | ||
| 19 | | `scanner/scanner.go` | Create | Load rules from YAML, compile regexes, scan bytes for matches | | ||
| 20 | | `scanner/scanner_test.go` | Create | Tests for rule loading and pattern matching | | ||
| 21 | | `proxy/proxy.go` | Modify | Add MITM CONNECT handling, integrate scanner into request flow | | ||
| 22 | | `proxy/proxy_test.go` | Modify | Add tests for MITM and body scanning integration | | ||
| 23 | | `cmd/nono-proxy/main.go` | Modify | Load CA, init scanner, write default rules.yaml, pass to proxy | | ||
| 24 | | `nono` | Modify | Bind-mount CA cert, set SSL_CERT_FILE and NODE_EXTRA_CA_CERTS | | ||
| 25 | | `go.mod` | Modify | Add `gopkg.in/yaml.v3` dependency | | ||
| 26 | |||
| 27 | --- | ||
| 28 | |||
| 29 | ### Task 1: CA — Generate and Load CA Certificate | ||
| 30 | |||
| 31 | **Files:** | ||
| 32 | - Create: `ca/ca.go` | ||
| 33 | - Create: `ca/ca_test.go` | ||
| 34 | |||
| 35 | - [ ] **Step 1: Write failing test for CA generation** | ||
| 36 | |||
| 37 | ```go | ||
| 38 | // ca/ca_test.go | ||
| 39 | package ca_test | ||
| 40 | |||
| 41 | import ( | ||
| 42 | "crypto/x509" | ||
| 43 | "encoding/pem" | ||
| 44 | "os" | ||
| 45 | "path/filepath" | ||
| 46 | "testing" | ||
| 47 | |||
| 48 | "nono/ca" | ||
| 49 | ) | ||
| 50 | |||
| 51 | func TestLoadOrCreateCA_GeneratesNewCA(t *testing.T) { | ||
| 52 | dir := t.TempDir() | ||
| 53 | |||
| 54 | caCert, caKey, err := ca.LoadOrCreate(dir) | ||
| 55 | if err != nil { | ||
| 56 | t.Fatalf("unexpected error: %v", err) | ||
| 57 | } | ||
| 58 | |||
| 59 | if caCert == nil { | ||
| 60 | t.Fatal("expected CA cert, got nil") | ||
| 61 | } | ||
| 62 | if caKey == nil { | ||
| 63 | t.Fatal("expected CA key, got nil") | ||
| 64 | } | ||
| 65 | if !caCert.IsCA { | ||
| 66 | t.Error("expected cert to be a CA") | ||
| 67 | } | ||
| 68 | if caCert.Subject.CommonName != "Nono Proxy CA" { | ||
| 69 | t.Errorf("expected CN 'Nono Proxy CA', got %q", caCert.Subject.CommonName) | ||
| 70 | } | ||
| 71 | |||
| 72 | // Files should exist on disk | ||
| 73 | if _, err := os.Stat(filepath.Join(dir, "ca.pem")); err != nil { | ||
| 74 | t.Errorf("ca.pem not written: %v", err) | ||
| 75 | } | ||
| 76 | if _, err := os.Stat(filepath.Join(dir, "ca.key")); err != nil { | ||
| 77 | t.Errorf("ca.key not written: %v", err) | ||
| 78 | } | ||
| 79 | } | ||
| 80 | ``` | ||
| 81 | |||
| 82 | - [ ] **Step 2: Run test to verify it fails** | ||
| 83 | |||
| 84 | Run: `go test ./ca/ -v -run TestLoadOrCreateCA_GeneratesNewCA` | ||
| 85 | Expected: FAIL — package `ca` does not exist | ||
| 86 | |||
| 87 | - [ ] **Step 3: Write minimal implementation** | ||
| 88 | |||
| 89 | ```go | ||
| 90 | // ca/ca.go | ||
| 91 | package ca | ||
| 92 | |||
| 93 | import ( | ||
| 94 | "crypto/ecdsa" | ||
| 95 | "crypto/elliptic" | ||
| 96 | "crypto/rand" | ||
| 97 | "crypto/x509" | ||
| 98 | "crypto/x509/pkix" | ||
| 99 | "encoding/pem" | ||
| 100 | "math/big" | ||
| 101 | "os" | ||
| 102 | "path/filepath" | ||
| 103 | "time" | ||
| 104 | ) | ||
| 105 | |||
| 106 | // LoadOrCreate loads a CA cert+key from dir, or generates a new one if missing. | ||
| 107 | func LoadOrCreate(dir string) (*x509.Certificate, *ecdsa.PrivateKey, error) { | ||
| 108 | certPath := filepath.Join(dir, "ca.pem") | ||
| 109 | keyPath := filepath.Join(dir, "ca.key") | ||
| 110 | |||
| 111 | certPEM, certErr := os.ReadFile(certPath) | ||
| 112 | keyPEM, keyErr := os.ReadFile(keyPath) | ||
| 113 | |||
| 114 | if certErr == nil && keyErr == nil { | ||
| 115 | return parse(certPEM, keyPEM) | ||
| 116 | } | ||
| 117 | |||
| 118 | return generate(certPath, keyPath) | ||
| 119 | } | ||
| 120 | |||
| 121 | func parse(certPEM, keyPEM []byte) (*x509.Certificate, *ecdsa.PrivateKey, error) { | ||
| 122 | block, _ := pem.Decode(certPEM) | ||
| 123 | cert, err := x509.ParseCertificate(block.Bytes) | ||
| 124 | if err != nil { | ||
| 125 | return nil, nil, err | ||
| 126 | } | ||
| 127 | |||
| 128 | keyBlock, _ := pem.Decode(keyPEM) | ||
| 129 | key, err := x509.ParseECPrivateKey(keyBlock.Bytes) | ||
| 130 | if err != nil { | ||
| 131 | return nil, nil, err | ||
| 132 | } | ||
| 133 | |||
| 134 | return cert, key, nil | ||
| 135 | } | ||
| 136 | |||
| 137 | func generate(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) { | ||
| 138 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||
| 139 | if err != nil { | ||
| 140 | return nil, nil, err | ||
| 141 | } | ||
| 142 | |||
| 143 | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | ||
| 144 | if err != nil { | ||
| 145 | return nil, nil, err | ||
| 146 | } | ||
| 147 | |||
| 148 | template := &x509.Certificate{ | ||
| 149 | SerialNumber: serial, | ||
| 150 | Subject: pkix.Name{CommonName: "Nono Proxy CA"}, | ||
| 151 | NotBefore: time.Now(), | ||
| 152 | NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), | ||
| 153 | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | ||
| 154 | BasicConstraintsValid: true, | ||
| 155 | IsCA: true, | ||
| 156 | } | ||
| 157 | |||
| 158 | certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) | ||
| 159 | if err != nil { | ||
| 160 | return nil, nil, err | ||
| 161 | } | ||
| 162 | |||
| 163 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | ||
| 164 | if err := os.WriteFile(certPath, certPEM, 0644); err != nil { | ||
| 165 | return nil, nil, err | ||
| 166 | } | ||
| 167 | |||
| 168 | keyDER, err := x509.MarshalECPrivateKey(key) | ||
| 169 | if err != nil { | ||
| 170 | return nil, nil, err | ||
| 171 | } | ||
| 172 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | ||
| 173 | if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { | ||
| 174 | return nil, nil, err | ||
| 175 | } | ||
| 176 | |||
| 177 | cert, err := x509.ParseCertificate(certDER) | ||
| 178 | if err != nil { | ||
| 179 | return nil, nil, err | ||
| 180 | } | ||
| 181 | |||
| 182 | return cert, key, nil | ||
| 183 | } | ||
| 184 | ``` | ||
| 185 | |||
| 186 | - [ ] **Step 4: Run test to verify it passes** | ||
| 187 | |||
| 188 | Run: `go test ./ca/ -v -run TestLoadOrCreateCA_GeneratesNewCA` | ||
| 189 | Expected: PASS | ||
| 190 | |||
| 191 | - [ ] **Step 5: Write failing test for loading existing CA** | ||
| 192 | |||
| 193 | ```go | ||
| 194 | // ca/ca_test.go (append) | ||
| 195 | func TestLoadOrCreateCA_LoadsExistingCA(t *testing.T) { | ||
| 196 | dir := t.TempDir() | ||
| 197 | |||
| 198 | // Generate first | ||
| 199 | cert1, _, err := ca.LoadOrCreate(dir) | ||
| 200 | if err != nil { | ||
| 201 | t.Fatalf("generate: %v", err) | ||
| 202 | } | ||
| 203 | |||
| 204 | // Load second time | ||
| 205 | cert2, _, err := ca.LoadOrCreate(dir) | ||
| 206 | if err != nil { | ||
| 207 | t.Fatalf("load: %v", err) | ||
| 208 | } | ||
| 209 | |||
| 210 | if !cert1.Equal(cert2) { | ||
| 211 | t.Error("expected same cert on reload") | ||
| 212 | } | ||
| 213 | } | ||
| 214 | ``` | ||
| 215 | |||
| 216 | - [ ] **Step 6: Run test to verify it passes** (implementation already handles this) | ||
| 217 | |||
| 218 | Run: `go test ./ca/ -v -run TestLoadOrCreateCA_LoadsExistingCA` | ||
| 219 | Expected: PASS | ||
| 220 | |||
| 221 | - [ ] **Step 7: Commit** | ||
| 222 | |||
| 223 | ```bash | ||
| 224 | git add ca/ | ||
| 225 | git commit -m "feat: add CA certificate generation and loading" | ||
| 226 | ``` | ||
| 227 | |||
| 228 | --- | ||
| 229 | |||
| 230 | ### Task 2: CA — Generate Leaf Certificates | ||
| 231 | |||
| 232 | **Files:** | ||
| 233 | - Modify: `ca/ca.go` | ||
| 234 | - Modify: `ca/ca_test.go` | ||
| 235 | |||
| 236 | - [ ] **Step 1: Write failing test for leaf cert generation** | ||
| 237 | |||
| 238 | ```go | ||
| 239 | // ca/ca_test.go (append) | ||
| 240 | func TestGenerateLeafCert(t *testing.T) { | ||
| 241 | dir := t.TempDir() | ||
| 242 | caCert, caKey, err := ca.LoadOrCreate(dir) | ||
| 243 | if err != nil { | ||
| 244 | t.Fatalf("CA setup: %v", err) | ||
| 245 | } | ||
| 246 | |||
| 247 | tlsCert, err := ca.GenerateLeaf("example.com", caCert, caKey) | ||
| 248 | if err != nil { | ||
| 249 | t.Fatalf("unexpected error: %v", err) | ||
| 250 | } | ||
| 251 | |||
| 252 | leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) | ||
| 253 | if err != nil { | ||
| 254 | t.Fatalf("parse leaf: %v", err) | ||
| 255 | } | ||
| 256 | |||
| 257 | if leaf.Subject.CommonName != "example.com" { | ||
| 258 | t.Errorf("expected CN 'example.com', got %q", leaf.Subject.CommonName) | ||
| 259 | } | ||
| 260 | |||
| 261 | // Verify the leaf is signed by the CA | ||
| 262 | pool := x509.NewCertPool() | ||
| 263 | pool.AddCert(caCert) | ||
| 264 | if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool}); err != nil { | ||
| 265 | t.Errorf("leaf cert not signed by CA: %v", err) | ||
| 266 | } | ||
| 267 | } | ||
| 268 | ``` | ||
| 269 | |||
| 270 | - [ ] **Step 2: Run test to verify it fails** | ||
| 271 | |||
| 272 | Run: `go test ./ca/ -v -run TestGenerateLeafCert` | ||
| 273 | Expected: FAIL — `ca.GenerateLeaf` undefined | ||
| 274 | |||
| 275 | - [ ] **Step 3: Write minimal implementation** | ||
| 276 | |||
| 277 | ```go | ||
| 278 | // ca/ca.go (append) | ||
| 279 | import "crypto/tls" | ||
| 280 | |||
| 281 | // GenerateLeaf creates a TLS certificate for host, signed by the CA. | ||
| 282 | func GenerateLeaf(host string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey) (tls.Certificate, error) { | ||
| 283 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||
| 284 | if err != nil { | ||
| 285 | return tls.Certificate{}, err | ||
| 286 | } | ||
| 287 | |||
| 288 | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | ||
| 289 | if err != nil { | ||
| 290 | return tls.Certificate{}, err | ||
| 291 | } | ||
| 292 | |||
| 293 | template := &x509.Certificate{ | ||
| 294 | SerialNumber: serial, | ||
| 295 | Subject: pkix.Name{CommonName: host}, | ||
| 296 | DNSNames: []string{host}, | ||
| 297 | NotBefore: time.Now(), | ||
| 298 | NotAfter: time.Now().Add(24 * time.Hour), | ||
| 299 | KeyUsage: x509.KeyUsageDigitalSignature, | ||
| 300 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | ||
| 301 | } | ||
| 302 | |||
| 303 | certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey) | ||
| 304 | if err != nil { | ||
| 305 | return tls.Certificate{}, err | ||
| 306 | } | ||
| 307 | |||
| 308 | return tls.Certificate{ | ||
| 309 | Certificate: [][]byte{certDER}, | ||
| 310 | PrivateKey: key, | ||
| 311 | }, nil | ||
| 312 | } | ||
| 313 | ``` | ||
| 314 | |||
| 315 | Note: The `crypto/tls` import needs to be added to the existing import block in `ca/ca.go`. | ||
| 316 | |||
| 317 | - [ ] **Step 4: Run test to verify it passes** | ||
| 318 | |||
| 319 | Run: `go test ./ca/ -v -run TestGenerateLeafCert` | ||
| 320 | Expected: PASS | ||
| 321 | |||
| 322 | - [ ] **Step 5: Commit** | ||
| 323 | |||
| 324 | ```bash | ||
| 325 | git add ca/ | ||
| 326 | git commit -m "feat: add leaf certificate generation for MITM" | ||
| 327 | ``` | ||
| 328 | |||
| 329 | --- | ||
| 330 | |||
| 331 | ### Task 3: Scanner — Load Rules from YAML | ||
| 332 | |||
| 333 | **Files:** | ||
| 334 | - Create: `scanner/scanner.go` | ||
| 335 | - Create: `scanner/scanner_test.go` | ||
| 336 | |||
| 337 | - [ ] **Step 1: Add yaml dependency** | ||
| 338 | |||
| 339 | Run: `go get gopkg.in/yaml.v3` | ||
| 340 | |||
| 341 | - [ ] **Step 2: Write failing test for loading rules** | ||
| 342 | |||
| 343 | ```go | ||
| 344 | // scanner/scanner_test.go | ||
| 345 | package scanner_test | ||
| 346 | |||
| 347 | import ( | ||
| 348 | "os" | ||
| 349 | "path/filepath" | ||
| 350 | "testing" | ||
| 351 | |||
| 352 | "nono/scanner" | ||
| 353 | ) | ||
| 354 | |||
| 355 | func TestNewScanner_LoadsRulesFromYAML(t *testing.T) { | ||
| 356 | dir := t.TempDir() | ||
| 357 | rulesPath := filepath.Join(dir, "rules.yaml") | ||
| 358 | os.WriteFile(rulesPath, []byte(`rules: | ||
| 359 | - name: ssh-key | ||
| 360 | pattern: "-----BEGIN RSA PRIVATE KEY-----" | ||
| 361 | - name: aws-key | ||
| 362 | pattern: "AKIA[0-9A-Z]{16}" | ||
| 363 | `), 0644) | ||
| 364 | |||
| 365 | s, err := scanner.New(rulesPath) | ||
| 366 | if err != nil { | ||
| 367 | t.Fatalf("unexpected error: %v", err) | ||
| 368 | } | ||
| 369 | |||
| 370 | if s.RuleCount() != 2 { | ||
| 371 | t.Errorf("expected 2 rules, got %d", s.RuleCount()) | ||
| 372 | } | ||
| 373 | } | ||
| 374 | |||
| 375 | func TestNewScanner_RejectsInvalidRegex(t *testing.T) { | ||
| 376 | dir := t.TempDir() | ||
| 377 | rulesPath := filepath.Join(dir, "rules.yaml") | ||
| 378 | os.WriteFile(rulesPath, []byte(`rules: | ||
| 379 | - name: bad-rule | ||
| 380 | pattern: "[invalid" | ||
| 381 | `), 0644) | ||
| 382 | |||
| 383 | _, err := scanner.New(rulesPath) | ||
| 384 | if err == nil { | ||
| 385 | t.Fatal("expected error for invalid regex") | ||
| 386 | } | ||
| 387 | } | ||
| 388 | ``` | ||
| 389 | |||
| 390 | - [ ] **Step 3: Run tests to verify they fail** | ||
| 391 | |||
| 392 | Run: `go test ./scanner/ -v` | ||
| 393 | Expected: FAIL — package `scanner` does not exist | ||
| 394 | |||
| 395 | - [ ] **Step 4: Write minimal implementation** | ||
| 396 | |||
| 397 | ```go | ||
| 398 | // scanner/scanner.go | ||
| 399 | package scanner | ||
| 400 | |||
| 401 | import ( | ||
| 402 | "fmt" | ||
| 403 | "os" | ||
| 404 | "regexp" | ||
| 405 | |||
| 406 | "gopkg.in/yaml.v3" | ||
| 407 | ) | ||
| 408 | |||
| 409 | type ruleConfig struct { | ||
| 410 | Name string `yaml:"name"` | ||
| 411 | Pattern string `yaml:"pattern"` | ||
| 412 | } | ||
| 413 | |||
| 414 | type rulesFile struct { | ||
| 415 | Rules []ruleConfig `yaml:"rules"` | ||
| 416 | } | ||
| 417 | |||
| 418 | type compiledRule struct { | ||
| 419 | name string | ||
| 420 | pattern *regexp.Regexp | ||
| 421 | } | ||
| 422 | |||
| 423 | // Finding represents a scanner match. | ||
| 424 | type Finding struct { | ||
| 425 | Rule string | ||
| 426 | Match string | ||
| 427 | } | ||
| 428 | |||
| 429 | // Scanner checks byte slices against a set of regex rules. | ||
| 430 | type Scanner struct { | ||
| 431 | rules []compiledRule | ||
| 432 | } | ||
| 433 | |||
| 434 | // New loads rules from a YAML file and compiles their regexes. | ||
| 435 | func New(path string) (*Scanner, error) { | ||
| 436 | data, err := os.ReadFile(path) | ||
| 437 | if err != nil { | ||
| 438 | return nil, err | ||
| 439 | } | ||
| 440 | |||
| 441 | var rf rulesFile | ||
| 442 | if err := yaml.Unmarshal(data, &rf); err != nil { | ||
| 443 | return nil, err | ||
| 444 | } | ||
| 445 | |||
| 446 | var rules []compiledRule | ||
| 447 | for _, rc := range rf.Rules { | ||
| 448 | re, err := regexp.Compile(rc.Pattern) | ||
| 449 | if err != nil { | ||
| 450 | return nil, fmt.Errorf("rule %q: %w", rc.Name, err) | ||
| 451 | } | ||
| 452 | rules = append(rules, compiledRule{name: rc.Name, pattern: re}) | ||
| 453 | } | ||
| 454 | |||
| 455 | return &Scanner{rules: rules}, nil | ||
| 456 | } | ||
| 457 | |||
| 458 | // RuleCount returns the number of loaded rules. | ||
| 459 | func (s *Scanner) RuleCount() int { | ||
| 460 | return len(s.rules) | ||
| 461 | } | ||
| 462 | ``` | ||
| 463 | |||
| 464 | - [ ] **Step 5: Run tests to verify they pass** | ||
| 465 | |||
| 466 | Run: `go test ./scanner/ -v` | ||
| 467 | Expected: PASS | ||
| 468 | |||
| 469 | - [ ] **Step 6: Commit** | ||
| 470 | |||
| 471 | ```bash | ||
| 472 | git add scanner/ go.mod go.sum | ||
| 473 | git commit -m "feat: add scanner package with YAML rule loading" | ||
| 474 | ``` | ||
| 475 | |||
| 476 | --- | ||
| 477 | |||
| 478 | ### Task 4: Scanner — Scan for Sensitive Patterns | ||
| 479 | |||
| 480 | **Files:** | ||
| 481 | - Modify: `scanner/scanner.go` | ||
| 482 | - Modify: `scanner/scanner_test.go` | ||
| 483 | |||
| 484 | - [ ] **Step 1: Write failing tests for scanning** | ||
| 485 | |||
| 486 | ```go | ||
| 487 | // scanner/scanner_test.go (append) | ||
| 488 | |||
| 489 | func writeRules(t *testing.T, content string) string { | ||
| 490 | t.Helper() | ||
| 491 | dir := t.TempDir() | ||
| 492 | path := filepath.Join(dir, "rules.yaml") | ||
| 493 | os.WriteFile(path, []byte(content), 0644) | ||
| 494 | return path | ||
| 495 | } | ||
| 496 | |||
| 497 | func TestScan_DetectsSSHPrivateKey(t *testing.T) { | ||
| 498 | path := writeRules(t, `rules: | ||
| 499 | - name: ssh-private-key | ||
| 500 | pattern: "-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----" | ||
| 501 | `) | ||
| 502 | s, _ := scanner.New(path) | ||
| 503 | |||
| 504 | findings := s.Scan([]byte("some data\n-----BEGIN RSA PRIVATE KEY-----\nMIIE...")) | ||
| 505 | if len(findings) != 1 { | ||
| 506 | t.Fatalf("expected 1 finding, got %d", len(findings)) | ||
| 507 | } | ||
| 508 | if findings[0].Rule != "ssh-private-key" { | ||
| 509 | t.Errorf("expected rule 'ssh-private-key', got %q", findings[0].Rule) | ||
| 510 | } | ||
| 511 | } | ||
| 512 | |||
| 513 | func TestScan_DetectsAWSKey(t *testing.T) { | ||
| 514 | path := writeRules(t, `rules: | ||
| 515 | - name: aws-access-key | ||
| 516 | pattern: "AKIA[0-9A-Z]{16}" | ||
| 517 | `) | ||
| 518 | s, _ := scanner.New(path) | ||
| 519 | |||
| 520 | findings := s.Scan([]byte(`{"key": "AKIAIOSFODNN7EXAMPLE"}`)) | ||
| 521 | if len(findings) != 1 { | ||
| 522 | t.Fatalf("expected 1 finding, got %d", len(findings)) | ||
| 523 | } | ||
| 524 | if findings[0].Rule != "aws-access-key" { | ||
| 525 | t.Errorf("expected rule 'aws-access-key', got %q", findings[0].Rule) | ||
| 526 | } | ||
| 527 | } | ||
| 528 | |||
| 529 | func TestScan_ReturnsMultipleFindings(t *testing.T) { | ||
| 530 | path := writeRules(t, `rules: | ||
| 531 | - name: ssh-private-key | ||
| 532 | pattern: "-----BEGIN RSA PRIVATE KEY-----" | ||
| 533 | - name: aws-access-key | ||
| 534 | pattern: "AKIA[0-9A-Z]{16}" | ||
| 535 | `) | ||
| 536 | s, _ := scanner.New(path) | ||
| 537 | |||
| 538 | body := []byte("-----BEGIN RSA PRIVATE KEY-----\nkey\nAKIAIOSFODNN7EXAMPLE") | ||
| 539 | findings := s.Scan(body) | ||
| 540 | if len(findings) != 2 { | ||
| 541 | t.Fatalf("expected 2 findings, got %d", len(findings)) | ||
| 542 | } | ||
| 543 | } | ||
| 544 | |||
| 545 | func TestScan_ReturnsEmptyForCleanBody(t *testing.T) { | ||
| 546 | path := writeRules(t, `rules: | ||
| 547 | - name: ssh-private-key | ||
| 548 | pattern: "-----BEGIN RSA PRIVATE KEY-----" | ||
| 549 | `) | ||
| 550 | s, _ := scanner.New(path) | ||
| 551 | |||
| 552 | findings := s.Scan([]byte("just some normal POST data")) | ||
| 553 | if len(findings) != 0 { | ||
| 554 | t.Errorf("expected 0 findings, got %d", len(findings)) | ||
| 555 | } | ||
| 556 | } | ||
| 557 | |||
| 558 | func TestScan_TruncatesMatchSnippet(t *testing.T) { | ||
| 559 | path := writeRules(t, `rules: | ||
| 560 | - name: ssh-private-key | ||
| 561 | pattern: "-----BEGIN RSA PRIVATE KEY-----" | ||
| 562 | `) | ||
| 563 | s, _ := scanner.New(path) | ||
| 564 | |||
| 565 | findings := s.Scan([]byte("-----BEGIN RSA PRIVATE KEY-----")) | ||
| 566 | if len(findings) != 1 { | ||
| 567 | t.Fatalf("expected 1 finding, got %d", len(findings)) | ||
| 568 | } | ||
| 569 | if len(findings[0].Match) > 40 { | ||
| 570 | t.Errorf("expected match snippet to be truncated, got %d chars", len(findings[0].Match)) | ||
| 571 | } | ||
| 572 | } | ||
| 573 | ``` | ||
| 574 | |||
| 575 | - [ ] **Step 2: Run tests to verify they fail** | ||
| 576 | |||
| 577 | Run: `go test ./scanner/ -v -run "TestScan_"` | ||
| 578 | Expected: FAIL — `s.Scan` undefined | ||
| 579 | |||
| 580 | - [ ] **Step 3: Write minimal implementation** | ||
| 581 | |||
| 582 | ```go | ||
| 583 | // scanner/scanner.go (append to Scanner methods) | ||
| 584 | |||
| 585 | // Scan checks body against all rules and returns any findings. | ||
| 586 | func (s *Scanner) Scan(body []byte) []Finding { | ||
| 587 | var findings []Finding | ||
| 588 | for _, rule := range s.rules { | ||
| 589 | match := rule.pattern.Find(body) | ||
| 590 | if match != nil { | ||
| 591 | snippet := string(match) | ||
| 592 | if len(snippet) > 40 { | ||
| 593 | snippet = snippet[:40] + "..." | ||
| 594 | } | ||
| 595 | findings = append(findings, Finding{ | ||
| 596 | Rule: rule.name, | ||
| 597 | Match: snippet, | ||
| 598 | }) | ||
| 599 | } | ||
| 600 | } | ||
| 601 | return findings | ||
| 602 | } | ||
| 603 | ``` | ||
| 604 | |||
| 605 | - [ ] **Step 4: Run tests to verify they pass** | ||
| 606 | |||
| 607 | Run: `go test ./scanner/ -v` | ||
| 608 | Expected: PASS | ||
| 609 | |||
| 610 | - [ ] **Step 5: Commit** | ||
| 611 | |||
| 612 | ```bash | ||
| 613 | git add scanner/ | ||
| 614 | git commit -m "feat: add request body scanning with regex rules" | ||
| 615 | ``` | ||
| 616 | |||
| 617 | --- | ||
| 618 | |||
| 619 | ### Task 5: Scanner — Default Rules File | ||
| 620 | |||
| 621 | **Files:** | ||
| 622 | - Modify: `scanner/scanner.go` | ||
| 623 | - Modify: `scanner/scanner_test.go` | ||
| 624 | |||
| 625 | - [ ] **Step 1: Write failing test for writing default rules** | ||
| 626 | |||
| 627 | ```go | ||
| 628 | // scanner/scanner_test.go (append) | ||
| 629 | |||
| 630 | func TestWriteDefaultRules_CreatesFile(t *testing.T) { | ||
| 631 | dir := t.TempDir() | ||
| 632 | path := filepath.Join(dir, "rules.yaml") | ||
| 633 | |||
| 634 | err := scanner.WriteDefaultRules(path) | ||
| 635 | if err != nil { | ||
| 636 | t.Fatalf("unexpected error: %v", err) | ||
| 637 | } | ||
| 638 | |||
| 639 | // Should be loadable | ||
| 640 | s, err := scanner.New(path) | ||
| 641 | if err != nil { | ||
| 642 | t.Fatalf("failed to load default rules: %v", err) | ||
| 643 | } | ||
| 644 | |||
| 645 | if s.RuleCount() < 9 { | ||
| 646 | t.Errorf("expected at least 9 default rules, got %d", s.RuleCount()) | ||
| 647 | } | ||
| 648 | } | ||
| 649 | |||
| 650 | func TestWriteDefaultRules_DoesNotOverwrite(t *testing.T) { | ||
| 651 | dir := t.TempDir() | ||
| 652 | path := filepath.Join(dir, "rules.yaml") | ||
| 653 | |||
| 654 | os.WriteFile(path, []byte(`rules: | ||
| 655 | - name: custom | ||
| 656 | pattern: "custom" | ||
| 657 | `), 0644) | ||
| 658 | |||
| 659 | err := scanner.WriteDefaultRules(path) | ||
| 660 | if err != nil { | ||
| 661 | t.Fatalf("unexpected error: %v", err) | ||
| 662 | } | ||
| 663 | |||
| 664 | s, _ := scanner.New(path) | ||
| 665 | if s.RuleCount() != 1 { | ||
| 666 | t.Errorf("expected 1 rule (not overwritten), got %d", s.RuleCount()) | ||
| 667 | } | ||
| 668 | } | ||
| 669 | ``` | ||
| 670 | |||
| 671 | - [ ] **Step 2: Run tests to verify they fail** | ||
| 672 | |||
| 673 | Run: `go test ./scanner/ -v -run "TestWriteDefaultRules"` | ||
| 674 | Expected: FAIL — `scanner.WriteDefaultRules` undefined | ||
| 675 | |||
| 676 | - [ ] **Step 3: Write minimal implementation** | ||
| 677 | |||
| 678 | ```go | ||
| 679 | // scanner/scanner.go (append) | ||
| 680 | |||
| 681 | const defaultRulesYAML = `rules: | ||
| 682 | - name: ssh-private-key | ||
| 683 | pattern: "-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----" | ||
| 684 | - name: pgp-private-key | ||
| 685 | pattern: "-----BEGIN PGP PRIVATE KEY BLOCK-----" | ||
| 686 | - name: basic-auth | ||
| 687 | pattern: "Authorization:\\s*Basic\\s+" | ||
| 688 | - name: bearer-token | ||
| 689 | pattern: "Authorization:\\s*Bearer\\s+" | ||
| 690 | - name: aws-access-key | ||
| 691 | pattern: "AKIA[0-9A-Z]{16}" | ||
| 692 | - name: github-token | ||
| 693 | pattern: "gh[ps]_[A-Za-z0-9_]{36,}" | ||
| 694 | - name: openai-key | ||
| 695 | pattern: "sk-[A-Za-z0-9]{32,}" | ||
| 696 | - name: password-field | ||
| 697 | pattern: "(password=|\"password\":\\s*\")" | ||
| 698 | - name: env-file | ||
| 699 | pattern: "(?m)^[A-Z_]+=.+\\n[A-Z_]+=.+\\n[A-Z_]+=.+" | ||
| 700 | ` | ||
| 701 | |||
| 702 | // WriteDefaultRules writes the default rules file if it does not exist. | ||
| 703 | func WriteDefaultRules(path string) error { | ||
| 704 | if _, err := os.Stat(path); err == nil { | ||
| 705 | return nil // already exists | ||
| 706 | } | ||
| 707 | return os.WriteFile(path, []byte(defaultRulesYAML), 0644) | ||
| 708 | } | ||
| 709 | ``` | ||
| 710 | |||
| 711 | - [ ] **Step 4: Run tests to verify they pass** | ||
| 712 | |||
| 713 | Run: `go test ./scanner/ -v` | ||
| 714 | Expected: PASS | ||
| 715 | |||
| 716 | - [ ] **Step 5: Commit** | ||
| 717 | |||
| 718 | ```bash | ||
| 719 | git add scanner/ | ||
| 720 | git commit -m "feat: add default scanner rules with write-if-missing" | ||
| 721 | ``` | ||
| 722 | |||
| 723 | --- | ||
| 724 | |||
| 725 | ### Task 6: Proxy — Integrate Scanner into HTTP Handling | ||
| 726 | |||
| 727 | **Files:** | ||
| 728 | - Modify: `proxy/proxy.go` | ||
| 729 | - Modify: `proxy/proxy_test.go` | ||
| 730 | |||
| 731 | - [ ] **Step 1: Write failing test for body scanning on HTTP requests** | ||
| 732 | |||
| 733 | ```go | ||
| 734 | // proxy/proxy_test.go (append) | ||
| 735 | |||
| 736 | func writeTestRules(t *testing.T) string { | ||
| 737 | t.Helper() | ||
| 738 | dir := t.TempDir() | ||
| 739 | path := filepath.Join(dir, "rules.yaml") | ||
| 740 | os.WriteFile(path, []byte(`rules: | ||
| 741 | - name: ssh-private-key | ||
| 742 | pattern: "-----BEGIN RSA PRIVATE KEY-----" | ||
| 743 | - name: aws-access-key | ||
| 744 | pattern: "AKIA[0-9A-Z]{16}" | ||
| 745 | `), 0644) | ||
| 746 | return path | ||
| 747 | } | ||
| 748 | |||
| 749 | func TestShouldBlockRequestWithSSHKey(t *testing.T) { | ||
| 750 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 751 | w.WriteHeader(http.StatusOK) | ||
| 752 | })) | ||
| 753 | defer backend.Close() | ||
| 754 | |||
| 755 | backendURL, _ := url.Parse(backend.URL) | ||
| 756 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 757 | os.WriteFile(hostsFile, []byte(backendURL.Hostname()+"\n"), 0644) | ||
| 758 | |||
| 759 | rulesPath := writeTestRules(t) | ||
| 760 | p := proxy.New(hostsFile, proxy.WithRules(rulesPath)) | ||
| 761 | srv := httptest.NewServer(p) | ||
| 762 | defer srv.Close() | ||
| 763 | |||
| 764 | client := newProxyClient(t, srv.URL) | ||
| 765 | |||
| 766 | body := strings.NewReader("data=-----BEGIN RSA PRIVATE KEY-----\nMIIE...") | ||
| 767 | resp, err := client.Post(backend.URL+"/upload", "text/plain", body) | ||
| 768 | if err != nil { | ||
| 769 | t.Fatalf("unexpected error: %v", err) | ||
| 770 | } | ||
| 771 | defer resp.Body.Close() | ||
| 772 | |||
| 773 | if resp.StatusCode != http.StatusForbidden { | ||
| 774 | t.Errorf("expected 403, got %d", resp.StatusCode) | ||
| 775 | } | ||
| 776 | } | ||
| 777 | |||
| 778 | func TestShouldAllowCleanRequest(t *testing.T) { | ||
| 779 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 780 | w.WriteHeader(http.StatusOK) | ||
| 781 | })) | ||
| 782 | defer backend.Close() | ||
| 783 | |||
| 784 | backendURL, _ := url.Parse(backend.URL) | ||
| 785 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 786 | os.WriteFile(hostsFile, []byte(backendURL.Hostname()+"\n"), 0644) | ||
| 787 | |||
| 788 | rulesPath := writeTestRules(t) | ||
| 789 | p := proxy.New(hostsFile, proxy.WithRules(rulesPath)) | ||
| 790 | srv := httptest.NewServer(p) | ||
| 791 | defer srv.Close() | ||
| 792 | |||
| 793 | client := newProxyClient(t, srv.URL) | ||
| 794 | |||
| 795 | body := strings.NewReader("just normal data") | ||
| 796 | resp, err := client.Post(backend.URL+"/upload", "text/plain", body) | ||
| 797 | if err != nil { | ||
| 798 | t.Fatalf("unexpected error: %v", err) | ||
| 799 | } | ||
| 800 | defer resp.Body.Close() | ||
| 801 | |||
| 802 | if resp.StatusCode != http.StatusOK { | ||
| 803 | t.Errorf("expected 200, got %d", resp.StatusCode) | ||
| 804 | } | ||
| 805 | } | ||
| 806 | ``` | ||
| 807 | |||
| 808 | - [ ] **Step 2: Run tests to verify they fail** | ||
| 809 | |||
| 810 | Run: `go test ./proxy/ -v -run "TestShouldBlock|TestShouldAllowClean"` | ||
| 811 | Expected: FAIL — `proxy.WithRules` undefined | ||
| 812 | |||
| 813 | - [ ] **Step 3: Update Proxy to accept scanner via functional options** | ||
| 814 | |||
| 815 | ```go | ||
| 816 | // proxy/proxy.go — replace the Proxy struct and New function | ||
| 817 | |||
| 818 | import ( | ||
| 819 | "bufio" | ||
| 820 | "bytes" | ||
| 821 | "fmt" | ||
| 822 | "io" | ||
| 823 | "log" | ||
| 824 | "net" | ||
| 825 | "net/http" | ||
| 826 | "os" | ||
| 827 | "strings" | ||
| 828 | |||
| 829 | "nono/scanner" | ||
| 830 | ) | ||
| 831 | |||
| 832 | type Proxy struct { | ||
| 833 | hostsFile string | ||
| 834 | scanner *scanner.Scanner | ||
| 835 | } | ||
| 836 | |||
| 837 | type Option func(*Proxy) | ||
| 838 | |||
| 839 | func WithRules(rulesPath string) Option { | ||
| 840 | return func(p *Proxy) { | ||
| 841 | s, err := scanner.New(rulesPath) | ||
| 842 | if err != nil { | ||
| 843 | log.Printf("WARNING: failed to load scanner rules: %v", err) | ||
| 844 | return | ||
| 845 | } | ||
| 846 | p.scanner = s | ||
| 847 | } | ||
| 848 | } | ||
| 849 | |||
| 850 | func New(hostsFile string, opts ...Option) *Proxy { | ||
| 851 | p := &Proxy{hostsFile: hostsFile} | ||
| 852 | for _, opt := range opts { | ||
| 853 | opt(p) | ||
| 854 | } | ||
| 855 | return p | ||
| 856 | } | ||
| 857 | ``` | ||
| 858 | |||
| 859 | Update `handleHTTP` to scan the request body before forwarding: | ||
| 860 | |||
| 861 | ```go | ||
| 862 | func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 863 | if findings := p.scanRequest(r); len(findings) > 0 { | ||
| 864 | names := make([]string, len(findings)) | ||
| 865 | for i, f := range findings { | ||
| 866 | names[i] = f.Rule | ||
| 867 | } | ||
| 868 | log.Printf("BLOCKED %s %s %v", r.Method, r.Host, names) | ||
| 869 | http.Error(w, fmt.Sprintf("request blocked: contains sensitive data (%s)", strings.Join(names, ", ")), http.StatusForbidden) | ||
| 870 | return | ||
| 871 | } | ||
| 872 | |||
| 873 | r.RequestURI = "" | ||
| 874 | resp, err := http.DefaultTransport.RoundTrip(r) | ||
| 875 | if err != nil { | ||
| 876 | http.Error(w, err.Error(), http.StatusBadGateway) | ||
| 877 | return | ||
| 878 | } | ||
| 879 | defer resp.Body.Close() | ||
| 880 | |||
| 881 | for k, vv := range resp.Header { | ||
| 882 | for _, v := range vv { | ||
| 883 | w.Header().Add(k, v) | ||
| 884 | } | ||
| 885 | } | ||
| 886 | w.WriteHeader(resp.StatusCode) | ||
| 887 | io.Copy(w, resp.Body) | ||
| 888 | } | ||
| 889 | |||
| 890 | func (p *Proxy) scanRequest(r *http.Request) []scanner.Finding { | ||
| 891 | if p.scanner == nil || r.Body == nil { | ||
| 892 | return nil | ||
| 893 | } | ||
| 894 | |||
| 895 | body, err := io.ReadAll(r.Body) | ||
| 896 | r.Body.Close() | ||
| 897 | if err != nil { | ||
| 898 | return nil | ||
| 899 | } | ||
| 900 | |||
| 901 | // Also scan headers (for Authorization) | ||
| 902 | var headerBuf bytes.Buffer | ||
| 903 | for k, vv := range r.Header { | ||
| 904 | for _, v := range vv { | ||
| 905 | fmt.Fprintf(&headerBuf, "%s: %s\n", k, v) | ||
| 906 | } | ||
| 907 | } | ||
| 908 | |||
| 909 | r.Body = io.NopCloser(bytes.NewReader(body)) | ||
| 910 | |||
| 911 | combined := append(headerBuf.Bytes(), body...) | ||
| 912 | return p.scanner.Scan(combined) | ||
| 913 | } | ||
| 914 | ``` | ||
| 915 | |||
| 916 | - [ ] **Step 4: Update existing tests to use new `New()` signature** | ||
| 917 | |||
| 918 | The existing tests call `proxy.New(hostsFile)` — this still works since `opts` is variadic. No changes needed. | ||
| 919 | |||
| 920 | - [ ] **Step 5: Run all tests** | ||
| 921 | |||
| 922 | Run: `go test ./proxy/ -v` | ||
| 923 | Expected: PASS (all existing + new tests) | ||
| 924 | |||
| 925 | - [ ] **Step 6: Commit** | ||
| 926 | |||
| 927 | ```bash | ||
| 928 | git add proxy/ scanner/ | ||
| 929 | git commit -m "feat: integrate scanner into HTTP request handling" | ||
| 930 | ``` | ||
| 931 | |||
| 932 | --- | ||
| 933 | |||
| 934 | ### Task 7: Proxy — MITM CONNECT Handling | ||
| 935 | |||
| 936 | **Files:** | ||
| 937 | - Modify: `proxy/proxy.go` | ||
| 938 | - Modify: `proxy/proxy_test.go` | ||
| 939 | |||
| 940 | - [ ] **Step 1: Write failing test for MITM CONNECT with body scanning** | ||
| 941 | |||
| 942 | ```go | ||
| 943 | // proxy/proxy_test.go (append) | ||
| 944 | |||
| 945 | import ( | ||
| 946 | "crypto/tls" | ||
| 947 | "crypto/x509" | ||
| 948 | |||
| 949 | nca "nono/ca" | ||
| 950 | ) | ||
| 951 | |||
| 952 | func TestShouldBlockHTTPSRequestWithAWSKey(t *testing.T) { | ||
| 953 | // Backend HTTPS server | ||
| 954 | backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 955 | w.WriteHeader(http.StatusOK) | ||
| 956 | })) | ||
| 957 | defer backend.Close() | ||
| 958 | |||
| 959 | backendURL, _ := url.Parse(backend.URL) | ||
| 960 | host := backendURL.Hostname() | ||
| 961 | |||
| 962 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 963 | os.WriteFile(hostsFile, []byte(host+"\n"), 0644) | ||
| 964 | |||
| 965 | caDir := t.TempDir() | ||
| 966 | caCert, caKey, err := nca.LoadOrCreate(caDir) | ||
| 967 | if err != nil { | ||
| 968 | t.Fatalf("CA setup: %v", err) | ||
| 969 | } | ||
| 970 | |||
| 971 | rulesPath := writeTestRules(t) | ||
| 972 | p := proxy.New(hostsFile, | ||
| 973 | proxy.WithRules(rulesPath), | ||
| 974 | proxy.WithCA(caCert, caKey), | ||
| 975 | proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}), | ||
| 976 | ) | ||
| 977 | srv := httptest.NewServer(p) | ||
| 978 | defer srv.Close() | ||
| 979 | |||
| 980 | // Client that trusts the nono CA | ||
| 981 | caPool := x509.NewCertPool() | ||
| 982 | caPool.AddCert(caCert) | ||
| 983 | |||
| 984 | proxyURL, _ := url.Parse(srv.URL) | ||
| 985 | client := &http.Client{ | ||
| 986 | Transport: &http.Transport{ | ||
| 987 | Proxy: http.ProxyURL(proxyURL), | ||
| 988 | TLSClientConfig: &tls.Config{ | ||
| 989 | RootCAs: caPool, | ||
| 990 | }, | ||
| 991 | }, | ||
| 992 | } | ||
| 993 | |||
| 994 | body := strings.NewReader("key=AKIAIOSFODNN7EXAMPLE") | ||
| 995 | resp, err := client.Post(backend.URL+"/upload", "text/plain", body) | ||
| 996 | if err != nil { | ||
| 997 | t.Fatalf("unexpected error: %v", err) | ||
| 998 | } | ||
| 999 | defer resp.Body.Close() | ||
| 1000 | |||
| 1001 | if resp.StatusCode != http.StatusForbidden { | ||
| 1002 | t.Errorf("expected 403, got %d", resp.StatusCode) | ||
| 1003 | } | ||
| 1004 | } | ||
| 1005 | |||
| 1006 | func TestShouldAllowCleanHTTPSRequest(t *testing.T) { | ||
| 1007 | backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 1008 | w.WriteHeader(http.StatusOK) | ||
| 1009 | w.Write([]byte("ok")) | ||
| 1010 | })) | ||
| 1011 | defer backend.Close() | ||
| 1012 | |||
| 1013 | backendURL, _ := url.Parse(backend.URL) | ||
| 1014 | host := backendURL.Hostname() | ||
| 1015 | |||
| 1016 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 1017 | os.WriteFile(hostsFile, []byte(host+"\n"), 0644) | ||
| 1018 | |||
| 1019 | caDir := t.TempDir() | ||
| 1020 | caCert, caKey, err := nca.LoadOrCreate(caDir) | ||
| 1021 | if err != nil { | ||
| 1022 | t.Fatalf("CA setup: %v", err) | ||
| 1023 | } | ||
| 1024 | |||
| 1025 | rulesPath := writeTestRules(t) | ||
| 1026 | p := proxy.New(hostsFile, | ||
| 1027 | proxy.WithRules(rulesPath), | ||
| 1028 | proxy.WithCA(caCert, caKey), | ||
| 1029 | proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}), | ||
| 1030 | ) | ||
| 1031 | srv := httptest.NewServer(p) | ||
| 1032 | defer srv.Close() | ||
| 1033 | |||
| 1034 | caPool := x509.NewCertPool() | ||
| 1035 | caPool.AddCert(caCert) | ||
| 1036 | |||
| 1037 | proxyURL, _ := url.Parse(srv.URL) | ||
| 1038 | client := &http.Client{ | ||
| 1039 | Transport: &http.Transport{ | ||
| 1040 | Proxy: http.ProxyURL(proxyURL), | ||
| 1041 | TLSClientConfig: &tls.Config{ | ||
| 1042 | RootCAs: caPool, | ||
| 1043 | }, | ||
| 1044 | }, | ||
| 1045 | } | ||
| 1046 | |||
| 1047 | resp, err := client.Post(backend.URL+"/data", "text/plain", strings.NewReader("clean")) | ||
| 1048 | if err != nil { | ||
| 1049 | t.Fatalf("unexpected error: %v", err) | ||
| 1050 | } | ||
| 1051 | defer resp.Body.Close() | ||
| 1052 | |||
| 1053 | if resp.StatusCode != http.StatusOK { | ||
| 1054 | t.Errorf("expected 200, got %d", resp.StatusCode) | ||
| 1055 | } | ||
| 1056 | } | ||
| 1057 | ``` | ||
| 1058 | |||
| 1059 | - [ ] **Step 2: Run tests to verify they fail** | ||
| 1060 | |||
| 1061 | Run: `go test ./proxy/ -v -run "TestShouldBlockHTTPS|TestShouldAllowCleanHTTPS"` | ||
| 1062 | Expected: FAIL — `proxy.WithCA` and `proxy.WithUpstreamTLS` undefined | ||
| 1063 | |||
| 1064 | - [ ] **Step 3: Add CA fields and MITM handleConnect** | ||
| 1065 | |||
| 1066 | Add options to `proxy.go`: | ||
| 1067 | |||
| 1068 | ```go | ||
| 1069 | import ( | ||
| 1070 | "crypto/ecdsa" | ||
| 1071 | "crypto/tls" | ||
| 1072 | "crypto/x509" | ||
| 1073 | "sync" | ||
| 1074 | |||
| 1075 | nca "nono/ca" | ||
| 1076 | ) | ||
| 1077 | |||
| 1078 | // Add to Proxy struct: | ||
| 1079 | type Proxy struct { | ||
| 1080 | hostsFile string | ||
| 1081 | scanner *scanner.Scanner | ||
| 1082 | caCert *x509.Certificate | ||
| 1083 | caKey *ecdsa.PrivateKey | ||
| 1084 | certCache map[string]*tls.Certificate | ||
| 1085 | certMu sync.Mutex | ||
| 1086 | upstreamTLS *tls.Config | ||
| 1087 | } | ||
| 1088 | |||
| 1089 | func WithCA(cert *x509.Certificate, key *ecdsa.PrivateKey) Option { | ||
| 1090 | return func(p *Proxy) { | ||
| 1091 | p.caCert = cert | ||
| 1092 | p.caKey = key | ||
| 1093 | p.certCache = make(map[string]*tls.Certificate) | ||
| 1094 | } | ||
| 1095 | } | ||
| 1096 | |||
| 1097 | func WithUpstreamTLS(cfg *tls.Config) Option { | ||
| 1098 | return func(p *Proxy) { | ||
| 1099 | p.upstreamTLS = cfg | ||
| 1100 | } | ||
| 1101 | } | ||
| 1102 | ``` | ||
| 1103 | |||
| 1104 | Replace `handleConnect`: | ||
| 1105 | |||
| 1106 | ```go | ||
| 1107 | func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { | ||
| 1108 | // If no CA configured, fall back to blind tunnel | ||
| 1109 | if p.caCert == nil { | ||
| 1110 | p.handleConnectTunnel(w, r) | ||
| 1111 | return | ||
| 1112 | } | ||
| 1113 | |||
| 1114 | p.handleConnectMITM(w, r) | ||
| 1115 | } | ||
| 1116 | |||
| 1117 | // handleConnectTunnel is the original blind-tunnel behavior. | ||
| 1118 | func (p *Proxy) handleConnectTunnel(w http.ResponseWriter, r *http.Request) { | ||
| 1119 | targetConn, err := net.Dial("tcp", r.Host) | ||
| 1120 | if err != nil { | ||
| 1121 | http.Error(w, err.Error(), http.StatusBadGateway) | ||
| 1122 | return | ||
| 1123 | } | ||
| 1124 | |||
| 1125 | hj, ok := w.(http.Hijacker) | ||
| 1126 | if !ok { | ||
| 1127 | http.Error(w, "hijacking not supported", http.StatusInternalServerError) | ||
| 1128 | return | ||
| 1129 | } | ||
| 1130 | |||
| 1131 | clientConn, _, err := hj.Hijack() | ||
| 1132 | if err != nil { | ||
| 1133 | targetConn.Close() | ||
| 1134 | return | ||
| 1135 | } | ||
| 1136 | |||
| 1137 | clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) | ||
| 1138 | |||
| 1139 | go io.Copy(targetConn, clientConn) | ||
| 1140 | io.Copy(clientConn, targetConn) | ||
| 1141 | |||
| 1142 | clientConn.Close() | ||
| 1143 | targetConn.Close() | ||
| 1144 | } | ||
| 1145 | |||
| 1146 | func (p *Proxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) { | ||
| 1147 | host := extractHost(r.Host) | ||
| 1148 | |||
| 1149 | hj, ok := w.(http.Hijacker) | ||
| 1150 | if !ok { | ||
| 1151 | http.Error(w, "hijacking not supported", http.StatusInternalServerError) | ||
| 1152 | return | ||
| 1153 | } | ||
| 1154 | |||
| 1155 | clientConn, _, err := hj.Hijack() | ||
| 1156 | if err != nil { | ||
| 1157 | return | ||
| 1158 | } | ||
| 1159 | |||
| 1160 | clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) | ||
| 1161 | |||
| 1162 | // Get or create leaf cert for this host | ||
| 1163 | leafCert, err := p.getOrCreateLeaf(host) | ||
| 1164 | if err != nil { | ||
| 1165 | log.Printf("MITM cert error for %s: %v", host, err) | ||
| 1166 | clientConn.Close() | ||
| 1167 | return | ||
| 1168 | } | ||
| 1169 | |||
| 1170 | // TLS handshake with client | ||
| 1171 | tlsClientConn := tls.Server(clientConn, &tls.Config{ | ||
| 1172 | Certificates: []tls.Certificate{*leafCert}, | ||
| 1173 | }) | ||
| 1174 | if err := tlsClientConn.Handshake(); err != nil { | ||
| 1175 | log.Printf("MITM client handshake error: %v", err) | ||
| 1176 | clientConn.Close() | ||
| 1177 | return | ||
| 1178 | } | ||
| 1179 | |||
| 1180 | // Read HTTP request from the TLS connection | ||
| 1181 | bufReader := bufio.NewReader(tlsClientConn) | ||
| 1182 | innerReq, err := http.ReadRequest(bufReader) | ||
| 1183 | if err != nil { | ||
| 1184 | tlsClientConn.Close() | ||
| 1185 | return | ||
| 1186 | } | ||
| 1187 | |||
| 1188 | // Scan the request | ||
| 1189 | if findings := p.scanRequest(innerReq); len(findings) > 0 { | ||
| 1190 | names := make([]string, len(findings)) | ||
| 1191 | for i, f := range findings { | ||
| 1192 | names[i] = f.Rule | ||
| 1193 | } | ||
| 1194 | log.Printf("BLOCKED %s %s %v", innerReq.Method, host, names) | ||
| 1195 | resp := &http.Response{ | ||
| 1196 | StatusCode: http.StatusForbidden, | ||
| 1197 | Proto: "HTTP/1.1", | ||
| 1198 | ProtoMajor: 1, | ||
| 1199 | ProtoMinor: 1, | ||
| 1200 | Header: make(http.Header), | ||
| 1201 | Body: io.NopCloser(strings.NewReader( | ||
| 1202 | fmt.Sprintf("request blocked: contains sensitive data (%s)", strings.Join(names, ", ")), | ||
| 1203 | )), | ||
| 1204 | } | ||
| 1205 | resp.Header.Set("Content-Type", "text/plain") | ||
| 1206 | resp.Write(tlsClientConn) | ||
| 1207 | tlsClientConn.Close() | ||
| 1208 | return | ||
| 1209 | } | ||
| 1210 | |||
| 1211 | // Connect to the real target | ||
| 1212 | upstreamTLSCfg := &tls.Config{ServerName: host} | ||
| 1213 | if p.upstreamTLS != nil { | ||
| 1214 | upstreamTLSCfg = p.upstreamTLS.Clone() | ||
| 1215 | upstreamTLSCfg.ServerName = host | ||
| 1216 | } | ||
| 1217 | targetConn, err := tls.Dial("tcp", r.Host, upstreamTLSCfg) | ||
| 1218 | if err != nil { | ||
| 1219 | log.Printf("MITM upstream dial error: %v", err) | ||
| 1220 | tlsClientConn.Close() | ||
| 1221 | return | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | // Forward the request to the target | ||
| 1225 | innerReq.URL.Scheme = "https" | ||
| 1226 | innerReq.URL.Host = r.Host | ||
| 1227 | innerReq.RequestURI = "" | ||
| 1228 | if err := innerReq.Write(targetConn); err != nil { | ||
| 1229 | targetConn.Close() | ||
| 1230 | tlsClientConn.Close() | ||
| 1231 | return | ||
| 1232 | } | ||
| 1233 | |||
| 1234 | // Relay the response back | ||
| 1235 | targetBuf := bufio.NewReader(targetConn) | ||
| 1236 | resp, err := http.ReadResponse(targetBuf, innerReq) | ||
| 1237 | if err != nil { | ||
| 1238 | targetConn.Close() | ||
| 1239 | tlsClientConn.Close() | ||
| 1240 | return | ||
| 1241 | } | ||
| 1242 | |||
| 1243 | resp.Write(tlsClientConn) | ||
| 1244 | resp.Body.Close() | ||
| 1245 | |||
| 1246 | tlsClientConn.Close() | ||
| 1247 | targetConn.Close() | ||
| 1248 | } | ||
| 1249 | |||
| 1250 | func (p *Proxy) getOrCreateLeaf(host string) (*tls.Certificate, error) { | ||
| 1251 | p.certMu.Lock() | ||
| 1252 | defer p.certMu.Unlock() | ||
| 1253 | |||
| 1254 | if cert, ok := p.certCache[host]; ok { | ||
| 1255 | return cert, nil | ||
| 1256 | } | ||
| 1257 | |||
| 1258 | cert, err := nca.GenerateLeaf(host, p.caCert, p.caKey) | ||
| 1259 | if err != nil { | ||
| 1260 | return nil, err | ||
| 1261 | } | ||
| 1262 | |||
| 1263 | p.certCache[host] = &cert | ||
| 1264 | return &cert, nil | ||
| 1265 | } | ||
| 1266 | ``` | ||
| 1267 | |||
| 1268 | - [ ] **Step 4: Run all tests** | ||
| 1269 | |||
| 1270 | Run: `go test ./proxy/ -v` | ||
| 1271 | Expected: PASS | ||
| 1272 | |||
| 1273 | - [ ] **Step 5: Commit** | ||
| 1274 | |||
| 1275 | ```bash | ||
| 1276 | git add proxy/ | ||
| 1277 | git commit -m "feat: add MITM CONNECT handling with body scanning" | ||
| 1278 | ``` | ||
| 1279 | |||
| 1280 | --- | ||
| 1281 | |||
| 1282 | ### Task 8: Main — Wire Up CA, Scanner, and Default Rules | ||
| 1283 | |||
| 1284 | **Files:** | ||
| 1285 | - Modify: `cmd/nono-proxy/main.go` | ||
| 1286 | |||
| 1287 | - [ ] **Step 1: Update main to load CA and scanner** | ||
| 1288 | |||
| 1289 | ```go | ||
| 1290 | // cmd/nono-proxy/main.go | ||
| 1291 | package main | ||
| 1292 | |||
| 1293 | import ( | ||
| 1294 | "fmt" | ||
| 1295 | "log" | ||
| 1296 | "net/http" | ||
| 1297 | "os" | ||
| 1298 | "path/filepath" | ||
| 1299 | |||
| 1300 | "nono/ca" | ||
| 1301 | "nono/proxy" | ||
| 1302 | "nono/scanner" | ||
| 1303 | ) | ||
| 1304 | |||
| 1305 | func main() { | ||
| 1306 | store := storePath() | ||
| 1307 | |||
| 1308 | if len(os.Args) > 1 && os.Args[1] == "allow" { | ||
| 1309 | if len(os.Args) < 3 { | ||
| 1310 | fmt.Fprintln(os.Stderr, "usage: nono-proxy allow <host>") | ||
| 1311 | os.Exit(1) | ||
| 1312 | } | ||
| 1313 | hostsFile := filepath.Join(store, "approved_hosts") | ||
| 1314 | if err := proxy.Allow(hostsFile, os.Args[2]); err != nil { | ||
| 1315 | log.Fatalf("failed to allow host: %v", err) | ||
| 1316 | } | ||
| 1317 | fmt.Printf("allowed %s\n", os.Args[2]) | ||
| 1318 | return | ||
| 1319 | } | ||
| 1320 | |||
| 1321 | addr := ":9854" | ||
| 1322 | os.MkdirAll(store, 0755) | ||
| 1323 | |||
| 1324 | hostsFile := filepath.Join(store, "approved_hosts") | ||
| 1325 | rulesPath := filepath.Join(store, "rules.yaml") | ||
| 1326 | |||
| 1327 | // Write default rules if missing | ||
| 1328 | if err := scanner.WriteDefaultRules(rulesPath); err != nil { | ||
| 1329 | log.Fatalf("failed to write default rules: %v", err) | ||
| 1330 | } | ||
| 1331 | |||
| 1332 | // Load or generate CA | ||
| 1333 | caCert, caKey, err := ca.LoadOrCreate(store) | ||
| 1334 | if err != nil { | ||
| 1335 | log.Fatalf("failed to load/create CA: %v", err) | ||
| 1336 | } | ||
| 1337 | log.Printf("CA cert: %s/ca.pem", store) | ||
| 1338 | |||
| 1339 | opts := []proxy.Option{ | ||
| 1340 | proxy.WithRules(rulesPath), | ||
| 1341 | proxy.WithCA(caCert, caKey), | ||
| 1342 | } | ||
| 1343 | |||
| 1344 | p := proxy.New(hostsFile, opts...) | ||
| 1345 | log.Printf("nono-proxy listening on %s (hosts: %s, rules: %s)", addr, hostsFile, rulesPath) | ||
| 1346 | log.Fatal(http.ListenAndServe(addr, p)) | ||
| 1347 | } | ||
| 1348 | |||
| 1349 | func storePath() string { | ||
| 1350 | store := os.Getenv("NONO_STORE") | ||
| 1351 | if store == "" { | ||
| 1352 | home, _ := os.UserHomeDir() | ||
| 1353 | store = filepath.Join(home, ".local", "share", "nono") | ||
| 1354 | } | ||
| 1355 | return store | ||
| 1356 | } | ||
| 1357 | ``` | ||
| 1358 | |||
| 1359 | - [ ] **Step 2: Verify it builds** | ||
| 1360 | |||
| 1361 | Run: `go build ./cmd/nono-proxy/` | ||
| 1362 | Expected: SUCCESS | ||
| 1363 | |||
| 1364 | - [ ] **Step 3: Commit** | ||
| 1365 | |||
| 1366 | ```bash | ||
| 1367 | git add cmd/nono-proxy/main.go | ||
| 1368 | git commit -m "feat: wire up CA and scanner in nono-proxy main" | ||
| 1369 | ``` | ||
| 1370 | |||
| 1371 | --- | ||
| 1372 | |||
| 1373 | ### Task 9: Nono Script — Trust CA in Sandbox | ||
| 1374 | |||
| 1375 | **Files:** | ||
| 1376 | - Modify: `nono` | ||
| 1377 | |||
| 1378 | - [ ] **Step 1: Add CA cert bind-mount and env vars to the nono script** | ||
| 1379 | |||
| 1380 | After the proxy detection block (line ~69), add CA cert trust: | ||
| 1381 | |||
| 1382 | ```bash | ||
| 1383 | # CA cert for MITM (set if ca.pem exists) | ||
| 1384 | CA_CERT="$STORE/ca.pem" | ||
| 1385 | if [[ -f "$CA_CERT" ]]; then | ||
| 1386 | args+=( | ||
| 1387 | --ro-bind "$CA_CERT" "$CA_CERT" | ||
| 1388 | --setenv SSL_CERT_FILE "$CA_CERT" | ||
| 1389 | --setenv NODE_EXTRA_CA_CERTS "$CA_CERT" | ||
| 1390 | ) | ||
| 1391 | fi | ||
| 1392 | ``` | ||
| 1393 | |||
| 1394 | - [ ] **Step 2: Manually verify** | ||
| 1395 | |||
| 1396 | Run: `./nono echo "test"` (in a sandbox with CA present) | ||
| 1397 | Expected: Should execute without error, `SSL_CERT_FILE` should be set | ||
| 1398 | |||
| 1399 | - [ ] **Step 3: Commit** | ||
| 1400 | |||
| 1401 | ```bash | ||
| 1402 | git add nono | ||
| 1403 | git commit -m "feat: trust nono CA cert inside sandbox" | ||
| 1404 | ``` | ||
| 1405 | |||
| 1406 | --- | ||
| 1407 | |||
| 1408 | ### Task 10: Run Full Test Suite and Verify | ||
| 1409 | |||
| 1410 | - [ ] **Step 1: Run all tests** | ||
| 1411 | |||
| 1412 | Run: `go test ./... -v` | ||
| 1413 | Expected: ALL PASS | ||
| 1414 | |||
| 1415 | - [ ] **Step 2: Run with race detector** | ||
| 1416 | |||
| 1417 | Run: `go test -race ./...` | ||
| 1418 | Expected: No race conditions | ||
| 1419 | |||
| 1420 | - [ ] **Step 3: Build and smoke test** | ||
| 1421 | |||
| 1422 | ```bash | ||
| 1423 | make build | ||
| 1424 | ./nono-proxy & | ||
| 1425 | # In another terminal: test a blocked request | ||
| 1426 | curl -x http://localhost:9854 http://httpbin.org/post -d "-----BEGIN RSA PRIVATE KEY-----" | ||
| 1427 | # Expected: 403 forbidden | ||
| 1428 | kill %1 | ||
| 1429 | ``` | ||
| 1430 | |||
| 1431 | - [ ] **Step 4: Final commit if any cleanup needed** | ||
| 1432 | |||
| 1433 | ```bash | ||
| 1434 | git add -A | ||
| 1435 | git commit -m "chore: final cleanup after exfil detection integration" | ||
| 1436 | ``` | ||