diff --git a/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-no-fix.json b/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-no-fix.json new file mode 100644 index 00000000..5f1c937b --- /dev/null +++ b/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-no-fix.json @@ -0,0 +1,30 @@ +{ + "Vulnerability": { + "CVSS": [], + "Description": "Root.io vulnerability record with no fix available", + "FixedIn": [ + { + "Name": "unpatched-package", + "NamespaceName": "rootio:distro:alpine:3.17", + "VendorAdvisory": { + "AdvisorySummary": [], + "NoAdvisory": true + }, + "Version": "", + "VersionFormat": "apk" + } + ], + "Link": "", + "Metadata": { + "CVE": [ + { + "Name": "CVE-2023-9999", + "Link": "" + } + ] + }, + "Name": "CVE-2023-9999", + "NamespaceName": "rootio:distro:alpine:3.17", + "Severity": "Medium" + } +} diff --git a/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-r007-pattern.json b/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-r007-pattern.json new file mode 100644 index 00000000..33dcdc04 --- /dev/null +++ b/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-r007-pattern.json @@ -0,0 +1,30 @@ +{ + "Vulnerability": { + "CVSS": [], + "Description": "Root.io vulnerability record with Alpine -rXX007X pattern", + "FixedIn": [ + { + "Name": "libssl3", + "NamespaceName": "rootio:distro:alpine:3.21", + "VendorAdvisory": { + "AdvisorySummary": [], + "NoAdvisory": false + }, + "Version": "3.0.8-r00071", + "VersionFormat": "apk" + } + ], + "Link": "", + "Metadata": { + "CVE": [ + { + "Name": "CVE-2024-1234", + "Link": "" + } + ] + }, + "Name": "CVE-2024-1234", + "NamespaceName": "rootio:distro:alpine:3.21", + "Severity": "High" + } +} diff --git a/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-unaffected.json b/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-unaffected.json new file mode 100644 index 00000000..71419488 --- /dev/null +++ b/pkg/process/v6/transformers/os/test-fixtures/rootio-alpine-unaffected.json @@ -0,0 +1,30 @@ +{ + "Vulnerability": { + "CVSS": [], + "Description": "Root.io vulnerability record indicating test-package is fixed", + "FixedIn": [ + { + "Name": "test-package", + "NamespaceName": "rootio:distro:alpine:3.17", + "VendorAdvisory": { + "AdvisorySummary": [], + "NoAdvisory": false + }, + "Version": "3.0.8-r4", + "VersionFormat": "apk" + } + ], + "Link": "", + "Metadata": { + "CVE": [ + { + "Name": "CVE-2023-1234", + "Link": "" + } + ] + }, + "Name": "CVE-2023-1234", + "NamespaceName": "rootio:distro:alpine:3.17", + "Severity": "Medium" + } +} \ No newline at end of file diff --git a/pkg/process/v6/transformers/os/transform.go b/pkg/process/v6/transformers/os/transform.go index 11eba2a0..a1866401 100644 --- a/pkg/process/v6/transformers/os/transform.go +++ b/pkg/process/v6/transformers/os/transform.go @@ -22,6 +22,10 @@ import ( "github.com/anchore/syft/syft/pkg" ) +const ( + rootioNamespacePrefix = "rootio" +) + // advisoryKey is an internal struct used for sorting and deduplicating advisories // that have both a link and ID from the vunnel results data type advisoryKey struct { @@ -30,6 +34,9 @@ type advisoryKey struct { } func Transform(vulnerability unmarshal.OSVulnerability, state provider.State) ([]data.Entry, error) { + if isRootIoNamespace(vulnerability.Vulnerability.NamespaceName) { + return processRootIoVulnerability(vulnerability, state) + } in := []any{ grypeDB.VulnerabilityHandle{ Name: vulnerability.Vulnerability.Name, @@ -56,6 +63,96 @@ func Transform(vulnerability unmarshal.OSVulnerability, state provider.State) ([ return transformers.NewEntries(in...), nil } +func isRootIoNamespace(namespace string) bool { + return strings.HasPrefix(namespace, rootioNamespacePrefix+":") +} + +func processRootIoVulnerability(vuln unmarshal.OSVulnerability, state provider.State) ([]data.Entry, error) { + var entries []any + + entries = append(entries, grypeDB.VulnerabilityHandle{ + Name: vuln.Vulnerability.Name, + ProviderID: state.Provider, + Provider: internal.ProviderModel(state), + Status: grypeDB.VulnerabilityActive, + ModifiedDate: internal.ParseTime(vuln.Vulnerability.Metadata.Updated), + PublishedDate: internal.ParseTime(vuln.Vulnerability.Metadata.Issued), + BlobValue: &grypeDB.VulnerabilityBlob{ + ID: vuln.Vulnerability.Name, + Assigners: nil, + Description: strings.TrimSpace(vuln.Vulnerability.Description), + References: getReferences(vuln), + Aliases: getAliases(vuln), + Severities: getSeverities(vuln), + }, + }) + + for _, u := range getRootIoUnaffectedPackages(vuln) { + entries = append(entries, u) + } + + return transformers.NewEntries(entries...), nil +} + +func getRootIoUnaffectedPackages(vuln unmarshal.OSVulnerability) []grypeDB.UnaffectedPackageHandle { + var uphs []grypeDB.UnaffectedPackageHandle + groups := groupFixedIns(vuln) + + for group, fixedIns := range groups { + for _, fixedIn := range fixedIns { + if fixedIn.Version != "" { + uph := grypeDB.UnaffectedPackageHandle{ + Package: getPackage(group), + OperatingSystem: getOperatingSystem(group.osName, group.id, group.osVersion, group.osChannel), + BlobValue: getRootIoUnaffectedBlob(vuln, fixedIn, group), + } + uphs = append(uphs, uph) + break + } + } + } + + sort.Sort(internal.ByUnaffectedPackage(uphs)) + return uphs +} + +func getRootIoUnaffectedBlob(vuln unmarshal.OSVulnerability, fixedIn unmarshal.OSFixedIn, group groupIndex) *grypeDB.PackageBlob { + cves := getAliases(vuln) + + constraint := determineRootIoConstraint(group.osName, fixedIn.Version) + ranges := []grypeDB.Range{ + { + Version: grypeDB.Version{ + Type: fixedIn.VersionFormat, + Constraint: constraint, + }, + }, + } + + return &grypeDB.PackageBlob{ + CVEs: cves, + Ranges: ranges, + } +} + +func determineRootIoConstraint(osName string, version string) string { + switch osName { + case "debian", "ubuntu": + // Debian/Ubuntu packages use .root.io suffix + // Example: 1.5.2-6+deb12u1.root.io.4 + return "version_contains .root.io" + case "alpine", "chainguard", "wolfi": + // Alpine packages may use either: + // 1. .root.io suffix (e.g., 3.0.8-r3.root.io.1) + // 2. -rXX007X pattern (e.g., 3.0.8-r00071, 3.0.8-r10074) + // We check for .root.io first as it's more common across distros + // The -rXX007X pattern check is handled in Grype's IsRootIoPackage() + return "version_contains .root.io" + default: + return "version_contains .root.io" + } +} + func getAffectedPackages(vuln unmarshal.OSVulnerability) []grypeDB.AffectedPackageHandle { var afs []grypeDB.AffectedPackageHandle groups := groupFixedIns(vuln) @@ -270,8 +367,22 @@ type osInfo struct { func getOSInfo(group string) osInfo { // derived from enterprise feed groups, expected to be of the form {distro release ID}:{version} + // or for Root.io: rootio:distro:{distro}:{version} feedGroupComponents := strings.Split(group, ":") + // Handle Root.io namespace format + if len(feedGroupComponents) >= 4 && feedGroupComponents[0] == rootioNamespacePrefix && feedGroupComponents[1] == "distro" { + // Root.io format: rootio:distro:alpine:3.17 + id := feedGroupComponents[2] + version := feedGroupComponents[3] + return osInfo{ + name: normalizeOsName(id), + id: rootioNamespacePrefix + "-" + id, // Prefix with rootio to distinguish + version: version, + channel: "", + } + } + id := feedGroupComponents[0] version := feedGroupComponents[1] channel := "" diff --git a/pkg/process/v6/transformers/os/transform_test.go b/pkg/process/v6/transformers/os/transform_test.go index 6e48f151..218604dd 100644 --- a/pkg/process/v6/transformers/os/transform_test.go +++ b/pkg/process/v6/transformers/os/transform_test.go @@ -1616,3 +1616,267 @@ func timeRef(ti time.Time) *time.Time { func strRef(s string) *string { return &s } + +func TestTransform_RootIoUnaffected(t *testing.T) { + // Test Root.io namespace-based processing + vulnerabilities := loadFixture(t, "test-fixtures/rootio-alpine-unaffected.json") + require.Len(t, vulnerabilities, 1, "expected exactly one vulnerability") + + state := inputProviderState("test-provider") + entries, err := Transform(vulnerabilities[0], state) + require.NoError(t, err) + require.NotEmpty(t, entries) + + // Should have both VulnerabilityHandle and UnaffectedPackageHandle entries + require.Len(t, entries, 1, "expected exactly one entry") + relatedEntries, ok := entries[0].Data.(transformers.RelatedEntries) + require.True(t, ok, "expected entry.Data to be of type RelatedEntries") + + // Should have VulnerabilityHandle + require.NotNil(t, relatedEntries.VulnerabilityHandle, "should have a VulnerabilityHandle") + assert.Equal(t, "CVE-2023-1234", relatedEntries.VulnerabilityHandle.Name) + + // Find UnaffectedPackageHandle in the Related field + var unaffectedPkg *grypeDB.UnaffectedPackageHandle + for _, related := range relatedEntries.Related { + if e, ok := related.(grypeDB.UnaffectedPackageHandle); ok { + unaffectedPkg = &e + break + } + } + require.NotNil(t, unaffectedPkg, "should have an UnaffectedPackageHandle") + require.NotNil(t, unaffectedPkg.BlobValue) + + // Check CVEs + assert.Equal(t, []string{"CVE-2023-1234"}, unaffectedPkg.BlobValue.CVEs) + + // Check constraint - should indicate packages with .root.io are unaffected + require.NotEmpty(t, unaffectedPkg.BlobValue.Ranges) + assert.Equal(t, "version_contains .root.io", unaffectedPkg.BlobValue.Ranges[0].Version.Constraint) + + // Check package details + assert.Equal(t, "test-package", unaffectedPkg.Package.Name) + assert.Equal(t, "apk", unaffectedPkg.Package.Ecosystem) + + // Check OS details + require.NotNil(t, unaffectedPkg.OperatingSystem) + assert.Equal(t, "alpine", unaffectedPkg.OperatingSystem.Name) + assert.Equal(t, "rootio-alpine", unaffectedPkg.OperatingSystem.ReleaseID) +} + +func TestIsRootIoNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + expected bool + }{ + { + name: "Root.io Alpine namespace", + namespace: "rootio:distro:alpine:3.17", + expected: true, + }, + { + name: "Root.io Debian namespace", + namespace: "rootio:distro:debian:11", + expected: true, + }, + { + name: "Root.io language namespace", + namespace: "rootio:language:python", + expected: true, + }, + { + name: "Regular Alpine namespace", + namespace: "alpine:3.17", + expected: false, + }, + { + name: "Empty namespace", + namespace: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isRootIoNamespace(tt.namespace) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestDetermineRootIoConstraint(t *testing.T) { + tests := []struct { + name string + osName string + version string + expected string + }{ + { + name: "Debian uses .root.io constraint", + osName: "debian", + version: "1.5.2-6+deb12u1", + expected: "version_contains .root.io", + }, + { + name: "Ubuntu uses .root.io constraint", + osName: "ubuntu", + version: "2.3.4-1ubuntu1", + expected: "version_contains .root.io", + }, + { + name: "Alpine uses .root.io constraint", + osName: "alpine", + version: "3.0.8-r4", + expected: "version_contains .root.io", + }, + { + name: "Chainguard uses .root.io constraint", + osName: "chainguard", + version: "1.2.3-r1", + expected: "version_contains .root.io", + }, + { + name: "Unknown OS defaults to .root.io", + osName: "fedora", + version: "1.2.3-1.fc38", + expected: "version_contains .root.io", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := determineRootIoConstraint(tt.osName, tt.version) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetOSInfo_RootIoNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + expected osInfo + }{ + { + name: "Root.io Alpine namespace", + namespace: "rootio:distro:alpine:3.17", + expected: osInfo{ + name: "alpine", + id: "rootio-alpine", + version: "3.17", + channel: "", + }, + }, + { + name: "Root.io Debian namespace", + namespace: "rootio:distro:debian:11", + expected: osInfo{ + name: "debian", + id: "rootio-debian", + version: "11", + channel: "", + }, + }, + { + name: "Regular Alpine namespace", + namespace: "alpine:3.17", + expected: osInfo{ + name: "alpine", + id: "alpine", + version: "3.17", + channel: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getOSInfo(tt.namespace) + assert.Equal(t, tt.expected.name, result.name) + assert.Equal(t, tt.expected.id, result.id) + assert.Equal(t, tt.expected.version, result.version) + assert.Equal(t, tt.expected.channel, result.channel) + }) + } +} + +func TestTransform_RootIoUnaffected_EmptyVersion(t *testing.T) { + // Test Root.io record with empty version (no fix available) + vulnerabilities := loadFixture(t, "test-fixtures/rootio-alpine-no-fix.json") + require.Len(t, vulnerabilities, 1, "expected exactly one vulnerability") + + state := inputProviderState("test-provider") + entries, err := Transform(vulnerabilities[0], state) + require.NoError(t, err) + require.NotEmpty(t, entries) + + // Should have VulnerabilityHandle but NO UnaffectedPackageHandle + require.Len(t, entries, 1, "expected exactly one entry") + relatedEntries, ok := entries[0].Data.(transformers.RelatedEntries) + require.True(t, ok, "expected entry.Data to be of type RelatedEntries") + + // Should have VulnerabilityHandle + require.NotNil(t, relatedEntries.VulnerabilityHandle, "should have a VulnerabilityHandle") + assert.Equal(t, "CVE-2023-9999", relatedEntries.VulnerabilityHandle.Name) + + // Should NOT have UnaffectedPackageHandle when version is empty + hasUnaffectedPackage := false + for _, related := range relatedEntries.Related { + if _, ok := related.(grypeDB.UnaffectedPackageHandle); ok { + hasUnaffectedPackage = true + break + } + } + assert.False(t, hasUnaffectedPackage, "should not have UnaffectedPackageHandle for empty version") +} + +func TestTransform_RootIoUnaffected_AlpineR007Pattern(t *testing.T) { + // Test Root.io record with Alpine -rXX007X version pattern + vulnerabilities := loadFixture(t, "test-fixtures/rootio-alpine-r007-pattern.json") + require.Len(t, vulnerabilities, 1, "expected exactly one vulnerability") + + state := inputProviderState("test-provider") + entries, err := Transform(vulnerabilities[0], state) + require.NoError(t, err) + require.NotEmpty(t, entries) + + // Should have both VulnerabilityHandle and UnaffectedPackageHandle entries + require.Len(t, entries, 1, "expected exactly one entry") + relatedEntries, ok := entries[0].Data.(transformers.RelatedEntries) + require.True(t, ok, "expected entry.Data to be of type RelatedEntries") + + // Should have VulnerabilityHandle + require.NotNil(t, relatedEntries.VulnerabilityHandle, "should have a VulnerabilityHandle") + assert.Equal(t, "CVE-2024-1234", relatedEntries.VulnerabilityHandle.Name) + + // Find UnaffectedPackageHandle in the Related field + var unaffectedPkg *grypeDB.UnaffectedPackageHandle + for _, related := range relatedEntries.Related { + if e, ok := related.(grypeDB.UnaffectedPackageHandle); ok { + unaffectedPkg = &e + break + } + } + require.NotNil(t, unaffectedPkg, "should have an UnaffectedPackageHandle") + require.NotNil(t, unaffectedPkg.BlobValue) + + // Check CVEs + assert.Equal(t, []string{"CVE-2024-1234"}, unaffectedPkg.BlobValue.CVEs) + + // Check constraint - should use .root.io even for Alpine -rXX007X pattern + // The actual pattern matching happens in Grype + require.NotEmpty(t, unaffectedPkg.BlobValue.Ranges) + assert.Equal(t, "version_contains .root.io", unaffectedPkg.BlobValue.Ranges[0].Version.Constraint) + + // Check package details + assert.Equal(t, "libssl3", unaffectedPkg.Package.Name) + assert.Equal(t, "apk", unaffectedPkg.Package.Ecosystem) + + // Check OS details + require.NotNil(t, unaffectedPkg.OperatingSystem) + assert.Equal(t, "alpine", unaffectedPkg.OperatingSystem.Name) + assert.Equal(t, "rootio-alpine", unaffectedPkg.OperatingSystem.ReleaseID) + assert.Equal(t, "3", unaffectedPkg.OperatingSystem.MajorVersion) + assert.Equal(t, "21", unaffectedPkg.OperatingSystem.MinorVersion) +}