mirror of
https://github.com/anchore/syft.git
synced 2026-05-20 04:05:24 +02:00
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:
parent
5b58ec96b7
commit
4321ecc66f
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user