From 48e91312e81350a6782da20a6f9ae182dcc741e1 Mon Sep 17 00:00:00 2001 From: PGray <77597544+PGrayCS@users.noreply.github.com> Date: Wed, 6 May 2026 14:07:49 +0100 Subject: [PATCH] fix(dotnet): align runtime CPEs with NVD (#4743) Signed-off-by: PGray Signed-off-by: Alex Goodman Co-authored-by: PGray --- syft/pkg/cataloger/dotnet/cataloger_test.go | 6 +- .../cataloger/dotnet/deps_binary_cataloger.go | 2 +- syft/pkg/cataloger/dotnet/package.go | 102 ++++++---- syft/pkg/cataloger/dotnet/package_test.go | 187 +++++++++++++----- 4 files changed, 202 insertions(+), 95 deletions(-) diff --git a/syft/pkg/cataloger/dotnet/cataloger_test.go b/syft/pkg/cataloger/dotnet/cataloger_test.go index 0b14a455f..163e5718a 100644 --- a/syft/pkg/cataloger/dotnet/cataloger_test.go +++ b/syft/pkg/cataloger/dotnet/cataloger_test.go @@ -730,7 +730,10 @@ func TestCataloger(t *testing.T) { if len(p.CPEs) == 0 { continue } - assert.Contains(t, p.Name, "Microsoft.NETCore.App") + if runtimeFamilyFromName(p.Name) != netRuntimeFamily { + continue + } + assert.Equal(t, runtimeCPEs(p.Name, p.Version), p.CPEs) return } t.Error("expected at least one runtime package with a CPE") @@ -1186,6 +1189,7 @@ func TestCataloger(t *testing.T) { "runtime.linux-x64.Microsoft.NETCore.DotNetHostPolicy @ 2.2.8 (/usr/share/dotnet/shared/Microsoft.NETCore.App/2.2.8/Microsoft.NETCore.App.deps.json)", // a compile target reference }, expectedRels: []string{ + "Microsoft.NETCore.App @ 2.2.8 (/usr/share/dotnet/shared/Microsoft.NETCore.App/2.2.8/Microsoft.NETCore.App.deps.json) [dependency-of] helloworld @ 1.0.0 (/app/helloworld.deps.json)", "Microsoft.NETCore.DotNetHostPolicy @ 2.2.8 (/usr/share/dotnet/shared/Microsoft.NETCore.App/2.2.8/Microsoft.NETCore.App.deps.json) [dependency-of] Microsoft.NETCore.App @ 2.2.8 (/usr/share/dotnet/shared/Microsoft.NETCore.App/2.2.8/Microsoft.NETCore.App.deps.json)", "Serilog @ 2.10.0 (/app/helloworld.deps.json) [dependency-of] Serilog.Sinks.Console @ 4.0.1 (/app/helloworld.deps.json)", "Serilog @ 2.10.0 (/app/helloworld.deps.json) [dependency-of] helloworld @ 1.0.0 (/app/helloworld.deps.json)", diff --git a/syft/pkg/cataloger/dotnet/deps_binary_cataloger.go b/syft/pkg/cataloger/dotnet/deps_binary_cataloger.go index 5fcf3c588..44c0a9e97 100644 --- a/syft/pkg/cataloger/dotnet/deps_binary_cataloger.go +++ b/syft/pkg/cataloger/dotnet/deps_binary_cataloger.go @@ -108,7 +108,7 @@ func (c depsBinaryCataloger) Catalog(_ context.Context, resolver file.Resolver) Name: "Microsoft.NETCore.App", Version: version, Type: pkg.DotnetPkg, - CPEs: runtimeCPEs(version), + CPEs: runtimeCPEs("Microsoft.NETCore.App", version), Locations: file.NewLocationSet(locs...), } pkgs = append(pkgs, rtp) diff --git a/syft/pkg/cataloger/dotnet/package.go b/syft/pkg/cataloger/dotnet/package.go index 22b1ff413..9a11745fe 100644 --- a/syft/pkg/cataloger/dotnet/package.go +++ b/syft/pkg/cataloger/dotnet/package.go @@ -22,6 +22,14 @@ var ( versionPunctuationRegex = regexp.MustCompile(`[.,]+`) ) +type runtimeFamily string + +const ( + unknownRuntimeFamily runtimeFamily = "" + netRuntimeFamily runtimeFamily = "net" + aspNetCoreRuntimeFamily runtimeFamily = "aspnet_core" +) + // newDotnetDepsPackage creates a new Dotnet dependency package from a logicalDepsJSONPackage. // Note that the new logicalDepsJSONPackage now directly holds library and executable information. func newDotnetDepsPackage(lp logicalDepsJSONPackage, depsLocation file.Location) *pkg.Package { @@ -36,7 +44,7 @@ func newDotnetDepsPackage(lp logicalDepsJSONPackage, depsLocation file.Location) var cpes []cpe.CPE if isRuntime(name) { - cpes = runtimeCPEs(ver) + cpes = runtimeCPEs(name, ver) } p := &pkg.Package{ @@ -56,52 +64,68 @@ func newDotnetDepsPackage(lp logicalDepsJSONPackage, depsLocation file.Location) } func isRuntime(name string) bool { - // found in a self-contained net8 app in the deps.json for the application - selfContainedRuntimeDependency := strings.HasPrefix(name, "runtimepack.Microsoft.NETCore.App.Runtime") - // found in net8 apps in the deps.json for the runtime - explicitRuntimeDependency := strings.HasPrefix(name, "Microsoft.NETCore.App.Runtime") - // found in net2 apps in the deps.json for the runtime - producesARuntime := strings.HasPrefix(name, "runtime") && strings.HasSuffix(name, "Microsoft.NETCore.App") - return selfContainedRuntimeDependency || explicitRuntimeDependency || producesARuntime + return runtimeFamilyFromName(name) != unknownRuntimeFamily } -func runtimeCPEs(ver string) []cpe.CPE { - // .NET Core Versions - // 2016: .NET Core 1.0, cpe:2.3:a:microsoft:dotnet_core:1.0:*:*:*:*:*:*:* - // 2016: .NET Core 1.1, cpe:2.3:a:microsoft:dotnet_core:1.1:*:*:*:*:*:*:* - // 2017: .NET Core 2.0, cpe:2.3:a:microsoft:dotnet_core:2.0:*:*:*:*:*:*:* - // 2018: .NET Core 2.1, cpe:2.3:a:microsoft:dotnet_core:2.1:*:*:*:*:*:*:* - // 2018: .NET Core 2.2, cpe:2.3:a:microsoft:dotnet_core:2.2:*:*:*:*:*:*:* - // 2019: .NET Core 3.0, cpe:2.3:a:microsoft:dotnet_core:3.0:*:*:*:*:*:*:* - // 2019: .NET Core 3.1, cpe:2.3:a:microsoft:dotnet_core:3.1:*:*:*:*:*:*:* +func runtimeFamilyFromName(name string) runtimeFamily { + normalizedName := strings.ToLower(name) - // Unified .NET Versions - // 2020: .NET 5.0, cpe:2.3:a:microsoft:dotnet:5.0:*:*:*:*:*:*:* - // 2021: .NET 6.0, cpe:2.3:a:microsoft:dotnet:6.0:*:*:*:*:*:*:* - // 2022: .NET 7.0, cpe:2.3:a:microsoft:dotnet:7.0:*:*:*:*:*:*:* - // 2023: .NET 8.0, cpe:2.3:a:microsoft:dotnet:8.0:*:*:*:*:*:*:* - // 2024: .NET 9.0, cpe:2.3:a:microsoft:dotnet:9.0:*:*:*:*:*:*:* - // 2025 ...? + // found in self-contained or framework-dependent apps in deps.json entries + if strings.HasPrefix(normalizedName, "runtimepack.microsoft.aspnetcore.app.runtime") || + strings.HasPrefix(normalizedName, "microsoft.aspnetcore.app.runtime") || + normalizedName == "microsoft.aspnetcore.app" || + (strings.HasPrefix(normalizedName, "runtime") && strings.HasSuffix(normalizedName, "microsoft.aspnetcore.app")) { + return aspNetCoreRuntimeFamily + } - fields := strings.Split(ver, ".") - majorVersion, err := strconv.Atoi(fields[0]) - if err != nil { - log.WithFields("error", err).Tracef("failed to parse .NET major version from %q", ver) + // found in self-contained, framework-dependent, and synthesized runtime packages + if strings.HasPrefix(normalizedName, "runtimepack.microsoft.netcore.app.runtime") || + strings.HasPrefix(normalizedName, "microsoft.netcore.app.runtime") || + normalizedName == "microsoft.netcore.app" || + (strings.HasPrefix(normalizedName, "runtime") && strings.HasSuffix(normalizedName, "microsoft.netcore.app")) { + return netRuntimeFamily + } + + return unknownRuntimeFamily +} + +func runtimeCPEs(name, ver string) []cpe.CPE { + family := runtimeFamilyFromName(name) + if family == unknownRuntimeFamily { return nil } - var minorVersion int - if len(fields) > 1 { - minorVersion, err = strconv.Atoi(fields[1]) - if err != nil { - log.WithFields("error", err).Tracef("failed to parse .NET minor version from %q", ver) - return nil - } + fields := strings.Split(ver, ".") + if len(fields) == 0 { + return nil } - productName := "dotnet" - if majorVersion < 5 { - productName = "dotnet_core" + normalizedVersionFields := make([]string, 0, len(fields)) + majorVersion, err := strconv.Atoi(fields[0]) + if err != nil { + log.WithFields("error", err).Tracef("failed to parse .NET runtime major version from %q", ver) + return nil + } + normalizedVersionFields = append(normalizedVersionFields, strconv.Itoa(majorVersion)) + + for _, field := range fields[1:] { + value, err := strconv.Atoi(field) + if err != nil { + log.WithFields("error", err).Tracef("failed to parse .NET runtime version component %q from %q", field, ver) + return nil + } + normalizedVersionFields = append(normalizedVersionFields, strconv.Itoa(value)) + } + + if len(normalizedVersionFields) == 1 { + normalizedVersionFields = append(normalizedVersionFields, "0") + } + + productName := ".net" + if family == aspNetCoreRuntimeFamily { + productName = "asp.net_core" + } else if majorVersion < 5 { + productName = ".net_core" } return []cpe.CPE{ @@ -110,7 +134,7 @@ func runtimeCPEs(ver string) []cpe.CPE { Part: "a", Vendor: "microsoft", Product: productName, - Version: fmt.Sprintf("%d.%d", majorVersion, minorVersion), + Version: strings.Join(normalizedVersionFields, "."), }, // we didn't find this in the underlying material, but this is the convention in NVD and we are certain this is a runtime package Source: cpe.DeclaredSource, diff --git a/syft/pkg/cataloger/dotnet/package_test.go b/syft/pkg/cataloger/dotnet/package_test.go index daff4e7f2..4e7f10aca 100644 --- a/syft/pkg/cataloger/dotnet/package_test.go +++ b/syft/pkg/cataloger/dotnet/package_test.go @@ -46,6 +46,56 @@ func Test_getDepsJSONFilePrefix(t *testing.T) { } } +func Test_newDotnetDepsPackage_AssignsRuntimeCPEs(t *testing.T) { + tests := []struct { + name string + nameVersion string + expected []cpe.CPE + }{ + { + name: ".NET runtime package", + nameVersion: "runtimepack.Microsoft.NETCore.App.Runtime.win-x64/10.0.4", + expected: []cpe.CPE{ + { + Attributes: cpe.Attributes{ + Part: "a", + Vendor: "microsoft", + Product: ".net", + Version: "10.0.4", + }, + Source: cpe.DeclaredSource, + }, + }, + }, + { + name: "ASP.NET Core runtime package", + nameVersion: "runtimepack.Microsoft.AspNetCore.App.Runtime.win-x64/10.0.4", + expected: []cpe.CPE{ + { + Attributes: cpe.Attributes{ + Part: "a", + Vendor: "microsoft", + Product: "asp.net_core", + Version: "10.0.4", + }, + Source: cpe.DeclaredSource, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual := newDotnetDepsPackage( + logicalDepsJSONPackage{NameVersion: tc.nameVersion}, + file.NewLocation("/app/test.deps.json"), + ) + + assert.Equal(t, tc.expected, actual.CPEs) + }) + } +} + func Test_NewDotnetBinaryPackage(t *testing.T) { tests := []struct { name string @@ -400,19 +450,21 @@ func Test_spaceNormalize(t *testing.T) { func TestRuntimeCPEs(t *testing.T) { tests := []struct { - name string - version string - expected []cpe.CPE + name string + packageName string + version string + expected []cpe.CPE }{ { - name: ".NET Core 1.0", - version: "1.0", + name: ".NET Core 1.0", + packageName: "Microsoft.NETCore.App", + version: "1.0", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet_core", + Product: ".net_core", Version: "1.0", }, Source: cpe.DeclaredSource, @@ -420,14 +472,15 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: ".NET Core 2.1", - version: "2.1", + name: ".NET Core 2.1", + packageName: "Microsoft.NETCore.App", + version: "2.1", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet_core", + Product: ".net_core", Version: "2.1", }, Source: cpe.DeclaredSource, @@ -435,14 +488,15 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: ".NET Core 3.1", - version: "3.1", + name: ".NET Core 3.1", + packageName: "Microsoft.NETCore.App", + version: "3.1", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet_core", + Product: ".net_core", Version: "3.1", }, Source: cpe.DeclaredSource, @@ -450,14 +504,15 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: ".NET Core 4.9 (hypothetical)", - version: "4.9", + name: ".NET Core 4.9 (hypothetical)", + packageName: "Microsoft.NETCore.App", + version: "4.9", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet_core", + Product: ".net_core", Version: "4.9", }, Source: cpe.DeclaredSource, @@ -465,14 +520,15 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: ".NET 5.0", - version: "5.0", + name: ".NET 5.0", + packageName: "Microsoft.NETCore.App", + version: "5.0", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet", + Product: ".net", Version: "5.0", }, Source: cpe.DeclaredSource, @@ -480,14 +536,15 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: ".NET 6.0", - version: "6.0", + name: ".NET 6.0", + packageName: "Microsoft.NETCore.App", + version: "6.0", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet", + Product: ".net", Version: "6.0", }, Source: cpe.DeclaredSource, @@ -495,14 +552,15 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: ".NET 8.0", - version: "8.0", + name: ".NET 8.0", + packageName: "Microsoft.NETCore.App", + version: "8.0", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet", + Product: ".net", Version: "8.0", }, Source: cpe.DeclaredSource, @@ -510,14 +568,15 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: ".NET 10.0 (future version)", - version: "10.0", + name: ".NET 10.0 (future version)", + packageName: "Microsoft.NETCore.App", + version: "10.0", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet", + Product: ".net", Version: "10.0", }, Source: cpe.DeclaredSource, @@ -525,14 +584,47 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: "Patch version should not be included", - version: "6.0.21", + name: "Patch version should be preserved", + packageName: "Microsoft.NETCore.App", + version: "6.0.21", expected: []cpe.CPE{ { Attributes: cpe.Attributes{ Part: "a", Vendor: "microsoft", - Product: "dotnet", + Product: ".net", + Version: "6.0.21", + }, + Source: cpe.DeclaredSource, + }, + }, + }, + { + name: "ASP.NET Core runtime", + packageName: "runtimepack.Microsoft.AspNetCore.App.Runtime.win-x64", + version: "9.0.10", + expected: []cpe.CPE{ + { + Attributes: cpe.Attributes{ + Part: "a", + Vendor: "microsoft", + Product: "asp.net_core", + Version: "9.0.10", + }, + Source: cpe.DeclaredSource, + }, + }, + }, + { + name: "Assumed minor version", + packageName: "Microsoft.NETCore.App", + version: "6", + expected: []cpe.CPE{ + { + Attributes: cpe.Attributes{ + Part: "a", + Vendor: "microsoft", + Product: ".net", Version: "6.0", }, Source: cpe.DeclaredSource, @@ -540,39 +632,26 @@ func TestRuntimeCPEs(t *testing.T) { }, }, { - name: "Assumed minor version", - version: "6", - expected: []cpe.CPE{ - { - Attributes: cpe.Attributes{ - Part: "a", - Vendor: "microsoft", - Product: "dotnet", - Version: "6.0", - }, - Source: cpe.DeclaredSource, - }, - }, + name: "Invalid version format", + packageName: "Microsoft.NETCore.App", + version: "invalid", + expected: nil, }, { - name: "Invalid version format", - version: "invalid", - expected: nil, - }, - { - name: "Empty version", - version: "", - expected: nil, + name: "Empty version", + packageName: "Microsoft.NETCore.App", + version: "", + expected: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result := runtimeCPEs(tc.version) + result := runtimeCPEs(tc.packageName, tc.version) if !reflect.DeepEqual(result, tc.expected) { - t.Errorf("runtimeCPEs(%q) = %+v; want %+v", - tc.version, result, tc.expected) + t.Errorf("runtimeCPEs(%q, %q) = %+v; want %+v", + tc.packageName, tc.version, result, tc.expected) } }) }