@@ -18,44 +18,6 @@ import (
18
18
xio "github.com/aquasecurity/trivy/pkg/x/io"
19
19
)
20
20
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
-
59
21
type Parser struct {
60
22
logger * log.Logger
61
23
}
@@ -96,7 +58,7 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
96
58
97
59
// Dependency path is a path to a dependency with a specific set of resolved subdependencies.
98
60
// 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 {
100
62
if info .IsDev {
101
63
continue
102
64
}
@@ -109,9 +71,10 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
109
71
var ref string
110
72
111
73
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 )
114
76
}
77
+ // Create Trivy's internal package ID
115
78
pkgID := packageID (name , version )
116
79
117
80
dependencies := make ([]string , 0 , len (info .Dependencies ))
@@ -139,34 +102,19 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
139
102
return pkgs , deps
140
103
}
141
104
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.
142
107
func (p * Parser ) parseV9 (lockFile LockFile ) ([]ftypes.Package , []ftypes.Dependency ) {
143
108
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
170
118
for _ , importer := range lockFile .Importers {
171
119
for n , v := range importer .DevDependencies {
172
120
devDeps [n ] = v .Version
@@ -176,74 +124,109 @@ func (p *Parser) parseV9(lockFile LockFile) ([]ftypes.Package, []ftypes.Dependen
176
124
}
177
125
}
178
126
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 != "" {
184
141
parsedVer = pkgInfo .Version
185
142
}
186
143
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).
189
147
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 {
192
152
relationship = ftypes .RelationshipDirect
193
153
}
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 {
195
156
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
197
158
}
198
159
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 ),
202
165
Name : name ,
203
166
Version : parsedVer ,
204
167
Relationship : relationship ,
205
168
Dev : dev ,
206
169
ExternalReferences : toExternalRefs (ref ),
207
170
}
171
+ resolvedPkgs [snapshotKey ] = pkg
208
172
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 ,
215
187
}
216
188
}
217
189
}
218
190
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 ]()
221
197
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 )
224
201
}
225
202
}
226
203
227
204
return lo .Values (resolvedPkgs ), lo .Values (resolvedDeps )
228
205
}
229
206
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
232
213
if visited .Contains (id ) {
233
214
return
234
215
}
216
+ // Get the package; skip if not found
235
217
pkg , ok := pkgs [id ]
236
218
if ! ok {
237
219
return
238
220
}
239
221
222
+ // Mark this package as a production dependency
240
223
pkg .Dev = false
241
224
pkgs [id ] = pkg
242
- visited .Append (id )
225
+ visited .Append (id ) // Track that we've processed this package
243
226
244
- // Update child deps
227
+ // Recursively process all dependencies of this package
245
228
for _ , depID := range deps [id ].DependsOn {
246
- p .markRootPkgs (depID , pkgs , deps , visited )
229
+ p .markRootPkgs (SnapshotKey ( depID ) , pkgs , deps , visited )
247
230
}
248
231
}
249
232
@@ -267,8 +250,14 @@ func (p *Parser) parseLockfileVersion(lockFile LockFile) float64 {
267
250
}
268
251
}
269
252
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 )
272
261
273
262
var scope string
274
263
scope , dPath = p .separateScope (dPath )
@@ -283,43 +272,43 @@ func (p *Parser) parseDepPath(depPath string, lockVer float64) (string, string,
283
272
284
273
ver := p .trimPeerDeps (dPath , lockVer )
285
274
286
- return name , ver , lo .Ternary (nonDefaultRegistry , depPath , "" )
275
+ return name , ver , lo .Ternary (nonDefaultRegistry , pnpmKey , "" )
287
276
}
288
277
289
- // trimRegistry trims registry (or `/` prefix) for depPath .
278
+ // trimRegistry trims registry (or `/` prefix) from a pnpm key .
290
279
// It returns true if non-default registry has been trimmed.
291
280
// e.g.
292
281
// - "registry.npmjs.org/lodash/4.17.10" => "lodash/4.17.10", false
293
282
// - "registry.npmjs.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9", false
294
283
// - "private.npm.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9", true
295
284
// - "/lodash/4.17.10" => "lodash/4.17.10", false
296
285
297
- func (p * Parser ) trimRegistry (depPath string , lockVer float64 ) (string , bool ) {
286
+ func (p * Parser ) trimRegistry (pnpmKey string , lockVer float64 ) (string , bool ) {
298
287
var nonDefaultRegistry bool
299
288
// lock file v9 doesn't use registry prefix
300
289
if lockVer < 9 {
301
290
var registry string
302
- registry , depPath , _ = strings .Cut (depPath , "/" )
291
+ registry , pnpmKey , _ = strings .Cut (pnpmKey , "/" )
303
292
if registry != "" && registry != "registry.npmjs.org" {
304
293
nonDefaultRegistry = true
305
294
}
306
295
}
307
- return depPath , nonDefaultRegistry
296
+ return pnpmKey , nonDefaultRegistry
308
297
}
309
298
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 .
311
300
// e.g.
312
301
// - 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 ) {
315
304
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 , "/" )
318
307
}
319
- return scope , depPath
308
+ return scope , pnpmKey
320
309
}
321
310
322
- // separateName separates pkg name and version.
311
+ // separateName separates package name and version from a pnpm key .
323
312
// e.g.
324
313
// - v5: "generator/7.21.9" => {"generator", "7.21.9"}
325
314
// - v6+: "7.21.5(@babel/[email protected] )" => "7.21.5"
@@ -330,26 +319,26 @@ func (p *Parser) separateScope(depPath string) (string, string) {
330
319
//
331
320
// Also version can contain peer deps:
332
321
333
- func (p * Parser ) separateName (depPath string , lockVer float64 ) (string , string ) {
322
+ func (p * Parser ) separateName (pnpmKey string , lockVer float64 ) (string , string ) {
334
323
sep := "@"
335
324
if lockVer < 6 {
336
325
sep = "/"
337
326
}
338
- name , version , _ := strings .Cut (depPath , sep )
327
+ name , version , _ := strings .Cut (pnpmKey , sep )
339
328
return name , version
340
329
}
341
330
342
- // Trim peer deps
331
+ // trimPeerDeps removes peer dependency suffixes from a version string.
343
332
// e.g.
344
333
// - v5: "7.21.5_@[email protected] " => "7.21.5"
345
334
// - 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 {
347
336
sep := "("
348
337
if lockVer < 6 {
349
338
sep = "_"
350
339
}
351
- version , _ , _ := strings .Cut (depPath , sep )
352
- return version
340
+ v , _ , _ := strings .Cut (version , sep )
341
+ return v
353
342
}
354
343
355
344
// parseVersion parses version.
0 commit comments