Skip to content

Commit

Permalink
feat(rust): remove dev deps and find direct deps for Cargo.lock (#3919)
Browse files Browse the repository at this point in the history
Co-authored-by: knqyf263 <knqyf263@gmail.com>
  • Loading branch information
DmitriyLewen and knqyf263 committed Mar 30, 2023
1 parent 10796a2 commit f8b5733
Show file tree
Hide file tree
Showing 10 changed files with 743 additions and 15 deletions.
3 changes: 2 additions & 1 deletion docs/docs/vulnerability/detection/language.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
| | *gradle.lockfile | - | - ||| excluded | - |
| Go | Binaries built by Go[^6] ||| - | - | excluded | - |
| | go.mod[^7] | - | - ||| included | - |
| Rust | Cargo.lock ||||| included | - |
| Rust | Cargo.lock ||||| excluded | |
| | Binaries built with [cargo-auditable](https://github.com/rust-secure-code/cargo-auditable) ||| - | - | excluded | - |
| C/C++ | conan.lock[^13] | - | - ||| excluded | - |
| Elixir | mix.lock[^13] | - | - ||| excluded ||
Expand All @@ -47,3 +47,4 @@ Example: [Dockerfile](https://github.com/aquasecurity/trivy-ci-test/blob/main/Do
[^11]: ✅ means "enabled" and `-` means "disabled" in the git repository scanning
[^12]: ✅ means that Trivy detects line numbers where each dependency is declared in the scanned file. Only supported in [json](../examples/report.md#json) and [sarif](../examples/report.md#sarif) formats. SARIF uses `startline == 1 and endline == 1` for unsupported file types
[^13]: To scan a filename other than the default filename use [file-patterns](../examples/others.md#file-patterns)
[^14]: When you scan `Cargo.lock` and `Cargo.toml` together. See about it [here](../languages/rust.md#cargo).
13 changes: 10 additions & 3 deletions docs/docs/vulnerability/languages/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The following table provides an outline of the features Trivy offers.

| Package manager | File | Transitive dependencies | Dev dependencies | License | Dependency graph | Position |
|-----------------|------------|:-----------------------:|:-----------------|:-------:|:----------------:|:--------:|
| Cargo | Cargo.lock || Included | - |||
| Cargo | Cargo.lock || Excluded[^1] | - |||

In addition, it supports binaries built with [cargo-auditable](https://github.com/rust-secure-code/cargo-auditable).

Expand All @@ -17,8 +17,15 @@ In addition, it supports binaries built with [cargo-auditable](https://github.co

### Cargo
Trivy searches for `Cargo.lock` to detect dependencies.
Since `Cargo.lock` doesn't have information about the development dependencies, it also includes them.

Trivy also supports dependency trees; however, to display an accurate tree, it needs to know whether each package is a direct dependency of the project.
Since this information is not included in `Cargo.lock`, Trivy parses `Cargo.toml`, which should be located next to `Cargo.lock`.
If you want to see the dependency tree, please ensure that `Cargo.toml` is present.

Scan `Cargo.lock` and `Cargo.toml` together also removes developer dependencies.

### Binaries
Trivy scans binaries built with [cargo-auditable](https://github.com/rust-secure-code/cargo-auditable).
If such a binary exists, Trivy will identify it as being built with cargo-audit and scan it.
If such a binary exists, Trivy will identify it as being built with cargo-audit and scan it.

[^1]: When you scan Cargo.lock and Cargo.toml together.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/Azure/go-autorest/autorest v0.11.28
github.com/Azure/go-autorest/autorest/adal v0.9.21
github.com/Azure/go-autorest/autorest/azure/auth v0.5.12
github.com/BurntSushi/toml v1.2.1
github.com/CycloneDX/cyclonedx-go v0.7.0
github.com/GoogleCloudPlatform/docker-credential-gcr v2.0.5+incompatible
github.com/Masterminds/sprig/v3 v3.2.3
Expand Down Expand Up @@ -117,7 +118,6 @@ require (
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
Expand Down
226 changes: 216 additions & 10 deletions pkg/fanal/analyzer/language/rust/cargo/cargo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,248 @@ package cargo

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"

"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"

"github.com/BurntSushi/toml"
"github.com/samber/lo"

dio "github.com/aquasecurity/go-dep-parser/pkg/io"
"github.com/aquasecurity/go-dep-parser/pkg/rust/cargo"
godeptypes "github.com/aquasecurity/go-dep-parser/pkg/types"
"github.com/aquasecurity/go-version/pkg/semver"
goversion "github.com/aquasecurity/go-version/pkg/version"
"github.com/aquasecurity/trivy/pkg/detector/library/compare"
"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/log"
"github.com/aquasecurity/trivy/pkg/utils/fsutils"
)

func init() {
analyzer.RegisterAnalyzer(&cargoLibraryAnalyzer{})
analyzer.RegisterPostAnalyzer(analyzer.TypeCargo, newCargoAnalyzer)
}

const version = 1

type cargoLibraryAnalyzer struct{}
var requiredFiles = []string{
types.CargoLock,
types.CargoToml,
}

type cargoAnalyzer struct {
lockParser godeptypes.Parser
comparer compare.GenericComparer
}

func newCargoAnalyzer(_ analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error) {
return &cargoAnalyzer{
lockParser: cargo.NewParser(),
comparer: compare.GenericComparer{},
}, nil
}

func (a cargoAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysisInput) (*analyzer.AnalysisResult, error) {
var apps []types.Application

func (a cargoLibraryAnalyzer) Analyze(_ context.Context, input analyzer.AnalysisInput) (*analyzer.AnalysisResult, error) {
res, err := language.Analyze(types.Cargo, input.FilePath, input.Content, cargo.NewParser())
required := func(path string, d fs.DirEntry) bool {
return filepath.Base(path) == types.CargoLock
}

err := fsutils.WalkDir(input.FS, ".", required, func(path string, d fs.DirEntry, r dio.ReadSeekerAt) error {
// Parse Cargo.lock
app, err := a.parseCargoLock(path, r)
if err != nil {
return xerrors.Errorf("parse error: %w", err)
} else if app == nil {
return nil
}

// Parse Cargo.toml alongside Cargo.lock to identify the direct dependencies
if err = a.removeDevDependencies(input.FS, filepath.Dir(path), app); err != nil {
return err
}
sort.Sort(types.Packages(app.Libraries))
apps = append(apps, *app)

return nil
})
if err != nil {
return nil, xerrors.Errorf("error with Cargo.lock: %w", err)
return nil, xerrors.Errorf("cargo walk error: %w", err)
}
return res, nil

return &analyzer.AnalysisResult{
Applications: apps,
}, nil
}

func (a cargoLibraryAnalyzer) Required(filePath string, _ os.FileInfo) bool {
func (a cargoAnalyzer) Required(filePath string, _ os.FileInfo) bool {
fileName := filepath.Base(filePath)
return fileName == types.CargoLock
return slices.Contains(requiredFiles, fileName)
}

func (a cargoLibraryAnalyzer) Type() analyzer.Type {
func (a cargoAnalyzer) Type() analyzer.Type {
return analyzer.TypeCargo
}

func (a cargoLibraryAnalyzer) Version() int {
func (a cargoAnalyzer) Version() int {
return version
}

func (a cargoAnalyzer) parseCargoLock(path string, r dio.ReadSeekerAt) (*types.Application, error) {
return language.Parse(types.Cargo, path, r, a.lockParser)
}

func (a cargoAnalyzer) removeDevDependencies(fsys fs.FS, dir string, app *types.Application) error {
cargoTOMLPath := filepath.Join(dir, types.CargoToml)
directDeps, err := a.parseCargoTOML(fsys, cargoTOMLPath)
if errors.Is(err, fs.ErrNotExist) {
log.Logger.Debugf("Cargo: %s not found", cargoTOMLPath)
return nil
} else if err != nil {
return xerrors.Errorf("unable to parse %s: %w", cargoTOMLPath, err)
}

// Cargo.toml file can contain same libraries with different versions.
// Save versions separately for version comparison by comparator
pkgIDs := lo.SliceToMap(app.Libraries, func(pkg types.Package) (string, types.Package) {
return pkg.ID, pkg
})

// Identify direct dependencies
pkgs := map[string]types.Package{}
for name, constraint := range directDeps {
for _, pkg := range app.Libraries {
if pkg.Name != name {
continue
}

if match, err := a.matchVersion(pkg.Version, constraint); err != nil {
log.Logger.Warnf("Unable to match Cargo version: package: %s, error: %s", pkg.ID, err)
continue
} else if match {
// Mark as a direct dependency
pkg.Indirect = false
pkgs[pkg.ID] = pkg
break
}
}
}

// Walk indirect dependencies
// Since it starts from direct dependencies, devDependencies will not appear in this walk.
for _, pkg := range pkgs {
a.walkIndirectDependencies(pkg, pkgIDs, pkgs)
}

pkgSlice := maps.Values(pkgs)
sort.Sort(types.Packages(pkgSlice))

// Save only prod libraries
app.Libraries = pkgSlice
return nil
}

type cargoToml struct {
Dependencies Dependencies `toml:"dependencies"`
Target map[string]map[string]Dependencies `toml:"target"`
Workspace map[string]Dependencies `toml:"workspace"`
}

type Dependencies map[string]interface{}

func (a cargoAnalyzer) parseCargoTOML(fsys fs.FS, path string) (map[string]string, error) {
// Parse Cargo.json
f, err := fsys.Open(path)
if err != nil {
return nil, xerrors.Errorf("file open error: %w", err)
}
defer func() { _ = f.Close() }()

tomlFile := cargoToml{}
deps := map[string]string{}
_, err = toml.NewDecoder(f).Decode(&tomlFile)
if err != nil {
return nil, xerrors.Errorf("toml decode error: %w", err)
}

dependencies := tomlFile.Dependencies

// https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#platform-specific-dependencies
for _, target := range tomlFile.Target {
maps.Copy(dependencies, target["dependencies"])
}

// https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#inheriting-a-dependency-from-a-workspace
maps.Copy(dependencies, tomlFile.Workspace["dependencies"])

for name, value := range tomlFile.Dependencies {
switch ver := value.(type) {
case string:
// e.g. regex = "1.5"
deps[name] = ver
case map[string]interface{}:
// e.g. serde = { version = "1.0", features = ["derive"] }
for k, v := range ver {
if k == "version" {
if vv, ok := v.(string); ok {
deps[name] = vv
}
break
}
}
}
}

return deps, nil
}

func (a cargoAnalyzer) walkIndirectDependencies(pkg types.Package, pkgIDs map[string]types.Package, deps map[string]types.Package) {
for _, pkgID := range pkg.DependsOn {
if _, ok := deps[pkgID]; ok {
continue
}

dep, ok := pkgIDs[pkgID]
if !ok {
continue
}

dep.Indirect = true
deps[dep.ID] = dep
a.walkIndirectDependencies(dep, pkgIDs, deps)
}
}

// cf. https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html
func (a cargoAnalyzer) matchVersion(currentVersion, constraint string) (bool, error) {
// `` == `^` - https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#caret-requirements
// Add `^` for correct version comparison
// - 1.2.3 -> ^1.2.3
// - 1.2.* -> 1.2.*
// - ^1.2 -> ^1.2
if _, err := goversion.Parse(constraint); err == nil {
constraint = fmt.Sprintf("^%s", constraint)
}

ver, err := semver.Parse(currentVersion)
if err != nil {
return false, xerrors.Errorf("version error (%s): %s", currentVersion, err)
}

c, err := semver.NewConstraints(constraint)
if err != nil {
return false, xerrors.Errorf("constraint error (%s): %s", currentVersion, err)
}

return c.Check(ver), nil
}

0 comments on commit f8b5733

Please sign in to comment.