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 package javascript
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"io" "io"
"iter"
"maps"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -84,8 +88,8 @@ func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, err
packages := make(map[string]pnpmPackage) packages := make(map[string]pnpmPackage)
// Direct dependencies // Direct dependencies — use sorted keys for deterministic output
for name, info := range p.Dependencies { for name, info := range sortedIter(p.Dependencies) {
ver, err := parseVersionField(name, info) ver, err := parseVersionField(name, info)
if err != nil { if err != nil {
log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency") 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 = "@" splitChar = "@"
} }
// All transitive dependencies // All transitive dependencies — use sorted keys for deterministic output
for key, pkgInfo := range p.Packages { for key, pkgInfo := range sortedIter(p.Packages) {
name, ver, ok := parsePnpmPackageKey(key, splitChar) name, ver, ok := parsePnpmPackageKey(key, splitChar)
if !ok { if !ok {
log.WithFields("key", key).Trace("unable to parse pnpm package key") 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) 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] var normalizedVersion = strings.SplitN(depVersion, "(", 2)[0]
dependencies[depName] = normalizedVersion 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. // 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>). // 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. // The separator for name and version is consistently '@' in v9+ keys.
name, ver, ok := parsePnpmPackageKey(key, "@") name, ver, ok := parsePnpmPackageKey(key, "@")
if !ok { 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} 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, "@") name, ver, ok := parsePnpmPackageKey(key, "@")
if !ok { if !ok {
log.WithFields("key", key).Trace("unable to parse pnpm v9 package snapshot key") 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 pkgKey := name + "@" + ver
if pkg, ok := packages[pkgKey]; ok { if pkg, ok := packages[pkgKey]; ok {
pkg.Dependencies = make(map[string]string) 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] var normalizedVersion = strings.SplitN(versionSpecifier, "(", 2)[0]
pkg.Dependencies[name] = normalizedVersion pkg.Dependencies[name] = normalizedVersion
} }
@ -253,6 +257,19 @@ func parsePnpmPackageKey(key, separator string) (name, version string, ok bool)
return name, version, true 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. // toSortedSlice converts the map of packages to a sorted slice for deterministic output.
func toSortedSlice(packages map[string]pnpmPackage) []pnpmPackage { func toSortedSlice(packages map[string]pnpmPackage) []pnpmPackage {
pkgs := make([]pnpmPackage, 0, len(packages)) pkgs := make([]pnpmPackage, 0, len(packages))

View File

@ -8,6 +8,9 @@ import (
"os" "os"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
@ -524,6 +527,61 @@ func Test_corruptPnpmLock(t *testing.T) {
TestParser(t, adapter.parsePnpmLock) 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) { func generateMockNpmRegistryHandler(responseFixture string) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)