From 3bf858594317844f6590c274829023f22490a82e Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 10:55:13 +0000 Subject: [PATCH] feat: add Catalog loader with dir/embed/filter support Co-Authored-By: Claude Opus 4.6 --- catalog/go-security.yaml | 10 ++ pkg/lint/catalog.go | 113 ++++++++++++++++ pkg/lint/catalog_test.go | 150 ++++++++++++++++++++++ pkg/lint/testdata/catalog/test-rules.yaml | 17 +++ 4 files changed, 290 insertions(+) create mode 100644 catalog/go-security.yaml create mode 100644 pkg/lint/catalog.go create mode 100644 pkg/lint/catalog_test.go create mode 100644 pkg/lint/testdata/catalog/test-rules.yaml diff --git a/catalog/go-security.yaml b/catalog/go-security.yaml new file mode 100644 index 0000000..e95ff0e --- /dev/null +++ b/catalog/go-security.yaml @@ -0,0 +1,10 @@ +- id: go-sec-001 + title: "SQL wildcard injection in LIKE clauses" + severity: high + languages: [go] + tags: [security, injection] + pattern: 'LIKE\s+\?' + fix: "Use parameterised LIKE with EscapeLike()" + found_in: [go-store] + first_seen: "2026-03-09" + detection: regex diff --git a/pkg/lint/catalog.go b/pkg/lint/catalog.go new file mode 100644 index 0000000..bef4898 --- /dev/null +++ b/pkg/lint/catalog.go @@ -0,0 +1,113 @@ +package lint + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" +) + +// severityOrder maps severity names to numeric ranks for threshold comparison. +var severityOrder = map[string]int{ + "info": 0, + "low": 1, + "medium": 2, + "high": 3, + "critical": 4, +} + +// Catalog holds a collection of lint rules loaded from YAML files. +type Catalog struct { + Rules []Rule +} + +// LoadDir reads all .yaml files from the given directory and returns a Catalog. +func LoadDir(dir string) (*Catalog, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("loading catalog from %s: %w", dir, err) + } + + var rules []Rule + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", entry.Name(), err) + } + parsed, err := ParseRules(data) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) + } + rules = append(rules, parsed...) + } + + return &Catalog{Rules: rules}, nil +} + +// LoadFS reads all .yaml files from the given directory within an fs.FS and returns a Catalog. +func LoadFS(fsys fs.FS, dir string) (*Catalog, error) { + entries, err := fs.ReadDir(fsys, dir) + if err != nil { + return nil, fmt.Errorf("loading catalog from embedded %s: %w", dir, err) + } + + var rules []Rule + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + data, err := fs.ReadFile(fsys, dir+"/"+entry.Name()) + if err != nil { + return nil, fmt.Errorf("reading embedded %s: %w", entry.Name(), err) + } + parsed, err := ParseRules(data) + if err != nil { + return nil, fmt.Errorf("parsing embedded %s: %w", entry.Name(), err) + } + rules = append(rules, parsed...) + } + + return &Catalog{Rules: rules}, nil +} + +// ForLanguage returns all rules that apply to the given language. +func (c *Catalog) ForLanguage(lang string) []Rule { + var result []Rule + for _, r := range c.Rules { + if slices.Contains(r.Languages, lang) { + result = append(result, r) + } + } + return result +} + +// AtSeverity returns all rules at or above the given severity threshold. +func (c *Catalog) AtSeverity(threshold string) []Rule { + minRank, ok := severityOrder[threshold] + if !ok { + return nil + } + + var result []Rule + for _, r := range c.Rules { + if rank, ok := severityOrder[r.Severity]; ok && rank >= minRank { + result = append(result, r) + } + } + return result +} + +// ByID returns the rule with the given ID, or nil if not found. +func (c *Catalog) ByID(id string) *Rule { + for i := range c.Rules { + if c.Rules[i].ID == id { + return &c.Rules[i] + } + } + return nil +} diff --git a/pkg/lint/catalog_test.go b/pkg/lint/catalog_test.go new file mode 100644 index 0000000..857d1ff --- /dev/null +++ b/pkg/lint/catalog_test.go @@ -0,0 +1,150 @@ +package lint + +import ( + "embed" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed testdata/catalog/*.yaml +var testCatalogFS embed.FS + +func TestLoadDir_Good(t *testing.T) { + // Use the real catalog/ directory at the repo root. + dir := findCatalogDir(t) + cat, err := LoadDir(dir) + require.NoError(t, err) + assert.NotEmpty(t, cat.Rules) + + // The seed file has at least one rule. + assert.Equal(t, "go-sec-001", cat.Rules[0].ID) +} + +func TestLoadDir_Bad_NonexistentDir(t *testing.T) { + _, err := LoadDir("/nonexistent/path/that/does/not/exist") + assert.Error(t, err) +} + +func TestLoadDir_Bad_EmptyDir(t *testing.T) { + dir := t.TempDir() + cat, err := LoadDir(dir) + require.NoError(t, err) + assert.Empty(t, cat.Rules) +} + +func TestLoadDir_Bad_InvalidYAML(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "bad.yaml"), []byte("{{{"), 0o644) + require.NoError(t, err) + + _, err = LoadDir(dir) + assert.Error(t, err) +} + +func TestLoadFS_Good(t *testing.T) { + cat, err := LoadFS(testCatalogFS, "testdata/catalog") + require.NoError(t, err) + assert.Len(t, cat.Rules, 2) +} + +func TestForLanguage_Good(t *testing.T) { + cat := &Catalog{ + Rules: []Rule{ + {ID: "go-1", Languages: []string{"go"}}, + {ID: "php-1", Languages: []string{"php"}}, + {ID: "both-1", Languages: []string{"go", "php"}}, + }, + } + + goRules := cat.ForLanguage("go") + assert.Len(t, goRules, 2) + assert.Equal(t, "go-1", goRules[0].ID) + assert.Equal(t, "both-1", goRules[1].ID) +} + +func TestForLanguage_Bad_NoMatch(t *testing.T) { + cat := &Catalog{ + Rules: []Rule{ + {ID: "go-1", Languages: []string{"go"}}, + }, + } + assert.Empty(t, cat.ForLanguage("rust")) +} + +func TestAtSeverity_Good(t *testing.T) { + cat := &Catalog{ + Rules: []Rule{ + {ID: "info-1", Severity: "info"}, + {ID: "low-1", Severity: "low"}, + {ID: "med-1", Severity: "medium"}, + {ID: "high-1", Severity: "high"}, + {ID: "crit-1", Severity: "critical"}, + }, + } + + high := cat.AtSeverity("high") + assert.Len(t, high, 2) + assert.Equal(t, "high-1", high[0].ID) + assert.Equal(t, "crit-1", high[1].ID) + + all := cat.AtSeverity("info") + assert.Len(t, all, 5) + + crit := cat.AtSeverity("critical") + assert.Len(t, crit, 1) +} + +func TestAtSeverity_Bad_UnknownSeverity(t *testing.T) { + cat := &Catalog{ + Rules: []Rule{ + {ID: "high-1", Severity: "high"}, + }, + } + // Unknown severity returns empty. + assert.Empty(t, cat.AtSeverity("catastrophic")) +} + +func TestByID_Good(t *testing.T) { + cat := &Catalog{ + Rules: []Rule{ + {ID: "go-sec-001", Title: "SQL injection"}, + {ID: "go-sec-002", Title: "Path traversal"}, + }, + } + + r := cat.ByID("go-sec-002") + require.NotNil(t, r) + assert.Equal(t, "Path traversal", r.Title) +} + +func TestByID_Bad_NotFound(t *testing.T) { + cat := &Catalog{ + Rules: []Rule{ + {ID: "go-sec-001"}, + }, + } + assert.Nil(t, cat.ByID("nonexistent")) +} + +// findCatalogDir locates the catalog/ directory relative to the repo root. +func findCatalogDir(t *testing.T) string { + t.Helper() + // Walk up from the test file to find the repo root with catalog/. + dir, err := os.Getwd() + require.NoError(t, err) + for { + candidate := filepath.Join(dir, "catalog") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find catalog/ directory") + } + dir = parent + } +} diff --git a/pkg/lint/testdata/catalog/test-rules.yaml b/pkg/lint/testdata/catalog/test-rules.yaml new file mode 100644 index 0000000..e9c66ae --- /dev/null +++ b/pkg/lint/testdata/catalog/test-rules.yaml @@ -0,0 +1,17 @@ +- id: test-001 + title: "Test rule one" + severity: high + languages: [go] + tags: [test] + pattern: 'TODO' + fix: "Remove TODO" + detection: regex + +- id: test-002 + title: "Test rule two" + severity: low + languages: [go, php] + tags: [test] + pattern: 'FIXME' + fix: "Fix the issue" + detection: regex