feat: add Rule struct with YAML parsing and validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21b1e60fd9
commit
34d77e2216
4 changed files with 241 additions and 0 deletions
10
go.mod
10
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
|
||||
)
|
||||
|
|
|
|||
10
go.sum
Normal file
10
go.sum
Normal 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
78
pkg/lint/rule.go
Normal 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
143
pkg/lint/rule_test.go
Normal 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())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue