Skip to content

Commit 54bb8bd

Browse files
authored
fix(nodejs): detect direct dependencies when using latest version for files yarn.lock + package.json (#7110)
1 parent 4e54a7e commit 54bb8bd

File tree

6 files changed

+132
-44
lines changed

6 files changed

+132
-44
lines changed

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

+19-8
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"bytes"
66
"io"
77
"regexp"
8+
"sort"
89
"strings"
910

1011
"github.com/samber/lo"
@@ -127,7 +128,7 @@ func ignoreProtocol(protocol string) bool {
127128
return false
128129
}
129130

130-
func parseResults(patternIDs map[string]string, dependsOn map[string][]string) (deps []ftypes.Dependency) {
131+
func parseResults(patternIDs map[string]string, dependsOn map[string][]string) (deps ftypes.Dependencies) {
131132
// find dependencies by patterns
132133
for pkgID, depPatterns := range dependsOn {
133134
depIDs := lo.Map(depPatterns, func(pattern string, index int) string {
@@ -269,14 +270,20 @@ func parseDependency(line string) (string, error) {
269270
}
270271
}
271272

272-
func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, error) {
273+
func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, map[string][]string, error) {
273274
lineNumber := 1
274-
var pkgs []ftypes.Package
275+
var pkgs ftypes.Packages
275276

276-
// patternIDs holds mapping between patterns and library IDs
277+
// patternIDs holds mapping between patterns and package IDs
277278
// e.g. ajv@^6.5.5 => [email protected]
279+
// This is needed to update dependencies from `DependsOn`.
278280
patternIDs := make(map[string]string)
279281

282+
// patternIDs holds mapping between package ID and patterns
283+
// e.g. `@babel/[email protected]` => [`@babel/helper-regex@^7.0.0`, `@babel/helper-regex@^7.4.4`]
284+
// This is needed to compare package patterns with patterns from package.json files in `fanal` package.
285+
pkgIDPatterns := make(map[string][]string)
286+
280287
scanner := bufio.NewScanner(r)
281288
scanner.Split(p.scanBlocks)
282289
dependsOn := make(map[string][]string)
@@ -285,7 +292,7 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
285292
lib, deps, newLine, err := p.parseBlock(block, lineNumber)
286293
lineNumber = newLine + 2
287294
if err != nil {
288-
return nil, nil, err
295+
return nil, nil, nil, err
289296
} else if lib.Name == "" {
290297
continue
291298
}
@@ -298,6 +305,7 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
298305
Locations: []ftypes.Location{lib.Location},
299306
})
300307

308+
pkgIDPatterns[pkgID] = lib.Patterns
301309
for _, pattern := range lib.Patterns {
302310
// e.g.
303311
// combined-stream@^1.0.6 => [email protected]
@@ -310,13 +318,16 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
310318
}
311319

312320
if err := scanner.Err(); err != nil {
313-
return nil, nil, xerrors.Errorf("failed to scan yarn.lock, got scanner error: %s", err.Error())
321+
return nil, nil, nil, xerrors.Errorf("failed to scan yarn.lock, got scanner error: %s", err.Error())
314322
}
315323

316-
// Replace dependency patterns with library IDs
324+
// Replace dependency patterns with package IDs
317325
// e.g. ajv@^6.5.5 => [email protected]
318326
deps := parseResults(patternIDs, dependsOn)
319-
return pkgs, deps, nil
327+
328+
sort.Sort(pkgs)
329+
sort.Sort(deps)
330+
return pkgs, deps, pkgIDPatterns, nil
320331
}
321332

322333
func packageID(name, version string) string {

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

+1-19
Original file line numberDiff line numberDiff line change
@@ -301,32 +301,14 @@ func TestParse(t *testing.T) {
301301
f, err := os.Open(tt.file)
302302
require.NoError(t, err)
303303

304-
got, deps, err := NewParser().Parse(f)
304+
got, deps, _, err := NewParser().Parse(f)
305305
require.NoError(t, err)
306306

307-
sortPkgs(got)
308-
sortPkgs(tt.want)
309307
assert.Equal(t, tt.want, got)
310308

311309
if tt.wantDeps != nil {
312-
sortDeps(deps)
313-
sortDeps(tt.wantDeps)
314310
assert.Equal(t, tt.wantDeps, deps)
315311
}
316312
})
317313
}
318314
}
319-
320-
func sortPkgs(pkgs ftypes.Packages) {
321-
sort.Sort(pkgs)
322-
for _, pkg := range pkgs {
323-
sort.Sort(pkg.Locations)
324-
}
325-
}
326-
327-
func sortDeps(deps ftypes.Dependencies) {
328-
sort.Sort(deps)
329-
for _, dep := range deps {
330-
sort.Strings(dep.DependsOn)
331-
}
332-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"dependencies": {
3+
"debug": "latest"
4+
},
5+
"devDependencies" : {
6+
"js-tokens": "^9.0.0"
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2+
# yarn lockfile v1
3+
4+
5+
debug@latest:
6+
version "4.3.5"
7+
resolved "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz"
8+
integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==
9+
dependencies:
10+
ms "2.1.2"
11+
12+
js-tokens@^9.0.0:
13+
version "9.0.0"
14+
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz"
15+
integrity sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==
16+
17+
18+
version "2.1.2"
19+
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
20+
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==

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

+29-17
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import (
1010
"path"
1111
"path/filepath"
1212
"regexp"
13+
"slices"
1314
"sort"
1415
"strings"
1516

1617
"github.com/hashicorp/go-multierror"
1718
"github.com/samber/lo"
1819
"golang.org/x/xerrors"
1920

21+
"github.com/aquasecurity/trivy/pkg/dependency"
2022
"github.com/aquasecurity/trivy/pkg/dependency/parser/nodejs/packagejson"
2123
"github.com/aquasecurity/trivy/pkg/dependency/parser/nodejs/yarn"
2224
"github.com/aquasecurity/trivy/pkg/detector/library/compare/npm"
@@ -42,7 +44,6 @@ var fragmentRegexp = regexp.MustCompile(`(\S+):(@?.*?)(@(.*?)|)$`)
4244
type yarnAnalyzer struct {
4345
logger *log.Logger
4446
packageJsonParser *packagejson.Parser
45-
lockParser language.Parser
4647
comparer npm.Comparer
4748
license *license.License
4849
}
@@ -51,12 +52,21 @@ func newYarnAnalyzer(opt analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error
5152
return &yarnAnalyzer{
5253
logger: log.WithPrefix("yarn"),
5354
packageJsonParser: packagejson.NewParser(),
54-
lockParser: yarn.NewParser(),
5555
comparer: npm.Comparer{},
5656
license: license.NewLicense(opt.LicenseScannerOption.ClassifierConfidenceLevel),
5757
}, nil
5858
}
5959

60+
type parserWithPatterns struct {
61+
patterns map[string][]string
62+
}
63+
64+
func (p *parserWithPatterns) Parse(r xio.ReadSeekerAt) ([]types.Package, []types.Dependency, error) {
65+
pkgs, deps, patterns, err := yarn.NewParser().Parse(r)
66+
p.patterns = patterns
67+
return pkgs, deps, err
68+
}
69+
6070
func (a yarnAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysisInput) (*analyzer.AnalysisResult, error) {
6171
var apps []types.Application
6272

@@ -65,8 +75,9 @@ func (a yarnAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysis
6575
}
6676

6777
err := fsutils.WalkDir(input.FS, ".", required, func(filePath string, d fs.DirEntry, r io.Reader) error {
78+
parser := &parserWithPatterns{}
6879
// Parse yarn.lock
69-
app, err := a.parseYarnLock(filePath, r)
80+
app, err := language.Parse(types.Yarn, filePath, r, parser)
7081
if err != nil {
7182
return xerrors.Errorf("parse error: %w", err)
7283
} else if app == nil {
@@ -79,7 +90,7 @@ func (a yarnAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysis
7990
}
8091

8192
// Parse package.json alongside yarn.lock to find direct deps and mark dev deps
82-
if err = a.analyzeDependencies(input.FS, path.Dir(filePath), app); err != nil {
93+
if err = a.analyzeDependencies(input.FS, path.Dir(filePath), app, parser.patterns); err != nil {
8394
a.logger.Warn("Unable to parse package.json to remove dev dependencies",
8495
log.FilePath(path.Join(path.Dir(filePath), types.NpmPkg)), log.Err(err))
8596
}
@@ -147,13 +158,9 @@ func (a yarnAnalyzer) Version() int {
147158
return version
148159
}
149160

150-
func (a yarnAnalyzer) parseYarnLock(filePath string, r io.Reader) (*types.Application, error) {
151-
return language.Parse(types.Yarn, filePath, r, a.lockParser)
152-
}
153-
154161
// analyzeDependencies analyzes the package.json file next to yarn.lock,
155162
// distinguishing between direct and transitive dependencies as well as production and development dependencies.
156-
func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.Application) error {
163+
func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.Application, patterns map[string][]string) error {
157164
packageJsonPath := path.Join(dir, types.NpmPkg)
158165
directDeps, directDevDeps, err := a.parsePackageJsonDependencies(fsys, packageJsonPath)
159166
if errors.Is(err, fs.ErrNotExist) {
@@ -170,13 +177,13 @@ func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.App
170177
})
171178

172179
// Walk prod dependencies
173-
pkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDeps, false)
180+
pkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDeps, patterns, false)
174181
if err != nil {
175182
return xerrors.Errorf("unable to walk dependencies: %w", err)
176183
}
177184

178185
// Walk dev dependencies
179-
devPkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDevDeps, true)
186+
devPkgs, err := a.walkDependencies(app.Packages, pkgIDs, directDevDeps, patterns, true)
180187
if err != nil {
181188
return xerrors.Errorf("unable to walk dependencies: %w", err)
182189
}
@@ -194,7 +201,7 @@ func (a yarnAnalyzer) analyzeDependencies(fsys fs.FS, dir string, app *types.App
194201
}
195202

196203
func (a yarnAnalyzer) walkDependencies(pkgs []types.Package, pkgIDs map[string]types.Package,
197-
directDeps map[string]string, dev bool) (map[string]types.Package, error) {
204+
directDeps map[string]string, patterns map[string][]string, dev bool) (map[string]types.Package, error) {
198205

199206
// Identify direct dependencies
200207
directPkgs := make(map[string]types.Package)
@@ -211,11 +218,16 @@ func (a yarnAnalyzer) walkDependencies(pkgs []types.Package, pkgIDs map[string]t
211218
constraint = m[4]
212219
}
213220

214-
// npm has own comparer to compare versions
215-
if match, err := a.comparer.MatchVersion(pkg.Version, constraint); err != nil {
216-
return nil, xerrors.Errorf("unable to match version for %s", pkg.Name)
217-
} else if !match {
218-
continue
221+
// Try to find an exact match to the pattern.
222+
// In some cases, patterns from yarn.lock and package.json may not match (e.g., yarn v2 uses the allowed version for ID).
223+
// Therefore, if the patterns don't match - compare versions.
224+
if pkgPatterns, found := patterns[pkg.ID]; !found || !slices.Contains(pkgPatterns, dependency.ID(types.Yarn, pkg.Name, constraint)) {
225+
// npm has own comparer to compare versions
226+
if match, err := a.comparer.MatchVersion(pkg.Version, constraint); err != nil {
227+
return nil, xerrors.Errorf("unable to match version for %s", pkg.Name)
228+
} else if !match {
229+
continue
230+
}
219231
}
220232

221233
// Mark as a direct dependency

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

+55
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,61 @@ func Test_yarnLibraryAnalyzer_Analyze(t *testing.T) {
354354
},
355355
},
356356
},
357+
{
358+
name: "package uses `latest` version",
359+
dir: "testdata/latest-version",
360+
want: &analyzer.AnalysisResult{
361+
Applications: []types.Application{
362+
{
363+
Type: types.Yarn,
364+
FilePath: "yarn.lock",
365+
Packages: types.Packages{
366+
{
367+
368+
Name: "debug",
369+
Version: "4.3.5",
370+
Relationship: types.RelationshipDirect,
371+
Locations: []types.Location{
372+
{
373+
StartLine: 5,
374+
EndLine: 10,
375+
},
376+
},
377+
DependsOn: []string{
378+
379+
},
380+
},
381+
{
382+
383+
Name: "js-tokens",
384+
Version: "9.0.0",
385+
Relationship: types.RelationshipDirect,
386+
Dev: true,
387+
Locations: []types.Location{
388+
{
389+
StartLine: 12,
390+
EndLine: 15,
391+
},
392+
},
393+
},
394+
{
395+
396+
Name: "ms",
397+
Version: "2.1.2",
398+
Indirect: true,
399+
Relationship: types.RelationshipIndirect,
400+
Locations: []types.Location{
401+
{
402+
StartLine: 17,
403+
EndLine: 20,
404+
},
405+
},
406+
},
407+
},
408+
},
409+
},
410+
},
411+
},
357412
{
358413
name: "happy path with alias rewrite",
359414
dir: "testdata/alias",

0 commit comments

Comments
 (0)