feat(rust): add root and workspace relationships/package for cargo lock files (#8676)

This commit is contained in:
DmitriyLewen
2025-04-14 14:40:39 +06:00
committed by GitHub
parent 8e25ca02c8
commit 93efe0789e
7 changed files with 418 additions and 13 deletions

View File

@@ -294,9 +294,10 @@ This feature allows you to focus on vulnerabilities in specific types of depende
In Trivy, there are four types of package relationships:
1. `root`: The root package being scanned
2. `direct`: Direct dependencies of the root package
3. `indirect`: Transitive dependencies
4. `unknown`: Packages whose relationship cannot be determined
2. `workspace`: Workspaces of the root package (Currently only `pom.xml` and `cargo.lock` files are supported)
3. `direct`: Direct dependencies of the root/workspace package
4. `indirect`: Transitive dependencies
5. `unknown`: Packages whose relationship cannot be determined
The available relationships may vary depending on the ecosystem.
To see which relationships are supported for a particular project, you can use the JSON output format and check the `Relationship` field:

View File

@@ -286,6 +286,15 @@ func TestRepository(t *testing.T) {
},
golden: "testdata/composer.lock.json.golden",
},
{
name: "cargo.lock",
args: args{
scanner: types.VulnerabilityScanner,
listAllPkgs: true,
input: "testdata/fixtures/repo/cargo",
},
golden: "testdata/cargo.lock.json.golden",
},
{
name: "multiple lockfiles",
args: args{

View File

@@ -0,0 +1,165 @@
{
"SchemaVersion": 2,
"CreatedAt": "2021-08-25T12:20:30.000000005Z",
"ArtifactName": "testdata/fixtures/repo/cargo",
"ArtifactType": "repository",
"Metadata": {
"ImageConfig": {
"architecture": "",
"created": "0001-01-01T00:00:00Z",
"os": "",
"rootfs": {
"type": "",
"diff_ids": null
},
"config": {}
}
},
"Results": [
{
"Target": "Cargo.lock",
"Class": "lang-pkgs",
"Type": "cargo",
"Packages": [
{
"ID": "app@0.1.0",
"Name": "app",
"Identifier": {
"PURL": "pkg:cargo/app@0.1.0",
"UID": "a4ce1e2c46af5d56"
},
"Version": "0.1.0",
"Relationship": "root",
"DependsOn": [
"memchr@1.0.2",
"regex@1.7.3"
],
"Layer": {},
"Locations": [
{
"StartLine": 14,
"EndLine": 21
}
]
},
{
"ID": "memchr@1.0.2",
"Name": "memchr",
"Identifier": {
"PURL": "pkg:cargo/memchr@1.0.2",
"UID": "427a73f0e28dc7df"
},
"Version": "1.0.2",
"Relationship": "direct",
"DependsOn": [
"libc@0.2.171"
],
"Layer": {},
"Locations": [
{
"StartLine": 29,
"EndLine": 36
}
]
},
{
"ID": "regex@1.7.3",
"Name": "regex",
"Identifier": {
"PURL": "pkg:cargo/regex@1.7.3",
"UID": "4633c68363763fad"
},
"Version": "1.7.3",
"Relationship": "direct",
"DependsOn": [
"aho-corasick@0.7.20",
"memchr@2.7.4",
"regex-syntax@0.6.29"
],
"Layer": {},
"Locations": [
{
"StartLine": 44,
"EndLine": 53
}
]
},
{
"ID": "aho-corasick@0.7.20",
"Name": "aho-corasick",
"Identifier": {
"PURL": "pkg:cargo/aho-corasick@0.7.20",
"UID": "994a6d343f8da957"
},
"Version": "0.7.20",
"Indirect": true,
"Relationship": "indirect",
"DependsOn": [
"memchr@2.7.4"
],
"Layer": {},
"Locations": [
{
"StartLine": 5,
"EndLine": 12
}
]
},
{
"ID": "libc@0.2.171",
"Name": "libc",
"Identifier": {
"PURL": "pkg:cargo/libc@0.2.171",
"UID": "5395cf65d65d1f19"
},
"Version": "0.2.171",
"Indirect": true,
"Relationship": "indirect",
"Layer": {},
"Locations": [
{
"StartLine": 23,
"EndLine": 27
}
]
},
{
"ID": "memchr@2.7.4",
"Name": "memchr",
"Identifier": {
"PURL": "pkg:cargo/memchr@2.7.4",
"UID": "3f037d5da23e5826"
},
"Version": "2.7.4",
"Indirect": true,
"Relationship": "indirect",
"Layer": {},
"Locations": [
{
"StartLine": 38,
"EndLine": 42
}
]
},
{
"ID": "regex-syntax@0.6.29",
"Name": "regex-syntax",
"Identifier": {
"PURL": "pkg:cargo/regex-syntax@0.6.29",
"UID": "2c8dd93ce2f15b00"
},
"Version": "0.6.29",
"Indirect": true,
"Relationship": "indirect",
"Layer": {},
"Locations": [
{
"StartLine": 55,
"EndLine": 59
}
]
}
]
}
]
}

81
integration/testdata/fixtures/repo/cargo/Cargo.lock generated vendored Normal file
View File

@@ -0,0 +1,81 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
dependencies = [
"memchr 2.7.4",
]
[[package]]
name = "app"
version = "0.1.0"
dependencies = [
"memchr 1.0.2",
"regex",
"winapi",
]
[[package]]
name = "libc"
version = "0.2.171"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
[[package]]
name = "memchr"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "148fab2e51b4f1cfc66da2a7c32981d1d3c083a803978268bb11fe4b86925e7a"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "regex"
version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d"
dependencies = [
"aho-corasick",
"memchr 2.7.4",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.6.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

View File

@@ -0,0 +1,15 @@
[package]
name = "app"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
regex = "=1.7.3"
[target.'cfg(not(target_os = "windows"))'.dependencies]
memchr = { version = "1.*", optional = true }
[dev-dependencies]
winapi = "*"

View File

@@ -12,13 +12,16 @@ import (
"path/filepath"
"slices"
"sort"
"strconv"
"github.com/BurntSushi/toml"
"github.com/mitchellh/hashstructure/v2"
"github.com/samber/lo"
"golang.org/x/xerrors"
"github.com/aquasecurity/go-version/pkg/semver"
goversion "github.com/aquasecurity/go-version/pkg/version"
"github.com/aquasecurity/trivy/pkg/dependency"
"github.com/aquasecurity/trivy/pkg/dependency/parser/rust/cargo"
"github.com/aquasecurity/trivy/pkg/detector/library/compare"
"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
@@ -107,7 +110,7 @@ func (a cargoAnalyzer) parseCargoLock(filePath string, r io.Reader) (*types.Appl
func (a cargoAnalyzer) removeDevDependencies(fsys fs.FS, dir string, app *types.Application) error {
cargoTOMLPath := path.Join(dir, types.CargoToml)
directDeps, err := a.parseRootCargoTOML(fsys, cargoTOMLPath)
root, workspaces, directDeps, err := a.parseRootCargoTOML(fsys, cargoTOMLPath)
if errors.Is(err, fs.ErrNotExist) {
a.logger.Debug("Cargo.toml not found", log.FilePath(cargoTOMLPath))
return nil
@@ -148,6 +151,37 @@ func (a cargoAnalyzer) removeDevDependencies(fsys fs.FS, dir string, app *types.
a.walkIndirectDependencies(pkg, pkgIDs, pkgs)
}
// Identify root and workspace packages
for pkgID, pkg := range pkgIDs {
switch {
case pkgID == root:
pkg.Relationship = types.RelationshipRoot
case slices.Contains(workspaces, pkgID):
pkg.Relationship = types.RelationshipWorkspace
default:
continue
}
// Root/workspace package may include dev dependencies in lock file, so we need to remove them.
pkg.DependsOn = lo.Filter(pkg.DependsOn, func(dep string, _ int) bool {
_, ok := pkgs[dep]
return ok
})
pkgs[pkgID] = pkg
}
// Cargo allows creating cargo.toml files without name and version.
// In this case, the lock file will not include this package.
// e.g. when root cargo.toml contains only workspaces.
// So we have to add it ourselves, and the ID in this case will be the hash of the toml file.
if _, ok := pkgs[root]; !ok {
pkgs[root] = types.Package{
ID: root,
Relationship: types.RelationshipRoot,
DependsOn: workspaces,
}
}
pkgSlice := lo.Values(pkgs)
sort.Sort(types.Packages(pkgSlice))
@@ -157,11 +191,17 @@ func (a cargoAnalyzer) removeDevDependencies(fsys fs.FS, dir string, app *types.
}
type cargoToml struct {
Package Package `toml:"package"`
Dependencies Dependencies `toml:"dependencies"`
Target map[string]map[string]Dependencies `toml:"target"`
Workspace cargoTomlWorkspace `toml:"workspace"`
}
type Package struct {
Name string `toml:"name"`
Version string `toml:"version"`
}
type cargoTomlWorkspace struct {
Dependencies Dependencies `toml:"dependencies"`
Members []string `toml:"members"`
@@ -171,20 +211,24 @@ type Dependencies map[string]any
// parseRootCargoTOML parses top-level Cargo.toml and returns dependencies.
// It also parses workspace members and their dependencies.
func (a cargoAnalyzer) parseRootCargoTOML(fsys fs.FS, filePath string) (map[string]string, error) {
dependencies, members, err := parseCargoTOML(fsys, filePath)
func (a cargoAnalyzer) parseRootCargoTOML(fsys fs.FS, filePath string) (string, []string, map[string]string, error) {
rootPkg, dependencies, members, err := a.parseCargoTOML(fsys, filePath)
if err != nil {
return nil, xerrors.Errorf("unable to parse %s: %w", filePath, err)
return "", nil, nil, xerrors.Errorf("unable to parse %s: %w", filePath, err)
}
// According to Cargo workspace RFC, workspaces can't be nested:
// https://github.com/nox/rust-rfcs/blob/master/text/1525-cargo-workspace.md#validating-a-workspace
var workspaces []string
for _, member := range members {
memberPath := path.Join(path.Dir(filePath), member, types.CargoToml)
memberDeps, _, err := parseCargoTOML(fsys, memberPath)
memberPkg, memberDeps, _, err := a.parseCargoTOML(fsys, memberPath)
if err != nil {
a.logger.Warn("Unable to parse Cargo.toml", log.String("member_path", memberPath), log.Err(err))
continue
}
workspaces = append(workspaces, memberPkg)
// Member dependencies shouldn't overwrite dependencies from root cargo.toml file
maps.Copy(memberDeps, dependencies)
dependencies = memberDeps
@@ -209,7 +253,7 @@ func (a cargoAnalyzer) parseRootCargoTOML(fsys fs.FS, filePath string) (map[stri
}
}
return deps, nil
return rootPkg, workspaces, deps, nil
}
func (a cargoAnalyzer) walkIndirectDependencies(pkg types.Package, pkgIDs, deps map[string]types.Package) {
@@ -254,11 +298,11 @@ func (a cargoAnalyzer) matchVersion(currentVersion, constraint string) (bool, er
return c.Check(ver), nil
}
func parseCargoTOML(fsys fs.FS, filePath string) (Dependencies, []string, error) {
func (a cargoAnalyzer) parseCargoTOML(fsys fs.FS, filePath string) (string, Dependencies, []string, error) {
// Parse Cargo.toml
f, err := fsys.Open(filePath)
if err != nil {
return nil, nil, xerrors.Errorf("file open error: %w", err)
return "", nil, nil, xerrors.Errorf("file open error: %w", err)
}
defer func() { _ = f.Close() }()
@@ -268,9 +312,11 @@ func parseCargoTOML(fsys fs.FS, filePath string) (Dependencies, []string, error)
// declare `dependencies` to avoid panic
dependencies := Dependencies{}
if _, err = toml.NewDecoder(f).Decode(&tomlFile); err != nil {
return nil, nil, xerrors.Errorf("toml decode error: %w", err)
return "", nil, nil, xerrors.Errorf("toml decode error: %w", err)
}
pkgID := a.packageID(tomlFile)
maps.Copy(dependencies, tomlFile.Dependencies)
// https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#platform-specific-dependencies
@@ -281,5 +327,23 @@ func parseCargoTOML(fsys fs.FS, filePath string) (Dependencies, []string, error)
// https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#inheriting-a-dependency-from-a-workspace
maps.Copy(dependencies, tomlFile.Workspace.Dependencies)
// https://doc.rust-lang.org/cargo/reference/workspaces.html#the-members-and-exclude-fields
return dependencies, tomlFile.Workspace.Members, nil
return pkgID, dependencies, tomlFile.Workspace.Members, nil
}
// packageID builds PackageID by Package name and version.
// If name is empty - use hash of cargoToml.
func (a cargoAnalyzer) packageID(cargoToml cargoToml) string {
if cargoToml.Package.Name != "" {
return dependency.ID(types.Cargo, cargoToml.Package.Name, cargoToml.Package.Version)
}
hash, err := hashstructure.Hash(cargoToml, hashstructure.FormatV2, &hashstructure.HashOptions{
ZeroNil: true,
IgnoreZeroValue: true,
})
if err != nil {
a.logger.Warn("unable to hash package", log.String("package", cargoToml.Package.Name), log.Err(err))
}
return strconv.FormatUint(hash, 16)
}

View File

@@ -27,6 +27,23 @@ func Test_cargoAnalyzer_Analyze(t *testing.T) {
Type: types.Cargo,
FilePath: "Cargo.lock",
Packages: types.Packages{
{
ID: "app@0.1.0",
Name: "app",
Version: "0.1.0",
Relationship: types.RelationshipRoot,
Locations: []types.Location{
{
StartLine: 13,
EndLine: 20,
},
},
DependsOn: []string{
"memchr@1.0.2",
"regex-syntax@0.5.6",
"regex@1.7.3",
},
},
{
ID: "memchr@1.0.2",
Name: "memchr",
@@ -153,6 +170,21 @@ func Test_cargoAnalyzer_Analyze(t *testing.T) {
Type: types.Cargo,
FilePath: "Cargo.lock",
Packages: types.Packages{
{
ID: "app@0.1.0",
Name: "app",
Version: "0.1.0",
Relationship: types.RelationshipRoot,
Locations: []types.Location{
{
StartLine: 4,
EndLine: 9,
},
},
DependsOn: []string{
"memchr@2.5.0",
},
},
{
ID: "memchr@2.5.0",
Name: "memchr",
@@ -413,6 +445,44 @@ func Test_cargoAnalyzer_Analyze(t *testing.T) {
Type: types.Cargo,
FilePath: "Cargo.lock",
Packages: types.Packages{
{
ID: "d0e1231acd612a0f",
Relationship: types.RelationshipRoot,
DependsOn: []string{
"member@0.1.0",
"member2@0.1.0",
},
},
{
ID: "member@0.1.0",
Name: "member",
Version: "0.1.0",
Relationship: types.RelationshipWorkspace,
Locations: []types.Location{
{
StartLine: 30,
EndLine: 35,
},
},
DependsOn: []string{
"gdb-command@0.7.6",
},
},
{
ID: "member2@0.1.0",
Name: "member2",
Version: "0.1.0",
Relationship: types.RelationshipWorkspace,
Locations: []types.Location{
{
StartLine: 37,
EndLine: 42,
},
},
DependsOn: []string{
"regex@1.10.2",
},
},
{
ID: "gdb-command@0.7.6",
Name: "gdb-command",