fix(javascript): ensure deterministic pnpm lockfile parsing (#4765)

* fix(javascript): ensure deterministic pnpm lockfile parsing

Replace nondeterministic Go map iteration with sorted key iteration
in both v6 and v9 pnpm lockfile parsers. When multiple lockfile keys
collapse to the same package key after peer dependency stripping, the
unsorted map iteration caused different entries to win on each run,
producing unstable artifact IDs and non-reproducible SBOM output.

Fixes #4648

Signed-off-by: lawrence3699 <lawrence3699@users.noreply.github.com>

* add regression test

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: lawrence3699 <lawrence3699@users.noreply.github.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: lawrence3699 <lawrence3699@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
chaoliang yan 2026-04-16 00:39:57 +10:00 committed by GitHub
parent 5b58ec96b7
commit 4321ecc66f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 83 additions and 8 deletions

View File

@ -1,9 +1,13 @@
package javascript
import (
"cmp"
"context"
"fmt"
"io"
"iter"
"maps"
"slices"
"sort"
"strconv"
"strings"
@ -84,8 +88,8 @@ func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, err
packages := make(map[string]pnpmPackage)
// Direct dependencies
for name, info := range p.Dependencies {
// Direct dependencies — use sorted keys for deterministic output
for name, info := range sortedIter(p.Dependencies) {
ver, err := parseVersionField(name, info)
if err != nil {
log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency")
@ -100,8 +104,8 @@ func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, err
splitChar = "@"
}
// All transitive dependencies
for key, pkgInfo := range p.Packages {
// All transitive dependencies — use sorted keys for deterministic output
for key, pkgInfo := range sortedIter(p.Packages) {
name, ver, ok := parsePnpmPackageKey(key, splitChar)
if !ok {
log.WithFields("key", key).Trace("unable to parse pnpm package key")
@ -115,7 +119,7 @@ func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, err
}
dependencies := make(map[string]string)
for depName, depVersion := range pkgInfo.Dependencies {
for depName, depVersion := range sortedIter(pkgInfo.Dependencies) {
var normalizedVersion = strings.SplitN(depVersion, "(", 2)[0]
dependencies[depName] = normalizedVersion
}
@ -136,7 +140,7 @@ func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) {
// 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, entry := range p.Packages {
for key, entry := range sortedIter(p.Packages) {
// The separator for name and version is consistently '@' in v9+ keys.
name, ver, ok := parsePnpmPackageKey(key, "@")
if !ok {
@ -147,7 +151,7 @@ func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) {
packages[pkgKey] = pnpmPackage{Name: name, Version: ver, Integrity: entry.Resolution["integrity"], Dev: entry.Dev}
}
for key, snapshotInfo := range p.Snapshots {
for key, snapshotInfo := range sortedIter(p.Snapshots) {
name, ver, ok := parsePnpmPackageKey(key, "@")
if !ok {
log.WithFields("key", key).Trace("unable to parse pnpm v9 package snapshot key")
@ -156,7 +160,7 @@ func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) {
pkgKey := name + "@" + ver
if pkg, ok := packages[pkgKey]; ok {
pkg.Dependencies = make(map[string]string)
for name, versionSpecifier := range snapshotInfo.Dependencies {
for name, versionSpecifier := range sortedIter(snapshotInfo.Dependencies) {
var normalizedVersion = strings.SplitN(versionSpecifier, "(", 2)[0]
pkg.Dependencies[name] = normalizedVersion
}
@ -253,6 +257,19 @@ func parsePnpmPackageKey(key, separator string) (name, version string, ok bool)
return name, version, true
}
// sortedIter returns an iterator over the map entries sorted by key, ensuring deterministic iteration order.
func sortedIter[K cmp.Ordered, V any](values map[K]V) iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
keys := slices.Collect(maps.Keys(values))
slices.Sort(keys)
for _, key := range keys {
if !yield(key, values[key]) {
return
}
}
}
}
// 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))

View File

@ -8,6 +8,9 @@ import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
@ -524,6 +527,61 @@ func Test_corruptPnpmLock(t *testing.T) {
TestParser(t, adapter.parsePnpmLock)
}
func TestParsePnpmLock_DeterministicWithCollidingPeerDeps(t *testing.T) {
// this test verifies that when multiple lockfile keys collapse to the same
// package key after peer-dep stripping (e.g., pkg@1.0.0(peer-a@1) and
// pkg@1.0.0(peer-b@2) both become pkg@1.0.0), the output is deterministic.
// Since we iterate in sorted key order and later keys overwrite earlier ones,
// the last key lexicographically wins.
// v9 lockfile with two entries that collapse to the same key
lockfileV9 := []byte(`
lockfileVersion: '9.0'
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}
snapshots:
some-pkg@1.0.0(peer-b@2.0.0): {}
some-pkg@1.0.0(peer-a@1.0.0): {}
`)
// run multiple times to catch nondeterminism
for i := 0; i < 10; i++ {
parser := &pnpmV9LockYaml{}
pkgs, err := parser.Parse(9.0, lockfileV9)
require.NoError(t, err)
require.Len(t, pkgs, 1, "expected exactly one package after key collision")
// sorted order: peer-a (AAA) then peer-b (BBB), so BBB overwrites AAA
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")
}
// v6 lockfile with two entries that collapse to the same key
lockfileV6 := []byte(`
lockfileVersion: '6.0'
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 i := 0; i < 10; i++ {
parser := &pnpmV6LockYaml{}
pkgs, err := parser.Parse(6.0, lockfileV6)
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) {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)