c181bad1
feat: add scanner package with YAML rules, body scanning, and defaults
a73x 2026-03-29 16:23
Commit message
go.mod
| Old | New | ||
|---|---|---|---|
| @@ -1,3 +1,5 @@ | |||
| 1 | module github.com/xanderle/nono | 1 | module github.com/xanderle/nono |
| 2 | 2 | ||
| 3 | go 1.26.1 | 3 | go 1.26.1 |
| 4 | |||
| 5 | require gopkg.in/yaml.v3 v3.0.1 // indirect | ||
go.sum
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,3 @@ | |||
| 1 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
| 2 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 3 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
scanner/scanner.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,113 @@ | |||
| 1 | package scanner | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "regexp" | ||
| 7 | |||
| 8 | "gopkg.in/yaml.v3" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // Finding represents a detected sensitive pattern match. | ||
| 12 | type Finding struct { | ||
| 13 | Rule string | ||
| 14 | Match string | ||
| 15 | } | ||
| 16 | |||
| 17 | type rule struct { | ||
| 18 | name string | ||
| 19 | pattern *regexp.Regexp | ||
| 20 | } | ||
| 21 | |||
| 22 | // Scanner holds compiled rules for scanning request bodies. | ||
| 23 | type Scanner struct { | ||
| 24 | rules []rule | ||
| 25 | } | ||
| 26 | |||
| 27 | type yamlRule struct { | ||
| 28 | Name string `yaml:"name"` | ||
| 29 | Pattern string `yaml:"pattern"` | ||
| 30 | } | ||
| 31 | |||
| 32 | type yamlConfig struct { | ||
| 33 | Rules []yamlRule `yaml:"rules"` | ||
| 34 | } | ||
| 35 | |||
| 36 | // New reads a YAML rules file, parses rules, and compiles regexes. | ||
| 37 | // Returns an error if the file cannot be read, parsed, or if any regex is invalid. | ||
| 38 | func New(path string) (*Scanner, error) { | ||
| 39 | data, err := os.ReadFile(path) | ||
| 40 | if err != nil { | ||
| 41 | return nil, fmt.Errorf("reading rules file: %w", err) | ||
| 42 | } | ||
| 43 | |||
| 44 | var cfg yamlConfig | ||
| 45 | if err := yaml.Unmarshal(data, &cfg); err != nil { | ||
| 46 | return nil, fmt.Errorf("parsing rules YAML: %w", err) | ||
| 47 | } | ||
| 48 | |||
| 49 | rules := make([]rule, 0, len(cfg.Rules)) | ||
| 50 | for _, yr := range cfg.Rules { | ||
| 51 | re, err := regexp.Compile(yr.Pattern) | ||
| 52 | if err != nil { | ||
| 53 | return nil, fmt.Errorf("compiling pattern for rule %q: %w", yr.Name, err) | ||
| 54 | } | ||
| 55 | rules = append(rules, rule{name: yr.Name, pattern: re}) | ||
| 56 | } | ||
| 57 | |||
| 58 | return &Scanner{rules: rules}, nil | ||
| 59 | } | ||
| 60 | |||
| 61 | // RuleCount returns the number of loaded rules. | ||
| 62 | func (s *Scanner) RuleCount() int { | ||
| 63 | return len(s.rules) | ||
| 64 | } | ||
| 65 | |||
| 66 | // Scan checks body against all rules and returns any findings. | ||
| 67 | // Match snippets are truncated to 40 characters. | ||
| 68 | func (s *Scanner) Scan(body []byte) []Finding { | ||
| 69 | var findings []Finding | ||
| 70 | for _, r := range s.rules { | ||
| 71 | match := r.pattern.Find(body) | ||
| 72 | if match == nil { | ||
| 73 | continue | ||
| 74 | } | ||
| 75 | snippet := string(match) | ||
| 76 | if len(snippet) > 40 { | ||
| 77 | snippet = snippet[:40] | ||
| 78 | } | ||
| 79 | findings = append(findings, Finding{Rule: r.name, Match: snippet}) | ||
| 80 | } | ||
| 81 | return findings | ||
| 82 | } | ||
| 83 | |||
| 84 | const defaultRulesYAML = `rules: | ||
| 85 | - name: ssh-private-key | ||
| 86 | pattern: "-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----" | ||
| 87 | - name: pgp-private-key | ||
| 88 | pattern: "-----BEGIN PGP PRIVATE KEY BLOCK-----" | ||
| 89 | - name: basic-auth | ||
| 90 | pattern: "Authorization:\\s*Basic\\s+" | ||
| 91 | - name: bearer-token | ||
| 92 | pattern: "Authorization:\\s*Bearer\\s+" | ||
| 93 | - name: aws-access-key | ||
| 94 | pattern: "AKIA[0-9A-Z]{16}" | ||
| 95 | - name: github-token | ||
| 96 | pattern: "gh[ps]_[A-Za-z0-9_]{36,}" | ||
| 97 | - name: openai-key | ||
| 98 | pattern: "sk-[A-Za-z0-9]{32,}" | ||
| 99 | - name: password-field | ||
| 100 | pattern: "(password=|\"password\":\\s*\")" | ||
| 101 | - name: env-file | ||
| 102 | pattern: "(?m)^[A-Z_]+=.+\\n[A-Z_]+=.+\\n[A-Z_]+=.+" | ||
| 103 | ` | ||
| 104 | |||
| 105 | // WriteDefaultRules writes the default rules YAML to path. | ||
| 106 | // Does nothing if the file already exists. | ||
| 107 | func WriteDefaultRules(path string) error { | ||
| 108 | if _, err := os.Stat(path); err == nil { | ||
| 109 | // file already exists, do not overwrite | ||
| 110 | return nil | ||
| 111 | } | ||
| 112 | return os.WriteFile(path, []byte(defaultRulesYAML), 0644) | ||
| 113 | } | ||
scanner/scanner_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,133 @@ | |||
| 1 | package scanner_test | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/xanderle/nono/scanner" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // Task 3: Load Rules from YAML | ||
| 12 | |||
| 13 | func TestNewScanner_LoadsRulesFromYAML(t *testing.T) { | ||
| 14 | dir := t.TempDir() | ||
| 15 | rulesPath := filepath.Join(dir, "rules.yaml") | ||
| 16 | os.WriteFile(rulesPath, []byte("rules:\n - name: ssh-key\n pattern: \"-----BEGIN RSA PRIVATE KEY-----\"\n - name: aws-key\n pattern: \"AKIA[0-9A-Z]{16}\"\n"), 0644) | ||
| 17 | |||
| 18 | s, err := scanner.New(rulesPath) | ||
| 19 | if err != nil { | ||
| 20 | t.Fatalf("unexpected error: %v", err) | ||
| 21 | } | ||
| 22 | if s.RuleCount() != 2 { | ||
| 23 | t.Errorf("expected 2 rules, got %d", s.RuleCount()) | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | func TestNewScanner_RejectsInvalidRegex(t *testing.T) { | ||
| 28 | dir := t.TempDir() | ||
| 29 | rulesPath := filepath.Join(dir, "rules.yaml") | ||
| 30 | os.WriteFile(rulesPath, []byte("rules:\n - name: bad-rule\n pattern: \"[invalid\"\n"), 0644) | ||
| 31 | |||
| 32 | _, err := scanner.New(rulesPath) | ||
| 33 | if err == nil { | ||
| 34 | t.Fatal("expected error for invalid regex") | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | // Task 4: Scan for Sensitive Patterns | ||
| 39 | |||
| 40 | func writeRules(t *testing.T, content string) string { | ||
| 41 | t.Helper() | ||
| 42 | dir := t.TempDir() | ||
| 43 | path := filepath.Join(dir, "rules.yaml") | ||
| 44 | os.WriteFile(path, []byte(content), 0644) | ||
| 45 | return path | ||
| 46 | } | ||
| 47 | |||
| 48 | func TestScan_DetectsSSHPrivateKey(t *testing.T) { | ||
| 49 | path := writeRules(t, "rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN (OPENSSH|RSA|DSA|EC|ED25519) PRIVATE KEY-----\"\n") | ||
| 50 | s, _ := scanner.New(path) | ||
| 51 | findings := s.Scan([]byte("some data\n-----BEGIN RSA PRIVATE KEY-----\nMIIE...")) | ||
| 52 | if len(findings) != 1 { | ||
| 53 | t.Fatalf("expected 1 finding, got %d", len(findings)) | ||
| 54 | } | ||
| 55 | if findings[0].Rule != "ssh-private-key" { | ||
| 56 | t.Errorf("expected rule 'ssh-private-key', got %q", findings[0].Rule) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | func TestScan_DetectsAWSKey(t *testing.T) { | ||
| 61 | path := writeRules(t, "rules:\n - name: aws-access-key\n pattern: \"AKIA[0-9A-Z]{16}\"\n") | ||
| 62 | s, _ := scanner.New(path) | ||
| 63 | findings := s.Scan([]byte("{\"key\": \"AKIAIOSFODNN7EXAMPLE\"}")) | ||
| 64 | if len(findings) != 1 { | ||
| 65 | t.Fatalf("expected 1 finding, got %d", len(findings)) | ||
| 66 | } | ||
| 67 | if findings[0].Rule != "aws-access-key" { | ||
| 68 | t.Errorf("expected rule 'aws-access-key', got %q", findings[0].Rule) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | func TestScan_ReturnsMultipleFindings(t *testing.T) { | ||
| 73 | path := writeRules(t, "rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN RSA PRIVATE KEY-----\"\n - name: aws-access-key\n pattern: \"AKIA[0-9A-Z]{16}\"\n") | ||
| 74 | s, _ := scanner.New(path) | ||
| 75 | body := []byte("-----BEGIN RSA PRIVATE KEY-----\nkey\nAKIAIOSFODNN7EXAMPLE") | ||
| 76 | findings := s.Scan(body) | ||
| 77 | if len(findings) != 2 { | ||
| 78 | t.Fatalf("expected 2 findings, got %d", len(findings)) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | func TestScan_ReturnsEmptyForCleanBody(t *testing.T) { | ||
| 83 | path := writeRules(t, "rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN RSA PRIVATE KEY-----\"\n") | ||
| 84 | s, _ := scanner.New(path) | ||
| 85 | findings := s.Scan([]byte("just some normal POST data")) | ||
| 86 | if len(findings) != 0 { | ||
| 87 | t.Errorf("expected 0 findings, got %d", len(findings)) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | func TestScan_TruncatesMatchSnippet(t *testing.T) { | ||
| 92 | path := writeRules(t, "rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN RSA PRIVATE KEY-----\"\n") | ||
| 93 | s, _ := scanner.New(path) | ||
| 94 | findings := s.Scan([]byte("-----BEGIN RSA PRIVATE KEY-----")) | ||
| 95 | if len(findings) != 1 { | ||
| 96 | t.Fatalf("expected 1 finding, got %d", len(findings)) | ||
| 97 | } | ||
| 98 | if len(findings[0].Match) > 40 { | ||
| 99 | t.Errorf("expected match snippet to be truncated, got %d chars", len(findings[0].Match)) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | // Task 5: Default Rules File | ||
| 104 | |||
| 105 | func TestWriteDefaultRules_CreatesFile(t *testing.T) { | ||
| 106 | dir := t.TempDir() | ||
| 107 | path := filepath.Join(dir, "rules.yaml") | ||
| 108 | err := scanner.WriteDefaultRules(path) | ||
| 109 | if err != nil { | ||
| 110 | t.Fatalf("unexpected error: %v", err) | ||
| 111 | } | ||
| 112 | s, err := scanner.New(path) | ||
| 113 | if err != nil { | ||
| 114 | t.Fatalf("failed to load default rules: %v", err) | ||
| 115 | } | ||
| 116 | if s.RuleCount() < 9 { | ||
| 117 | t.Errorf("expected at least 9 default rules, got %d", s.RuleCount()) | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | func TestWriteDefaultRules_DoesNotOverwrite(t *testing.T) { | ||
| 122 | dir := t.TempDir() | ||
| 123 | path := filepath.Join(dir, "rules.yaml") | ||
| 124 | os.WriteFile(path, []byte("rules:\n - name: custom\n pattern: \"custom\"\n"), 0644) | ||
| 125 | err := scanner.WriteDefaultRules(path) | ||
| 126 | if err != nil { | ||
| 127 | t.Fatalf("unexpected error: %v", err) | ||
| 128 | } | ||
| 129 | s, _ := scanner.New(path) | ||
| 130 | if s.RuleCount() != 1 { | ||
| 131 | t.Errorf("expected 1 rule (not overwritten), got %d", s.RuleCount()) | ||
| 132 | } | ||
| 133 | } | ||