Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions internal/rules/purl.go
Original file line number Diff line number Diff line change
@@ -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
}
261 changes: 261 additions & 0 deletions internal/rules/purl_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading