diff --git a/syft/pkg/cataloger/dotnet/parse_packages_lock.go b/syft/pkg/cataloger/dotnet/parse_packages_lock.go index 3348d5a0f..685f60eed 100644 --- a/syft/pkg/cataloger/dotnet/parse_packages_lock.go +++ b/syft/pkg/cataloger/dotnet/parse_packages_lock.go @@ -4,8 +4,9 @@ import ( "context" "encoding/json" "fmt" + "maps" "slices" - "sort" + "strings" "github.com/anchore/packageurl-go" "github.com/anchore/syft/internal/log" @@ -18,6 +19,8 @@ import ( var _ generic.Parser = parseDotnetPackagesLock +var packagesLockTypePrecedence = []string{"Project", "Direct", "CentralTransitive", "Transitive"} + type dotnetPackagesLock struct { Version int `json:"version"` Dependencies map[string]map[string]dotnetPackagesLockDep `json:"dependencies"` @@ -31,11 +34,38 @@ type dotnetPackagesLockDep struct { Dependencies map[string]string `json:"dependencies,omitempty"` } -func parseDotnetPackagesLock(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { //nolint:funlen - var pkgs []pkg.Package - var pkgMap = make(map[string]pkg.Package) - var relationships []artifact.Relationship +type packagesLockEntry struct { + name string + dep dotnetPackagesLockDep +} +func (e packagesLockEntry) nameVersion() string { + return createNameAndVersion(e.name, e.dep.Resolved) +} + +// key identifies the package an entry describes. NuGet package IDs are case-insensitive, so two spellings of the +// same ID and version are one package. +func (e packagesLockEntry) key() string { + return createNameAndVersion(strings.ToLower(e.name), e.dep.Resolved) +} + +type packagesLockFramework struct { + name string + entries []packagesLockEntry + // byName is keyed by the lowercased package name, since NuGet package IDs are case-insensitive. + byName map[string]packagesLockEntry +} + +func (f packagesLockFramework) find(name string) (packagesLockEntry, bool) { + e, ok := f.byName[strings.ToLower(name)] + return e, ok +} + +type packagesLockEdge struct { + child, parent artifact.ID +} + +func parseDotnetPackagesLock(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { dec := json.NewDecoder(reader) // unmarshal file @@ -44,70 +74,21 @@ func parseDotnetPackagesLock(_ context.Context, _ file.Resolver, _ *generic.Envi return nil, nil, fmt.Errorf("failed to parse packages.lock.json file: %w", err) } - // collect all deps here - allDependencies := make(map[string]dotnetPackagesLockDep) - - var names []string - for _, dependencies := range lockFile.Dependencies { - for name, dep := range dependencies { - depNameVersion := createNameAndVersion(name, dep.Resolved) - - if slices.Contains(names, depNameVersion) { - continue - } - - names = append(names, depNameVersion) - allDependencies[depNameVersion] = dep - } - } - - // sort the names so that the order of the packages is deterministic - sort.Strings(names) + frameworks := newPackagesLockFrameworks(lockFile) // create artifact for each pkg - for _, nameVersion := range names { - name, _ := extractNameAndVersion(nameVersion) + var pkgs []pkg.Package + pkgMap := make(map[string]pkg.Package) - dep := allDependencies[nameVersion] - dotnetPkg := newDotnetPackagesLockPackage(name, dep, reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)) + for _, entry := range mergePackagesLockEntries(frameworks) { + dotnetPkg := newDotnetPackagesLockPackage(entry.name, entry.dep, reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)) if dotnetPkg != nil { pkgs = append(pkgs, *dotnetPkg) - pkgMap[nameVersion] = *dotnetPkg + pkgMap[entry.key()] = *dotnetPkg } } - // fill up relationships - for depNameVersion, dep := range allDependencies { - parentPkg, ok := pkgMap[depNameVersion] - if !ok { - log.Debugf("package \"%s\" not found in map of all packages", depNameVersion) - continue - } - - for childDepName, childDepVersion := range dep.Dependencies { - childDepNameVersion := createNameAndVersion(childDepName, childDepVersion) - - // try and find pkg for dependency with exact name and version - childPkg, ok := pkgMap[childDepNameVersion] - if !ok { - // no exact match found, lets match on name only, lockfile will contain other version of pkg - cpkg, ok := findPkgByName(childDepName, pkgMap) - if !ok { - log.Debugf("dependency \"%s\" of package \"%s\" not found in map of all packages", childDepNameVersion, depNameVersion) - continue - } - - childPkg = *cpkg - } - - rel := artifact.Relationship{ - From: childPkg, - To: parentPkg, - Type: artifact.DependencyOfRelationship, - } - relationships = append(relationships, rel) - } - } + relationships := packagesLockRelationships(frameworks, pkgMap) // sort the relationships for deterministic output relationship.Sort(relationships) @@ -115,6 +96,147 @@ func parseDotnetPackagesLock(_ context.Context, _ file.Resolver, _ *generic.Envi return pkgs, relationships, nil } +func newPackagesLockFrameworks(lockFile dotnetPackagesLock) []packagesLockFramework { + var frameworks []packagesLockFramework + + for _, frameworkName := range slices.Sorted(maps.Keys(lockFile.Dependencies)) { + deps := lockFile.Dependencies[frameworkName] + framework := packagesLockFramework{ + name: frameworkName, + byName: make(map[string]packagesLockEntry, len(deps)), + } + + for _, name := range slices.Sorted(maps.Keys(deps)) { + entry := packagesLockEntry{name: name, dep: deps[name]} + framework.entries = append(framework.entries, entry) + framework.byName[strings.ToLower(name)] = entry + } + + frameworks = append(frameworks, framework) + } + + return frameworks +} + +func mergePackagesLockEntries(frameworks []packagesLockFramework) []packagesLockEntry { + merged := make(map[string]packagesLockEntry) + + for _, framework := range frameworks { + for _, entry := range framework.entries { + key := entry.key() + if existing, ok := merged[key]; ok && !isMoreDirect(entry.dep.Type, existing.dep.Type) { + continue + } + merged[key] = entry + } + } + + var entries []packagesLockEntry + for _, key := range slices.Sorted(maps.Keys(merged)) { + entries = append(entries, merged[key]) + } + + return entries +} + +func isMoreDirect(a, b string) bool { + rankA, rankB := packagesLockTypeRank(a), packagesLockTypeRank(b) + if rankA != rankB { + return rankA < rankB + } + return a < b +} + +func packagesLockTypeRank(t string) int { + if i := slices.Index(packagesLockTypePrecedence, t); i >= 0 { + return i + } + return len(packagesLockTypePrecedence) +} + +func packagesLockRelationships(frameworks []packagesLockFramework, pkgMap map[string]pkg.Package) []artifact.Relationship { + var relationships []artifact.Relationship + + frameworksByName := make(map[string]packagesLockFramework, len(frameworks)) + for _, framework := range frameworks { + frameworksByName[framework.name] = framework + } + + seen := make(map[packagesLockEdge]struct{}) + for _, framework := range frameworks { + base, hasBase := frameworksByName[baseFrameworkName(framework.name)] + if !hasBase || base.name == framework.name { + base = packagesLockFramework{} + } + + for _, entry := range framework.entries { + parentPkg, ok := pkgMap[entry.key()] + if !ok { + log.Debugf("package %q not found in map of all packages", entry.nameVersion()) + continue + } + + relationships = append(relationships, packagesLockEntryRelationships(entry, parentPkg, framework, base, pkgMap, seen)...) + } + } + + return relationships +} + +func packagesLockEntryRelationships(entry packagesLockEntry, parentPkg pkg.Package, framework, base packagesLockFramework, pkgMap map[string]pkg.Package, seen map[packagesLockEdge]struct{}) []artifact.Relationship { + var relationships []artifact.Relationship + + for _, childName := range slices.Sorted(maps.Keys(entry.dep.Dependencies)) { + child, ok := findPackagesLockDependency(childName, framework, base) + if !ok { + log.Debugf("dependency %q of package %q not found under target framework %q", childName, entry.nameVersion(), framework.name) + continue + } + + childPkg, ok := pkgMap[child.key()] + if !ok { + log.Debugf("package %q not found in map of all packages", child.nameVersion()) + continue + } + + // a package listing itself (possibly under another casing) is not a dependency + if childPkg.ID() == parentPkg.ID() { + continue + } + + key := packagesLockEdge{child: childPkg.ID(), parent: parentPkg.ID()} + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + relationships = append(relationships, artifact.Relationship{ + From: childPkg, + To: parentPkg, + Type: artifact.DependencyOfRelationship, + }) + } + + return relationships +} + +// findPackagesLockDependency finds the entry a dependency edge points at. The version on an edge is only the lower +// bound of a version range, so the edge resolves to whatever version its target framework resolved. Runtime-specific +// sections such as "net8.0/win-x64" may reference packages listed only in their base framework ("net8.0"). An edge +// that resolves nowhere is dropped rather than guessed from another target framework. +func findPackagesLockDependency(name string, framework, base packagesLockFramework) (packagesLockEntry, bool) { + if entry, ok := framework.find(name); ok { + return entry, true + } + return base.find(name) +} + +// baseFrameworkName returns the target framework of a runtime-specific section, such as "net8.0" for "net8.0/win-x64". +func baseFrameworkName(frameworkName string) string { + name, _, _ := strings.Cut(frameworkName, "/") + return name +} + func newDotnetPackagesLockPackage(name string, dep dotnetPackagesLockDep, locations ...file.Location) *pkg.Package { metadata := pkg.DotnetPackagesLockEntry{ Name: name, @@ -150,14 +272,3 @@ func packagesLockPackageURL(name, version string) string { "", ).ToString() } - -func findPkgByName(pkgName string, pkgMap map[string]pkg.Package) (*pkg.Package, bool) { - for pkgNameVersion, pkg := range pkgMap { - name, _ := extractNameAndVersion(pkgNameVersion) - if name == pkgName { - return &pkg, true - } - } - - return nil, false -} diff --git a/syft/pkg/cataloger/dotnet/parse_packages_lock_test.go b/syft/pkg/cataloger/dotnet/parse_packages_lock_test.go index 24ce33454..ffdf09892 100644 --- a/syft/pkg/cataloger/dotnet/parse_packages_lock_test.go +++ b/syft/pkg/cataloger/dotnet/parse_packages_lock_test.go @@ -3,6 +3,9 @@ package dotnet import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" @@ -197,3 +200,304 @@ func TestParseDotnetPackagesLock(t *testing.T) { pkgtest.TestFileParser(t, fixture, parseDotnetPackagesLock, expectedPkgs, expectedRelationships) } + +func TestParseDotnetPackagesLock_multipleTargetFrameworks(t *testing.T) { + fixture := "testdata/packages.lock-multi-framework.json" + fixtureLocationSet := file.NewLocationSet(file.NewLocation(fixture)) + + myLibPkg := pkg.Package{ + Name: "MyLib", + Version: "1.0.0", + PURL: "pkg:nuget/MyLib@1.0.0", + Locations: fixtureLocationSet, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPackagesLockEntry{ + Name: "MyLib", + Version: "1.0.0", + ContentHash: "mylibhash==", + Type: "Direct", + }, + } + + log4net2Pkg := pkg.Package{ + Name: "log4net", + Version: "2.0.5", + PURL: "pkg:nuget/log4net@2.0.5", + Locations: fixtureLocationSet, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPackagesLockEntry{ + Name: "log4net", + Version: "2.0.5", + ContentHash: "log4net205hash==", + Type: "Transitive", + }, + } + + log4net1Pkg := pkg.Package{ + Name: "log4net", + Version: "1.2.15", + PURL: "pkg:nuget/log4net@1.2.15", + Locations: fixtureLocationSet, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPackagesLockEntry{ + Name: "log4net", + Version: "1.2.15", + ContentHash: "log4net1215hash==", + Type: "Transitive", + }, + } + + // resolved to the same version under both frameworks (spelled "newtonsoft.json" under one), but declared "Direct" by + // only one of them + newtonsoftPkg := pkg.Package{ + Name: "Newtonsoft.Json", + Version: "13.0.3", + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + Locations: fixtureLocationSet, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPackagesLockEntry{ + Name: "Newtonsoft.Json", + Version: "13.0.3", + ContentHash: "newtonsoft1303hash==", + Type: "Direct", + }, + } + + // only listed under the runtime-specific "net8.0/win-x64" section, and depends on a package from "net8.0" + myLibNativePkg := pkg.Package{ + Name: "runtime.win-x64.MyLib.Native", + Version: "1.0.0", + PURL: "pkg:nuget/runtime.win-x64.MyLib.Native@1.0.0", + Locations: fixtureLocationSet, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPackagesLockEntry{ + Name: "runtime.win-x64.MyLib.Native", + Version: "1.0.0", + ContentHash: "mylibnativehash==", + Type: "Transitive", + }, + } + + // only listed under "net8.0", so the "netstandard2.0" edge to it must be dropped rather than guessed + serilogPkg := pkg.Package{ + Name: "Serilog", + Version: "3.1.1", + PURL: "pkg:nuget/Serilog@3.1.1", + Locations: fixtureLocationSet, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPackagesLockEntry{ + Name: "Serilog", + Version: "3.1.1", + ContentHash: "serilog311hash==", + Type: "Transitive", + }, + } + + expectedPkgs := []pkg.Package{ + myLibPkg, + newtonsoftPkg, + log4net1Pkg, + log4net2Pkg, + myLibNativePkg, + serilogPkg, + } + + // the same package is resolved to a different version per target framework, so both edges must be captured, while + // the Newtonsoft.Json edge declared by both frameworks must appear only once + expectedRelationships := []artifact.Relationship{ + { + From: log4net1Pkg, + To: myLibPkg, + Type: artifact.DependencyOfRelationship, + }, + { + From: log4net2Pkg, + To: myLibPkg, + Type: artifact.DependencyOfRelationship, + }, + { + From: log4net2Pkg, + To: myLibNativePkg, + Type: artifact.DependencyOfRelationship, + }, + { + From: newtonsoftPkg, + To: myLibPkg, + Type: artifact.DependencyOfRelationship, + }, + } + + pkgtest.TestFileParser(t, fixture, parseDotnetPackagesLock, expectedPkgs, expectedRelationships) +} + +func TestParseDotnetPackagesLock_projectReference(t *testing.T) { + fixture := "testdata/packages.lock-project-reference.json" + fixtureLocationSet := file.NewLocationSet(file.NewLocation(fixture)) + + newtonsoftPkg := pkg.Package{ + Name: "Newtonsoft.Json", + Version: "13.0.3", + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + Locations: fixtureLocationSet, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPackagesLockEntry{ + Name: "Newtonsoft.Json", + Version: "13.0.3", + ContentHash: "newtonsoft1303hash==", + Type: "Transitive", + }, + } + + // a project reference has no resolved version or content hash + myLibPkg := pkg.Package{ + Name: "mylib", + PURL: "pkg:nuget/mylib", + Locations: fixtureLocationSet, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPackagesLockEntry{ + Name: "mylib", + Type: "Project", + }, + } + + expectedPkgs := []pkg.Package{ + newtonsoftPkg, + myLibPkg, + } + + // project references declare their dependencies as version ranges, which resolve to the version the target + // framework resolved + expectedRelationships := []artifact.Relationship{ + { + From: newtonsoftPkg, + To: myLibPkg, + Type: artifact.DependencyOfRelationship, + }, + } + + pkgtest.TestFileParser(t, fixture, parseDotnetPackagesLock, expectedPkgs, expectedRelationships) +} + +func Test_findPackagesLockDependency(t *testing.T) { + frameworks := newPackagesLockFrameworks(dotnetPackagesLock{ + Dependencies: map[string]map[string]dotnetPackagesLockDep{ + "net8.0": { + "log4net": {Resolved: "2.0.5"}, + }, + "net8.0/win-x64": { + "runtime.win-x64.Native": {Resolved: "1.0.0"}, + }, + "netstandard2.0": { + "log4net": {Resolved: "1.2.15"}, + "Newtonsoft.Json": {Resolved: "13.0.3"}, + }, + }, + }) + net8, rid, netstandard := frameworks[0], frameworks[1], frameworks[2] + + tests := []struct { + name string + depName string + framework packagesLockFramework + base packagesLockFramework + wantVersion string + wantFound bool + }{ + { + name: "resolves to the version pinned by its own target framework", + depName: "log4net", + framework: netstandard, + wantVersion: "1.2.15", + wantFound: true, + }, + { + name: "package names are matched case-insensitively", + depName: "LOG4NET", + framework: net8, + wantVersion: "2.0.5", + wantFound: true, + }, + { + name: "runtime-specific sections fall back to their base target framework", + depName: "log4net", + framework: rid, + base: net8, + wantVersion: "2.0.5", + wantFound: true, + }, + { + name: "a package absent from the target framework is not guessed from another one", + depName: "Newtonsoft.Json", + framework: net8, + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, found := findPackagesLockDependency(tt.depName, tt.framework, tt.base) + require.Equal(t, tt.wantFound, found) + if !tt.wantFound { + return + } + assert.Equal(t, tt.wantVersion, got.dep.Resolved) + }) + } +} + +func Test_mergePackagesLockEntries_typePrecedence(t *testing.T) { + tests := []struct { + name string + types map[string]string // target framework -> type + wantType string + }{ + { + name: "direct wins over transitive", + types: map[string]string{"net8.0": "Transitive", "netstandard2.0": "Direct"}, + wantType: "Direct", + }, + { + name: "central transitive wins over transitive regardless of framework order", + types: map[string]string{"net8.0": "Transitive", "netstandard2.0": "CentralTransitive"}, + wantType: "CentralTransitive", + }, + { + name: "central transitive wins over transitive in the reverse framework order", + types: map[string]string{"net8.0": "CentralTransitive", "netstandard2.0": "Transitive"}, + wantType: "CentralTransitive", + }, + { + name: "direct wins over central transitive", + types: map[string]string{"net472": "CentralTransitive", "net8.0": "Direct", "netstandard2.0": "Transitive"}, + wantType: "Direct", + }, + { + name: "known types win over unknown ones", + types: map[string]string{"net8.0": "Unknown", "netstandard2.0": "Transitive"}, + wantType: "Transitive", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockFile := dotnetPackagesLock{Dependencies: map[string]map[string]dotnetPackagesLockDep{}} + for framework, depType := range tt.types { + lockFile.Dependencies[framework] = map[string]dotnetPackagesLockDep{ + "Newtonsoft.Json": {Type: depType, Resolved: "13.0.3"}, + } + } + + entries := mergePackagesLockEntries(newPackagesLockFrameworks(lockFile)) + require.Len(t, entries, 1) + assert.Equal(t, tt.wantType, entries[0].dep.Type) + }) + } +} diff --git a/syft/pkg/cataloger/dotnet/testdata/packages.lock-multi-framework.json b/syft/pkg/cataloger/dotnet/testdata/packages.lock-multi-framework.json new file mode 100644 index 000000000..e9d4dd200 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/testdata/packages.lock-multi-framework.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "MyLib": { + "type": "Direct", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "mylibhash==", + "dependencies": { + "log4net": "2.0.5", + "Newtonsoft.Json": "13.0.3" + } + }, + "log4net": { + "type": "Transitive", + "resolved": "2.0.5", + "contentHash": "log4net205hash==" + }, + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.3, )", + "resolved": "13.0.3", + "contentHash": "newtonsoft1303hash==" + }, + "Serilog": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "serilog311hash==" + } + }, + "net8.0/win-x64": { + "runtime.win-x64.MyLib.Native": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "mylibnativehash==", + "dependencies": { + "log4net": "2.0.0", + "RUNTIME.WIN-X64.MYLIB.NATIVE": "1.0.0" + } + } + }, + "netstandard2.0": { + "MyLib": { + "type": "Direct", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "mylibhash==", + "dependencies": { + "log4net": "1.2.15", + "newtonsoft.json": "13.0.3", + "Serilog": "3.1.1" + } + }, + "log4net": { + "type": "Transitive", + "resolved": "1.2.15", + "contentHash": "log4net1215hash==" + }, + "newtonsoft.json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "newtonsoft1303hash==" + } + } + } +} diff --git a/syft/pkg/cataloger/dotnet/testdata/packages.lock-project-reference.json b/syft/pkg/cataloger/dotnet/testdata/packages.lock-project-reference.json new file mode 100644 index 000000000..e09cfe2b0 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/testdata/packages.lock-project-reference.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "newtonsoft1303hash==" + }, + "mylib": { + "type": "Project", + "dependencies": { + "Newtonsoft.Json": "[13.0.3, )" + } + } + } + } +}