Skip to content

Commit bf4cd4f

Browse files
feat(nodejs): add root and workspace for yarn packages (#8535)
Co-authored-by: knqyf263 <[email protected]>
1 parent 6562082 commit bf4cd4f

File tree

8 files changed

+309
-76
lines changed

8 files changed

+309
-76
lines changed

docs/docs/coverage/language/nodejs.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,26 @@ Trivy analyzes `node_modules` for licenses.
4343
By default, Trivy doesn't report development dependencies. Use the `--include-dev-deps` flag to include them.
4444

4545
### Yarn
46-
Trivy parses `yarn.lock`, which doesn't contain information about development dependencies.
47-
Trivy also uses `package.json` file to handle [aliases](https://classic.yarnpkg.com/lang/en/docs/cli/add/#toc-yarn-add-alias).
46+
Trivy parses `yarn.lock`.
4847

49-
To exclude devDependencies and allow aliases, `package.json` also needs to be present next to `yarn.lock`.
48+
Trivy also analyzes additional files to gather more information about the detected dependencies.
5049

51-
Trivy analyzes `.yarn` (Yarn 2+) or `node_modules` (Yarn Classic) folder next to the yarn.lock file to detect licenses.
50+
- package.json
51+
- node_modules/**
5252

53-
By default, Trivy doesn't report development dependencies. Use the `--include-dev-deps` flag to include them.
53+
#### Package relationships
54+
`yarn.lock` files don't contain information about package relationships, such as direct or indirect dependencies.
55+
To enrich this information, Trivy parses the `package.json` file located next to the `yarn.lock` file as well as workspace `package.json` files.
56+
57+
By default, Trivy doesn't report development dependencies.
58+
Use the `--include-dev-deps` flag to include them in the results.
59+
60+
#### Development dependencies
61+
`yarn.lock` files don't contain information about package groups, such as production and development dependencies.
62+
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.
63+
64+
#### Licenses
65+
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.
5466

5567
### pnpm
5668
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.
7486

7587
[dependency-graph]: ../../configuration/reporting.md#show-origins-of-vulnerable-dependencies
7688
[pnpm-lockfile-v6]: https://github.com/pnpm/spec/blob/fd3238639af86c09b7032cc942bab3438b497036/lockfile/6.0.md
89+
[yarn-aliases]: https://classic.yarnpkg.com/lang/en/docs/cli/add/#toc-yarn-add-alias
7790

7891
[^1]: [yarn.lock](#bun) must be generated

docs/docs/scanner/vulnerability.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ This feature allows you to focus on vulnerabilities in specific types of depende
294294
In Trivy, there are four types of package relationships:
295295
296296
1. `root`: The root package being scanned
297-
2. `workspace`: Workspaces of the root package (Currently only `pom.xml` and `cargo.lock` files are supported)
297+
2. `workspace`: Workspaces of the root package (Currently only `pom.xml`, `yarn.lock` and `cargo.lock` files are supported)
298298
3. `direct`: Direct dependencies of the root/workspace package
299299
4. `indirect`: Transitive dependencies
300300
5. `unknown`: Packages whose relationship cannot be determined

integration/testdata/yarn.json.golden

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@
2121
"Class": "lang-pkgs",
2222
"Type": "yarn",
2323
"Packages": [
24+
{
25+
26+
"Name": "integration",
27+
"Identifier": {
28+
"PURL": "pkg:npm/[email protected]",
29+
"UID": "830dfbb17accac93"
30+
},
31+
"Version": "1.0.0",
32+
"Licenses": [
33+
"MIT"
34+
],
35+
"Relationship": "root",
36+
"DependsOn": [
37+
38+
],
39+
"Layer": {}
40+
},
2441
{
2542
2643
"Name": "jquery",

pkg/fanal/analyzer/language/nodejs/yarn/testdata/monorepo/packages/package2/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
{
2-
"name": "package2",
32
"private": true,
4-
"version": "0.0.0",
53
"type": "module",
64
"dependencies": {
75
"is-odd": "^3.0.1"

pkg/fanal/analyzer/language/nodejs/yarn/yarn.go

Lines changed: 125 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package yarn
22

33
import (
44
"archive/zip"
5+
"cmp"
56
"context"
67
"errors"
78
"io"
@@ -27,6 +28,7 @@ import (
2728
"github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/nodejs/license"
2829
"github.com/aquasecurity/trivy/pkg/fanal/types"
2930
"github.com/aquasecurity/trivy/pkg/log"
31+
"github.com/aquasecurity/trivy/pkg/set"
3032
"github.com/aquasecurity/trivy/pkg/utils/fsutils"
3133
xio "github.com/aquasecurity/trivy/pkg/x/io"
3234
)
@@ -162,36 +164,28 @@ func (a yarnAnalyzer) Version() int {
162164
// distinguishing between direct and transitive dependencies as well as production and development dependencies.
163165
func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.Application, patterns map[string][]string) error {
164166
packageJsonPath := path.Join(dir, types.NpmPkg)
165-
directDeps, directDevDeps, err := a.parsePackageJsonDependencies(fsys, packageJsonPath)
167+
root, workspaces, err := a.parsePackageJSON(fsys, packageJsonPath)
166168
if errors.Is(err, fs.ErrNotExist) {
167169
a.logger.Debug("package.json not found", log.FilePath(packageJsonPath))
168170
return nil
169171
} else if err != nil {
170-
return xerrors.Errorf("unable to parse %s: %w", dir, err)
172+
return xerrors.Errorf("unable to parse root package.json: %w", err)
171173
}
172174

173-
// yarn.lock file can contain same packages with different versions
174-
// save versions separately for version comparison by comparator
175-
pkgIDs := lo.SliceToMap(app.Packages, func(pkg types.Package) (string, types.Package) {
175+
// Since yarn.lock file can contain same packages with different versions
176+
// we need to save versions separately for version comparison.
177+
pkgs := lo.SliceToMap(app.Packages, func(pkg types.Package) (string, types.Package) {
176178
return pkg.ID, pkg
177179
})
178180

179-
// Walk prod dependencies
180-
pkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDeps, patterns, false)
181-
if err != nil {
182-
return xerrors.Errorf("unable to walk dependencies: %w", err)
181+
if err := a.resolveRootDependencies(&root, pkgs, patterns); err != nil {
182+
return xerrors.Errorf("unable to resolve root dependencies: %w", err)
183183
}
184184

185-
// Walk dev dependencies
186-
devPkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDevDeps, patterns, true)
187-
if err != nil {
188-
return xerrors.Errorf("unable to walk dependencies: %w", err)
185+
if err := a.resolveWorkspaceDependencies(workspaces, pkgs, patterns); err != nil {
186+
return xerrors.Errorf("unable to resolve workspace dependencies: %w", err)
189187
}
190188

191-
// Merge prod and dev dependencies.
192-
// If the same package is found in both prod and dev dependencies, use the one in prod.
193-
pkgs = lo.Assign(devPkgs, pkgs)
194-
195189
pkgSlice := lo.Values(pkgs)
196190
sort.Sort(types.Packages(pkgSlice))
197191

@@ -200,11 +194,94 @@ func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.App
200194
return nil
201195
}
202196

203-
func (a yarnAnalyzer) walkDependencies(pkgs []types.Package, pkgIDs map[string]types.Package,
204-
directDeps map[string]string, patterns map[string][]string, dev bool) (map[string]types.Package, error) {
197+
func (a yarnAnalyzer) parsePackageJSON(fsys fs.FS, filePath string) (packagejson.Package, []packagejson.Package, error) {
198+
// Parse package.json
199+
f, err := fsys.Open(filePath)
200+
if err != nil {
201+
return packagejson.Package{}, nil, xerrors.Errorf("file open error: %w", err)
202+
}
203+
defer func() { _ = f.Close() }()
204+
205+
root, err := a.packageJsonParser.Parse(f)
206+
if err != nil {
207+
return packagejson.Package{}, nil, xerrors.Errorf("parse error: %w", err)
208+
}
209+
210+
root.Package.ID = cmp.Or(root.Package.ID, filePath) // In case the package.json doesn't have a name or version
211+
root.Package.Relationship = types.RelationshipRoot
212+
213+
workspaces, err := a.traverseWorkspaces(fsys, path.Dir(filePath), root.Workspaces)
214+
if err != nil {
215+
return packagejson.Package{}, nil, xerrors.Errorf("traverse workspaces error: %w", err)
216+
}
217+
for i := range workspaces {
218+
workspaces[i].Package.Relationship = types.RelationshipWorkspace
219+
220+
// Add workspace as a child of root
221+
root.DependsOn = append(root.DependsOn, workspaces[i].ID)
222+
}
223+
224+
return root, workspaces, nil
225+
}
226+
227+
func (a yarnAnalyzer) resolveRootDependencies(root *packagejson.Package, pkgs map[string]types.Package,
228+
patterns map[string][]string) error {
229+
if err := a.resolveDependencies(root, pkgs, patterns); err != nil {
230+
return xerrors.Errorf("unable to resolve dependencies: %w", err)
231+
}
232+
233+
// Add root package to the package map
234+
slices.Sort(root.Package.DependsOn)
235+
pkgs[root.Package.ID] = root.Package
236+
237+
return nil
238+
}
239+
240+
func (a yarnAnalyzer) resolveWorkspaceDependencies(workspaces []packagejson.Package, pkgs map[string]types.Package,
241+
patterns map[string][]string) error {
242+
if len(workspaces) == 0 {
243+
return nil
244+
}
245+
246+
for _, workspace := range workspaces {
247+
if err := a.resolveDependencies(&workspace, pkgs, patterns); err != nil {
248+
return xerrors.Errorf("unable to resolve dependencies: %w", err)
249+
}
250+
251+
// Add workspace to the package map
252+
slices.Sort(workspace.Package.DependsOn)
253+
pkgs[workspace.ID] = workspace.Package
254+
}
255+
256+
return nil
257+
}
258+
259+
// resolveDependencies resolves production and development dependencies from direct dependencies and patterns.
260+
// It also flags dependencies as direct or indirect and updates the dependencies of the parent package.
261+
func (a yarnAnalyzer) resolveDependencies(pkg *packagejson.Package, pkgs map[string]types.Package, patterns map[string][]string) error {
262+
// Recursively walk dependencies and flags development dependencies.
263+
// Walk development dependencies first to avoid overwriting production dependencies.
264+
directDevDeps := pkg.DevDependencies
265+
if err := a.walkDependencies(&pkg.Package, pkgs, directDevDeps, patterns, true); err != nil {
266+
return xerrors.Errorf("unable to walk dependencies: %w", err)
267+
}
268+
269+
// Recursively walk dependencies and flags production dependencies.
270+
directProdDeps := lo.Assign(pkg.Dependencies, pkg.OptionalDependencies)
271+
if err := a.walkDependencies(&pkg.Package, pkgs, directProdDeps, patterns, false); err != nil {
272+
return xerrors.Errorf("unable to walk dependencies: %w", err)
273+
}
274+
275+
return nil
276+
}
277+
278+
// walkDependencies recursively walk dependencies and flags them as direct or indirect.
279+
// Note that it overwrites the existing package map.
280+
func (a yarnAnalyzer) walkDependencies(parent *types.Package, pkgs map[string]types.Package, directDeps map[string]string,
281+
patterns map[string][]string, dev bool) error {
205282

206283
// Identify direct dependencies
207-
directPkgs := make(map[string]types.Package)
284+
seenIDs := set.New[string]()
208285
for _, pkg := range pkgs {
209286
constraint, ok := directDeps[pkg.Name]
210287
if !ok {
@@ -224,76 +301,59 @@ func (a yarnAnalyzer) walkDependencies(pkgs []types.Package, pkgIDs map[string]t
224301
if pkgPatterns, found := patterns[pkg.ID]; !found || !slices.Contains(pkgPatterns, dependency.ID(types.Yarn, pkg.Name, constraint)) {
225302
// npm has own comparer to compare versions
226303
if match, err := a.comparer.MatchVersion(pkg.Version, constraint); err != nil {
227-
return nil, xerrors.Errorf("unable to match version for %s", pkg.Name)
304+
return xerrors.Errorf("unable to match version for %s", pkg.Name)
228305
} else if !match {
229306
continue
230307
}
231308
}
232309

310+
// If the package is already marked as a production dependency, skip overwriting it.
311+
// Since the dev field is boolean, it cannot determine if the package is already processed,
312+
// so we need to check the relationship field.
313+
if pkg.Relationship == types.RelationshipUnknown || pkg.Dev {
314+
pkg.Dev = dev
315+
}
316+
233317
// Mark as a direct dependency
234318
pkg.Indirect = false
235319
pkg.Relationship = types.RelationshipDirect
236-
pkg.Dev = dev
237-
directPkgs[pkg.ID] = pkg
238320

239-
}
321+
pkgs[pkg.ID] = pkg
322+
seenIDs.Append(pkg.ID)
240323

241-
// Walk indirect dependencies
242-
for _, pkg := range directPkgs {
243-
a.walkIndirectDependencies(pkg, pkgIDs, directPkgs)
324+
// Add a direct dependency to the parent package
325+
parent.DependsOn = append(parent.DependsOn, pkg.ID)
326+
327+
// Walk indirect dependencies
328+
a.walkIndirectDependencies(pkg, pkgs, seenIDs)
244329
}
245330

246-
return directPkgs, nil
331+
return nil
247332
}
248333

249-
func (a yarnAnalyzer) walkIndirectDependencies(pkg types.Package, pkgIDs, deps map[string]types.Package) {
334+
func (a yarnAnalyzer) walkIndirectDependencies(pkg types.Package, pkgs map[string]types.Package, seenIDs set.Set[string]) {
250335
for _, pkgID := range pkg.DependsOn {
251-
if _, ok := deps[pkgID]; ok {
252-
continue
336+
if seenIDs.Contains(pkgID) {
337+
continue // Skip if we've already seen this package
253338
}
254339

255-
dep, ok := pkgIDs[pkgID]
340+
dep, ok := pkgs[pkgID]
256341
if !ok {
257342
continue
258343
}
259344

345+
if dep.Relationship == types.RelationshipUnknown || dep.Dev {
346+
dep.Dev = pkg.Dev
347+
}
260348
dep.Indirect = true
261349
dep.Relationship = types.RelationshipIndirect
262-
dep.Dev = pkg.Dev
263-
deps[dep.ID] = dep
264-
a.walkIndirectDependencies(dep, pkgIDs, deps)
265-
}
266-
}
350+
pkgs[dep.ID] = dep
267351

268-
func (a yarnAnalyzer) parsePackageJsonDependencies(fsys fs.FS, filePath string) (map[string]string, map[string]string, error) {
269-
// Parse package.json
270-
f, err := fsys.Open(filePath)
271-
if err != nil {
272-
return nil, nil, xerrors.Errorf("file open error: %w", err)
273-
}
274-
defer func() { _ = f.Close() }()
352+
seenIDs.Append(dep.ID)
275353

276-
rootPkg, err := a.packageJsonParser.Parse(f)
277-
if err != nil {
278-
return nil, nil, xerrors.Errorf("parse error: %w", err)
279-
}
280-
281-
// Merge dependencies and optionalDependencies
282-
dependencies := lo.Assign(rootPkg.Dependencies, rootPkg.OptionalDependencies)
283-
devDependencies := rootPkg.DevDependencies
284-
285-
if len(rootPkg.Workspaces) > 0 {
286-
pkgs, err := a.traverseWorkspaces(fsys, path.Dir(filePath), rootPkg.Workspaces)
287-
if err != nil {
288-
return nil, nil, xerrors.Errorf("traverse workspaces error: %w", err)
289-
}
290-
for _, pkg := range pkgs {
291-
dependencies = lo.Assign(dependencies, pkg.Dependencies, pkg.OptionalDependencies)
292-
devDependencies = lo.Assign(devDependencies, pkg.DevDependencies)
293-
}
354+
// Recursively walk dependencies
355+
a.walkIndirectDependencies(dep, pkgs, seenIDs)
294356
}
295-
296-
return dependencies, devDependencies, nil
297357
}
298358

299359
func (a yarnAnalyzer) traverseWorkspaces(fsys fs.FS, dir string, workspaces []string) ([]packagejson.Package, error) {
@@ -308,6 +368,7 @@ func (a yarnAnalyzer) traverseWorkspaces(fsys fs.FS, dir string, workspaces []st
308368
if err != nil {
309369
return xerrors.Errorf("unable to parse %q: %w", path, err)
310370
}
371+
pkg.Package.ID = cmp.Or(pkg.Package.ID, path) // In case the package.json doesn't have a name or version
311372
pkgs = append(pkgs, pkg)
312373
return nil
313374
}

0 commit comments

Comments
 (0)