Skip to content

Commit 6e34f8b

Browse files
committed
refactor: move detection of dev/direct deps to dep parser
1 parent cdebe92 commit 6e34f8b

File tree

2 files changed

+75
-142
lines changed
  • pkg
    • dependency/parser/nodejs/yarn
    • fanal/analyzer/language/nodejs/yarn

2 files changed

+75
-142
lines changed

pkg/dependency/parser/nodejs/yarn/parse.go

+53-15
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"bytes"
66
"io"
77
"regexp"
8+
"slices"
9+
"sort"
810
"strings"
911

1012
"github.com/samber/lo"
@@ -127,7 +129,7 @@ func ignoreProtocol(protocol string) bool {
127129
return false
128130
}
129131

130-
func parseResults(patternIDs map[string]string, dependsOn map[string][]string) (deps []ftypes.Dependency) {
132+
func parseResults(patternIDs map[string]string, dependsOn map[string][]string) (deps ftypes.Dependencies) {
131133
// find dependencies by patterns
132134
for pkgID, depPatterns := range dependsOn {
133135
depIDs := lo.Map(depPatterns, func(pattern string, index int) string {
@@ -269,9 +271,15 @@ func parseDependency(line string) (string, error) {
269271
}
270272
}
271273

272-
func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, error) {
274+
func (p *Parser) Parse(r xio.ReadSeekerAt, pkgJsonDirect, pkgJsonDirectDev map[string]string) ([]ftypes.Package, []ftypes.Dependency, error) {
273275
lineNumber := 1
274-
var pkgs []ftypes.Package
276+
var pkgs = make(map[string]ftypes.Package)
277+
var directPkgs, directDevPkgs []string
278+
279+
// Package.json file of project contains direct/direct Dev deps in key-value format (`name`->`version constraint` e.g. `"js-tokens": "^2.0.0"`)
280+
// We need to get pattern to match packageID and pattern when parsing packages
281+
directPatterns := lo.MapToSlice(pkgJsonDirect, func(name string, ver string) string { return packageID(name, ver) })
282+
directDevPatterns := lo.MapToSlice(pkgJsonDirectDev, func(name string, ver string) string { return packageID(name, ver) })
275283

276284
// patternIDs holds mapping between patterns and library IDs
277285
// e.g. ajv@^6.5.5 => [email protected]
@@ -291,12 +299,19 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
291299
}
292300

293301
pkgID := packageID(lib.Name, lib.Version)
302+
pkg := ftypes.Package{
303+
ID: pkgID,
304+
Name: lib.Name,
305+
Version: lib.Version,
306+
Locations: []ftypes.Location{lib.Location},
307+
}
294308
for _, pattern := range lib.Patterns {
295-
// Use `<pkg_name>@latest` ID for packages with `pattern` that uses `latest` version.
296-
// This is necessary to find direct dependencies when matching against the associated `package.json` file.
297-
// pkg.ID will be updated to Trivy ID format (`<pkgName>@<pkgVersion>`) later after checking `package.json` file.
298-
if _, ver, _ := strings.Cut(pattern, "@"); ver == "latest" {
299-
pkgID = pattern
309+
// Update `Relationship` and `Dev` fields for Direct pkgs
310+
if slices.Contains(directDevPatterns, pattern) {
311+
directDevPkgs = append(directDevPkgs, pkgID)
312+
}
313+
if slices.Contains(directPatterns, pattern) {
314+
directPkgs = append(directPkgs, pkgID)
300315
}
301316
// e.g.
302317
// combined-stream@^1.0.6 => [email protected]
@@ -307,12 +322,7 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
307322
}
308323
}
309324

310-
pkgs = append(pkgs, ftypes.Package{
311-
ID: pkgID,
312-
Name: lib.Name,
313-
Version: lib.Version,
314-
Locations: []ftypes.Location{lib.Location},
315-
})
325+
pkgs[pkgID] = pkg
316326
}
317327

318328
if err := scanner.Err(); err != nil {
@@ -322,7 +332,35 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
322332
// Replace dependency patterns with library IDs
323333
// e.g. ajv@^6.5.5 => [email protected]
324334
deps := parseResults(patternIDs, dependsOn)
325-
return pkgs, deps, nil
335+
336+
// Walk to dependsOn and update `relationship` and `Dev` fields
337+
depsMap := lo.SliceToMap(deps, func(dep ftypes.Dependency) (string, []string) { return dep.ID, dep.DependsOn })
338+
for _, pkgID := range directDevPkgs {
339+
walkDependencies(pkgs, pkgID, depsMap, ftypes.RelationshipDirect, true)
340+
}
341+
for _, pkgID := range directPkgs {
342+
walkDependencies(pkgs, pkgID, depsMap, ftypes.RelationshipDirect, false)
343+
}
344+
345+
pkgSlice := lo.Values(pkgs)
346+
sort.Sort(ftypes.Packages(pkgSlice))
347+
sort.Sort(deps)
348+
349+
return pkgSlice, deps, nil
350+
}
351+
352+
func walkDependencies(pkgs map[string]ftypes.Package, pkgID string, deps map[string][]string, relationship ftypes.Relationship, dev bool) {
353+
pkg := pkgs[pkgID]
354+
// Update pkg fields
355+
pkg.Relationship = relationship
356+
pkg.Indirect = lo.Ternary(relationship == ftypes.RelationshipDirect, false, true)
357+
pkg.Dev = dev
358+
pkgs[pkgID] = pkg
359+
360+
// Update child dependencies
361+
for _, depID := range deps[pkgID] {
362+
walkDependencies(pkgs, depID, deps, ftypes.RelationshipIndirect, dev)
363+
}
326364
}
327365

328366
func packageID(name, version string) string {

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

+22-127
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,12 @@ import (
1010
"path"
1111
"path/filepath"
1212
"regexp"
13-
"sort"
1413
"strings"
1514

1615
"github.com/hashicorp/go-multierror"
1716
"github.com/samber/lo"
1817
"golang.org/x/xerrors"
1918

20-
"github.com/aquasecurity/trivy/pkg/dependency"
2119
"github.com/aquasecurity/trivy/pkg/dependency/parser/nodejs/packagejson"
2220
"github.com/aquasecurity/trivy/pkg/dependency/parser/nodejs/yarn"
2321
"github.com/aquasecurity/trivy/pkg/detector/library/compare/npm"
@@ -47,7 +45,7 @@ var fragmentRegexp = regexp.MustCompile(`(\S+):(@?.*?)(@(.*?)|)$`)
4745
type yarnAnalyzer struct {
4846
logger *log.Logger
4947
packageJsonParser *packagejson.Parser
50-
lockParser language.Parser
48+
lockParser *yarn.Parser
5149
comparer npm.Comparer
5250
license *license.License
5351
}
@@ -70,8 +68,18 @@ func (a yarnAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysis
7068
}
7169

7270
err := fsutils.WalkDir(input.FS, ".", required, func(filePath string, d fs.DirEntry, r io.Reader) error {
71+
// Detect direct and direct dev dependencies to use them when parsing yarn lock file
72+
packageJsonPath := path.Join(path.Dir(filePath), types.NpmPkg)
73+
directDeps, directDevDeps, err := a.parsePackageJsonDependencies(input.FS, packageJsonPath)
74+
if errors.Is(err, fs.ErrNotExist) {
75+
a.logger.Debug("package.json not found", log.FilePath(packageJsonPath))
76+
} else if err != nil {
77+
a.logger.Warn("Unable to parse package.json to remove dev dependencies",
78+
log.FilePath(packageJsonPath), log.Err(err))
79+
}
80+
7381
// Parse yarn.lock
74-
app, err := a.parseYarnLock(filePath, r)
82+
app, err := a.parseYarnLock(filePath, r, directDeps, directDevDeps)
7583
if err != nil {
7684
return xerrors.Errorf("parse error: %w", err)
7785
} else if app == nil {
@@ -83,12 +91,6 @@ func (a yarnAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysis
8391
a.logger.Debug("Unable to traverse licenses", log.Err(err))
8492
}
8593

86-
// Parse package.json alongside yarn.lock to find direct deps and mark dev deps
87-
if err = a.analyzeDependencies(input.FS, path.Dir(filePath), app); err != nil {
88-
a.logger.Warn("Unable to parse package.json to remove dev dependencies",
89-
log.FilePath(path.Join(path.Dir(filePath), types.NpmPkg)), log.Err(err))
90-
}
91-
9294
// Fill licenses
9395
for i, lib := range app.Packages {
9496
if l, ok := licenses[lib.ID]; ok {
@@ -152,128 +154,21 @@ func (a yarnAnalyzer) Version() int {
152154
return version
153155
}
154156

155-
func (a yarnAnalyzer) parseYarnLock(filePath string, r io.Reader) (*types.Application, error) {
156-
return language.Parse(types.Yarn, filePath, r, a.lockParser)
157+
type pkgJsonDeps struct {
158+
directDeps map[string]string
159+
directDevDeps map[string]string
157160
}
158161

159-
// analyzeDependencies analyzes the package.json file next to yarn.lock,
160-
// distinguishing between direct and transitive dependencies as well as production and development dependencies.
161-
func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.Application) error {
162-
packageJsonPath := path.Join(dir, types.NpmPkg)
163-
directDeps, directDevDeps, err := a.parsePackageJsonDependencies(fsys, packageJsonPath)
164-
if errors.Is(err, fs.ErrNotExist) {
165-
a.logger.Debug("package.json not found", log.FilePath(packageJsonPath))
166-
return nil
167-
} else if err != nil {
168-
return xerrors.Errorf("unable to parse %s: %w", dir, err)
169-
}
170-
171-
// yarn.lock file can contain same packages with different versions
172-
// save versions separately for version comparison by comparator
173-
pkgIDs := lo.SliceToMap(app.Packages, func(pkg types.Package) (string, types.Package) {
174-
return pkg.ID, pkg
175-
})
176-
177-
// Walk prod dependencies
178-
pkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDeps, false)
179-
if err != nil {
180-
return xerrors.Errorf("unable to walk dependencies: %w", err)
181-
}
182-
183-
// Walk dev dependencies
184-
devPkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDevDeps, true)
185-
if err != nil {
186-
return xerrors.Errorf("unable to walk dependencies: %w", err)
187-
}
188-
189-
// Merge prod and dev dependencies.
190-
// If the same package is found in both prod and dev dependencies, use the one in prod.
191-
pkgs = lo.Assign(devPkgs, pkgs)
192-
193-
pkgSlice := lo.MapToSlice(pkgs, func(_ string, pkg types.Package) types.Package {
194-
// Use Trivy ID format for dependencies with `latest` version in `ID` (`version` field contains the correct version)
195-
if verFromID(pkg.ID) == latestVersion {
196-
pkg.ID = dependency.ID(types.Yarn, pkg.Name, pkg.Version)
197-
}
198-
return pkg
199-
})
200-
sort.Sort(types.Packages(pkgSlice))
201-
202-
// Save packages
203-
app.Packages = pkgSlice
204-
return nil
162+
func (d pkgJsonDeps) Parse(r xio.ReadSeekerAt) ([]types.Package, []types.Dependency, error) {
163+
return yarn.NewParser().Parse(r, d.directDeps, d.directDevDeps)
205164
}
206165

207-
func (a yarnAnalyzer) walkDependencies(pkgs []types.Package, pkgIDs map[string]types.Package,
208-
directDeps map[string]string, dev bool) (map[string]types.Package, error) {
209-
210-
// Identify direct dependencies
211-
directPkgs := make(map[string]types.Package)
212-
for _, pkg := range pkgs {
213-
constraint, ok := directDeps[pkg.Name]
214-
if !ok {
215-
continue
216-
}
217-
218-
if constraint == latestVersion {
219-
// pkgID with `latest` version uses `<pkgName>@latest` format.
220-
if verFromID(pkg.ID) != latestVersion {
221-
continue
222-
}
223-
} else {
224-
// Handle aliases
225-
// cf. https://classic.yarnpkg.com/lang/en/docs/cli/add/#toc-yarn-add-alias
226-
if m := fragmentRegexp.FindStringSubmatch(constraint); len(m) == 5 {
227-
pkg.Name = m[2] // original name
228-
constraint = m[4]
229-
}
230-
231-
// npm has own comparer to compare versions
232-
if match, err := a.comparer.MatchVersion(pkg.Version, constraint); err != nil {
233-
return nil, xerrors.Errorf("unable to match version for %s", pkg.Name)
234-
} else if !match {
235-
continue
236-
}
237-
}
238-
239-
// Mark as a direct dependency
240-
pkg.Indirect = false
241-
pkg.Relationship = types.RelationshipDirect
242-
pkg.Dev = dev
243-
directPkgs[pkg.ID] = pkg
244-
245-
}
246-
247-
// Walk indirect dependencies
248-
for _, pkg := range directPkgs {
249-
a.walkIndirectDependencies(pkg, pkgIDs, directPkgs)
250-
}
251-
252-
return directPkgs, nil
253-
}
254-
255-
func verFromID(id string) string {
256-
_, ver, _ := strings.Cut(id, "@")
257-
return ver
258-
}
259-
260-
func (a yarnAnalyzer) walkIndirectDependencies(pkg types.Package, pkgIDs, deps map[string]types.Package) {
261-
for _, pkgID := range pkg.DependsOn {
262-
if _, ok := deps[pkgID]; ok {
263-
continue
264-
}
265-
266-
dep, ok := pkgIDs[pkgID]
267-
if !ok {
268-
continue
269-
}
270-
271-
dep.Indirect = true
272-
dep.Relationship = types.RelationshipIndirect
273-
dep.Dev = pkg.Dev
274-
deps[dep.ID] = dep
275-
a.walkIndirectDependencies(dep, pkgIDs, deps)
166+
func (a yarnAnalyzer) parseYarnLock(filePath string, r io.Reader, directDeps, directDevDeps map[string]string) (*types.Application, error) {
167+
pkgJson := pkgJsonDeps{
168+
directDeps: directDeps,
169+
directDevDeps: directDevDeps,
276170
}
171+
return language.Parse(types.Yarn, filePath, r, pkgJson)
277172
}
278173

279174
func (a yarnAnalyzer) parsePackageJsonDependencies(fsys fs.FS, filePath string) (map[string]string, map[string]string, error) {

0 commit comments

Comments
 (0)