feat(dotnet): add dependency graph support for .deps.json files (#9726)

Co-authored-by: DmitriyLewen <91113035+DmitriyLewen@users.noreply.github.com>
Co-authored-by: DmitriyLewen <dmitriy.lewen@smartforce.io>
This commit is contained in:
Alex Lutz
2025-10-31 23:54:46 -06:00
committed by GitHub
parent 445cd2b6b4
commit 18c0ee86f3
6 changed files with 295 additions and 83 deletions

View File

@@ -13,7 +13,7 @@ The following table provides an outline of the features Trivy offers.
| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position |
|:---------------:|--------------------|:-----------------------:|:----------------:|:------------------------------------:|:--------:|
| .Net Core | *.deps.json | ✓ | Excluded | - | ✓ |
| .Net Core | *.deps.json | ✓ | Excluded | | ✓ |
| NuGet | packages.config | ✓ | Excluded | - | - |
| NuGet | *Packages.props | - | Excluded | - | - |
| NuGet | packages.lock.json | ✓ | Included | ✓ | ✓ |

View File

@@ -10,6 +10,22 @@
"Class": "lang-pkgs",
"Type": "dotnet-core",
"Packages": [
{
"ID": "Microsoft.VisualStudio.TestPlatform.Common/17.2.0-release-20220408-11",
"Name": "Microsoft.VisualStudio.TestPlatform.Common",
"Identifier": {
"PURL": "pkg:nuget/Microsoft.VisualStudio.TestPlatform.Common@17.2.0-release-20220408-11",
"UID": "2d904514f21a2664"
},
"Version": "17.2.0-release-20220408-11",
"Relationship": "root",
"Locations": [
{
"StartLine": 15,
"EndLine": 19
}
]
},
{
"ID": "Newtonsoft.Json/9.0.1",
"Name": "Newtonsoft.Json",

View File

@@ -2,6 +2,7 @@ package core_deps
import (
"context"
"slices"
"sort"
"strings"
"sync"
@@ -32,9 +33,10 @@ type RuntimeTarget struct {
}
type TargetLib struct {
Runtime any `json:"runtime"`
RuntimeTargets any `json:"runtimeTargets"`
Native any `json:"native"`
Dependencies map[string]string `json:"dependencies"`
Runtime any `json:"runtime"`
RuntimeTargets any `json:"runtimeTargets"`
Native any `json:"native"`
}
type Parser struct {
@@ -55,41 +57,109 @@ func (p *Parser) Parse(_ context.Context, r xio.ReadSeekerAt) ([]ftypes.Package,
return nil, nil, xerrors.Errorf("failed to decode .deps.json file: %w", err)
}
var pkgs ftypes.Packages
for nameVer, lib := range depsFile.Libraries {
if !strings.EqualFold(lib.Type, "package") {
continue
}
// Get target libraries for RuntimeTarget
targetLibs, targetLibsFound := depsFile.Targets[depsFile.RuntimeTarget.Name]
if !targetLibsFound {
// If the target is not found, take all dependencies
p.logger.Debug("Unable to find `Target` for Runtime Target Name. All dependencies from `libraries` section will be included in the report", log.String("Runtime Target Name", depsFile.RuntimeTarget.Name))
}
split := strings.Split(nameVer, "/")
if len(split) != 2 {
// First pass: collect all packages
var projectNameVer string
pkgs := make(map[string]ftypes.Package, len(depsFile.Libraries))
for nameVer, lib := range depsFile.Libraries {
name, version, ok := strings.Cut(nameVer, "/")
if !ok {
// Invalid name
p.logger.Warn("Cannot parse .NET library version", log.String("library", nameVer))
continue
}
// Take target libraries for RuntimeTarget
if targetLibs, ok := depsFile.Targets[depsFile.RuntimeTarget.Name]; !ok {
// If the target is not found, take all dependencies
p.once.Do(func() {
p.logger.Debug("Unable to find `Target` for Runtime Target Name. All dependencies from `libraries` section will be included in the report", log.String("Runtime Target Name", depsFile.RuntimeTarget.Name))
})
} else if !p.isRuntimeLibrary(targetLibs, nameVer) {
// Skip unsupported library types
if !strings.EqualFold(lib.Type, "package") && !strings.EqualFold(lib.Type, "project") {
continue
}
// Skip non-runtime libraries if target libraries are available
if targetLibsFound && !p.isRuntimeLibrary(targetLibs, nameVer) {
// Skip non-runtime libraries
// cf. https://github.com/aquasecurity/trivy/pull/7039#discussion_r1674566823
continue
}
pkgs = append(pkgs, ftypes.Package{
ID: dependency.ID(ftypes.DotNetCore, split[0], split[1]),
Name: split[0],
Version: split[1],
pkg := ftypes.Package{
ID: packageID(name, version),
Name: name,
Version: version,
Locations: []ftypes.Location{ftypes.Location(lib.Location)},
})
}
// Identify root package
if strings.EqualFold(lib.Type, "project") {
if projectNameVer != "" {
p.logger.Warn("Multiple root projects found in .deps.json", log.String("existing_root", projectNameVer), log.String("new_root", nameVer))
continue
}
projectNameVer = nameVer
pkg.Relationship = ftypes.RelationshipRoot
}
pkgs[pkg.ID] = pkg
}
sort.Sort(pkgs)
return pkgs, nil, nil
if len(pkgs) == 0 {
return nil, nil, nil
}
// If target libraries are not found, return all collected packages without dependencies
if !targetLibsFound {
pkgSlice := lo.Values(pkgs)
sort.Sort(ftypes.Packages(pkgSlice))
return pkgSlice, nil, nil
}
directDeps := lo.MapToSlice(targetLibs[projectNameVer].Dependencies, packageID)
// Second pass: build dependency graph + fill Relationships from targets section
var deps ftypes.Dependencies
for pkgID, pkg := range pkgs {
// Fill relationship field for package
// If Root package didn't find or don't have dependencies, skip setting Relationship,
// because most likely file is broken.
// Root package Relationship is already set
if len(directDeps) > 0 && pkg.Relationship != ftypes.RelationshipRoot {
pkg.Relationship = lo.Ternary(slices.Contains(directDeps, pkgID), ftypes.RelationshipDirect, ftypes.RelationshipIndirect)
pkgs[pkgID] = pkg
}
// Build dependency graph
dependencies, ok := targetLibs[pkgID]
// Package doesn't have dependencies
if !ok {
continue
}
var dependsOn []string
for depName, depVersion := range dependencies.Dependencies {
depID := packageID(depName, depVersion)
// Only create dependencies for packages that exist in package lists
if _, exists := pkgs[depID]; exists {
dependsOn = append(dependsOn, depID)
}
}
if len(dependsOn) > 0 {
deps = append(deps, ftypes.Dependency{
ID: pkgID,
DependsOn: dependsOn,
})
}
}
pkgSlice := lo.Values(pkgs)
sort.Sort(ftypes.Packages(pkgSlice))
sort.Sort(deps)
return pkgSlice, deps, nil
}
// isRuntimeLibrary returns true if library contains `runtime`, `runtimeTarget` or `native` sections, or if the library is missing from `targetLibs`.
@@ -105,5 +175,9 @@ func (p *Parser) isRuntimeLibrary(targetLibs map[string]TargetLib, library strin
return true
}
// Check that `runtime`, `runtimeTarget` and `native` sections are not empty
return !lo.IsEmpty(lib)
return !lo.IsEmpty(lib.Runtime) || !lo.IsEmpty(lib.RuntimeTargets) || !lo.IsEmpty(lib.Native)
}
func packageID(name, version string) string {
return dependency.ID(ftypes.DotNetCore, name, version)
}

View File

@@ -2,7 +2,6 @@ package core_deps
import (
"os"
"sort"
"testing"
"github.com/stretchr/testify/assert"
@@ -13,15 +12,130 @@ import (
func TestParse(t *testing.T) {
tests := []struct {
name string
file string // Test input file
want []ftypes.Package
wantErr string
name string
file string // Test input file
want []ftypes.Package
wantDeps []ftypes.Dependency
wantErr string
}{
{
name: "happy path",
file: "testdata/happy.deps.json",
want: []ftypes.Package{
{
ID: "ExampleApp1/1.0.0",
Name: "ExampleApp1",
Version: "1.0.0",
Relationship: ftypes.RelationshipRoot,
Locations: []ftypes.Location{
{
StartLine: 28,
EndLine: 32,
},
},
},
{
ID: "Newtonsoft.Json/13.0.1",
Name: "Newtonsoft.Json",
Version: "13.0.1",
Relationship: ftypes.RelationshipDirect,
Locations: []ftypes.Location{
{
StartLine: 33,
EndLine: 39,
},
},
},
},
wantDeps: []ftypes.Dependency{
{
ID: "ExampleApp1/1.0.0",
DependsOn: []string{"Newtonsoft.Json/13.0.1"},
},
},
},
{
name: "happy path with skipped libs",
file: "testdata/without-runtime.deps.json",
want: []ftypes.Package{
{
ID: "hello2/1.0.0",
Name: "hello2",
Version: "1.0.0",
Relationship: ftypes.RelationshipRoot,
Locations: []ftypes.Location{
{
StartLine: 61,
EndLine: 65,
},
},
},
{
ID: "JsonDiffPatch/2.0.61",
Name: "JsonDiffPatch",
Version: "2.0.61",
Relationship: ftypes.RelationshipDirect,
Locations: []ftypes.Location{
{
StartLine: 66,
EndLine: 72,
},
},
},
{
ID: "Libuv/1.9.1",
Name: "Libuv",
Version: "1.9.1",
Relationship: ftypes.RelationshipIndirect,
Locations: []ftypes.Location{
{
StartLine: 73,
EndLine: 79,
},
},
},
{
ID: "System.Collections.Immutable/1.3.0",
Name: "System.Collections.Immutable",
Version: "1.3.0",
Relationship: ftypes.RelationshipIndirect,
Locations: []ftypes.Location{
{
StartLine: 101,
EndLine: 107,
},
},
},
},
wantDeps: []ftypes.Dependency{
{
ID: "hello2/1.0.0",
DependsOn: []string{"JsonDiffPatch/2.0.61"},
},
},
},
{
name: "happy path without libs",
file: "testdata/no-libraries.deps.json",
want: nil,
wantDeps: nil,
},
{
name: "target libs not found",
file: "testdata/missing-target.deps.json",
want: []ftypes.Package{
{
ID: "ExampleApp1/1.0.0",
Name: "ExampleApp1",
Version: "1.0.0",
Relationship: ftypes.RelationshipRoot,
Locations: []ftypes.Location{
{
StartLine: 28,
EndLine: 32,
},
},
},
{
ID: "Newtonsoft.Json/13.0.1",
Name: "Newtonsoft.Json",
@@ -34,50 +148,7 @@ func TestParse(t *testing.T) {
},
},
},
},
{
name: "happy path with skipped libs",
file: "testdata/without-runtime.deps.json",
want: []ftypes.Package{
{
ID: "JsonDiffPatch/2.0.61",
Name: "JsonDiffPatch",
Version: "2.0.61",
Locations: []ftypes.Location{
{
StartLine: 66,
EndLine: 72,
},
},
},
{
ID: "Libuv/1.9.1",
Name: "Libuv",
Version: "1.9.1",
Locations: []ftypes.Location{
{
StartLine: 73,
EndLine: 79,
},
},
},
{
ID: "System.Collections.Immutable/1.3.0",
Name: "System.Collections.Immutable",
Version: "1.3.0",
Locations: []ftypes.Location{
{
StartLine: 101,
EndLine: 107,
},
},
},
},
},
{
name: "happy path without libs",
file: "testdata/no-libraries.deps.json",
want: nil,
wantDeps: nil,
},
{
name: "sad path",
@@ -91,17 +162,15 @@ func TestParse(t *testing.T) {
f, err := os.Open(tt.file)
require.NoError(t, err)
got, _, err := NewParser().Parse(t.Context(), f)
got, gotDeps, err := NewParser().Parse(t.Context(), f)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
sort.Sort(ftypes.Packages(got))
sort.Sort(ftypes.Packages(tt.want))
assert.Equal(t, tt.want, got)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
assert.Equal(t, tt.wantDeps, gotDeps)
})
}
}

View File

@@ -0,0 +1,41 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v5.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v6.0": {
"ExampleApp1/1.0.0": {
"dependencies": {
"Newtonsoft.Json": "13.0.1"
},
"runtime": {
"ExampleApp1.dll": {}
}
},
"Newtonsoft.Json/13.0.1": {
"runtime": {
"lib/netstandard2.0/Newtonsoft.Json.dll": {
"assemblyVersion": "13.0.0.0",
"fileVersion": "13.0.1.25517"
}
}
}
}
},
"libraries": {
"ExampleApp1/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Newtonsoft.Json/13.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==",
"path": "newtonsoft.json/13.0.1",
"hashPath": "newtonsoft.json.13.0.1.nupkg.sha512"
}
}
}

View File

@@ -27,6 +27,18 @@ func Test_depsLibraryAnalyzer_Analyze(t *testing.T) {
Type: types.DotNetCore,
FilePath: "testdata/datacollector.deps.json",
Packages: types.Packages{
{
ID: "Microsoft.VisualStudio.TestPlatform.Common/17.2.0-release-20220408-11",
Name: "Microsoft.VisualStudio.TestPlatform.Common",
Version: "17.2.0-release-20220408-11",
Relationship: types.RelationshipRoot,
Locations: []types.Location{
{
StartLine: 15,
EndLine: 19,
},
},
},
{
ID: "Newtonsoft.Json/9.0.1",
Name: "Newtonsoft.Json",