Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dotnet): add license support for NuGet #5217

Merged
merged 7 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions docs/docs/coverage/language/dotnet.md
Original file line number Diff line number Diff line change
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
85 changes: 69 additions & 16 deletions pkg/fanal/analyzer/language/dotnet/nuget/nuget.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,112 @@ 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(types.NuGet, 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 (a nugetLibraryAnalyzer) Analyze(_ context.Context, input analyzer.AnalysisInput) (*analyzer.AnalysisResult, error) {
// Set the default parser
parser := lock.NewParser()
func newNugetLibraryAnalyzer(_ analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error) {
return &nugetLibraryAnalyzer{
lockParser: lock.NewParser(),
configParser: config.NewParser(),
licenseParser: newNuspecParser(),
}, nil
}

targetFile := filepath.Base(input.FilePath)
if targetFile == configFile {
parser = config.NewParser()
func (a *nugetLibraryAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysisInput) (*analyzer.AnalysisResult, error) {
var apps []types.Application
foundLicenses := make(map[string][]string)

// 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 {
if !errors.Is(err, fs.ErrNotExist) {
return xerrors.Errorf("license find error: %w", err)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We might want to aggregate the statements.

Suggested change
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return xerrors.Errorf("license find error: %w", err)
}
}
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return xerrors.Errorf("license find error: %w", err)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right! Thanks! Updated in 1d0fed1.

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
Original file line number Diff line number Diff line change
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: "[email protected]",
Name: "Newtonsoft.Json",
Version: "12.0.3",
Locations: []types.Location{
{
StartLine: 5,
EndLine: 10,
},
},
Licenses: []string{"MIT"},
},
{
ID: "[email protected]",
Name: "NuGet.Frameworks",
Version: "5.7.0",
Locations: []types.Location{
{
StartLine: 11,
EndLine: 19,
},
},
DependsOn: []string{"[email protected]"},
},
},
},
},
},
},
{
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: "[email protected]",
Expand All @@ -61,6 +107,7 @@ func Test_nugetibraryAnalyzer_Analyze(t *testing.T) {
EndLine: 10,
},
},
Licenses: []string{"MIT"},
},
{
ID: "[email protected]",
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: "[email protected]",
Name: "Newtonsoft.Json",
Version: "12.0.3",
Locations: []types.Location{
{
StartLine: 5,
EndLine: 10,
},
},
},
{
ID: "[email protected]",
Name: "NuGet.Frameworks",
Version: "5.7.0",
Locations: []types.Location{
{
StartLine: 11,
EndLine: 19,
},
},
DependsOn: []string{"[email protected]"},
},
},
},
},
},
},
}
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
Loading