feat(nodejs): add root and workspace for yarn packages (#8535)

Co-authored-by: knqyf263 <knqyf263@gmail.com>
This commit is contained in:
DmitriyLewen
2025-04-30 20:49:49 +06:00
committed by GitHub
parent 6562082e28
commit bf4cd4f2d2
8 changed files with 312 additions and 79 deletions

View File

@@ -43,14 +43,26 @@ Trivy analyzes `node_modules` for licenses.
By default, Trivy doesn't report development dependencies. Use the `--include-dev-deps` flag to include them.
### Yarn
Trivy parses `yarn.lock`, which doesn't contain information about development dependencies.
Trivy also uses `package.json` file to handle [aliases](https://classic.yarnpkg.com/lang/en/docs/cli/add/#toc-yarn-add-alias).
Trivy parses `yarn.lock`.
To exclude devDependencies and allow aliases, `package.json` also needs to be present next to `yarn.lock`.
Trivy also analyzes additional files to gather more information about the detected dependencies.
Trivy analyzes `.yarn` (Yarn 2+) or `node_modules` (Yarn Classic) folder next to the yarn.lock file to detect licenses.
- package.json
- node_modules/**
By default, Trivy doesn't report development dependencies. Use the `--include-dev-deps` flag to include them.
#### Package relationships
`yarn.lock` files don't contain information about package relationships, such as direct or indirect dependencies.
To enrich this information, Trivy parses the `package.json` file located next to the `yarn.lock` file as well as workspace `package.json` files.
By default, Trivy doesn't report development dependencies.
Use the `--include-dev-deps` flag to include them in the results.
#### Development dependencies
`yarn.lock` files don't contain information about package groups, such as production and development dependencies.
To identify dev dependencies and support [aliases][yarn-aliases], Trivy parses the `package.json` file located next to the `yarn.lock` file as well as workspace `package.json` files.
#### Licenses
Trivy analyzes the `.yarn` directory (for Yarn 2+) or the `node_modules` directory (for Yarn Classic) located next to the `yarn.lock` file to detect licenses.
### pnpm
Trivy parses `pnpm-lock.yaml`, then finds production dependencies and builds a [tree][dependency-graph] of dependencies with vulnerabilities.
@@ -74,5 +86,6 @@ It only extracts package names, versions and licenses for those packages.
[dependency-graph]: ../../configuration/reporting.md#show-origins-of-vulnerable-dependencies
[pnpm-lockfile-v6]: https://github.com/pnpm/spec/blob/fd3238639af86c09b7032cc942bab3438b497036/lockfile/6.0.md
[yarn-aliases]: https://classic.yarnpkg.com/lang/en/docs/cli/add/#toc-yarn-add-alias
[^1]: [yarn.lock](#bun) must be generated

View File

@@ -294,7 +294,7 @@ 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. `workspace`: Workspaces of the root package (Currently only `pom.xml` and `cargo.lock` files are supported)
2. `workspace`: Workspaces of the root package (Currently only `pom.xml`, `yarn.lock` 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

View File

@@ -21,6 +21,23 @@
"Class": "lang-pkgs",
"Type": "yarn",
"Packages": [
{
"ID": "integration@1.0.0",
"Name": "integration",
"Identifier": {
"PURL": "pkg:npm/integration@1.0.0",
"UID": "830dfbb17accac93"
},
"Version": "1.0.0",
"Licenses": [
"MIT"
],
"Relationship": "root",
"DependsOn": [
"jquery@3.2.1"
],
"Layer": {}
},
{
"ID": "jquery@3.2.1",
"Name": "jquery",

View File

@@ -1,7 +1,5 @@
{
"name": "package2",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"is-odd": "^3.0.1"

View File

@@ -2,6 +2,7 @@ package yarn
import (
"archive/zip"
"cmp"
"context"
"errors"
"io"
@@ -27,6 +28,7 @@ import (
"github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/nodejs/license"
"github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/set"
"github.com/aquasecurity/trivy/pkg/utils/fsutils"
xio "github.com/aquasecurity/trivy/pkg/x/io"
)
@@ -162,36 +164,28 @@ func (a yarnAnalyzer) Version() int {
// distinguishing between direct and transitive dependencies as well as production and development dependencies.
func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.Application, patterns map[string][]string) error {
packageJsonPath := path.Join(dir, types.NpmPkg)
directDeps, directDevDeps, err := a.parsePackageJsonDependencies(fsys, packageJsonPath)
root, workspaces, err := a.parsePackageJSON(fsys, packageJsonPath)
if errors.Is(err, fs.ErrNotExist) {
a.logger.Debug("package.json not found", log.FilePath(packageJsonPath))
return nil
} else if err != nil {
return xerrors.Errorf("unable to parse %s: %w", dir, err)
return xerrors.Errorf("unable to parse root package.json: %w", err)
}
// yarn.lock file can contain same packages with different versions
// save versions separately for version comparison by comparator
pkgIDs := lo.SliceToMap(app.Packages, func(pkg types.Package) (string, types.Package) {
// Since yarn.lock file can contain same packages with different versions
// we need to save versions separately for version comparison.
pkgs := lo.SliceToMap(app.Packages, func(pkg types.Package) (string, types.Package) {
return pkg.ID, pkg
})
// Walk prod dependencies
pkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDeps, patterns, false)
if err != nil {
return xerrors.Errorf("unable to walk dependencies: %w", err)
if err := a.resolveRootDependencies(&root, pkgs, patterns); err != nil {
return xerrors.Errorf("unable to resolve root dependencies: %w", err)
}
// Walk dev dependencies
devPkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDevDeps, patterns, true)
if err != nil {
return xerrors.Errorf("unable to walk dependencies: %w", err)
if err := a.resolveWorkspaceDependencies(workspaces, pkgs, patterns); err != nil {
return xerrors.Errorf("unable to resolve workspace dependencies: %w", err)
}
// Merge prod and dev dependencies.
// If the same package is found in both prod and dev dependencies, use the one in prod.
pkgs = lo.Assign(devPkgs, pkgs)
pkgSlice := lo.Values(pkgs)
sort.Sort(types.Packages(pkgSlice))
@@ -200,11 +194,94 @@ func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.App
return nil
}
func (a yarnAnalyzer) walkDependencies(pkgs []types.Package, pkgIDs map[string]types.Package,
directDeps map[string]string, patterns map[string][]string, dev bool) (map[string]types.Package, error) {
func (a yarnAnalyzer) parsePackageJSON(fsys fs.FS, filePath string) (packagejson.Package, []packagejson.Package, error) {
// Parse package.json
f, err := fsys.Open(filePath)
if err != nil {
return packagejson.Package{}, nil, xerrors.Errorf("file open error: %w", err)
}
defer func() { _ = f.Close() }()
root, err := a.packageJsonParser.Parse(f)
if err != nil {
return packagejson.Package{}, nil, xerrors.Errorf("parse error: %w", err)
}
root.Package.ID = cmp.Or(root.Package.ID, filePath) // In case the package.json doesn't have a name or version
root.Package.Relationship = types.RelationshipRoot
workspaces, err := a.traverseWorkspaces(fsys, path.Dir(filePath), root.Workspaces)
if err != nil {
return packagejson.Package{}, nil, xerrors.Errorf("traverse workspaces error: %w", err)
}
for i := range workspaces {
workspaces[i].Package.Relationship = types.RelationshipWorkspace
// Add workspace as a child of root
root.DependsOn = append(root.DependsOn, workspaces[i].ID)
}
return root, workspaces, nil
}
func (a yarnAnalyzer) resolveRootDependencies(root *packagejson.Package, pkgs map[string]types.Package,
patterns map[string][]string) error {
if err := a.resolveDependencies(root, pkgs, patterns); err != nil {
return xerrors.Errorf("unable to resolve dependencies: %w", err)
}
// Add root package to the package map
slices.Sort(root.Package.DependsOn)
pkgs[root.Package.ID] = root.Package
return nil
}
func (a yarnAnalyzer) resolveWorkspaceDependencies(workspaces []packagejson.Package, pkgs map[string]types.Package,
patterns map[string][]string) error {
if len(workspaces) == 0 {
return nil
}
for _, workspace := range workspaces {
if err := a.resolveDependencies(&workspace, pkgs, patterns); err != nil {
return xerrors.Errorf("unable to resolve dependencies: %w", err)
}
// Add workspace to the package map
slices.Sort(workspace.Package.DependsOn)
pkgs[workspace.ID] = workspace.Package
}
return nil
}
// resolveDependencies resolves production and development dependencies from direct dependencies and patterns.
// It also flags dependencies as direct or indirect and updates the dependencies of the parent package.
func (a yarnAnalyzer) resolveDependencies(pkg *packagejson.Package, pkgs map[string]types.Package, patterns map[string][]string) error {
// Recursively walk dependencies and flags development dependencies.
// Walk development dependencies first to avoid overwriting production dependencies.
directDevDeps := pkg.DevDependencies
if err := a.walkDependencies(&pkg.Package, pkgs, directDevDeps, patterns, true); err != nil {
return xerrors.Errorf("unable to walk dependencies: %w", err)
}
// Recursively walk dependencies and flags production dependencies.
directProdDeps := lo.Assign(pkg.Dependencies, pkg.OptionalDependencies)
if err := a.walkDependencies(&pkg.Package, pkgs, directProdDeps, patterns, false); err != nil {
return xerrors.Errorf("unable to walk dependencies: %w", err)
}
return nil
}
// walkDependencies recursively walk dependencies and flags them as direct or indirect.
// Note that it overwrites the existing package map.
func (a yarnAnalyzer) walkDependencies(parent *types.Package, pkgs map[string]types.Package, directDeps map[string]string,
patterns map[string][]string, dev bool) error {
// Identify direct dependencies
directPkgs := make(map[string]types.Package)
seenIDs := set.New[string]()
for _, pkg := range pkgs {
constraint, ok := directDeps[pkg.Name]
if !ok {
@@ -224,78 +301,61 @@ func (a yarnAnalyzer) walkDependencies(pkgs []types.Package, pkgIDs map[string]t
if pkgPatterns, found := patterns[pkg.ID]; !found || !slices.Contains(pkgPatterns, dependency.ID(types.Yarn, pkg.Name, constraint)) {
// npm has own comparer to compare versions
if match, err := a.comparer.MatchVersion(pkg.Version, constraint); err != nil {
return nil, xerrors.Errorf("unable to match version for %s", pkg.Name)
return xerrors.Errorf("unable to match version for %s", pkg.Name)
} else if !match {
continue
}
}
// If the package is already marked as a production dependency, skip overwriting it.
// Since the dev field is boolean, it cannot determine if the package is already processed,
// so we need to check the relationship field.
if pkg.Relationship == types.RelationshipUnknown || pkg.Dev {
pkg.Dev = dev
}
// Mark as a direct dependency
pkg.Indirect = false
pkg.Relationship = types.RelationshipDirect
pkg.Dev = dev
directPkgs[pkg.ID] = pkg
pkgs[pkg.ID] = pkg
seenIDs.Append(pkg.ID)
// Add a direct dependency to the parent package
parent.DependsOn = append(parent.DependsOn, pkg.ID)
// Walk indirect dependencies
a.walkIndirectDependencies(pkg, pkgs, seenIDs)
}
// Walk indirect dependencies
for _, pkg := range directPkgs {
a.walkIndirectDependencies(pkg, pkgIDs, directPkgs)
}
return directPkgs, nil
return nil
}
func (a yarnAnalyzer) walkIndirectDependencies(pkg types.Package, pkgIDs, deps map[string]types.Package) {
func (a yarnAnalyzer) walkIndirectDependencies(pkg types.Package, pkgs map[string]types.Package, seenIDs set.Set[string]) {
for _, pkgID := range pkg.DependsOn {
if _, ok := deps[pkgID]; ok {
continue
if seenIDs.Contains(pkgID) {
continue // Skip if we've already seen this package
}
dep, ok := pkgIDs[pkgID]
dep, ok := pkgs[pkgID]
if !ok {
continue
}
if dep.Relationship == types.RelationshipUnknown || dep.Dev {
dep.Dev = pkg.Dev
}
dep.Indirect = true
dep.Relationship = types.RelationshipIndirect
dep.Dev = pkg.Dev
deps[dep.ID] = dep
a.walkIndirectDependencies(dep, pkgIDs, deps)
pkgs[dep.ID] = dep
seenIDs.Append(dep.ID)
// Recursively walk dependencies
a.walkIndirectDependencies(dep, pkgs, seenIDs)
}
}
func (a yarnAnalyzer) parsePackageJsonDependencies(fsys fs.FS, filePath string) (map[string]string, map[string]string, error) {
// Parse package.json
f, err := fsys.Open(filePath)
if err != nil {
return nil, nil, xerrors.Errorf("file open error: %w", err)
}
defer func() { _ = f.Close() }()
rootPkg, err := a.packageJsonParser.Parse(f)
if err != nil {
return nil, nil, xerrors.Errorf("parse error: %w", err)
}
// Merge dependencies and optionalDependencies
dependencies := lo.Assign(rootPkg.Dependencies, rootPkg.OptionalDependencies)
devDependencies := rootPkg.DevDependencies
if len(rootPkg.Workspaces) > 0 {
pkgs, err := a.traverseWorkspaces(fsys, path.Dir(filePath), rootPkg.Workspaces)
if err != nil {
return nil, nil, xerrors.Errorf("traverse workspaces error: %w", err)
}
for _, pkg := range pkgs {
dependencies = lo.Assign(dependencies, pkg.Dependencies, pkg.OptionalDependencies)
devDependencies = lo.Assign(devDependencies, pkg.DevDependencies)
}
}
return dependencies, devDependencies, nil
}
func (a yarnAnalyzer) traverseWorkspaces(fsys fs.FS, dir string, workspaces []string) ([]packagejson.Package, error) {
var pkgs []packagejson.Package
@@ -308,6 +368,7 @@ func (a yarnAnalyzer) traverseWorkspaces(fsys fs.FS, dir string, workspaces []st
if err != nil {
return xerrors.Errorf("unable to parse %q: %w", path, err)
}
pkg.Package.ID = cmp.Or(pkg.Package.ID, path) // In case the package.json doesn't have a name or version
pkgs = append(pkgs, pkg)
return nil
}

View File

@@ -26,6 +26,20 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
Type: types.Yarn,
FilePath: "yarn.lock",
Packages: types.Packages{
{
ID: "90@1.0.0",
Name: "90",
Version: "1.0.0",
Relationship: types.RelationshipRoot,
Licenses: []string{
"MIT",
},
DependsOn: []string{
"js-tokens@2.0.0",
"prop-types@15.7.2",
"scheduler@0.13.6",
},
},
{
ID: "js-tokens@2.0.0",
Name: "js-tokens",
@@ -134,7 +148,7 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
},
},
{
name: "Project with workspace placed in sub dir",
name: "project with workspace placed in sub dir",
dir: "testdata/project-with-workspace-in-subdir",
want: &analyzer.AnalysisResult{
Applications: []types.Application{
@@ -142,6 +156,27 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
Type: types.Yarn,
FilePath: "foo/yarn.lock",
Packages: types.Packages{
{
ID: "@test/foo@1.0.0",
Name: "@test/foo",
Version: "1.0.0",
Relationship: types.RelationshipRoot,
Licenses: []string{
"MIT",
},
DependsOn: []string{
"@test/bar-generators@0.0.1",
},
},
{
ID: "@test/bar-generators@0.0.1",
Name: "@test/bar-generators",
Version: "0.0.1",
Relationship: types.RelationshipWorkspace,
DependsOn: []string{
"hoek@6.1.3",
},
},
{
ID: "hoek@6.1.3",
Name: "hoek",
@@ -307,6 +342,16 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
Type: types.Yarn,
FilePath: "yarn.lock",
Packages: []types.Package{
{
ID: "yarn-3-licenses@1.0.0",
Name: "yarn-3-licenses",
Version: "1.0.0",
Relationship: types.RelationshipRoot,
DependsOn: []string{
"is-callable@1.2.7",
"is-odd@3.0.1",
},
},
{
ID: "is-callable@1.2.7",
Name: "is-callable",
@@ -362,6 +407,14 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
Type: types.Yarn,
FilePath: "yarn.lock",
Packages: types.Packages{
{
ID: "package.json",
Relationship: types.RelationshipRoot,
DependsOn: []string{
"debug@4.3.5",
"js-tokens@9.0.0",
},
},
{
ID: "debug@4.3.5",
Name: "debug",
@@ -417,6 +470,21 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
Type: types.Yarn,
FilePath: "yarn.lock",
Packages: types.Packages{
{
ID: "test@1.0.0",
Name: "test",
Version: "1.0.0",
Relationship: types.RelationshipRoot,
Licenses: []string{
"MIT",
},
DependsOn: []string{
"foo-debug@4.3.4",
"foo-json@0.8.33",
"foo-ms@2.1.3",
"foo-uuid@9.0.7",
},
},
{
ID: "foo-json@0.8.33",
Name: "@types/jsonstream",
@@ -535,6 +603,54 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
Type: types.Yarn,
FilePath: "yarn.lock",
Packages: types.Packages{
{
ID: "yarn-workspace-test@1.0.0",
Name: "yarn-workspace-test",
Version: "1.0.0",
Relationship: types.RelationshipRoot,
DependsOn: []string{
"c@0.0.0",
"package1@0.0.0",
"packages/package2/package.json",
"prettier@2.8.8",
"util1@0.0.0",
},
},
{
ID: "packages/package2/package.json",
Relationship: types.RelationshipWorkspace,
DependsOn: []string{
"is-odd@3.0.1",
},
},
{
ID: "c@0.0.0",
Name: "c",
Version: "0.0.0",
Relationship: types.RelationshipWorkspace,
DependsOn: []string{
"is-number@7.0.0",
},
},
{
ID: "package1@0.0.0",
Name: "package1",
Version: "0.0.0",
Relationship: types.RelationshipWorkspace,
DependsOn: []string{
"scheduler@0.23.0",
},
},
{
ID: "util1@0.0.0",
Name: "util1",
Version: "0.0.0",
Relationship: types.RelationshipWorkspace,
DependsOn: []string{
"js-tokens@8.0.1",
"prop-types@15.8.1",
},
},
{
ID: "is-number@7.0.0",
Name: "is-number",
@@ -702,6 +818,13 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
Type: types.Yarn,
FilePath: "yarn.lock",
Packages: []types.Package{
{
ID: "package.json",
Relationship: types.RelationshipRoot,
DependsOn: []string{
"@vue/compiler-sfc@2.7.14",
},
},
{
ID: "@vue/compiler-sfc@2.7.14",
Name: "@vue/compiler-sfc",

View File

@@ -267,6 +267,13 @@ func ApplyLayers(layers []ftypes.BlobInfo) ftypes.ArtifactDetail {
}
func newPURL(pkgType ftypes.TargetType, metadata types.Metadata, pkg ftypes.Package) *packageurl.PackageURL {
// Possible cases when package doesn't have name/version (e.g. local package.json).
// For these cases we don't need to create PURL, because this PURL will be incorrect.
// TODO Dmitriy - move to `purl` package
if pkg.Name == "" {
return nil
}
p, err := purl.New(pkgType, metadata, pkg)
if err != nil {
log.Error("Failed to create PackageURL", log.Err(err))

View File

@@ -487,13 +487,27 @@ func excludeDevDeps(apps []ftypes.Application, include bool) {
onceInfo := sync.OnceFunc(func() {
log.Info("Suppressing dependencies for development and testing. To display them, try the '--include-dev-deps' flag.")
})
for i := range apps {
apps[i].Packages = lo.Filter(apps[i].Packages, func(lib ftypes.Package, _ int) bool {
if lib.Dev {
devDeps := set.New[string]()
apps[i].Packages = lo.Filter(apps[i].Packages, func(pkg ftypes.Package, _ int) bool {
if pkg.Dev {
onceInfo()
devDeps.Append(pkg.ID)
}
return !lib.Dev
return !pkg.Dev
})
// Remove development dependencies from dependencies of root and workspace packages
for j, pkg := range apps[i].Packages {
if pkg.Relationship != ftypes.RelationshipRoot && pkg.Relationship != ftypes.RelationshipWorkspace {
continue
}
apps[i].Packages[j].DependsOn = lo.Filter(apps[i].Packages[j].DependsOn, func(dep string, _ int) bool {
return !devDeps.Contains(dep)
})
}
}
}