feat: add cataloger for NuGet packages (#3484)

* add cataloger for dotnet packages.lock.json files

Signed-off-by: Kemosabert <bert.coppens14@gmail.com>

* add entry for dotnet packages.lock files

Signed-off-by: Kemosabert <bert.coppens14@gmail.com>

* add unit test for dotnet packages.lock cataloger

Signed-off-by: Kemosabert <bert.coppens14@gmail.com>

* add test for faulty packages.lock.json file

Signed-off-by: Kemosabert <bert.coppens14@gmail.com>

* add missing name metadata

Signed-off-by: Kemosabert <bert.coppens14@gmail.com>

* ensure package appears with version

Signed-off-by: Kemosabert <bert.coppens14@gmail.com>

* add example of conflicting dependencies

Signed-off-by: Kemosabert <bert.coppens14@gmail.com>

* fix linting

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

* bump json schema and fix tests

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

* move section

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

---------

Signed-off-by: Kemosabert <bert.coppens14@gmail.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:
Bert Coppens 2025-01-16 20:57:17 +01:00 committed by GitHub
parent 6b2d73d4b7
commit 512319337f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 3235 additions and 4 deletions

View File

@ -1,5 +1,6 @@
issues:
max-same-issues: 25
uniq-by-line: false
# TODO: enable this when we have coverage on docstring comments
# # The list of ids of default excludes to include or disable.
@ -60,8 +61,6 @@ linters-settings:
gosec:
excludes:
- G115
output:
uniq-by-line: false
run:
timeout: 10m
tests: false

View File

@ -3,5 +3,5 @@ package internal
const (
// JSONSchemaVersion is the current schema version output by the JSON encoder
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
JSONSchemaVersion = "16.0.18"
JSONSchemaVersion = "16.0.19"
)

View File

@ -114,6 +114,7 @@ func DefaultPackageTaskFactories() PackageTaskFactories {
newSimplePackageTaskFactory(ocaml.NewOpamPackageManagerCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "ocaml", "opam"),
// language-specific package for both image and directory scans (but not necessarily declared) ////////////////////////////////////////
newSimplePackageTaskFactory(dotnet.NewDotnetPackagesLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.ImageTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "dotnet", "c#"),
newSimplePackageTaskFactory(dotnet.NewDotnetPortableExecutableCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, pkgcataloging.LanguageTag, "dotnet", "c#", "binary"),
newSimplePackageTaskFactory(python.NewInstalledPackageCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, pkgcataloging.LanguageTag, "python"),
newPackageTaskFactory(

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "anchore.io/schema/syft/json/16.0.18/document",
"$id": "anchore.io/schema/syft/json/16.0.19/document",
"$ref": "#/$defs/Document",
"$defs": {
"AlpmDbEntry": {
@ -492,6 +492,29 @@
"hashPath"
]
},
"DotnetPackagesLockEntry": {
"properties": {
"name": {
"type": "string"
},
"version": {
"type": "string"
},
"contentHash": {
"type": "string"
},
"type": {
"type": "string"
}
},
"type": "object",
"required": [
"name",
"version",
"contentHash",
"type"
]
},
"DotnetPortableExecutableEntry": {
"properties": {
"assemblyVersion": {
@ -1648,6 +1671,9 @@
{
"$ref": "#/$defs/DotnetDepsEntry"
},
{
"$ref": "#/$defs/DotnetPackagesLockEntry"
},
{
"$ref": "#/$defs/DotnetPortableExecutableEntry"
},

View File

@ -19,6 +19,7 @@ func Test_OriginatorSupplier(t *testing.T) {
pkg.ConaninfoEntry{},
pkg.DartPubspecLockEntry{},
pkg.DotnetDepsEntry{},
pkg.DotnetPackagesLockEntry{},
pkg.ELFBinaryPackageNoteJSONPayload{},
pkg.ElixirMixLockEntry{},
pkg.ErlangRebarLockEntry{},

View File

@ -17,6 +17,7 @@ func AllTypes() []any {
pkg.ConaninfoEntry{},
pkg.DartPubspecLockEntry{},
pkg.DotnetDepsEntry{},
pkg.DotnetPackagesLockEntry{},
pkg.DotnetPortableExecutableEntry{},
pkg.DpkgDBEntry{},
pkg.ELFBinaryPackageNoteJSONPayload{},

View File

@ -109,6 +109,7 @@ var jsonTypes = makeJSONTypes(
jsonNamesWithoutLookup(pkg.RustBinaryAuditEntry{}, "rust-cargo-audit-entry", "RustCargoPackageMetadata"), // the legacy value is split into two types, where the other is preferred
jsonNames(pkg.WordpressPluginEntry{}, "wordpress-plugin-entry", "WordpressMetadata"),
jsonNames(pkg.LuaRocksPackage{}, "luarocks-package"),
jsonNames(pkg.DotnetPackagesLockEntry{}, "dotnet-packages-lock-entry"),
)
func expandLegacyNameVariants(names ...string) []string {

View File

@ -19,3 +19,7 @@ func NewDotnetPortableExecutableCataloger() pkg.Cataloger {
return generic.NewCataloger("dotnet-portable-executable-cataloger").
WithParserByGlobs(parseDotnetPortableExecutable, "**/*.dll", "**/*.exe")
}
func NewDotnetPackagesLockCataloger() pkg.Cataloger {
return generic.NewCataloger("dotnet-packages-lock-cataloger").WithParserByGlobs(parseDotnetPackagesLock, "**/packages.lock.json")
}

View File

@ -0,0 +1,159 @@
package dotnet
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/relationship"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
var _ generic.Parser = parseDotnetPackagesLock
type dotnetPackagesLock struct {
Version int `json:"version"`
Dependencies map[string]map[string]dotnetPackagesLockDep `json:"dependencies"`
}
type dotnetPackagesLockDep struct {
Type string `json:"type"`
Requested string `json:"requested"`
Resolved string `json:"resolved"`
ContentHash string `json:"contentHash"`
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
dec := json.NewDecoder(reader)
// unmarshal file
var lockFile dotnetPackagesLock
if err := dec.Decode(&lockFile); err != nil {
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)
// create artifact for each pkg
for _, nameVersion := range names {
name, _ := extractNameAndVersion(nameVersion)
dep := allDependencies[nameVersion]
dotnetPkg := newDotnetPackagesLockPackage(name, dep, reader.Location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation))
if dotnetPkg != nil {
pkgs = append(pkgs, *dotnetPkg)
pkgMap[nameVersion] = *dotnetPkg
}
}
// fill up relationships
for depNameVersion, dep := range allDependencies {
parentPkg, ok := pkgMap[depNameVersion]
if !ok {
log.Debug("package \"%s\" not found in map of all pacakges", 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.Debug("dependency \"%s\" of package \"%s\" not found in map of all packages", childDepNameVersion, depNameVersion)
continue
}
childPkg = *cpkg
}
rel := artifact.Relationship{
From: parentPkg,
To: childPkg,
Type: artifact.DependencyOfRelationship,
}
relationships = append(relationships, rel)
}
}
// sort the relationships for deterministic output
relationship.Sort(relationships)
return pkgs, relationships, nil
}
func newDotnetPackagesLockPackage(name string, dep dotnetPackagesLockDep, locations ...file.Location) *pkg.Package {
metadata := pkg.DotnetPackagesLockEntry{
Name: name,
Version: dep.Resolved,
ContentHash: dep.ContentHash,
Type: dep.Type,
}
return &pkg.Package{
Name: name,
Version: dep.Resolved,
Type: pkg.DotnetPkg,
Metadata: metadata,
Locations: file.NewLocationSet(locations...),
Language: pkg.Dotnet,
PURL: packagesLockPackageURL(name, dep.Resolved),
}
}
func packagesLockPackageURL(name, version string) string {
var qualifiers packageurl.Qualifiers
return packageurl.NewPackageURL(
packageurl.TypeNuget, // See explanation in syft/pkg/cataloger/dotnet/package.go as to why this was chosen.
"",
name,
version,
qualifiers,
"",
).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
}

View File

@ -0,0 +1,199 @@
package dotnet
import (
"testing"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func Test_corruptDotnetPackagesLock(t *testing.T) {
pkgtest.NewCatalogTester().
FromFile(t, "test-fixtures/glob-paths/src/packages.lock.json").
WithError().
TestParser(t, parseDotnetDeps)
}
func TestParseDotnetPackagesLock(t *testing.T) {
fixture := "test-fixtures/packages.lock.json"
fixtureLocationSet := file.NewLocationSet(file.NewLocation(fixture))
autoMapperPkg := pkg.Package{
Name: "AutoMapper",
Version: "13.0.1",
PURL: "pkg:nuget/AutoMapper@13.0.1",
Locations: fixtureLocationSet,
Language: pkg.Dotnet,
Type: pkg.DotnetPkg,
Metadata: pkg.DotnetPackagesLockEntry{
Name: "AutoMapper",
Version: "13.0.1",
ContentHash: "/Fx1SbJ16qS7dU4i604Sle+U9VLX+WSNVJggk6MupKVkYvvBm4XqYaeFuf67diHefHKHs50uQIS2YEDFhPCakQ==",
Type: "Direct",
},
}
bootstrapPkg := pkg.Package{
Name: "bootstrap",
Version: "5.0.0",
PURL: "pkg:nuget/bootstrap@5.0.0",
Locations: fixtureLocationSet,
Language: pkg.Dotnet,
Type: pkg.DotnetPkg,
Metadata: pkg.DotnetPackagesLockEntry{
Name: "bootstrap",
Version: "5.0.0",
ContentHash: "NKQFzFwrfWOMjTwr+X/2iJyCveuAGF+fNzkxyB0YW45+InVhcE9PUxoL1a8Vmc/Lq9E/CQd4DjO8kU32P4w/Gg==",
Type: "Direct",
},
}
log4netPkg := 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: "AEqPZz+v+OikfnR2SqRVdQPnSaLq5y9Iz1CfRQZ9kTKPYCXHG6zYmDHb7wJotICpDLMr/JqokyjiqKAjUKp0ng==",
Type: "Direct",
},
}
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: "KPajjkU1rbF6uY2rnakbh36LB9z9FVcYlciyOi6C5SJ3AMNywxjCGxBTN/Hl5nQEinRLuWvHWPF8W7YHh9sONw==",
Type: "Direct",
},
}
dependencyInjectionAbstractionsPkg := pkg.Package{
Name: "Microsoft.Extensions.DependencyInjection.Abstractions",
Version: "9.0.0",
PURL: "pkg:nuget/Microsoft.Extensions.DependencyInjection.Abstractions@9.0.0",
Locations: fixtureLocationSet,
Language: pkg.Dotnet,
Type: pkg.DotnetPkg,
Metadata: pkg.DotnetPackagesLockEntry{
Name: "Microsoft.Extensions.DependencyInjection.Abstractions",
Version: "9.0.0",
ContentHash: "xlzi2IYREJH3/m6+lUrQlujzX8wDitm4QGnUu6kUXTQAWPuZY8i+ticFJbzfqaetLA6KR/rO6Ew/HuYD+bxifg==",
Type: "Transitive",
},
}
extensionOptionsPkg := pkg.Package{
Name: "Microsoft.Extensions.Options",
Version: "9.0.0",
PURL: "pkg:nuget/Microsoft.Extensions.Options@9.0.0",
Locations: fixtureLocationSet,
Language: pkg.Dotnet,
Type: pkg.DotnetPkg,
Metadata: pkg.DotnetPackagesLockEntry{
Name: "Microsoft.Extensions.Options",
Version: "9.0.0",
ContentHash: "dzXN0+V1AyjOe2xcJ86Qbo233KHuLEY0njf/P2Kw8SfJU+d45HNS2ctJdnEnrWbM9Ye2eFgaC5Mj9otRMU6IsQ==",
Type: "Transitive",
},
}
extensionPrimitivesPkg := pkg.Package{
Name: "Microsoft.Extensions.Primitives",
Version: "9.0.0",
PURL: "pkg:nuget/Microsoft.Extensions.Primitives@9.0.0",
Locations: fixtureLocationSet,
Language: pkg.Dotnet,
Type: pkg.DotnetPkg,
Metadata: pkg.DotnetPackagesLockEntry{
Name: "Microsoft.Extensions.Primitives",
Version: "9.0.0",
ContentHash: "9+PnzmQFfEFNR9J2aDTfJGGupShHjOuGw4VUv+JB044biSHrnmCIMD+mJHmb2H7YryrfBEXDurxQ47gJZdCKNQ==",
Type: "Transitive",
},
}
compilerServicesUnsafePkg := pkg.Package{
Name: "System.Runtime.CompilerServices.Unsafe",
Version: "9.0.0",
PURL: "pkg:nuget/System.Runtime.CompilerServices.Unsafe@9.0.0",
Locations: fixtureLocationSet,
Language: pkg.Dotnet,
Type: pkg.DotnetPkg,
Metadata: pkg.DotnetPackagesLockEntry{
Name: "System.Runtime.CompilerServices.Unsafe",
Version: "9.0.0",
ContentHash: "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==",
Type: "Transitive",
},
}
microsoftLoggingPkg := pkg.Package{
Name: "Microsoft.Extensions.Logging",
Version: "9.0.0",
PURL: "pkg:nuget/Microsoft.Extensions.Logging@9.0.0",
Locations: fixtureLocationSet,
Language: pkg.Dotnet,
Type: pkg.DotnetPkg,
Metadata: pkg.DotnetPackagesLockEntry{
Name: "Microsoft.Extensions.Logging",
Version: "9.0.0",
ContentHash: "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==",
Type: "Direct",
},
}
expectedPkgs := []pkg.Package{
autoMapperPkg,
compilerServicesUnsafePkg,
dependencyInjectionAbstractionsPkg,
microsoftLoggingPkg,
extensionOptionsPkg,
extensionPrimitivesPkg,
bootstrapPkg,
log4net1Pkg,
log4netPkg,
}
expectedRelationships := []artifact.Relationship{
{
From: autoMapperPkg,
To: extensionOptionsPkg,
Type: artifact.DependencyOfRelationship,
},
{
From: extensionOptionsPkg,
To: dependencyInjectionAbstractionsPkg,
Type: artifact.DependencyOfRelationship,
},
{
From: extensionOptionsPkg,
To: extensionPrimitivesPkg,
Type: artifact.DependencyOfRelationship,
},
{
From: extensionPrimitivesPkg,
To: compilerServicesUnsafePkg,
Type: artifact.DependencyOfRelationship,
},
{
From: microsoftLoggingPkg,
To: extensionOptionsPkg,
Type: artifact.DependencyOfRelationship,
},
}
pkgtest.TestFileParser(t, fixture, parseDotnetPackagesLock, expectedPkgs, expectedRelationships)
}

View File

@ -0,0 +1 @@
"i am bogus"

View File

@ -0,0 +1,78 @@
{
"version": 1,
"dependencies": {
"net8.0": {
"AutoMapper": {
"type": "Direct",
"requested": "[13.0.1, )",
"resolved": "13.0.1",
"contentHash": "/Fx1SbJ16qS7dU4i604Sle+U9VLX+WSNVJggk6MupKVkYvvBm4XqYaeFuf67diHefHKHs50uQIS2YEDFhPCakQ==",
"dependencies": {
"Microsoft.Extensions.Options": "6.0.0"
}
},
"bootstrap": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "NKQFzFwrfWOMjTwr+X/2iJyCveuAGF+fNzkxyB0YW45+InVhcE9PUxoL1a8Vmc/Lq9E/CQd4DjO8kU32P4w/Gg=="
},
"log4net": {
"type": "Direct",
"requested": "[2.0.5, )",
"resolved": "2.0.5",
"contentHash": "AEqPZz+v+OikfnR2SqRVdQPnSaLq5y9Iz1CfRQZ9kTKPYCXHG6zYmDHb7wJotICpDLMr/JqokyjiqKAjUKp0ng=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "xlzi2IYREJH3/m6+lUrQlujzX8wDitm4QGnUu6kUXTQAWPuZY8i+ticFJbzfqaetLA6KR/rO6Ew/HuYD+bxifg=="
},
"Microsoft.Extensions.Logging": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==",
"dependencies": {
"Microsoft.Extensions.Options": "9.0.0"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "dzXN0+V1AyjOe2xcJ86Qbo233KHuLEY0njf/P2Kw8SfJU+d45HNS2ctJdnEnrWbM9Ye2eFgaC5Mj9otRMU6IsQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "9+PnzmQFfEFNR9J2aDTfJGGupShHjOuGw4VUv+JB044biSHrnmCIMD+mJHmb2H7YryrfBEXDurxQ47gJZdCKNQ==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "9.0.0"
}
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
}
},
".netcore2.0": {
"bootstrap": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "NKQFzFwrfWOMjTwr+X/2iJyCveuAGF+fNzkxyB0YW45+InVhcE9PUxoL1a8Vmc/Lq9E/CQd4DjO8kU32P4w/Gg=="
},
"log4net": {
"type": "Direct",
"requested": "[1.2.15, )",
"resolved": "1.2.15",
"contentHash": "KPajjkU1rbF6uY2rnakbh36LB9z9FVcYlciyOi6C5SJ3AMNywxjCGxBTN/Hl5nQEinRLuWvHWPF8W7YHh9sONw=="
}
}
}
}

View File

@ -9,6 +9,14 @@ type DotnetDepsEntry struct {
HashPath string `mapstructure:"hashPath" json:"hashPath"`
}
// DotnetPackagesLockEntry is a struct that represents a single entry found in the "dependencies" section in a .NET packages.lock.json file.
type DotnetPackagesLockEntry struct {
Name string `mapstructure:"name" json:"name"`
Version string `mapstructure:"version" json:"version"`
ContentHash string `mapstructure:"contentHash" json:"contentHash"`
Type string `mapstructure:"type" json:"type"`
}
// DotnetPortableExecutableEntry is a struct that represents a single entry found within "VersionResources" section of a .NET Portable Executable binary file.
type DotnetPortableExecutableEntry struct {
AssemblyVersion string `json:"assemblyVersion"`