From 93efe0789ed9d9a71e04e93d87be63032ad9cae7 Mon Sep 17 00:00:00 2001 From: DmitriyLewen <91113035+DmitriyLewen@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:40:39 +0600 Subject: [PATCH] feat(rust): add root and workspace relationships/package for `cargo` lock files (#8676) --- docs/docs/scanner/vulnerability.md | 7 +- integration/repo_test.go | 9 + integration/testdata/cargo.lock.json.golden | 165 ++++++++++++++++++ .../testdata/fixtures/repo/cargo/Cargo.lock | 81 +++++++++ .../testdata/fixtures/repo/cargo/Cargo.toml | 15 ++ .../analyzer/language/rust/cargo/cargo.go | 84 +++++++-- .../language/rust/cargo/cargo_test.go | 70 ++++++++ 7 files changed, 418 insertions(+), 13 deletions(-) create mode 100644 integration/testdata/cargo.lock.json.golden create mode 100644 integration/testdata/fixtures/repo/cargo/Cargo.lock create mode 100644 integration/testdata/fixtures/repo/cargo/Cargo.toml diff --git a/docs/docs/scanner/vulnerability.md b/docs/docs/scanner/vulnerability.md index 0eb47aaaa8..1534e5d4d0 100644 --- a/docs/docs/scanner/vulnerability.md +++ b/docs/docs/scanner/vulnerability.md @@ -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: diff --git a/integration/repo_test.go b/integration/repo_test.go index 5a87d196f6..a5cc880aeb 100644 --- a/integration/repo_test.go +++ b/integration/repo_test.go @@ -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{ diff --git a/integration/testdata/cargo.lock.json.golden b/integration/testdata/cargo.lock.json.golden new file mode 100644 index 0000000000..e289f008fb --- /dev/null +++ b/integration/testdata/cargo.lock.json.golden @@ -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 + } + ] + } + ] + } + ] +} diff --git a/integration/testdata/fixtures/repo/cargo/Cargo.lock b/integration/testdata/fixtures/repo/cargo/Cargo.lock new file mode 100644 index 0000000000..d3b73a3baf --- /dev/null +++ b/integration/testdata/fixtures/repo/cargo/Cargo.lock @@ -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" diff --git a/integration/testdata/fixtures/repo/cargo/Cargo.toml b/integration/testdata/fixtures/repo/cargo/Cargo.toml new file mode 100644 index 0000000000..86bc84d930 --- /dev/null +++ b/integration/testdata/fixtures/repo/cargo/Cargo.toml @@ -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 = "*" \ No newline at end of file diff --git a/pkg/fanal/analyzer/language/rust/cargo/cargo.go b/pkg/fanal/analyzer/language/rust/cargo/cargo.go index a13ea0bfda..a9cad4165c 100644 --- a/pkg/fanal/analyzer/language/rust/cargo/cargo.go +++ b/pkg/fanal/analyzer/language/rust/cargo/cargo.go @@ -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) } diff --git a/pkg/fanal/analyzer/language/rust/cargo/cargo_test.go b/pkg/fanal/analyzer/language/rust/cargo/cargo_test.go index 43bdc3afbb..11fa15bde9 100644 --- a/pkg/fanal/analyzer/language/rust/cargo/cargo_test.go +++ b/pkg/fanal/analyzer/language/rust/cargo/cargo_test.go @@ -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",