github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/javascript/parse_pnpm_lock.go (about) 1 package javascript 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 "sort" 8 "strconv" 9 "strings" 10 11 "go.yaml.in/yaml/v3" 12 13 "github.com/anchore/syft/internal/log" 14 "github.com/anchore/syft/internal/unknown" 15 "github.com/anchore/syft/syft/artifact" 16 "github.com/anchore/syft/syft/file" 17 "github.com/anchore/syft/syft/pkg" 18 "github.com/anchore/syft/syft/pkg/cataloger/generic" 19 "github.com/anchore/syft/syft/pkg/cataloger/internal/dependency" 20 ) 21 22 // pnpmPackage holds the raw name and version extracted from the lockfile. 23 type pnpmPackage struct { 24 Name string 25 Version string 26 Integrity string 27 Dependencies map[string]string 28 } 29 30 // pnpmLockfileParser defines the interface for parsing different versions of pnpm lockfiles. 31 type pnpmLockfileParser interface { 32 Parse(version float64, data []byte) ([]pnpmPackage, error) 33 } 34 35 type pnpmV6PackageEntry struct { 36 Resolution map[string]string `yaml:"resolution"` 37 Dependencies map[string]string `yaml:"dependencies"` 38 } 39 40 // pnpmV6LockYaml represents the structure of pnpm lockfiles for versions < 9.0. 41 type pnpmV6LockYaml struct { 42 Dependencies map[string]interface{} `yaml:"dependencies"` 43 Packages map[string]pnpmV6PackageEntry `yaml:"packages"` 44 } 45 46 type pnpmV9SnapshotEntry struct { 47 Dependencies map[string]string `yaml:"dependencies"` 48 Optional bool `yaml:"optional"` 49 OptionalDependencies map[string]string `yaml:"optionalDependencies"` 50 TransitivePeerDependencies []string `yaml:"transitivePeerDependencies"` 51 } 52 53 type pnpmV9PackageEntry struct { 54 Resolution map[string]string `yaml:"resolution"` 55 PeerDependencies map[string]string `yaml:"peerDependencies"` 56 } 57 58 // pnpmV9LockYaml represents the structure of pnpm lockfiles for versions >= 9.0. 59 type pnpmV9LockYaml struct { 60 LockfileVersion string `yaml:"lockfileVersion"` 61 Importers map[string]interface{} `yaml:"importers"` // Using interface{} for forward compatibility 62 Packages map[string]pnpmV9PackageEntry `yaml:"packages"` 63 Snapshots map[string]pnpmV9SnapshotEntry `yaml:"snapshots"` 64 } 65 66 type genericPnpmLockAdapter struct { 67 cfg CatalogerConfig 68 } 69 70 func newGenericPnpmLockAdapter(cfg CatalogerConfig) genericPnpmLockAdapter { 71 return genericPnpmLockAdapter{ 72 cfg: cfg, 73 } 74 } 75 76 // Parse implements the pnpmLockfileParser interface for v6-v8 lockfiles. 77 func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, error) { 78 if err := yaml.Unmarshal(data, p); err != nil { 79 return nil, fmt.Errorf("failed to unmarshal pnpm v6 lockfile: %w", err) 80 } 81 82 packages := make(map[string]pnpmPackage) 83 84 // Direct dependencies 85 for name, info := range p.Dependencies { 86 ver, err := parseVersionField(name, info) 87 if err != nil { 88 log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency") 89 continue 90 } 91 key := name + "@" + ver 92 packages[key] = pnpmPackage{Name: name, Version: ver} 93 } 94 95 splitChar := "/" 96 if version >= 6.0 { 97 splitChar = "@" 98 } 99 100 // All transitive dependencies 101 for key, pkgInfo := range p.Packages { 102 name, ver, ok := parsePnpmPackageKey(key, splitChar) 103 if !ok { 104 log.WithFields("key", key).Trace("unable to parse pnpm package key") 105 continue 106 } 107 pkgKey := name + "@" + ver 108 109 integrity := "" 110 if value, ok := pkgInfo.Resolution["integrity"]; ok { 111 integrity = value 112 } 113 114 dependencies := make(map[string]string) 115 for depName, depVersion := range pkgInfo.Dependencies { 116 var normalizedVersion = strings.SplitN(depVersion, "(", 2)[0] 117 dependencies[depName] = normalizedVersion 118 } 119 120 packages[pkgKey] = pnpmPackage{Name: name, Version: ver, Integrity: integrity, Dependencies: dependencies} 121 } 122 123 return toSortedSlice(packages), nil 124 } 125 126 // Parse implements the PnpmLockfileParser interface for v9+ lockfiles. 127 func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) { 128 if err := yaml.Unmarshal(data, p); err != nil { 129 return nil, fmt.Errorf("failed to unmarshal pnpm v9 lockfile: %w", err) 130 } 131 132 packages := make(map[string]pnpmPackage) 133 134 // In v9, all resolved dependencies are listed in the top-level "packages" field. 135 // The key format is like /<name>@<version> or /<name>@<version>(<peer-deps>). 136 for key, entry := range p.Packages { 137 // The separator for name and version is consistently '@' in v9+ keys. 138 name, ver, ok := parsePnpmPackageKey(key, "@") 139 if !ok { 140 log.WithFields("key", key).Trace("unable to parse pnpm v9 package key") 141 continue 142 } 143 pkgKey := name + "@" + ver 144 packages[pkgKey] = pnpmPackage{Name: name, Version: ver, Integrity: entry.Resolution["integrity"]} 145 } 146 147 for key, snapshotInfo := range p.Snapshots { 148 name, ver, ok := parsePnpmPackageKey(key, "@") 149 if !ok { 150 log.WithFields("key", key).Trace("unable to parse pnpm v9 package snapshot key") 151 continue 152 } 153 pkgKey := name + "@" + ver 154 if pkg, ok := packages[pkgKey]; ok { 155 pkg.Dependencies = make(map[string]string) 156 for name, versionSpecifier := range snapshotInfo.Dependencies { 157 var normalizedVersion = strings.SplitN(versionSpecifier, "(", 2)[0] 158 pkg.Dependencies[name] = normalizedVersion 159 } 160 packages[pkgKey] = pkg 161 } else { 162 log.WithFields("package", pkgKey).Trace("package not found in packages map") 163 } 164 } 165 166 return toSortedSlice(packages), nil 167 } 168 169 // newPnpmLockfileParser is a factory function that returns the correct parser for the given lockfile version. 170 func newPnpmLockfileParser(version float64) pnpmLockfileParser { 171 if version >= 9.0 { 172 return &pnpmV9LockYaml{} 173 } 174 return &pnpmV6LockYaml{} 175 } 176 177 // parsePnpmLock is the main parser function for pnpm-lock.yaml files. 178 func (a genericPnpmLockAdapter) parsePnpmLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { 179 data, err := io.ReadAll(reader) 180 if err != nil { 181 return nil, nil, fmt.Errorf("failed to load pnpm-lock.yaml file: %w", err) 182 } 183 184 var lockfile struct { 185 Version string `yaml:"lockfileVersion"` 186 } 187 if err := yaml.Unmarshal(data, &lockfile); err != nil { 188 return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml version: %w", err) 189 } 190 191 version, err := strconv.ParseFloat(lockfile.Version, 64) 192 if err != nil { 193 return nil, nil, fmt.Errorf("invalid lockfile version %q: %w", lockfile.Version, err) 194 } 195 196 parser := newPnpmLockfileParser(version) 197 pnpmPkgs, err := parser.Parse(version, data) 198 if err != nil { 199 return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml file: %w", err) 200 } 201 202 packages := make([]pkg.Package, len(pnpmPkgs)) 203 for i, p := range pnpmPkgs { 204 packages[i] = newPnpmPackage(ctx, a.cfg, resolver, reader.Location, p.Name, p.Version, p.Integrity, p.Dependencies) 205 } 206 207 return packages, dependency.Resolve(pnpmLockDependencySpecifier, packages), unknown.IfEmptyf(packages, "unable to determine packages") 208 } 209 210 // parseVersionField extracts the version string from a dependency entry. 211 func parseVersionField(name string, info interface{}) (string, error) { 212 switch v := info.(type) { 213 case string: 214 return v, nil 215 case map[string]interface{}: 216 if ver, ok := v["version"].(string); ok { 217 // e.g., "1.2.3(react@17.0.0)" -> "1.2.3" 218 return strings.SplitN(ver, "(", 2)[0], nil 219 } 220 return "", fmt.Errorf("version field is not a string for %q", name) 221 default: 222 return "", fmt.Errorf("unsupported dependency type %T for %q", info, name) 223 } 224 } 225 226 // parsePnpmPackageKey extracts the package name and version from a lockfile package key. 227 // Handles formats like: 228 // - /@babel/runtime/7.16.7 229 // - /@types/node@14.18.12 230 // - /is-glob@4.0.3 231 // - /@babel/helper-plugin-utils@7.24.7(@babel/core@7.24.7) 232 func parsePnpmPackageKey(key, separator string) (name, version string, ok bool) { 233 // Strip peer dependency information, e.g., (...) 234 key = strings.SplitN(key, "(", 2)[0] 235 236 // Strip leading slash 237 key = strings.TrimPrefix(key, "/") 238 239 parts := strings.Split(key, separator) 240 if len(parts) < 2 { 241 return "", "", false 242 } 243 244 version = parts[len(parts)-1] 245 name = strings.Join(parts[:len(parts)-1], separator) 246 247 return name, version, true 248 } 249 250 // toSortedSlice converts the map of packages to a sorted slice for deterministic output. 251 func toSortedSlice(packages map[string]pnpmPackage) []pnpmPackage { 252 pkgs := make([]pnpmPackage, 0, len(packages)) 253 for _, p := range packages { 254 pkgs = append(pkgs, p) 255 } 256 257 sort.Slice(pkgs, func(i, j int) bool { 258 if pkgs[i].Name == pkgs[j].Name { 259 return pkgs[i].Version < pkgs[j].Version 260 } 261 return pkgs[i].Name < pkgs[j].Name 262 }) 263 264 return pkgs 265 }