a73x

c181bad1

feat: add scanner package with YAML rules, body scanning, and defaults

a73x   2026-03-29 16:23

Commit message
feat: add scanner package with YAML rules, body scanning, and defaults

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 }