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..7a4a30d 100644 --- a/internal/ruleset.go +++ b/internal/ruleset.go @@ -78,6 +78,18 @@ 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, + }, "E020": { Code: "E020", Name: "check-unicode-escape-sequences",