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