feat: add Catalog loader with dir/embed/filter support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 10:55:13 +00:00
parent 34d77e2216
commit 3bf8585943
4 changed files with 290 additions and 0 deletions

10
catalog/go-security.yaml Normal file
View file

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

113
pkg/lint/catalog.go Normal file
View file

@ -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
}

150
pkg/lint/catalog_test.go Normal file
View file

@ -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
}
}

View file

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