From 34d77e22162e2e645caa0e1e545286698e189918 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 10:54:15 +0000 Subject: [PATCH] feat: add Rule struct with YAML parsing and validation Co-Authored-By: Claude Opus 4.6 --- go.mod | 10 +++ go.sum | 10 +++ pkg/lint/rule.go | 78 +++++++++++++++++++++++ pkg/lint/rule_test.go | 143 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 go.sum create mode 100644 pkg/lint/rule.go create mode 100644 pkg/lint/rule_test.go diff --git a/go.mod b/go.mod index 2679143..0a8a62a 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/go.sum @@ -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= diff --git a/pkg/lint/rule.go b/pkg/lint/rule.go new file mode 100644 index 0000000..a683ecb --- /dev/null +++ b/pkg/lint/rule.go @@ -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 +} diff --git a/pkg/lint/rule_test.go b/pkg/lint/rule_test.go new file mode 100644 index 0000000..4213915 --- /dev/null +++ b/pkg/lint/rule_test.go @@ -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()) +}