a73x

3f877c9e

docs: add exfiltration detection implementation plan

a73x   2026-03-29 16:14

Commit message
docs: add exfiltration detection implementation plan

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