Skip to content

Commit

Permalink
feat(dotnet): add license support for NuGet (#5217)
Browse files Browse the repository at this point in the history
* add nuspec files support

* docs: docs, log messages, comments refactoring

* save found licences to use next time

* refactor

* refactor

* fix typo
  • Loading branch information
DmitriyLewen committed Sep 28, 2023
1 parent 5c18475 commit 3dd5b1e
Show file tree
Hide file tree
Showing 10 changed files with 349 additions and 51 deletions.
21 changes: 17 additions & 4 deletions docs/docs/coverage/language/dotnet.md
Expand Up @@ -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.

Expand All @@ -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
82 changes: 67 additions & 15 deletions pkg/fanal/analyzer/language/dotnet/nuget/nuget.go
Expand Up @@ -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
}
134 changes: 102 additions & 32 deletions pkg/fanal/analyzer/language/dotnet/nuget/nuget_test.go
Expand Up @@ -3,7 +3,6 @@ package nuget
import (
"context"
"os"
"sort"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -61,6 +107,7 @@ func Test_nugetibraryAnalyzer_Analyze(t *testing.T) {
EndLine: 10,
},
},
Licenses: []string{"MIT"},
},
{
ID: "NuGet.Frameworks@5.7.0",
Expand All @@ -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)
})
Expand Down

0 comments on commit 3dd5b1e

Please sign in to comment.