feat: add Catalog loader with dir/embed/filter support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
34d77e2216
commit
3bf8585943
4 changed files with 290 additions and 0 deletions
10
catalog/go-security.yaml
Normal file
10
catalog/go-security.yaml
Normal 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
113
pkg/lint/catalog.go
Normal 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
150
pkg/lint/catalog_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
17
pkg/lint/testdata/catalog/test-rules.yaml
vendored
Normal file
17
pkg/lint/testdata/catalog/test-rules.yaml
vendored
Normal 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
|
||||
Loading…
Add table
Reference in a new issue