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
|
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))
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user