fix(dotnet): resolve packages.lock dependencies deterministically (#5210)

* fix(dotnet): resolve packages.lock dependencies within their target framework

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>

* fix(dotnet): make packages.lock package metadata deterministic across target frameworks

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>

* fix: parse packages lock tests

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>

* fix: update lockfile parse to read into stable structure

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>

* fix: remove stale comments

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>

* test: new test for project entry case

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>

* fix(dotnet): key packages.lock packages case-insensitively

NuGet IDs are case-insensitive, and edge lookup already treated them that way, but package identity used the exact-case name. Two spellings of one ID and version across target frameworks became two packages, and type precedence never ran between them.

- packages merge on the lowercased name + version, display name comes from the winning entry
- a package listing itself (under any casing) no longer produces a self edge

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Christopher Angelo Phillips
2026-10-01 21:21:09 +00:00
committed by GitHub
co-authored by Alex Goodman
parent cc326e45a6
commit c33fa32cf1
4 changed files with 572 additions and 72 deletions
+183 -72
View File
@@ -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
}
@@ -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)
})
}
}
@@ -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=="
}
}
}
}
@@ -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, )"
}
}
}
}
}