docs/superpowers/plans/2026-03-29-exfil-detection.md
Ref: Size: 32.7 KiB
# 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"
```