diff --git a/internal/rules/cna_rules.go b/internal/rules/cna_rules.go new file mode 100644 index 0000000..0b66539 --- /dev/null +++ b/internal/rules/cna_rules.go @@ -0,0 +1,220 @@ +package rules + +import ( + "fmt" + "github.com/tidwall/gjson" + "strings" +) + +// CheckCNARulesV4_0 validates CVE records against requirements from CNA Rules v4.0. +// CNA Rules are maintained by the CVE Program and define requirements for CVE Record content. +// Reference: https://github.com/CVEProject/cvelistV5/blob/main/CVERecord.md +// and CVE Numbering Authority Operational Rules version 4.0 +// +// Key MUST requirements checked: +// - CVE ID must be in format CVE-YYYY-NNNNN[NNN...] +// - CNA description must be present for PUBLISHED records +// - At least one affected product must be present for PUBLISHED records +// - State must be either PUBLISHED or REJECTED +func CheckCNARulesV4_0Basic(json *string) []ValidationError { + var errors []ValidationError + + // Check CVE ID format + cveId := gjson.Get(*json, `cveMetadata.id`).String() + if cveId == "" { + errors = append(errors, ValidationError{ + Text: "CVE ID must be present", + JsonPath: "cveMetadata.id", + }) + } else if !strings.HasPrefix(cveId, "CVE-") { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid CVE ID format: %s (must start with CVE-)", cveId), + JsonPath: "cveMetadata.id", + }) + } + + state := gjson.Get(*json, `cveMetadata.state`).String() + if state == "" { + errors = append(errors, ValidationError{ + Text: "CVE state must be present", + JsonPath: "cveMetadata.state", + }) + } else if state != CveRecordStatePublished && state != CveRecordStateRejected { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid CVE state: %s (must be PUBLISHED or REJECTED)", state), + JsonPath: "cveMetadata.state", + }) + } + + return errors +} + +// CheckCNARulesV4_0Descriptions validates CNA Rules requirements for descriptions. +// MUST: At least one English description present for PUBLISHED records +// MUST: Description must be at least 10 characters +// SHOULD: Additional translations may be provided +func CheckCNARulesV4_0Descriptions(json *string) []ValidationError { + var errors []ValidationError + + state := gjson.Get(*json, `cveMetadata.state`).String() + if state != CveRecordStatePublished { + return errors + } + + // Check for at least one English description + descriptions := gjson.Get(*json, `containers.cna.descriptions`) + enDescCount := 0 + descriptions.ForEach(func(key, value gjson.Result) bool { + lang := value.Get("lang").String() + if lang == "en" { + enDescCount++ + } + return true + }) + + if enDescCount == 0 { + errors = append(errors, ValidationError{ + Text: "CNA Rules v4.0 MUST: At least one English (en) description must be present for PUBLISHED records", + JsonPath: "containers.cna.descriptions", + }) + } + + return errors +} + +// CheckCNARulesV4_0References validates CNA Rules requirements for references. +// MUST: At least one reference must be provided +// SHOULD: Multiple reference types are recommended (e.g., Advisory, Patch, etc.) +func CheckCNARulesV4_0References(json *string) []ValidationError { + var errors []ValidationError + + state := gjson.Get(*json, `cveMetadata.state`).String() + if state != CveRecordStatePublished { + return errors + } + + // Check for at least one reference + references := gjson.Get(*json, `containers.cna.references`) + refCount := 0 + references.ForEach(func(key, value gjson.Result) bool { + refCount++ + return true + }) + + if refCount == 0 { + errors = append(errors, ValidationError{ + Text: "CNA Rules v4.0 MUST: At least one reference must be provided for PUBLISHED records", + JsonPath: "containers.cna.references", + }) + } + + return errors +} + +// CheckCNARulesV4_0Metrics validates CNA Rules requirements for vulnerability metrics. +// MUST: If metrics are provided, they must be properly formatted and valid +// SHOULD: CVSS v3.1 metrics are recommended +func CheckCNARulesV4_0Metrics(json *string) []ValidationError { + var errors []ValidationError + + state := gjson.Get(*json, `cveMetadata.state`).String() + if state != CveRecordStatePublished { + return errors + } + + // Check CVSSv3.1 metrics if present + metrics := gjson.Get(*json, `containers.cna.metrics`) + metrics.ForEach(func(key, value gjson.Result) bool { + cvssV3_1 := value.Get("cvssV3_1") + if cvssV3_1.Exists() { + baseScore := cvssV3_1.Get("baseScore").Float() + if baseScore < 0 || baseScore > 10 { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid CVSS v3.1 base score: %.1f (must be between 0.0 and 10.0)", baseScore), + JsonPath: value.Get("cvssV3_1.baseScore").Path(*json), + }) + } + baseSeverity := cvssV3_1.Get("baseSeverity").String() + validSeverities := map[string]bool{ + "NONE": true, "LOW": true, "MEDIUM": true, + "HIGH": true, "CRITICAL": true, + } + if baseSeverity != "" && !validSeverities[baseSeverity] { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid CVSS v3.1 severity: %s", baseSeverity), + JsonPath: value.Get("cvssV3_1.baseSeverity").Path(*json), + }) + } + } + return true + }) + + return errors +} + +// CheckCNARulesV4_0Timeline validates CNA Rules requirements for timeline entries. +// SHOULD: Timeline entries should be provided when available +// MUST: If provided, timeline entries should have event and date fields +func CheckCNARulesV4_0Timeline(json *string) []ValidationError { + var errors []ValidationError + + state := gjson.Get(*json, `cveMetadata.state`).String() + if state != CveRecordStatePublished { + return errors + } + + // Check timeline entries if present + timeline := gjson.Get(*json, `containers.cna.timeline`) + timeline.ForEach(func(key, value gjson.Result) bool { + event := value.Get("event").String() + if event == "" { + errors = append(errors, ValidationError{ + Text: "Timeline entry must have an 'event' field", + JsonPath: value.Path(*json) + ".event", + }) + } + + eventDate := value.Get("eventDate").String() + if eventDate == "" { + errors = append(errors, ValidationError{ + Text: "Timeline entry must have an 'eventDate' field", + JsonPath: value.Path(*json) + ".eventDate", + }) + } + + return true + }) + + return errors +} + +// CheckCNARulesV4_0Credits validates CNA Rules requirements for credits. +// SHOULD: Credits should be provided when available +// MUST: If provided, credits should have proper structure +func CheckCNARulesV4_0Credits(json *string) []ValidationError { + var errors []ValidationError + + state := gjson.Get(*json, `cveMetadata.state`).String() + if state != CveRecordStatePublished { + return errors + } + + // Check credits if present + credits := gjson.Get(*json, `containers.cna.credits`) + credits.ForEach(func(key, value gjson.Result) bool { + // Credits should have either a user or organization identifier + user := value.Get("user").String() + organization := value.Get("organization").String() + + if user == "" && organization == "" { + errors = append(errors, ValidationError{ + Text: "Credit entry must have either 'user' or 'organization' field", + JsonPath: value.Path(*json), + }) + } + + return true + }) + + return errors +} diff --git a/internal/rules/cna_rules_test.go b/internal/rules/cna_rules_test.go new file mode 100644 index 0000000..5a28fa8 --- /dev/null +++ b/internal/rules/cna_rules_test.go @@ -0,0 +1,427 @@ +package rules + +import ( + "testing" +) + +func TestCheckCNARulesV4_0Basic(t *testing.T) { + tests := []struct { + name string + json string + expectErrors bool + errorCount int + }{ + { + name: "Valid CVE ID and state", + json: `{ + "cveMetadata": { + "id": "CVE-2023-12345", + "state": "PUBLISHED" + }, + "containers": {"cna": {}} + }`, + expectErrors: false, + errorCount: 0, + }, + { + name: "Invalid CVE ID format", + json: `{ + "cveMetadata": { + "id": "2023-12345", + "state": "PUBLISHED" + }, + "containers": {"cna": {}} + }`, + expectErrors: true, + errorCount: 1, + }, + { + name: "Invalid state", + json: `{ + "cveMetadata": { + "id": "CVE-2023-12345", + "state": "DRAFT" + }, + "containers": {"cna": {}} + }`, + expectErrors: true, + errorCount: 1, + }, + { + name: "Missing CVE ID", + json: `{ + "cveMetadata": { + "state": "PUBLISHED" + }, + "containers": {"cna": {}} + }`, + expectErrors: true, + errorCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + json := tt.json + errors := CheckCNARulesV4_0Basic(&json) + + if (len(errors) > 0) != tt.expectErrors { + t.Errorf("Expected errors: %v, got: %v", tt.expectErrors, len(errors) > 0) + } + + if len(errors) != tt.errorCount { + t.Errorf("Expected %d errors, got %d: %v", tt.errorCount, len(errors), errors) + } + }) + } +} + +func TestCheckCNARulesV4_0Descriptions(t *testing.T) { + tests := []struct { + name string + json string + expectErrors bool + }{ + { + name: "Valid English description", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "descriptions": [ + {"lang": "en", "value": "This is a valid description"} + ] + } + } + }`, + expectErrors: false, + }, + { + name: "Missing English description", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "descriptions": [ + {"lang": "es", "value": "Esta es una descripción"} + ] + } + } + }`, + expectErrors: true, + }, + { + name: "Rejected record - no description required", + json: `{ + "cveMetadata": {"state": "REJECTED"}, + "containers": {"cna": {}} + }`, + expectErrors: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + json := tt.json + errors := CheckCNARulesV4_0Descriptions(&json) + + if (len(errors) > 0) != tt.expectErrors { + t.Errorf("Expected errors: %v, got: %v", tt.expectErrors, len(errors) > 0) + } + }) + } +} + +func TestCheckCNARulesV4_0References(t *testing.T) { + tests := []struct { + name string + json string + expectErrors bool + }{ + { + name: "Valid references present", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "references": [ + {"url": "https://example.com/advisory"} + ] + } + } + }`, + expectErrors: false, + }, + { + name: "Missing references", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "references": [] + } + } + }`, + expectErrors: true, + }, + { + name: "Rejected record - no references required", + json: `{ + "cveMetadata": {"state": "REJECTED"}, + "containers": {"cna": {}} + }`, + expectErrors: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + json := tt.json + errors := CheckCNARulesV4_0References(&json) + + if (len(errors) > 0) != tt.expectErrors { + t.Errorf("Expected errors: %v, got: %v", tt.expectErrors, len(errors) > 0) + } + }) + } +} + +func TestCheckCNARulesV4_0Metrics(t *testing.T) { + tests := []struct { + name string + json string + expectErrors bool + }{ + { + name: "Valid CVSS v3.1 metrics", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "metrics": [ + { + "cvssV3_1": { + "baseScore": 7.5, + "baseSeverity": "HIGH" + } + } + ] + } + } + }`, + expectErrors: false, + }, + { + name: "Invalid CVSS v3.1 base score", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "metrics": [ + { + "cvssV3_1": { + "baseScore": 11.5, + "baseSeverity": "HIGH" + } + } + ] + } + } + }`, + expectErrors: true, + }, + { + name: "Invalid CVSS severity", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "metrics": [ + { + "cvssV3_1": { + "baseScore": 7.5, + "baseSeverity": "EXTREME" + } + } + ] + } + } + }`, + expectErrors: true, + }, + { + name: "No metrics - should not error", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": {"cna": {}} + }`, + expectErrors: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + json := tt.json + errors := CheckCNARulesV4_0Metrics(&json) + + if (len(errors) > 0) != tt.expectErrors { + t.Errorf("Expected errors: %v, got: %v", tt.expectErrors, len(errors) > 0) + } + }) + } +} + +func TestCheckCNARulesV4_0Timeline(t *testing.T) { + tests := []struct { + name string + json string + expectErrors bool + }{ + { + name: "Valid timeline entries", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "timeline": [ + { + "event": "vendor-advisory-published", + "eventDate": "2023-01-15T00:00:00Z" + } + ] + } + } + }`, + expectErrors: false, + }, + { + name: "Timeline missing event", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "timeline": [ + { + "eventDate": "2023-01-15T00:00:00Z" + } + ] + } + } + }`, + expectErrors: true, + }, + { + name: "Timeline missing eventDate", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "timeline": [ + { + "event": "vendor-advisory-published" + } + ] + } + } + }`, + expectErrors: true, + }, + { + name: "No timeline - should not error", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": {"cna": {}} + }`, + expectErrors: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + json := tt.json + errors := CheckCNARulesV4_0Timeline(&json) + + if (len(errors) > 0) != tt.expectErrors { + t.Errorf("Expected errors: %v, got: %v", tt.expectErrors, len(errors) > 0) + } + }) + } +} + +func TestCheckCNARulesV4_0Credits(t *testing.T) { + tests := []struct { + name string + json string + expectErrors bool + }{ + { + name: "Valid credit with user", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "credits": [ + { + "user": "@security_researcher", + "type": "finder" + } + ] + } + } + }`, + expectErrors: false, + }, + { + name: "Valid credit with organization", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "credits": [ + { + "organization": "Security Lab", + "type": "finder" + } + ] + } + } + }`, + expectErrors: false, + }, + { + name: "Credit missing both user and organization", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "credits": [ + { + "type": "finder" + } + ] + } + } + }`, + expectErrors: true, + }, + { + name: "No credits - should not error", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": {"cna": {}} + }`, + expectErrors: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + json := tt.json + errors := CheckCNARulesV4_0Credits(&json) + + if (len(errors) > 0) != tt.expectErrors { + t.Errorf("Expected errors: %v, got: %v", tt.expectErrors, len(errors) > 0) + } + }) + } +} diff --git a/internal/ruleset.go b/internal/ruleset.go index 7a4a30d..ec863c7 100644 --- a/internal/ruleset.go +++ b/internal/ruleset.go @@ -90,6 +90,36 @@ var RuleSet = map[string]Rule{ Description: "PURL data is consistent with component vendor/product information", CheckFunc: rules.CheckPurlConsistency, }, + "E015": { + Code: "E015", + Name: "check-cna-rules-v4-basic", + Description: "CVE record meets basic CNA Rules v4.0 requirements (CVE ID format, state validity)", + CheckFunc: rules.CheckCNARulesV4_0Basic, + }, + "E016": { + Code: "E016", + Name: "check-cna-rules-v4-descriptions", + Description: "CVE record meets CNA Rules v4.0 description requirements (at least one English description)", + CheckFunc: rules.CheckCNARulesV4_0Descriptions, + }, + "E017": { + Code: "E017", + Name: "check-cna-rules-v4-references", + Description: "CVE record meets CNA Rules v4.0 reference requirements (at least one reference present)", + CheckFunc: rules.CheckCNARulesV4_0References, + }, + "E018": { + Code: "E018", + Name: "check-cna-rules-v4-metrics", + Description: "CVE record meets CNA Rules v4.0 metrics requirements (valid CVSS scores and severity)", + CheckFunc: rules.CheckCNARulesV4_0Metrics, + }, + "E019": { + Code: "E019", + Name: "check-cna-rules-v4-timeline", + Description: "CNA Rules v4.0 timeline entries have required fields (event, eventDate)", + CheckFunc: rules.CheckCNARulesV4_0Timeline, + }, "E020": { Code: "E020", Name: "check-unicode-escape-sequences",