Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 153 additions & 59 deletions syft/pkg/cataloger/javascript/parse_pnpm_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"context"
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"

Expand All @@ -21,92 +21,186 @@ import (
// integrity check
var _ generic.Parser = parsePnpmLock

type pnpmLockYaml struct {
Version string `json:"lockfileVersion" yaml:"lockfileVersion"`
Dependencies map[string]interface{} `json:"dependencies" yaml:"dependencies"`
Packages map[string]interface{} `json:"packages" yaml:"packages"`
// pnpmPackage holds the raw name and version extracted from the lockfile.
type pnpmPackage struct {
Name string
Version string
}

func parsePnpmLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
bytes, err := io.ReadAll(reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to load pnpm-lock.yaml file: %w", err)
}
// pnpmLockfileParser defines the interface for parsing different versions of pnpm lockfiles.
type pnpmLockfileParser interface {
Parse(version float64, data []byte) ([]pnpmPackage, error)
}

// pnpmV6LockYaml represents the structure of pnpm lockfiles for versions < 9.0.
type pnpmV6LockYaml struct {
Dependencies map[string]interface{} `yaml:"dependencies"`
Packages map[string]interface{} `yaml:"packages"`
}

var pkgs []pkg.Package
var lockFile pnpmLockYaml
// pnpmV9LockYaml represents the structure of pnpm lockfiles for versions >= 9.0.
type pnpmV9LockYaml struct {
LockfileVersion string `yaml:"lockfileVersion"`
Importers map[string]interface{} `yaml:"importers"` // Using interface{} for forward compatibility
Packages map[string]interface{} `yaml:"packages"`
}

if err := yaml.Unmarshal(bytes, &lockFile); err != nil {
return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml file: %w", err)
// Parse implements the pnpmLockfileParser interface for v6-v8 lockfiles.
func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, error) {
if err := yaml.Unmarshal(data, p); err != nil {
return nil, fmt.Errorf("failed to unmarshal pnpm v6 lockfile: %w", err)
}

lockVersion, _ := strconv.ParseFloat(lockFile.Version, 64)

for name, info := range lockFile.Dependencies {
version := ""

switch info := info.(type) {
case string:
version = info
case map[string]interface{}:
v, ok := info["version"]
if !ok {
break
}
ver, ok := v.(string)
if ok {
version = parseVersion(ver)
}
default:
log.Tracef("unsupported pnpm dependency type: %+v", info)
continue
}
packages := make(map[string]pnpmPackage)

if hasPkg(pkgs, name, version) {
// Direct dependencies
for name, info := range p.Dependencies {
ver, err := parseVersionField(name, info)
if err != nil {
log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency")
continue
}

pkgs = append(pkgs, newPnpmPackage(ctx, resolver, reader.Location, name, version))
key := name + "@" + ver
packages[key] = pnpmPackage{Name: name, Version: ver}
}

packageNameRegex := regexp.MustCompile(`^/?([^(]*)(?:\(.*\))*$`)
splitChar := "/"
if lockVersion >= 6.0 {
if version >= 6.0 {
splitChar = "@"
}

// parse packages from packages section of pnpm-lock.yaml
for nameVersion := range lockFile.Packages {
nameVersion = packageNameRegex.ReplaceAllString(nameVersion, "$1")
nameVersionSplit := strings.Split(strings.TrimPrefix(nameVersion, "/"), splitChar)
// All transitive dependencies
for key := range p.Packages {
name, ver, ok := parsePnpmPackageKey(key, splitChar)
if !ok {
log.WithFields("key", key).Trace("unable to parse pnpm package key")
continue
}
pkgKey := name + "@" + ver
packages[pkgKey] = pnpmPackage{Name: name, Version: ver}
}

// last element in split array is version
version := nameVersionSplit[len(nameVersionSplit)-1]
return toSortedSlice(packages), nil
}

// Parse implements the PnpmLockfileParser interface for v9+ lockfiles.
func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) {
if err := yaml.Unmarshal(data, p); err != nil {
return nil, fmt.Errorf("failed to unmarshal pnpm v9 lockfile: %w", err)
}

// construct name from all array items other than last item (version)
name := strings.Join(nameVersionSplit[:len(nameVersionSplit)-1], splitChar)
packages := make(map[string]pnpmPackage)

if hasPkg(pkgs, name, version) {
// In v9, all resolved dependencies are listed in the top-level "packages" field.
// The key format is like /<name>@<version> or /<name>@<version>(<peer-deps>).
for key := range p.Packages {
// The separator for name and version is consistently '@' in v9+ keys.
name, ver, ok := parsePnpmPackageKey(key, "@")
if !ok {
log.WithFields("key", key).Trace("unable to parse pnpm v9 package key")
continue
}
pkgKey := name + "@" + ver
packages[pkgKey] = pnpmPackage{Name: name, Version: ver}
}

return toSortedSlice(packages), nil
}

pkgs = append(pkgs, newPnpmPackage(ctx, resolver, reader.Location, name, version))
// newPnpmLockfileParser is a factory function that returns the correct parser for the given lockfile version.
func newPnpmLockfileParser(version float64) pnpmLockfileParser {
if version >= 9.0 {
return &pnpmV9LockYaml{}
}
return &pnpmV6LockYaml{}
}

pkg.Sort(pkgs)
// parsePnpmLock is the main parser function for pnpm-lock.yaml files.
func parsePnpmLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
data, err := io.ReadAll(reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to load pnpm-lock.yaml file: %w", err)
}

var lockfile struct {
Version string `yaml:"lockfileVersion"`
}
if err := yaml.Unmarshal(data, &lockfile); err != nil {
return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml version: %w", err)
}

version, err := strconv.ParseFloat(lockfile.Version, 64)
if err != nil {
return nil, nil, fmt.Errorf("invalid lockfile version %q: %w", lockfile.Version, err)
}

parser := newPnpmLockfileParser(version)
pnpmPkgs, err := parser.Parse(version, data)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml file: %w", err)
}

return pkgs, nil, unknown.IfEmptyf(pkgs, "unable to determine packages")
packages := make([]pkg.Package, len(pnpmPkgs))
for i, p := range pnpmPkgs {
packages[i] = newPnpmPackage(ctx, resolver, reader.Location, p.Name, p.Version)
}

return packages, nil, unknown.IfEmptyf(packages, "unable to determine packages")
}

func hasPkg(pkgs []pkg.Package, name, version string) bool {
for _, p := range pkgs {
if p.Name == name && p.Version == version {
return true
// parseVersionField extracts the version string from a dependency entry.
func parseVersionField(name string, info interface{}) (string, error) {
switch v := info.(type) {
case string:
return v, nil
case map[string]interface{}:
if ver, ok := v["version"].(string); ok {
// e.g., "1.2.3(react@17.0.0)" -> "1.2.3"
return strings.SplitN(ver, "(", 2)[0], nil
}
return "", fmt.Errorf("version field is not a string for %q", name)
default:
return "", fmt.Errorf("unsupported dependency type %T for %q", info, name)
}
return false
}

func parseVersion(version string) string {
return strings.SplitN(version, "(", 2)[0]
// parsePnpmPackageKey extracts the package name and version from a lockfile package key.
// Handles formats like:
// - /@babel/runtime/7.16.7
// - /@types/node@14.18.12
// - /is-glob@4.0.3
// - /@babel/helper-plugin-utils@7.24.7(@babel/core@7.24.7)
func parsePnpmPackageKey(key, separator string) (name, version string, ok bool) {
// Strip peer dependency information, e.g., (...)
key = strings.SplitN(key, "(", 2)[0]

// Strip leading slash
key = strings.TrimPrefix(key, "/")

parts := strings.Split(key, separator)
if len(parts) < 2 {
return "", "", false
}

version = parts[len(parts)-1]
name = strings.Join(parts[:len(parts)-1], separator)

return name, version, true
}

// toSortedSlice converts the map of packages to a sorted slice for deterministic output.
func toSortedSlice(packages map[string]pnpmPackage) []pnpmPackage {
pkgs := make([]pnpmPackage, 0, len(packages))
for _, p := range packages {
pkgs = append(pkgs, p)
}

sort.Slice(pkgs, func(i, j int) bool {
if pkgs[i].Name == pkgs[j].Name {
return pkgs[i].Version < pkgs[j].Version
}
return pkgs[i].Name < pkgs[j].Name
})

return pkgs
}
44 changes: 44 additions & 0 deletions syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,50 @@ func TestParsePnpmV6Lock(t *testing.T) {
pkgtest.TestFileParser(t, fixture, parsePnpmLock, expectedPkgs, expectedRelationships)
}

func TestParsePnpmLockV9(t *testing.T) {
var expectedRelationships []artifact.Relationship
fixture := "test-fixtures/pnpm-v9/pnpm-lock.yaml"
locationSet := file.NewLocationSet(file.NewLocation(fixture))

expected := []pkg.Package{
{
Name: "@babel/core",
Version: "7.24.7",
PURL: "pkg:npm/%40babel/core@7.24.7",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
},
{
Name: "@babel/helper-plugin-utils",
Version: "7.24.7",
PURL: "pkg:npm/%40babel/helper-plugin-utils@7.24.7",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
},
{
Name: "is-positive",
Version: "3.1.0",
PURL: "pkg:npm/is-positive@3.1.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
},
{
Name: "rollup",
Version: "4.18.0",
PURL: "pkg:npm/rollup@4.18.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
},
}

// TODO: no relationships are under test
pkgtest.TestFileParser(t, fixture, parsePnpmLock, expected, expectedRelationships)
}

func Test_corruptPnpmLock(t *testing.T) {
pkgtest.NewCatalogTester().
FromFile(t, "test-fixtures/corrupt/pnpm-lock.yaml").
Expand Down
31 changes: 31 additions & 0 deletions syft/pkg/cataloger/javascript/test-fixtures/pnpm-v9/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading