Skip to content

Commit 4517e8c

Browse files
amitverseDmitriyLewenknqyf263
authored
fix(nodejs): use snapshot string as Package.ID for pnpm packages (#9330)
Co-authored-by: DmitriyLewen <[email protected]> Co-authored-by: knqyf263 <[email protected]>
1 parent a70d8e7 commit 4517e8c

File tree

7 files changed

+373
-136
lines changed

7 files changed

+373
-136
lines changed

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

Lines changed: 105 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -18,44 +18,6 @@ import (
1818
xio "github.com/aquasecurity/trivy/pkg/x/io"
1919
)
2020

21-
type PackageResolution struct {
22-
Tarball string `yaml:"tarball,omitempty"`
23-
}
24-
25-
type PackageInfo struct {
26-
Resolution PackageResolution `yaml:"resolution"`
27-
Dependencies map[string]string `yaml:"dependencies,omitempty"`
28-
DevDependencies map[string]string `yaml:"devDependencies,omitempty"`
29-
IsDev bool `yaml:"dev,omitempty"`
30-
Name string `yaml:"name,omitempty"`
31-
Version string `yaml:"version,omitempty"`
32-
}
33-
34-
type LockFile struct {
35-
LockfileVersion any `yaml:"lockfileVersion"`
36-
Dependencies map[string]any `yaml:"dependencies,omitempty"`
37-
DevDependencies map[string]any `yaml:"devDependencies,omitempty"`
38-
Packages map[string]PackageInfo `yaml:"packages,omitempty"`
39-
40-
// V9
41-
Importers map[string]Importer `yaml:"importers,omitempty"`
42-
Snapshots map[string]Snapshot `yaml:"snapshots,omitempty"`
43-
}
44-
45-
type Importer struct {
46-
Dependencies map[string]ImporterDepVersion `yaml:"dependencies,omitempty"`
47-
DevDependencies map[string]ImporterDepVersion `yaml:"devDependencies,omitempty"`
48-
}
49-
50-
type ImporterDepVersion struct {
51-
Version string `yaml:"version,omitempty"`
52-
}
53-
54-
type Snapshot struct {
55-
Dependencies map[string]string `yaml:"dependencies,omitempty"`
56-
OptionalDependencies map[string]string `yaml:"optionalDependencies,omitempty"`
57-
}
58-
5921
type Parser struct {
6022
logger *log.Logger
6123
}
@@ -96,7 +58,7 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
9658

9759
// Dependency path is a path to a dependency with a specific set of resolved subdependencies.
9860
// cf. https://github.com/pnpm/spec/blob/ad27a225f81d9215becadfa540ef05fa4ad6dd60/dependency-path.md
99-
for depPath, info := range lockFile.Packages {
61+
for pkgKey, info := range lockFile.Packages {
10062
if info.IsDev {
10163
continue
10264
}
@@ -109,9 +71,10 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
10971
var ref string
11072

11173
if name == "" {
112-
name, version, ref = p.parseDepPath(depPath, lockVer)
113-
version = p.parseVersion(depPath, version, lockVer)
74+
name, version, ref = p.parsePnpmKey(string(pkgKey), lockVer)
75+
version = p.parseVersion(string(pkgKey), version, lockVer)
11476
}
77+
// Create Trivy's internal package ID
11578
pkgID := packageID(name, version)
11679

11780
dependencies := make([]string, 0, len(info.Dependencies))
@@ -139,34 +102,19 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
139102
return pkgs, deps
140103
}
141104

105+
// parseV9 parses pnpm-lock.yaml version 9.x format and returns packages and their dependencies.
106+
// Version 9 introduced "snapshots" where each snapshot represents a package with its exact resolved dependencies.
142107
func (p *Parser) parseV9(lockFile LockFile) ([]ftypes.Package, []ftypes.Dependency) {
143108
lockVer := 9.0
144-
resolvedPkgs := make(map[string]ftypes.Package)
145-
resolvedDeps := make(map[string]ftypes.Dependency)
146-
147-
// Check all snapshots and save with resolved versions
148-
resolvedSnapshots := make(map[string][]string)
149-
for depPath, snapshot := range lockFile.Snapshots {
150-
name, version, _ := p.parseDepPath(depPath, lockVer)
151-
152-
var dependsOn []string
153-
for depName, depVer := range lo.Assign(snapshot.OptionalDependencies, snapshot.Dependencies) {
154-
depVer = p.trimPeerDeps(depVer, lockVer) // pnpm has already separated dep name. therefore, we only need to separate peer deps.
155-
depVer = p.parseVersion(depPath, depVer, lockVer)
156-
id := packageID(depName, depVer)
157-
if _, ok := lockFile.Packages[id]; ok {
158-
dependsOn = append(dependsOn, id)
159-
}
160-
}
161-
if len(dependsOn) > 0 {
162-
resolvedSnapshots[packageID(name, version)] = dependsOn
163-
}
164-
165-
}
166-
167-
// Parse `Importers` to find all direct dependencies
168-
devDeps := make(map[string]string)
169-
deps := make(map[string]string)
109+
resolvedPkgs := make(map[SnapshotKey]ftypes.Package)
110+
resolvedDeps := make(map[SnapshotKey]ftypes.Dependency)
111+
112+
// Step 1: Extract direct dependencies from the "importers" section.
113+
// The "importers" section contains the dependencies defined in package.json files.
114+
// We need to identify which packages are direct dependencies (vs transitive)
115+
// and which are development dependencies (vs production dependencies).
116+
devDeps := make(map[string]string) // name -> version for dev dependencies
117+
deps := make(map[string]string) // name -> version for production dependencies
170118
for _, importer := range lockFile.Importers {
171119
for n, v := range importer.DevDependencies {
172120
devDeps[n] = v.Version
@@ -176,74 +124,109 @@ func (p *Parser) parseV9(lockFile LockFile) ([]ftypes.Package, []ftypes.Dependen
176124
}
177125
}
178126

179-
for depPath, pkgInfo := range lockFile.Packages {
180-
name, ver, ref := p.parseDepPath(depPath, lockVer)
181-
parsedVer := p.parseVersion(depPath, ver, lockVer)
182-
183-
if pkgInfo.Version != "" {
127+
// Step 2: Process each snapshot to create package entries.
128+
// Each snapshot represents a unique package installation with specific peer dependencies.
129+
// The snapshotKey is the key that uniquely identifies this package instance,
130+
// including any peer dependency information (e.g., "[email protected]([email protected])").
131+
for snapshotKey, snapshot := range lockFile.Snapshots {
132+
name, version, ref := p.parsePnpmKey(string(snapshotKey), lockVer)
133+
// Clean and validate the version string (remove file: or http: prefixes if invalid)
134+
parsedVer := p.parseVersion(string(snapshotKey), version, lockVer)
135+
136+
// Try to get the exact version from the "packages" section if available.
137+
// The "packages" section may contain more accurate version information
138+
// for packages installed from non-standard sources (git, local files, etc.).
139+
pkgKey := PackageKey(packageID(name, version))
140+
if pkgInfo, ok := lockFile.Packages[pkgKey]; ok && pkgInfo.Version != "" {
184141
parsedVer = pkgInfo.Version
185142
}
186143

187-
// By default, pkg is dev pkg.
188-
// We will update `Dev` field later.
144+
// Step 3: Determine if this package is a direct or transitive dependency,
145+
// and whether it's a development or production dependency.
146+
// By default, assume it's a development dependency (will be corrected later if needed).
189147
dev := true
190-
relationship := ftypes.RelationshipIndirect
191-
if v, ok := devDeps[name]; ok && p.trimPeerDeps(v, lockVer) == ver {
148+
relationship := ftypes.RelationshipIndirect // Assume transitive by default
149+
150+
// Check if this package matches a direct dev dependency
151+
if v, ok := devDeps[name]; ok && p.trimPeerDeps(v, lockVer) == version {
192152
relationship = ftypes.RelationshipDirect
193153
}
194-
if v, ok := deps[name]; ok && p.trimPeerDeps(v, lockVer) == ver {
154+
// Check if this package matches a direct production dependency
155+
if v, ok := deps[name]; ok && p.trimPeerDeps(v, lockVer) == version {
195156
relationship = ftypes.RelationshipDirect
196-
dev = false // mark root direct deps to update `dev` field of their child deps.
157+
dev = false // This is a production dependency, not a dev dependency
197158
}
198159

199-
id := packageID(name, parsedVer)
200-
resolvedPkgs[id] = ftypes.Package{
201-
ID: id,
160+
// Create the package entry with all extracted information.
161+
pkg := ftypes.Package{
162+
// ID is the full snapshotKey which uniquely identifies this package instance
163+
// including any peer dependency context.
164+
ID: string(snapshotKey),
202165
Name: name,
203166
Version: parsedVer,
204167
Relationship: relationship,
205168
Dev: dev,
206169
ExternalReferences: toExternalRefs(ref),
207170
}
171+
resolvedPkgs[snapshotKey] = pkg
208172

209-
// Save child deps
210-
if dependsOn, ok := resolvedSnapshots[depPath]; ok {
211-
sort.Strings(dependsOn)
212-
resolvedDeps[id] = ftypes.Dependency{
213-
ID: id,
214-
DependsOn: dependsOn, // Deps from dependsOn has been resolved when parsing snapshots
173+
// Step 4: Build the dependency graph by recording what this package depends on.
174+
var dependsOn []string // List of snapshot keys this package depends on
175+
for depName, depVer := range lo.Assign(snapshot.OptionalDependencies, snapshot.Dependencies) {
176+
normalizedDepVer := p.trimPeerDeps(depVer, lockVer)
177+
// Only include dependencies that are actually installed (exist in "packages" section).
178+
if _, ok := lockFile.Packages[PackageKey(packageID(depName, normalizedDepVer))]; ok {
179+
// Use the original name/version string (with peer deps) to build the snapshot key correctly.
180+
dependsOn = append(dependsOn, packageID(depName, depVer))
181+
}
182+
}
183+
if len(dependsOn) > 0 {
184+
resolvedDeps[snapshotKey] = ftypes.Dependency{
185+
ID: string(snapshotKey),
186+
DependsOn: dependsOn,
215187
}
216188
}
217189
}
218190

219-
visited := set.New[string]()
220-
// Overwrite the `Dev` field for dev deps and their child dependencies.
191+
// Step 5: Propagate the "production" status to all transitive dependencies.
192+
// If a package is a production dependency (Dev=false), all packages it depends on
193+
// should also be marked as production dependencies, even if they were initially
194+
// marked as dev dependencies. This ensures we correctly identify which packages
195+
// are needed for production vs only for development.
196+
visited := set.New[SnapshotKey]()
221197
for _, pkg := range resolvedPkgs {
222-
if !pkg.Dev {
223-
p.markRootPkgs(pkg.ID, resolvedPkgs, resolvedDeps, visited)
198+
if !pkg.Dev { // If this is a production dependency
199+
// Recursively mark this package and all its dependencies as production
200+
p.markRootPkgs(SnapshotKey(pkg.ID), resolvedPkgs, resolvedDeps, visited)
224201
}
225202
}
226203

227204
return lo.Values(resolvedPkgs), lo.Values(resolvedDeps)
228205
}
229206

230-
// markRootPkgs sets `Dev` to false for non dev dependency.
231-
func (p *Parser) markRootPkgs(id string, pkgs map[string]ftypes.Package, deps map[string]ftypes.Dependency, visited set.Set[string]) {
207+
// markRootPkgs recursively marks a package and all its dependencies as production dependencies.
208+
// This is used to propagate the production status from direct production dependencies
209+
// to all their transitive dependencies, ensuring that any package required for production
210+
// is correctly identified, even if it's also listed as a dev dependency elsewhere.
211+
func (p *Parser) markRootPkgs(id SnapshotKey, pkgs map[SnapshotKey]ftypes.Package, deps map[SnapshotKey]ftypes.Dependency, visited set.Set[SnapshotKey]) {
212+
// Avoid infinite recursion in case of circular dependencies
232213
if visited.Contains(id) {
233214
return
234215
}
216+
// Get the package; skip if not found
235217
pkg, ok := pkgs[id]
236218
if !ok {
237219
return
238220
}
239221

222+
// Mark this package as a production dependency
240223
pkg.Dev = false
241224
pkgs[id] = pkg
242-
visited.Append(id)
225+
visited.Append(id) // Track that we've processed this package
243226

244-
// Update child deps
227+
// Recursively process all dependencies of this package
245228
for _, depID := range deps[id].DependsOn {
246-
p.markRootPkgs(depID, pkgs, deps, visited)
229+
p.markRootPkgs(SnapshotKey(depID), pkgs, deps, visited)
247230
}
248231
}
249232

@@ -267,8 +250,14 @@ func (p *Parser) parseLockfileVersion(lockFile LockFile) float64 {
267250
}
268251
}
269252

270-
func (p *Parser) parseDepPath(depPath string, lockVer float64) (string, string, string) {
271-
dPath, nonDefaultRegistry := p.trimRegistry(depPath, lockVer)
253+
// parsePnpmKey parses a pnpm package key (either PackageKey or SnapshotKey)
254+
// and extracts the package name, version, and optional registry reference.
255+
// The key format varies between pnpm versions:
256+
// - v5: "registry.npmjs.org/@babel/generator/7.21.9"
257+
// - v6+: "@babel/[email protected]"
258+
// - v9+: "@babel/[email protected]([email protected])" (SnapshotKey with peers)
259+
func (p *Parser) parsePnpmKey(pnpmKey string, lockVer float64) (string, string, string) {
260+
dPath, nonDefaultRegistry := p.trimRegistry(pnpmKey, lockVer)
272261

273262
var scope string
274263
scope, dPath = p.separateScope(dPath)
@@ -283,43 +272,43 @@ func (p *Parser) parseDepPath(depPath string, lockVer float64) (string, string,
283272

284273
ver := p.trimPeerDeps(dPath, lockVer)
285274

286-
return name, ver, lo.Ternary(nonDefaultRegistry, depPath, "")
275+
return name, ver, lo.Ternary(nonDefaultRegistry, pnpmKey, "")
287276
}
288277

289-
// trimRegistry trims registry (or `/` prefix) for depPath.
278+
// trimRegistry trims registry (or `/` prefix) from a pnpm key.
290279
// It returns true if non-default registry has been trimmed.
291280
// e.g.
292281
// - "registry.npmjs.org/lodash/4.17.10" => "lodash/4.17.10", false
293282
// - "registry.npmjs.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9", false
294283
// - "private.npm.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9", true
295284
// - "/lodash/4.17.10" => "lodash/4.17.10", false
296285
297-
func (p *Parser) trimRegistry(depPath string, lockVer float64) (string, bool) {
286+
func (p *Parser) trimRegistry(pnpmKey string, lockVer float64) (string, bool) {
298287
var nonDefaultRegistry bool
299288
// lock file v9 doesn't use registry prefix
300289
if lockVer < 9 {
301290
var registry string
302-
registry, depPath, _ = strings.Cut(depPath, "/")
291+
registry, pnpmKey, _ = strings.Cut(pnpmKey, "/")
303292
if registry != "" && registry != "registry.npmjs.org" {
304293
nonDefaultRegistry = true
305294
}
306295
}
307-
return depPath, nonDefaultRegistry
296+
return pnpmKey, nonDefaultRegistry
308297
}
309298

310-
// separateScope separates the scope (if set) from the rest of the depPath.
299+
// separateScope separates the scope (if set) from the rest of the pnpm key.
311300
// e.g.
312301
// - v5: "@babel/generator/7.21.9" => {"babel", "generator/7.21.9"}
313-
// - v6+: "@babel/[email protected]" => "{"babel", "[email protected]"}
314-
func (p *Parser) separateScope(depPath string) (string, string) {
302+
// - v6+: "@babel/[email protected]" => {"babel", "[email protected]"}
303+
func (p *Parser) separateScope(pnpmKey string) (string, string) {
315304
var scope string
316-
if strings.HasPrefix(depPath, "@") {
317-
scope, depPath, _ = strings.Cut(depPath, "/")
305+
if strings.HasPrefix(pnpmKey, "@") {
306+
scope, pnpmKey, _ = strings.Cut(pnpmKey, "/")
318307
}
319-
return scope, depPath
308+
return scope, pnpmKey
320309
}
321310

322-
// separateName separates pkg name and version.
311+
// separateName separates package name and version from a pnpm key.
323312
// e.g.
324313
// - v5: "generator/7.21.9" => {"generator", "7.21.9"}
325314
// - v6+: "7.21.5(@babel/[email protected])" => "7.21.5"
@@ -330,26 +319,26 @@ func (p *Parser) separateScope(depPath string) (string, string) {
330319
//
331320
// Also version can contain peer deps:
332321
333-
func (p *Parser) separateName(depPath string, lockVer float64) (string, string) {
322+
func (p *Parser) separateName(pnpmKey string, lockVer float64) (string, string) {
334323
sep := "@"
335324
if lockVer < 6 {
336325
sep = "/"
337326
}
338-
name, version, _ := strings.Cut(depPath, sep)
327+
name, version, _ := strings.Cut(pnpmKey, sep)
339328
return name, version
340329
}
341330

342-
// Trim peer deps
331+
// trimPeerDeps removes peer dependency suffixes from a version string.
343332
// e.g.
344333
// - v5: "7.21.5_@[email protected]" => "7.21.5"
345334
// - v6+: "7.21.5(@babel/[email protected])" => "7.21.5"
346-
func (p *Parser) trimPeerDeps(depPath string, lockVer float64) string {
335+
func (p *Parser) trimPeerDeps(version string, lockVer float64) string {
347336
sep := "("
348337
if lockVer < 6 {
349338
sep = "_"
350339
}
351-
version, _, _ := strings.Cut(depPath, sep)
352-
return version
340+
v, _, _ := strings.Cut(version, sep)
341+
return v
353342
}
354343

355344
// parseVersion parses version.

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ func TestParse(t *testing.T) {
6565
want: pnpmV9CyclicImport,
6666
wantDeps: pnpmV9CyclicImportDeps,
6767
},
68+
{
69+
name: "v9 with same version and different peers for dependency",
70+
file: "testdata/pnpm-lock_v9_same-vers-diff-peers.yaml",
71+
want: pnpmV9SameVersDiffPeers,
72+
wantDeps: pnpmV9SameVersDiffPeersDeps,
73+
},
6874
}
6975

7076
for _, tt := range tests {
@@ -94,7 +100,7 @@ func TestParse(t *testing.T) {
94100
}
95101
}
96102

97-
func Test_parseDepPath(t *testing.T) {
103+
func Test_parsePnpmKey(t *testing.T) {
98104
tests := []struct {
99105
name string
100106
lockFileVer float64
@@ -229,7 +235,7 @@ func Test_parseDepPath(t *testing.T) {
229235
for _, tt := range tests {
230236
t.Run(tt.name, func(t *testing.T) {
231237
p := NewParser()
232-
gotName, gotVersion, gotRef := p.parseDepPath(tt.pkg, tt.lockFileVer)
238+
gotName, gotVersion, gotRef := p.parsePnpmKey(tt.pkg, tt.lockFileVer)
233239
require.Equal(t, tt.wantName, gotName)
234240
require.Equal(t, tt.wantVersion, gotVersion)
235241
require.Equal(t, tt.wantRef, gotRef)

0 commit comments

Comments
 (0)