feat: add Rule struct with YAML parsing and validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 10:54:15 +00:00
parent 21b1e60fd9
commit 34d77e2216
4 changed files with 241 additions and 0 deletions

10
go.mod
View file

@ -1,3 +1,13 @@
module forge.lthn.ai/core/lint
go 1.26.0
require (
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)

10
go.sum Normal file
View file

@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

78
pkg/lint/rule.go Normal file
View file

@ -0,0 +1,78 @@
package lint
import (
"fmt"
"regexp"
"slices"
"gopkg.in/yaml.v3"
)
// validSeverities defines the allowed severity levels, ordered from lowest to highest.
var validSeverities = []string{"info", "low", "medium", "high", "critical"}
// Rule represents a single lint rule loaded from a YAML catalog file.
type Rule struct {
ID string `yaml:"id" json:"id"`
Title string `yaml:"title" json:"title"`
Severity string `yaml:"severity" json:"severity"`
Languages []string `yaml:"languages" json:"languages"`
Tags []string `yaml:"tags" json:"tags"`
Pattern string `yaml:"pattern" json:"pattern"`
ExcludePattern string `yaml:"exclude_pattern" json:"exclude_pattern,omitempty"`
Fix string `yaml:"fix" json:"fix"`
FoundIn []string `yaml:"found_in" json:"found_in,omitempty"`
ExampleBad string `yaml:"example_bad" json:"example_bad,omitempty"`
ExampleGood string `yaml:"example_good" json:"example_good,omitempty"`
FirstSeen string `yaml:"first_seen" json:"first_seen,omitempty"`
Detection string `yaml:"detection" json:"detection"`
AutoFixable bool `yaml:"auto_fixable" json:"auto_fixable"`
}
// Validate checks that the rule has all required fields and that regex patterns compile.
func (r *Rule) Validate() error {
if r.ID == "" {
return fmt.Errorf("rule validation: id must not be empty")
}
if r.Title == "" {
return fmt.Errorf("rule %s: title must not be empty", r.ID)
}
if r.Severity == "" {
return fmt.Errorf("rule %s: severity must not be empty", r.ID)
}
if !slices.Contains(validSeverities, r.Severity) {
return fmt.Errorf("rule %s: severity %q is not valid (want one of %v)", r.ID, r.Severity, validSeverities)
}
if len(r.Languages) == 0 {
return fmt.Errorf("rule %s: languages must not be empty", r.ID)
}
if r.Pattern == "" {
return fmt.Errorf("rule %s: pattern must not be empty", r.ID)
}
if r.Detection == "" {
return fmt.Errorf("rule %s: detection must not be empty", r.ID)
}
// Only validate regex compilation when detection type is regex.
if r.Detection == "regex" {
if _, err := regexp.Compile(r.Pattern); err != nil {
return fmt.Errorf("rule %s: pattern does not compile: %w", r.ID, err)
}
if r.ExcludePattern != "" {
if _, err := regexp.Compile(r.ExcludePattern); err != nil {
return fmt.Errorf("rule %s: exclude_pattern does not compile: %w", r.ID, err)
}
}
}
return nil
}
// ParseRules unmarshals YAML data into a slice of Rule.
func ParseRules(data []byte) ([]Rule, error) {
var rules []Rule
if err := yaml.Unmarshal(data, &rules); err != nil {
return nil, fmt.Errorf("parsing rules: %w", err)
}
return rules, nil
}

143
pkg/lint/rule_test.go Normal file
View file

@ -0,0 +1,143 @@
package lint
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validRule() Rule {
return Rule{
ID: "go-sec-001",
Title: "SQL wildcard injection in LIKE clauses",
Severity: "high",
Languages: []string{"go"},
Tags: []string{"security"},
Pattern: `LIKE\s+\?`,
Fix: "Use parameterised LIKE with EscapeLike()",
Detection: "regex",
}
}
func TestParseRules_Good(t *testing.T) {
data := []byte(`
- id: go-sec-001
title: "SQL wildcard injection"
severity: high
languages: [go]
tags: [security]
pattern: 'LIKE\s+\?'
fix: "Use parameterised LIKE"
detection: regex
auto_fixable: false
- id: go-sec-002
title: "Path traversal"
severity: high
languages: [go]
tags: [security]
pattern: 'filepath\.Join'
fix: "Use securejoin"
detection: regex
`)
rules, err := ParseRules(data)
require.NoError(t, err)
assert.Len(t, rules, 2)
assert.Equal(t, "go-sec-001", rules[0].ID)
assert.Equal(t, "go-sec-002", rules[1].ID)
assert.Equal(t, []string{"go"}, rules[0].Languages)
assert.False(t, rules[0].AutoFixable)
}
func TestParseRules_Bad_InvalidYAML(t *testing.T) {
data := []byte(`{{{not yaml at all`)
_, err := ParseRules(data)
assert.Error(t, err)
}
func TestParseRules_Bad_EmptyInput(t *testing.T) {
rules, err := ParseRules([]byte(""))
require.NoError(t, err)
assert.Empty(t, rules)
}
func TestValidate_Good(t *testing.T) {
r := validRule()
assert.NoError(t, r.Validate())
}
func TestValidate_Good_WithExcludePattern(t *testing.T) {
r := validRule()
r.ExcludePattern = `securejoin|ValidatePath`
assert.NoError(t, r.Validate())
}
func TestValidate_Bad_EmptyID(t *testing.T) {
r := validRule()
r.ID = ""
err := r.Validate()
assert.ErrorContains(t, err, "id")
}
func TestValidate_Bad_EmptyTitle(t *testing.T) {
r := validRule()
r.Title = ""
err := r.Validate()
assert.ErrorContains(t, err, "title")
}
func TestValidate_Bad_EmptySeverity(t *testing.T) {
r := validRule()
r.Severity = ""
err := r.Validate()
assert.ErrorContains(t, err, "severity")
}
func TestValidate_Bad_InvalidSeverity(t *testing.T) {
r := validRule()
r.Severity = "catastrophic"
err := r.Validate()
assert.ErrorContains(t, err, "severity")
}
func TestValidate_Bad_EmptyLanguages(t *testing.T) {
r := validRule()
r.Languages = nil
err := r.Validate()
assert.ErrorContains(t, err, "languages")
}
func TestValidate_Bad_EmptyPattern(t *testing.T) {
r := validRule()
r.Pattern = ""
err := r.Validate()
assert.ErrorContains(t, err, "pattern")
}
func TestValidate_Bad_EmptyDetection(t *testing.T) {
r := validRule()
r.Detection = ""
err := r.Validate()
assert.ErrorContains(t, err, "detection")
}
func TestValidate_Bad_InvalidRegex(t *testing.T) {
r := validRule()
r.Pattern = `[invalid(`
err := r.Validate()
assert.ErrorContains(t, err, "pattern")
}
func TestValidate_Bad_InvalidExcludeRegex(t *testing.T) {
r := validRule()
r.ExcludePattern = `[invalid(`
err := r.Validate()
assert.ErrorContains(t, err, "exclude_pattern")
}
func TestValidate_Good_NonRegexDetection(t *testing.T) {
r := validRule()
r.Detection = "ast"
r.Pattern = "this is not a valid regex [[ but detection is not regex"
assert.NoError(t, r.Validate())
}