diff --git a/syft/pkg/cataloger/javascript/parse_pnpm_lock.go b/syft/pkg/cataloger/javascript/parse_pnpm_lock.go index c25b6c9f5..706bcdfe9 100644 --- a/syft/pkg/cataloger/javascript/parse_pnpm_lock.go +++ b/syft/pkg/cataloger/javascript/parse_pnpm_lock.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "io" - "regexp" + "sort" "strconv" "strings" @@ -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 } +// 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"` +} + +// 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"` +} + +// 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) + } + + packages := make(map[string]pnpmPackage) + + // 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 + } + key := name + "@" + ver + packages[key] = pnpmPackage{Name: name, Version: ver} + } + + splitChar := "/" + if version >= 6.0 { + 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} + } + + 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) + } + + packages := make(map[string]pnpmPackage) + + // In v9, all resolved dependencies are listed in the top-level "packages" field. + // The key format is like /@ or /@(). + 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 +} + +// 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{} +} + +// 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) { - bytes, err := io.ReadAll(reader) + data, err := io.ReadAll(reader) if err != nil { return nil, nil, fmt.Errorf("failed to load pnpm-lock.yaml file: %w", err) } - var pkgs []pkg.Package - var lockFile pnpmLockYaml + 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) + } - if err := yaml.Unmarshal(bytes, &lockFile); err != nil { + 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) } - 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 - } - - if hasPkg(pkgs, name, version) { - continue - } - - pkgs = append(pkgs, newPnpmPackage(ctx, resolver, reader.Location, name, version)) + packages := make([]pkg.Package, len(pnpmPkgs)) + for i, p := range pnpmPkgs { + packages[i] = newPnpmPackage(ctx, resolver, reader.Location, p.Name, p.Version) } - packageNameRegex := regexp.MustCompile(`^/?([^(]*)(?:\(.*\))*$`) - splitChar := "/" - if lockVersion >= 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) - - // last element in split array is version - version := nameVersionSplit[len(nameVersionSplit)-1] - - // construct name from all array items other than last item (version) - name := strings.Join(nameVersionSplit[:len(nameVersionSplit)-1], splitChar) - - if hasPkg(pkgs, name, version) { - continue - } - - pkgs = append(pkgs, newPnpmPackage(ctx, resolver, reader.Location, name, version)) - } - - pkg.Sort(pkgs) - - return pkgs, nil, unknown.IfEmptyf(pkgs, "unable to determine packages") + 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 } diff --git a/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go b/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go index 8b5ed7d0a..7ba17a546 100644 --- a/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go +++ b/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go @@ -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"). diff --git a/syft/pkg/cataloger/javascript/test-fixtures/pnpm-v9/pnpm-lock.yaml b/syft/pkg/cataloger/javascript/test-fixtures/pnpm-v9/pnpm-lock.yaml new file mode 100644 index 000000000..19b4b02e4 --- /dev/null +++ b/syft/pkg/cataloger/javascript/test-fixtures/pnpm-v9/pnpm-lock.yaml @@ -0,0 +1,31 @@ +lockfileVersion: '9.0' + +importers: + .: + dependencies: + is-positive: + specifier: ^3.1.0 + version: 3.1.0 + rollup: + specifier: ^4.18.0 + version: 4.18.0 + +packages: + /@babel/core@7.24.7: + resolution: {integrity: sha512-4RjkiFFI42+268iBv2nC+iMLTJGQW3u9P7YvA3x/6MDrJ9IYZ8I/xx5a2GIhY5gBTOcI4iC5S5in2fGjE+P4Yw==} + dev: false + + /@babel/helper-plugin-utils@7.24.7(@babel/core@7.24.7): + resolution: {integrity: sha512-8A2+zKm53/3w4rwbX11FMW/yFS6c5Vam02P/dw01aK6KbwkKqBaIt3eEATiKtn9I2uS1itk8/aZ2yZ/kURee4Q==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dev: false + + /is-positive@3.1.0: + resolution: {integrity: sha512-9ffLCf_f5sopimAhg2g91a7b9Rw5A1aA9eI6S391S3VEzYw99I3iKjcZGxLp25s0cRxNBV5aL2mhn7421SSlA==} + dev: false + + /rollup@4.18.0: + resolution: {integrity: sha512-QpQY2Q5i0y0Q3RoAvoChE/R5iN2k05N//bNvQbC2XvRjHFT1qWJ2r3n1bNqE+gGRJaeuQf0BxE42D7CyuLh3ZQ==} + hasBin: true + dev: false