Skip to content

Commit 1e08648

Browse files
authored
feat(nodejs): add v9 pnpm lock file support (#6617)
1 parent 9515695 commit 1e08648

File tree

5 files changed

+698
-72
lines changed

5 files changed

+698
-72
lines changed

docs/docs/coverage/language/nodejs.md

+8-8
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ The following scanners are supported.
1313

1414
The following table provides an outline of the features Trivy offers.
1515

16-
| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position |
17-
|:---------------:|-------------------|:-----------------------:|:-----------------:|:------------------------------------:|:--------:|
18-
| npm | package-lock.json || [Excluded](#npm) |||
19-
| Yarn | yarn.lock || [Excluded](#yarn) |||
20-
| pnpm | pnpm-lock.yaml || Excluded || - |
21-
| Bun | yarn.lock || [Excluded](#yarn) |||
16+
| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position |
17+
|:---------------:|-------------------|:-----------------------:|:---------------------------------:|:------------------------------------:|:--------:|
18+
| npm | package-lock.json || [Excluded](#npm) |||
19+
| Yarn | yarn.lock || [Excluded](#yarn) |||
20+
| pnpm | pnpm-lock.yaml || [Excluded](#lock-file-v9-version) || - |
21+
| Bun | yarn.lock || [Excluded](#yarn) |||
2222

2323
In addition, Trivy scans installed packages with `package.json`.
2424

@@ -55,8 +55,8 @@ By default, Trivy doesn't report development dependencies. Use the `--include-de
5555
### pnpm
5656
Trivy parses `pnpm-lock.yaml`, then finds production dependencies and builds a [tree][dependency-graph] of dependencies with vulnerabilities.
5757

58-
!!! note
59-
Trivy currently only supports Lockfile [v6][pnpm-lockfile-v6] or earlier.
58+
#### lock file v9 version
59+
Trivy supports `Dev` field for `pnpm-lock.yaml` v9 or later. Use the `--include-dev-deps` flag to include the developer's dependencies in the result.
6060

6161
### Bun
6262
Trivy supports scanning `yarn.lock` files generated by [Bun](https://bun.sh/docs/install/lockfile#how-do-i-inspect-bun-s-lockfile). You can use the command `bun install -y` to generate a Yarn-compatible `yarn.lock`.

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

+236-42
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package pnpm
22

33
import (
44
"fmt"
5+
"sort"
56
"strconv"
67
"strings"
78

89
"github.com/samber/lo"
10+
"golang.org/x/exp/maps"
911
"golang.org/x/xerrors"
1012
"gopkg.in/yaml.v3"
1113

@@ -34,6 +36,28 @@ type LockFile struct {
3436
Dependencies map[string]any `yaml:"dependencies,omitempty"`
3537
DevDependencies map[string]any `yaml:"devDependencies,omitempty"`
3638
Packages map[string]PackageInfo `yaml:"packages,omitempty"`
39+
40+
// V9
41+
Importers Importer `yaml:"importers,omitempty"`
42+
Snapshots map[string]Snapshot `yaml:"snapshots,omitempty"`
43+
}
44+
45+
type Importer struct {
46+
Root RootImporter `yaml:".,omitempty"`
47+
}
48+
49+
type RootImporter struct {
50+
Dependencies map[string]ImporterDepVersion `yaml:"dependencies,omitempty"`
51+
DevDependencies map[string]ImporterDepVersion `yaml:"devDependencies,omitempty"`
52+
}
53+
54+
type ImporterDepVersion struct {
55+
Version string `yaml:"version,omitempty"`
56+
}
57+
58+
type Snapshot struct {
59+
Dependencies map[string]string `yaml:"dependencies,omitempty"`
60+
OptionalDependencies map[string]string `yaml:"optionalDependencies,omitempty"`
3761
}
3862

3963
type Parser struct {
@@ -57,8 +81,16 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
5781
return nil, nil, nil
5882
}
5983

60-
pkgs, deps := p.parse(lockVer, lockFile)
84+
var pkgs []ftypes.Package
85+
var deps []ftypes.Dependency
86+
if lockVer >= 9 {
87+
pkgs, deps = p.parseV9(lockFile)
88+
} else {
89+
pkgs, deps = p.parse(lockVer, lockFile)
90+
}
6191

92+
sort.Sort(ftypes.Packages(pkgs))
93+
sort.Sort(ftypes.Dependencies(deps))
6294
return pkgs, deps, nil
6395
}
6496

@@ -78,9 +110,11 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
78110
// cf. https://github.com/pnpm/spec/blob/274ff02de23376ad59773a9f25ecfedd03a41f64/lockfile/6.0.md#packagesdependencypathname
79111
name := info.Name
80112
version := info.Version
113+
var ref string
81114

82115
if name == "" {
83-
name, version = p.parsePackage(depPath, lockVer)
116+
name, version, ref = p.parseDepPath(depPath, lockVer)
117+
version = p.parseVersion(depPath, version, lockVer)
84118
}
85119
pkgID := packageID(name, version)
86120

@@ -90,13 +124,15 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
90124
}
91125

92126
pkgs = append(pkgs, ftypes.Package{
93-
ID: pkgID,
94-
Name: name,
95-
Version: version,
96-
Relationship: lo.Ternary(isDirectPkg(name, lockFile.Dependencies), ftypes.RelationshipDirect, ftypes.RelationshipIndirect),
127+
ID: pkgID,
128+
Name: name,
129+
Version: version,
130+
Relationship: lo.Ternary(isDirectPkg(name, lockFile.Dependencies), ftypes.RelationshipDirect, ftypes.RelationshipIndirect),
131+
ExternalReferences: toExternalRefs(ref),
97132
})
98133

99134
if len(dependencies) > 0 {
135+
sort.Strings(dependencies)
100136
deps = append(deps, ftypes.Dependency{
101137
ID: pkgID,
102138
DependsOn: dependencies,
@@ -107,6 +143,98 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
107143
return pkgs, deps
108144
}
109145

146+
func (p *Parser) parseV9(lockFile LockFile) ([]ftypes.Package, []ftypes.Dependency) {
147+
lockVer := 9.0
148+
resolvedPkgs := make(map[string]ftypes.Package)
149+
resolvedDeps := make(map[string]ftypes.Dependency)
150+
151+
// Check all snapshots and save with resolved versions
152+
resolvedSnapshots := make(map[string][]string)
153+
for depPath, snapshot := range lockFile.Snapshots {
154+
name, version, _ := p.parseDepPath(depPath, lockVer)
155+
156+
var dependsOn []string
157+
for depName, depVer := range lo.Assign(snapshot.OptionalDependencies, snapshot.Dependencies) {
158+
depVer = p.trimPeerDeps(depVer, lockVer) // pnpm has already separated dep name. therefore, we only need to separate peer deps.
159+
depVer = p.parseVersion(depPath, depVer, lockVer)
160+
id := packageID(depName, depVer)
161+
if _, ok := lockFile.Packages[id]; ok {
162+
dependsOn = append(dependsOn, id)
163+
}
164+
}
165+
if len(dependsOn) > 0 {
166+
resolvedSnapshots[packageID(name, version)] = dependsOn
167+
}
168+
169+
}
170+
171+
for depPath, pkgInfo := range lockFile.Packages {
172+
name, ver, ref := p.parseDepPath(depPath, lockVer)
173+
parsedVer := p.parseVersion(depPath, ver, lockVer)
174+
175+
if pkgInfo.Version != "" {
176+
parsedVer = pkgInfo.Version
177+
}
178+
179+
// By default, pkg is dev pkg.
180+
// We will update `Dev` field later.
181+
dev := true
182+
relationship := ftypes.RelationshipIndirect
183+
if dep, ok := lockFile.Importers.Root.DevDependencies[name]; ok && dep.Version == ver {
184+
relationship = ftypes.RelationshipDirect
185+
}
186+
if dep, ok := lockFile.Importers.Root.Dependencies[name]; ok && dep.Version == ver {
187+
relationship = ftypes.RelationshipDirect
188+
dev = false // mark root direct deps to update `dev` field of their child deps.
189+
}
190+
191+
id := packageID(name, parsedVer)
192+
resolvedPkgs[id] = ftypes.Package{
193+
ID: id,
194+
Name: name,
195+
Version: parsedVer,
196+
Relationship: relationship,
197+
Dev: dev,
198+
ExternalReferences: toExternalRefs(ref),
199+
}
200+
201+
// Save child deps
202+
if dependsOn, ok := resolvedSnapshots[depPath]; ok {
203+
sort.Strings(dependsOn)
204+
resolvedDeps[id] = ftypes.Dependency{
205+
ID: id,
206+
DependsOn: dependsOn, // Deps from dependsOn has been resolved when parsing snapshots
207+
}
208+
}
209+
}
210+
211+
// Overwrite the `Dev` field for dev deps and their child dependencies.
212+
for _, pkg := range resolvedPkgs {
213+
if !pkg.Dev {
214+
p.markRootPkgs(pkg.ID, resolvedPkgs, resolvedDeps)
215+
}
216+
}
217+
218+
return maps.Values(resolvedPkgs), maps.Values(resolvedDeps)
219+
}
220+
221+
// markRootPkgs sets `Dev` to false for non dev dependency.
222+
func (p *Parser) markRootPkgs(id string, pkgs map[string]ftypes.Package, deps map[string]ftypes.Dependency) {
223+
pkg, ok := pkgs[id]
224+
if !ok {
225+
return
226+
}
227+
228+
pkg.Dev = false
229+
pkgs[id] = pkg
230+
231+
// Update child deps
232+
for _, depID := range deps[id].DependsOn {
233+
p.markRootPkgs(depID, pkgs, deps)
234+
}
235+
return
236+
}
237+
110238
func (p *Parser) parseLockfileVersion(lockFile LockFile) float64 {
111239
switch v := lockFile.LockfileVersion.(type) {
112240
// v5
@@ -127,55 +255,109 @@ func (p *Parser) parseLockfileVersion(lockFile LockFile) float64 {
127255
}
128256
}
129257

130-
// cf. https://github.com/pnpm/pnpm/blob/ce61f8d3c29eee46cee38d56ced45aea8a439a53/packages/dependency-path/src/index.ts#L112-L163
131-
func (p *Parser) parsePackage(depPath string, lockFileVersion float64) (string, string) {
132-
// The version separator is different between v5 and v6+.
133-
versionSep := "@"
134-
if lockFileVersion < 6 {
135-
versionSep = "/"
258+
func (p *Parser) parseDepPath(depPath string, lockVer float64) (string, string, string) {
259+
dPath, nonDefaultRegistry := p.trimRegistry(depPath, lockVer)
260+
261+
var scope string
262+
scope, dPath = p.separateScope(dPath)
263+
264+
var name string
265+
name, dPath = p.separateName(dPath, lockVer)
266+
267+
// add scope to pkg name
268+
if scope != "" {
269+
name = fmt.Sprintf("%s/%s", scope, name)
136270
}
137-
return p.parseDepPath(depPath, versionSep)
271+
272+
ver := p.trimPeerDeps(dPath, lockVer)
273+
274+
return name, ver, lo.Ternary(nonDefaultRegistry, depPath, "")
138275
}
139276

140-
func (p *Parser) parseDepPath(depPath, versionSep string) (string, string) {
141-
// Skip registry
142-
// e.g.
143-
// - "registry.npmjs.org/lodash/4.17.10" => "lodash/4.17.10"
144-
// - "registry.npmjs.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9"
145-
// - "/lodash/4.17.10" => "lodash/4.17.10"
146-
_, depPath, _ = strings.Cut(depPath, "/")
277+
// trimRegistry trims registry (or `/` prefix) for depPath.
278+
// It returns true if non-default registry has been trimmed.
279+
// e.g.
280+
// - "registry.npmjs.org/lodash/4.17.10" => "lodash/4.17.10", false
281+
// - "registry.npmjs.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9", false
282+
// - "private.npm.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9", true
283+
// - "/lodash/4.17.10" => "lodash/4.17.10", false
284+
285+
func (p *Parser) trimRegistry(depPath string, lockVer float64) (string, bool) {
286+
var nonDefaultRegistry bool
287+
// lock file v9 doesn't use registry prefix
288+
if lockVer < 9 {
289+
var registry string
290+
registry, depPath, _ = strings.Cut(depPath, "/")
291+
if registry != "" && registry != "registry.npmjs.org" {
292+
nonDefaultRegistry = true
293+
}
294+
}
295+
return depPath, nonDefaultRegistry
296+
}
147297

148-
// Parse scope
149-
// e.g.
150-
// - v5: "@babel/generator/7.21.9" => {"babel", "generator/7.21.9"}
151-
// - v6+: "@babel/[email protected]" => "{"babel", "[email protected]"}
298+
// separateScope separates the scope (if set) from the rest of the depPath.
299+
// e.g.
300+
// - v5: "@babel/generator/7.21.9" => {"babel", "generator/7.21.9"}
301+
// - v6+: "@babel/[email protected]" => "{"babel", "[email protected]"}
302+
func (p *Parser) separateScope(depPath string) (string, string) {
152303
var scope string
153304
if strings.HasPrefix(depPath, "@") {
154305
scope, depPath, _ = strings.Cut(depPath, "/")
155306
}
307+
return scope, depPath
308+
}
156309

157-
// Parse package name
158-
// e.g.
159-
// - v5: "generator/7.21.9" => {"generator", "7.21.9"}
160-
// - v6+: "[email protected]" => {"helper-annotate-as-pure", "7.18.6"}
161-
var name, version string
162-
name, version, _ = strings.Cut(depPath, versionSep)
163-
if scope != "" {
164-
name = fmt.Sprintf("%s/%s", scope, name)
310+
// separateName separates pkg name and version.
311+
// e.g.
312+
// - v5: "generator/7.21.9" => {"generator", "7.21.9"}
313+
// - v6+: "7.21.5(@babel/[email protected])" => "7.21.5"
314+
//
315+
// for v9+ version can be filePath or link:
316+
// - "package1@file:package1:"
317+
// - "is-negative@https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5"
318+
//
319+
// Also version can contain peer deps:
320+
321+
func (p *Parser) separateName(depPath string, lockVer float64) (string, string) {
322+
sep := "@"
323+
if lockVer < 6 {
324+
sep = "/"
325+
}
326+
name, version, _ := strings.Cut(depPath, sep)
327+
return name, version
328+
}
329+
330+
// Trim peer deps
331+
// e.g.
332+
// - v5: "7.21.5_@[email protected]" => "7.21.5"
333+
// - v6+: "7.21.5(@babel/[email protected])" => "7.21.5"
334+
func (p *Parser) trimPeerDeps(depPath string, lockVer float64) string {
335+
sep := "("
336+
if lockVer < 6 {
337+
sep = "_"
165338
}
166-
// Trim peer deps
167-
// e.g.
168-
// - v5: "7.21.5_@[email protected]" => "7.21.5"
169-
// - v6+: "7.21.5(@babel/[email protected])" => "7.21.5"
170-
if idx := strings.IndexAny(version, "_("); idx != -1 {
171-
version = version[:idx]
339+
version, _, _ := strings.Cut(depPath, sep)
340+
return version
341+
}
342+
343+
// parseVersion parses version.
344+
// v9 can use filePath or link as version - we need to clear these versions.
345+
// e.g.
346+
// - "package1@file:package1:"
347+
// - "is-negative@https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5"
348+
//
349+
// Other versions should be semver valid.
350+
func (p *Parser) parseVersion(depPath, ver string, lockVer float64) string {
351+
if lockVer < 9 && (strings.HasPrefix(ver, "file:") || strings.HasPrefix(ver, "http")) {
352+
return ""
172353
}
173-
if _, err := semver.Parse(version); err != nil {
354+
if _, err := semver.Parse(ver); err != nil {
174355
p.logger.Debug("Skip non-semver package", log.String("pkg_path", depPath),
175-
log.String("version", version), log.Err(err))
176-
return "", ""
356+
log.String("version", ver), log.Err(err))
357+
return ""
177358
}
178-
return name, version
359+
360+
return ver
179361
}
180362

181363
func isDirectPkg(name string, directDeps map[string]interface{}) bool {
@@ -186,3 +368,15 @@ func isDirectPkg(name string, directDeps map[string]interface{}) bool {
186368
func packageID(name, version string) string {
187369
return dependency.ID(ftypes.Pnpm, name, version)
188370
}
371+
372+
func toExternalRefs(ref string) []ftypes.ExternalRef {
373+
if ref == "" {
374+
return nil
375+
}
376+
return []ftypes.ExternalRef{
377+
{
378+
Type: ftypes.RefVCS,
379+
URL: ref,
380+
},
381+
}
382+
}

0 commit comments

Comments
 (0)