diff --git a/syft/pkg/cataloger/javascript/parse_pnpm_lock.go b/syft/pkg/cataloger/javascript/parse_pnpm_lock.go index 4d83ecfa6..eede2ccc8 100644 --- a/syft/pkg/cataloger/javascript/parse_pnpm_lock.go +++ b/syft/pkg/cataloger/javascript/parse_pnpm_lock.go @@ -89,6 +89,8 @@ func (p *pnpmV6LockYaml) Parse(version float64, doc *yaml.Node) ([]pnpmPackage, return nil, fmt.Errorf("failed to unmarshal pnpm v6 lockfile: %w", err) } + isV5 := version < 6.0 + packages := make(map[string]pnpmPackage) // Direct dependencies — use sorted keys for deterministic output @@ -98,6 +100,9 @@ func (p *pnpmV6LockYaml) Parse(version float64, doc *yaml.Node) ([]pnpmPackage, log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency") continue } + if isV5 { + ver = stripPnpmV5PeerSuffix(ver) + } key := name + "@" + ver packages[key] = pnpmPackage{Name: name, Version: ver} } @@ -114,6 +119,9 @@ func (p *pnpmV6LockYaml) Parse(version float64, doc *yaml.Node) ([]pnpmPackage, log.WithFields("key", key).Trace("unable to parse pnpm package key") continue } + if isV5 { + ver = stripPnpmV5PeerSuffix(ver) + } pkgKey := name + "@" + ver integrity := "" @@ -124,6 +132,9 @@ func (p *pnpmV6LockYaml) Parse(version float64, doc *yaml.Node) ([]pnpmPackage, dependencies := make(map[string]string) for depName, depVersion := range sortedIter(pkgInfo.Dependencies) { var normalizedVersion = strings.SplitN(depVersion, "(", 2)[0] + if isV5 { + normalizedVersion = stripPnpmV5PeerSuffix(normalizedVersion) + } dependencies[depName] = normalizedVersion } @@ -315,6 +326,22 @@ func parseVersionField(name string, info any) (string, error) { } } +// stripPnpmV5PeerSuffix removes the underscore-delimited peer dependency suffix used by +// pnpm v5 lockfiles, e.g. "5.3.2_acorn@8.8.0" or "4.10.0_fzn43tb6bdtdxy2s3aqevve2su" -> "5.3.2" / "4.10.0". +// Lockfile v6+ encodes the same information in parentheses, which is stripped separately. +// Only values that look like a registry version (leading digit) are stripped, so that +// link:/file:/git specifiers are left untouched. +func stripPnpmV5PeerSuffix(version string) string { + idx := strings.Index(version, "_") + if idx <= 0 { + return version + } + if version[0] < '0' || version[0] > '9' { + return version + } + return version[:idx] +} + // parsePnpmPackageKey extracts the package name and version from a lockfile package key. // Handles formats like: // - /@babel/runtime/7.16.7 diff --git a/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go b/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go index aca4a7c52..700603ee3 100644 --- a/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go +++ b/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go @@ -310,6 +310,66 @@ func TestParsePnpmV6Lock(t *testing.T) { pkgtest.TestFileParser(t, fixture, adapter.parsePnpmLock, expectedPkgs, expectedRelationships) } +func TestParsePnpmLockV5PeerSuffix(t *testing.T) { + // pnpm v5 lockfiles encode resolved peer dependencies as an underscore-delimited + // suffix of the dependency path, either readable (e.g. "5.3.2_acorn@8.8.0") or + // hashed (e.g. "4.10.0_fzn43tb6bdtdxy2s3aqevve2su"). The suffix must be stripped + // from reported versions, just like the parenthesized form in v6+ lockfiles. + fixture := "testdata/pnpm-v5-peer-suffix/pnpm-lock.yaml" + + locationSet := file.NewLocationSet(file.NewLocation(fixture)) + + expectedPkgs := []pkg.Package{ + { + Name: "acorn", + Version: "8.8.0", + PURL: "pkg:npm/acorn@8.8.0", + Locations: locationSet, + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.PnpmLockEntry{ + Resolution: pkg.PnpmLockResolution{Integrity: "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w=="}, + Dependencies: map[string]string{}, + }, + }, + { + Name: "acorn-jsx", + Version: "5.3.2", + PURL: "pkg:npm/acorn-jsx@5.3.2", + Locations: locationSet, + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.PnpmLockEntry{ + Resolution: pkg.PnpmLockResolution{Integrity: "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="}, + Dependencies: map[string]string{ + "acorn": "8.8.0", + }, + }, + }, + { + Name: "webpack-cli", + Version: "4.10.0", + PURL: "pkg:npm/webpack-cli@4.10.0", + Locations: locationSet, + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.PnpmLockEntry{ + Resolution: pkg.PnpmLockResolution{Integrity: "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w=="}, + Dependencies: map[string]string{}, + }, + }, + } + expectedRelationships := []artifact.Relationship{ + { + From: expectedPkgs[0], + To: expectedPkgs[1], + Type: artifact.DependencyOfRelationship, + }, + } + adapter := newGenericPnpmLockAdapter(CatalogerConfig{}) + pkgtest.TestFileParser(t, fixture, adapter.parsePnpmLock, expectedPkgs, expectedRelationships) +} + func TestParsePnpmLockV9(t *testing.T) { var expectedRelationships []artifact.Relationship fixture := "testdata/pnpm-v9/pnpm-lock.yaml" @@ -592,6 +652,27 @@ packages: assert.Equal(t, "1.0.0", pkgs[0].Version) assert.Equal(t, "sha512-BBB", pkgs[0].Integrity, "expected last lexicographic key to win") } + + // v5 lockfile with two entries that collapse to the same key (underscore peer-dep suffixes) + lockfileV5 := []byte(` +lockfileVersion: 5.4 +packages: + /some-pkg/1.0.0_peer-b@2.0.0: + resolution: {integrity: sha512-BBB} + /some-pkg/1.0.0_peer-a@1.0.0: + resolution: {integrity: sha512-AAA} +`) + + for range 10 { + parser := &pnpmV6LockYaml{} + pkgs, err := parser.Parse(5.4, lockfileV5) + require.NoError(t, err) + require.Len(t, pkgs, 1, "expected exactly one package after key collision") + + assert.Equal(t, "some-pkg", pkgs[0].Name) + assert.Equal(t, "1.0.0", pkgs[0].Version) + assert.Equal(t, "sha512-BBB", pkgs[0].Integrity, "expected last lexicographic key to win") + } } func generateMockNpmRegistryHandler(responseFixture string) func(w http.ResponseWriter, r *http.Request) { diff --git a/syft/pkg/cataloger/javascript/testdata/pnpm-v5-peer-suffix/pnpm-lock.yaml b/syft/pkg/cataloger/javascript/testdata/pnpm-v5-peer-suffix/pnpm-lock.yaml new file mode 100644 index 000000000..0cde2acef --- /dev/null +++ b/syft/pkg/cataloger/javascript/testdata/pnpm-v5-peer-suffix/pnpm-lock.yaml @@ -0,0 +1,27 @@ +lockfileVersion: 5.4 + +specifiers: + acorn: ^8.8.0 + acorn-jsx: ^5.3.2 + webpack-cli: ^4.10.0 + +dependencies: + acorn: 8.8.0 + acorn-jsx: 5.3.2_acorn@8.8.0 + webpack-cli: 4.10.0_fzn43tb6bdtdxy2s3aqevve2su + +packages: + /acorn/8.8.0: + resolution: {integrity: sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==} + engines: {node: '>=0.4.0'} + hasBin: true + + /acorn-jsx/5.3.2_acorn@8.8.0: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.8.0 + + /webpack-cli/4.10.0_fzn43tb6bdtdxy2s3aqevve2su: + resolution: {integrity: sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==}