diff --git a/docs/docs/coverage/language/dotnet.md b/docs/docs/coverage/language/dotnet.md
index b8373612972c..c5251b4ab20e 100644
--- a/docs/docs/coverage/language/dotnet.md
+++ b/docs/docs/coverage/language/dotnet.md
@@ -7,7 +7,7 @@ The following scanners are supported.
| Artifact | SBOM | Vulnerability | License |
|-----------|:----:|:-------------:|:-------:|
| .Net Core | ✓ | ✓ | - |
-| NuGet | ✓ | ✓ | - |
+| NuGet | ✓ | ✓ | ✓ |
The following table provides an outline of the features Trivy offers.
@@ -17,18 +17,31 @@ The following table provides an outline of the features Trivy offers.
| NuGet | packages.config | ✓ | Excluded | - | - |
| NuGet | packages.lock.json | ✓ | Included | ✓ | ✓ |
-### *.deps.json
+## *.deps.json
Trivy parses `*.deps.json` files. Trivy currently excludes dev dependencies from the report.
-### packages.config
+## packages.config
Trivy only finds dependency names and versions from `packages.config` files. To build dependency graph, it is better to use `packages.lock.json` files.
-### packages.lock.json
+### license detection
+`packages.config` files don't have information about the licenses used.
+Trivy uses [*.nuspec][nuspec] files from [global packages folder][global-packages] to detect licenses.
+!!! note
+ The `licenseUrl` field is [deprecated][license-url]. Trivy doesn't parse this field and only checks the [license] field (license `expression` type only).
+Currently only the default path and `NUGET_PACKAGES` environment variable are supported.
+
+## packages.lock.json
Don't forgot to [enable][enable-lock] lock files in your project.
!!! tip
Please make sure your lock file is up-to-date after modifying dependencies.
+### license detection
+Same as [packages.config](#license-detection)
[enable-lock]: https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#enabling-the-lock-file
[dependency-graph]: ../../configuration/reporting.md#show-origins-of-vulnerable-dependencies
+[nuspec]: https://learn.microsoft.com/en-us/nuget/reference/nuspec
+[global-packages]: https://learn.microsoft.com/en-us/nuget/consume-packages/managing-the-global-packages-and-cache-folders
+[license]: https://learn.microsoft.com/en-us/nuget/reference/nuspec#license
+[license-url]: https://learn.microsoft.com/en-us/nuget/reference/nuspec#licenseurl
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/nuget.go b/pkg/fanal/analyzer/language/dotnet/nuget/nuget.go
index f53f2da274e2..045241db73d6 100644
--- a/pkg/fanal/analyzer/language/dotnet/nuget/nuget.go
+++ b/pkg/fanal/analyzer/language/dotnet/nuget/nuget.go
@@ -2,58 +2,110 @@ package nuget
import (
"context"
+ "errors"
+ "io"
+ "io/fs"
"os"
"path/filepath"
+ "sort"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"github.com/aquasecurity/go-dep-parser/pkg/nuget/config"
"github.com/aquasecurity/go-dep-parser/pkg/nuget/lock"
+ godeptypes "github.com/aquasecurity/go-dep-parser/pkg/types"
"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
"github.com/aquasecurity/trivy/pkg/fanal/analyzer/language"
"github.com/aquasecurity/trivy/pkg/fanal/types"
+ "github.com/aquasecurity/trivy/pkg/utils/fsutils"
)
func init() {
- analyzer.RegisterAnalyzer(&nugetLibraryAnalyzer{})
+ analyzer.RegisterPostAnalyzer(analyzer.TypeNuget, newNugetLibraryAnalyzer)
}
const (
- version = 2
+ version = 3
lockFile = types.NuGetPkgsLock
configFile = types.NuGetPkgsConfig
)
var requiredFiles = []string{lockFile, configFile}
-type nugetLibraryAnalyzer struct{}
+type nugetLibraryAnalyzer struct {
+ lockParser godeptypes.Parser
+ configParser godeptypes.Parser
+ licenseParser nuspecParser
+}
+
+func newNugetLibraryAnalyzer(_ analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error) {
+ return &nugetLibraryAnalyzer{
+ lockParser: lock.NewParser(),
+ configParser: config.NewParser(),
+ licenseParser: newNuspecParser(),
+ }, nil
+}
-func (a nugetLibraryAnalyzer) Analyze(_ context.Context, input analyzer.AnalysisInput) (*analyzer.AnalysisResult, error) {
- // Set the default parser
- parser := lock.NewParser()
+func (a *nugetLibraryAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysisInput) (*analyzer.AnalysisResult, error) {
+ var apps []types.Application
+ foundLicenses := make(map[string][]string)
- targetFile := filepath.Base(input.FilePath)
- if targetFile == configFile {
- parser = config.NewParser()
+ // We saved only config and lock files in the FS,
+ // so we need to parse all saved files
+ required := func(path string, d fs.DirEntry) bool {
+ return true
}
- res, err := language.Analyze(types.NuGet, input.FilePath, input.Content, parser)
+ err := fsutils.WalkDir(input.FS, ".", required, func(path string, d fs.DirEntry, r io.Reader) error {
+ // Set the default parser
+ parser := a.lockParser
+
+ targetFile := filepath.Base(path)
+ if targetFile == configFile {
+ parser = a.configParser
+ }
+
+ app, err := language.Parse(types.NuGet, path, r, parser)
+ if err != nil {
+ return xerrors.Errorf("NuGet parse error: %w", err)
+ }
+
+ for i, lib := range app.Libraries {
+ license, ok := foundLicenses[lib.ID]
+ if !ok {
+ license, err = a.licenseParser.findLicense(lib.Name, lib.Version)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return xerrors.Errorf("license find error: %w", err)
+ }
+ foundLicenses[lib.ID] = license
+ }
+
+ app.Libraries[i].Licenses = license
+ }
+
+ sort.Sort(app.Libraries)
+ apps = append(apps, *app)
+ return nil
+ })
if err != nil {
- return nil, xerrors.Errorf("NuGet analysis error: %w", err)
+ return nil, xerrors.Errorf("NuGet walk error: %w", err)
}
- return res, nil
+
+ return &analyzer.AnalysisResult{
+ Applications: apps,
+ }, nil
}
-func (a nugetLibraryAnalyzer) Required(filePath string, _ os.FileInfo) bool {
+func (a *nugetLibraryAnalyzer) Required(filePath string, _ os.FileInfo) bool {
fileName := filepath.Base(filePath)
return slices.Contains(requiredFiles, fileName)
}
-func (a nugetLibraryAnalyzer) Type() analyzer.Type {
+func (a *nugetLibraryAnalyzer) Type() analyzer.Type {
return analyzer.TypeNuget
}
-func (a nugetLibraryAnalyzer) Version() int {
+func (a *nugetLibraryAnalyzer) Version() int {
return version
}
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/nuget_test.go b/pkg/fanal/analyzer/language/dotnet/nuget/nuget_test.go
index 72ea517c96fa..7dae7368e115 100644
--- a/pkg/fanal/analyzer/language/dotnet/nuget/nuget_test.go
+++ b/pkg/fanal/analyzer/language/dotnet/nuget/nuget_test.go
@@ -3,7 +3,6 @@ package nuget
import (
"context"
"os"
- "sort"
"testing"
"github.com/stretchr/testify/assert"
@@ -15,19 +14,22 @@ import (
func Test_nugetibraryAnalyzer_Analyze(t *testing.T) {
tests := []struct {
- name string
- inputFile string
- want *analyzer.AnalysisResult
- wantErr string
+ name string
+ dir string
+ env map[string]string
+ want *analyzer.AnalysisResult
}{
{
- name: "happy path config file",
- inputFile: "testdata/packages.config",
+ name: "happy path config file.",
+ dir: "testdata/config",
+ env: map[string]string{
+ "HOME": "testdata/repository",
+ },
want: &analyzer.AnalysisResult{
Applications: []types.Application{
{
Type: types.NuGet,
- FilePath: "testdata/packages.config",
+ FilePath: "packages.config",
Libraries: types.Packages{
{
Name: "Microsoft.AspNet.WebApi",
@@ -43,13 +45,57 @@ func Test_nugetibraryAnalyzer_Analyze(t *testing.T) {
},
},
{
- name: "happy path lock file",
- inputFile: "testdata/packages.lock.json",
+ name: "happy path lock file.",
+ dir: "testdata/lock",
+ env: map[string]string{
+ "HOME": "testdata/repository",
+ },
+ want: &analyzer.AnalysisResult{
+ Applications: []types.Application{
+ {
+ Type: types.NuGet,
+ FilePath: "packages.lock.json",
+ Libraries: types.Packages{
+ {
+ ID: "Newtonsoft.Json@12.0.3",
+ Name: "Newtonsoft.Json",
+ Version: "12.0.3",
+ Locations: []types.Location{
+ {
+ StartLine: 5,
+ EndLine: 10,
+ },
+ },
+ Licenses: []string{"MIT"},
+ },
+ {
+ ID: "NuGet.Frameworks@5.7.0",
+ Name: "NuGet.Frameworks",
+ Version: "5.7.0",
+ Locations: []types.Location{
+ {
+ StartLine: 11,
+ EndLine: 19,
+ },
+ },
+ DependsOn: []string{"Newtonsoft.Json@12.0.3"},
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "happy path lock file. `NUGET_PACKAGES` env is used",
+ dir: "testdata/lock",
+ env: map[string]string{
+ "NUGET_PACKAGES": "testdata/repository/.nuget/packages",
+ },
want: &analyzer.AnalysisResult{
Applications: []types.Application{
{
Type: types.NuGet,
- FilePath: "testdata/packages.lock.json",
+ FilePath: "packages.lock.json",
Libraries: types.Packages{
{
ID: "Newtonsoft.Json@12.0.3",
@@ -61,6 +107,7 @@ func Test_nugetibraryAnalyzer_Analyze(t *testing.T) {
EndLine: 10,
},
},
+ Licenses: []string{"MIT"},
},
{
ID: "NuGet.Frameworks@5.7.0",
@@ -80,35 +127,58 @@ func Test_nugetibraryAnalyzer_Analyze(t *testing.T) {
},
},
{
- name: "sad path",
- inputFile: "testdata/invalid.txt",
- wantErr: "NuGet analysis error",
+ name: "happy path lock file. `.nuget` directory doesn't exist",
+ dir: "testdata/lock",
+ env: map[string]string{
+ "HOME": "testdata/invalid",
+ },
+ want: &analyzer.AnalysisResult{
+ Applications: []types.Application{
+ {
+ Type: types.NuGet,
+ FilePath: "packages.lock.json",
+ Libraries: types.Packages{
+ {
+ ID: "Newtonsoft.Json@12.0.3",
+ Name: "Newtonsoft.Json",
+ Version: "12.0.3",
+ Locations: []types.Location{
+ {
+ StartLine: 5,
+ EndLine: 10,
+ },
+ },
+ },
+ {
+ ID: "NuGet.Frameworks@5.7.0",
+ Name: "NuGet.Frameworks",
+ Version: "5.7.0",
+ Locations: []types.Location{
+ {
+ StartLine: 11,
+ EndLine: 19,
+ },
+ },
+ DependsOn: []string{"Newtonsoft.Json@12.0.3"},
+ },
+ },
+ },
+ },
+ },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- f, err := os.Open(tt.inputFile)
+ for env, path := range tt.env {
+ t.Setenv(env, path)
+ }
+ a, err := newNugetLibraryAnalyzer(analyzer.AnalyzerOptions{})
require.NoError(t, err)
- defer f.Close()
- a := nugetLibraryAnalyzer{}
- ctx := context.Background()
- got, err := a.Analyze(ctx, analyzer.AnalysisInput{
- FilePath: tt.inputFile,
- Content: f,
+ got, err := a.PostAnalyze(context.Background(), analyzer.PostAnalysisInput{
+ FS: os.DirFS(tt.dir),
})
- if tt.wantErr != "" {
- require.NotNil(t, err)
- assert.Contains(t, err.Error(), tt.wantErr)
- return
- }
-
- // Sort libraries for consistency
- for _, app := range got.Applications {
- sort.Sort(app.Libraries)
- }
-
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
})
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/nuspec.go b/pkg/fanal/analyzer/language/dotnet/nuget/nuspec.go
new file mode 100644
index 000000000000..edefec61ed16
--- /dev/null
+++ b/pkg/fanal/analyzer/language/dotnet/nuget/nuspec.go
@@ -0,0 +1,82 @@
+package nuget
+
+import (
+ "encoding/xml"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/xerrors"
+
+ "github.com/aquasecurity/trivy/pkg/log"
+ "github.com/aquasecurity/trivy/pkg/utils/fsutils"
+)
+
+const nuspecExt = "nuspec"
+
+// https://learn.microsoft.com/en-us/nuget/reference/nuspec
+type Package struct {
+ Metadata Metadata `xml:"metadata"`
+}
+
+type Metadata struct {
+ License License `xml:"license"`
+}
+
+type License struct {
+ Text string `xml:",chardata"`
+ Type string `xml:"type,attr"`
+}
+
+type nuspecParser struct {
+ packagesDir string // global packages folder - https: //learn.microsoft.com/en-us/nuget/consume-packages/managing-the-global-packages-and-cache-folders
+}
+
+func newNuspecParser() nuspecParser {
+ // https: //learn.microsoft.com/en-us/nuget/consume-packages/managing-the-global-packages-and-cache-folders
+ packagesDir := os.Getenv("NUGET_PACKAGES")
+ if packagesDir == "" {
+ packagesDir = filepath.Join(os.Getenv("HOME"), ".nuget", "packages")
+ }
+
+ if !fsutils.DirExists(packagesDir) {
+ log.Logger.Debugf("The nuget packages directory couldn't be found. License search disabled")
+ return nuspecParser{}
+ }
+
+ return nuspecParser{
+ packagesDir: packagesDir,
+ }
+}
+
+func (p nuspecParser) findLicense(name, version string) ([]string, error) {
+ if p.packagesDir == "" {
+ return nil, nil
+ }
+
+ // package path uses lowercase letters only
+ // e.g. `$HOME/.nuget/packages/newtonsoft.json/13.0.3/newtonsoft.json.nuspec`
+ // for `Newtonsoft.Json` v13.0.3
+ name = strings.ToLower(name)
+ version = strings.ToLower(version)
+
+ nuspecFileName := fmt.Sprintf("%s.%s", name, nuspecExt)
+ path := filepath.Join(p.packagesDir, name, version, nuspecFileName)
+
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, xerrors.Errorf("unable to open %q file: %w", path, err)
+ }
+ defer func() { _ = f.Close() }()
+
+ var pkg Package
+ if err = xml.NewDecoder(f).Decode(&pkg); err != nil {
+ return nil, xerrors.Errorf("unable to decode %q file: %w", path, err)
+ }
+
+ if license := pkg.Metadata.License; license.Type != "expression" || license.Text == "" {
+ return nil, nil
+ }
+ return []string{pkg.Metadata.License.Text}, nil
+}
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/testdata/packages.config b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/config/packages.config
similarity index 100%
rename from pkg/fanal/analyzer/language/dotnet/nuget/testdata/packages.config
rename to pkg/fanal/analyzer/language/dotnet/nuget/testdata/config/packages.config
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/testdata/packages.lock.json b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/lock/packages.lock.json
similarity index 100%
rename from pkg/fanal/analyzer/language/dotnet/nuget/testdata/packages.lock.json
rename to pkg/fanal/analyzer/language/dotnet/nuget/testdata/lock/packages.lock.json
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/newtonsoft.json/12.0.3/newtonsoft.json.nuspec b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/newtonsoft.json/12.0.3/newtonsoft.json.nuspec
new file mode 100755
index 000000000000..c215566e4e41
--- /dev/null
+++ b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/newtonsoft.json/12.0.3/newtonsoft.json.nuspec
@@ -0,0 +1,42 @@
+
+
+
+ Newtonsoft.Json
+ 12.0.3
+ Json.NET
+ James Newton-King
+ James Newton-King
+ false
+ MIT
+ https://licenses.nuget.org/MIT
+ packageIcon.png
+ https://www.newtonsoft.com/json
+ Json.NET is a popular high-performance JSON framework for .NET
+ Copyright © James Newton-King 2008
+ json
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/newtonsoft.json/6.0.4/newtonsoft.json.nuspec b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/newtonsoft.json/6.0.4/newtonsoft.json.nuspec
new file mode 100755
index 000000000000..4f361ab9c2d5
--- /dev/null
+++ b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/newtonsoft.json/6.0.4/newtonsoft.json.nuspec
@@ -0,0 +1,16 @@
+
+
+
+ Newtonsoft.Json
+ 6.0.4
+ Json.NET
+ James Newton-King
+ James Newton-King
+ https://raw.github.com/JamesNK/Newtonsoft.Json/master/LICENSE.md
+ http://james.newtonking.com/json
+ false
+ Json.NET is a popular high-performance JSON framework for .NET
+ en-US
+ json
+
+
\ No newline at end of file
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/nuget.frameworks/5.7.0/nuget.frameworks.nuspec b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/nuget.frameworks/5.7.0/nuget.frameworks.nuspec
new file mode 100755
index 000000000000..21ad4997758e
--- /dev/null
+++ b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/repository/.nuget/packages/nuget.frameworks/5.7.0/nuget.frameworks.nuspec
@@ -0,0 +1,23 @@
+
+
+
+ NuGet.Frameworks
+ 5.7.0+b804bf4ba62c0b47c77bbf3e22e196b57cd7a556
+ Microsoft
+ Microsoft
+ true
+ LICENSE.txt
+ https://aka.ms/nugetprj
+ https://raw.githubusercontent.com/NuGet/Media/master/Images/MainLogo/256x256/nuget_256.png
+ NuGet's understanding of target frameworks.
+ © Microsoft Corporation. All rights reserved.
+ nuget
+ true
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/fanal/analyzer/language/dotnet/nuget/testdata/invalid.txt b/pkg/fanal/analyzer/language/dotnet/nuget/testdata/sad/invalid.txt
similarity index 100%
rename from pkg/fanal/analyzer/language/dotnet/nuget/testdata/invalid.txt
rename to pkg/fanal/analyzer/language/dotnet/nuget/testdata/sad/invalid.txt