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/rules/purl.go b/internal/rules/purl.go new file mode 100644 index 0000000..7c60c0a --- /dev/null +++ b/internal/rules/purl.go @@ -0,0 +1,124 @@ +package rules + +import ( + "fmt" + "github.com/package-url/packageurl-go" + "github.com/tidwall/gjson" + "strings" +) + +// CheckPurlFormat validates that PURL (Package URL) strings in the CVE record are valid. +// This function checks PURL entries in the "components" section which may be added to CVE schema. +// PURLs should follow the Package URL specification: https://github.com/package-url/packageurl-go +func CheckPurlFormat(json *string) []ValidationError { + if gjson.Get(*json, `cveMetadata.state`).String() != "PUBLISHED" { + // REJECTED records do not require components + return nil + } + + var errors []ValidationError + + // Check PURLs in components section (if present) + // This path is being added to CVE schema per: https://github.com/CVEProject/cve-schema/pull/407 + components := gjson.Get(*json, `containers.cna.components`) + components.ForEach(func(key, value gjson.Result) bool { + purl := value.Get("purl").String() + if purl != "" { + if !strings.HasPrefix(purl, "pkg:") { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid PURL format: missing 'pkg:' prefix: %s", purl), + JsonPath: value.Get("purl").Path(*json), + }) + } else { + _, err := packageurl.FromString(purl) + if err != nil { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid PURL format: %s (error: %v)", purl, err), + JsonPath: value.Get("purl").Path(*json), + }) + } + } + } + return true + }) + + // Check PURLs in affects section (for version strings that may be PURLs) + // PURLs in version fields are already validated in CheckInvalidVersion, + // but we can add additional validation if needed here + + // Check PURLs in references section (if references contain PURL data) + // Some implementations may include PURL data in reference metadata + affected := gjson.Get(*json, `containers.cna.affected`) + affected.ForEach(func(akey, avalue gjson.Result) bool { + // Check for PURL in affected components + purl := avalue.Get("purl").String() + if purl != "" { + if !strings.HasPrefix(purl, "pkg:") { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid PURL format in affected: missing 'pkg:' prefix: %s", purl), + JsonPath: avalue.Get("purl").Path(*json), + }) + } else { + _, err := packageurl.FromString(purl) + if err != nil { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid PURL format in affected: %s (error: %v)", purl, err), + JsonPath: avalue.Get("purl").Path(*json), + }) + } + } + } + return true + }) + + return errors +} + +// CheckPurlConsistency validates that PURLs are consistent with vendor/product information. +// If a PURL is provided, it should align with the vendor and product fields. +func CheckPurlConsistency(json *string) []ValidationError { + if gjson.Get(*json, `cveMetadata.state`).String() != "PUBLISHED" { + return nil + } + + var errors []ValidationError + + components := gjson.Get(*json, `containers.cna.components`) + components.ForEach(func(key, value gjson.Result) bool { + purl := value.Get("purl").String() + if purl != "" && strings.HasPrefix(purl, "pkg:") { + parsedPurl, err := packageurl.FromString(purl) + if err == nil { + // Get the namespace and name from the component + componentNamespace := value.Get("namespace").String() + componentName := value.Get("name").String() + + // Validate that PURL namespace/name matches component data + if componentNamespace != "" && parsedPurl.Namespace != componentNamespace { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf( + "PURL namespace '%s' does not match component namespace '%s'", + parsedPurl.Namespace, + componentNamespace, + ), + JsonPath: value.Get("purl").Path(*json), + }) + } + + if componentName != "" && parsedPurl.Name != componentName { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf( + "PURL name '%s' does not match component name '%s'", + parsedPurl.Name, + componentName, + ), + JsonPath: value.Get("purl").Path(*json), + }) + } + } + } + return true + }) + + return errors +} diff --git a/internal/rules/purl_test.go b/internal/rules/purl_test.go new file mode 100644 index 0000000..5026a12 --- /dev/null +++ b/internal/rules/purl_test.go @@ -0,0 +1,261 @@ +package rules + +import ( + "testing" +) + +func TestCheckPurlFormat(t *testing.T) { + tests := []struct { + name string + json string + expectErrors bool + errorCount int + }{ + { + name: "Valid PURL format", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + {"purl": "pkg:npm/lodash@4.17.21"} + ] + } + } + }`, + expectErrors: false, + errorCount: 0, + }, + { + name: "Valid PURL with qualifiers", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + {"purl": "pkg:npm/lodash@4.17.21?arch=x86_64"} + ] + } + } + }`, + expectErrors: false, + errorCount: 0, + }, + { + name: "Invalid PURL - missing pkg: prefix", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + {"purl": "npm/lodash@4.17.21"} + ] + } + } + }`, + expectErrors: true, + errorCount: 1, + }, + { + name: "Invalid PURL format", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + {"purl": "pkg:!!!invalid"} + ] + } + } + }`, + expectErrors: true, + errorCount: 1, + }, + { + name: "Empty PURL should not error", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + {"name": "component", "purl": ""} + ] + } + } + }`, + expectErrors: false, + errorCount: 0, + }, + { + name: "Rejected record should not be checked", + json: `{ + "cveMetadata": {"state": "REJECTED"}, + "containers": { + "cna": { + "components": [ + {"purl": "invalid"} + ] + } + } + }`, + expectErrors: false, + errorCount: 0, + }, + { + name: "PURL in affected section", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "affected": [ + { + "purl": "pkg:npm/lodash@4.17.21", + "vendor": "lodash", + "product": "lodash", + "versions": [ + {"version": "4.17.20", "status": "affected"} + ] + } + ] + } + } + }`, + expectErrors: false, + errorCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + json := tt.json + errors := CheckPurlFormat(&json) + + if (len(errors) > 0) != tt.expectErrors { + t.Errorf("Expected errors: %v, got: %v (errors: %v)", tt.expectErrors, len(errors) > 0, errors) + } + + if len(errors) != tt.errorCount { + t.Errorf("Expected %d errors, got %d: %v", tt.errorCount, len(errors), errors) + } + }) + } +} + +func TestCheckPurlConsistency(t *testing.T) { + tests := []struct { + name string + json string + expectErrors bool + errorCount int + }{ + { + name: "Consistent PURL and component data", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + { + "purl": "pkg:npm/@angular/core@12.0.0", + "namespace": "@angular", + "name": "core" + } + ] + } + } + }`, + expectErrors: false, + errorCount: 0, + }, + { + name: "Inconsistent PURL namespace", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + { + "purl": "pkg:npm/@angular/core@12.0.0", + "namespace": "@react", + "name": "core" + } + ] + } + } + }`, + expectErrors: true, + errorCount: 1, + }, + { + name: "Inconsistent PURL name", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + { + "purl": "pkg:npm/@angular/core@12.0.0", + "namespace": "@angular", + "name": "react" + } + ] + } + } + }`, + expectErrors: true, + errorCount: 1, + }, + { + name: "No namespace in PURL", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + { + "purl": "pkg:npm/lodash@4.17.21", + "name": "lodash" + } + ] + } + } + }`, + expectErrors: false, + errorCount: 0, + }, + { + name: "Invalid PURL should not be checked for consistency", + json: `{ + "cveMetadata": {"state": "PUBLISHED"}, + "containers": { + "cna": { + "components": [ + { + "purl": "invalid", + "namespace": "ns", + "name": "name" + } + ] + } + } + }`, + expectErrors: false, + errorCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + json := tt.json + errors := CheckPurlConsistency(&json) + + if (len(errors) > 0) != tt.expectErrors { + t.Errorf("Expected errors: %v, got: %v (errors: %v)", tt.expectErrors, len(errors) > 0, errors) + } + + if len(errors) != tt.errorCount { + t.Errorf("Expected %d errors, got %d: %v", tt.errorCount, len(errors), errors) + } + }) + } +} diff --git a/internal/ruleset.go b/internal/ruleset.go index 7aae1a8..ec863c7 100644 --- a/internal/ruleset.go +++ b/internal/ruleset.go @@ -78,6 +78,48 @@ var RuleSet = map[string]Rule{ Description: "Version type is not set to 'custom' (should be avoided per schema docs)", CheckFunc: rules.CheckCustomVersionType, }, + "E013": { + Code: "E013", + Name: "check-purl-format", + Description: "PURL (Package URL) strings are valid and follow the specification", + CheckFunc: rules.CheckPurlFormat, + }, + "E014": { + Code: "E014", + Name: "check-purl-consistency", + 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",