mirror of
https://github.com/anchore/syft.git
synced 2025-11-17 16:33:21 +01:00
feat: Parse pnpm v9 lockfiles (#4256)
Signed-off-by: bernardoamc <bernardo.amc@gmail.com>
This commit is contained in:
parent
3b82a3724a
commit
231f04ae0e
@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -21,92 +21,186 @@ import (
|
|||||||
// integrity check
|
// integrity check
|
||||||
var _ generic.Parser = parsePnpmLock
|
var _ generic.Parser = parsePnpmLock
|
||||||
|
|
||||||
type pnpmLockYaml struct {
|
// pnpmPackage holds the raw name and version extracted from the lockfile.
|
||||||
Version string `json:"lockfileVersion" yaml:"lockfileVersion"`
|
type pnpmPackage struct {
|
||||||
Dependencies map[string]interface{} `json:"dependencies" yaml:"dependencies"`
|
Name string
|
||||||
Packages map[string]interface{} `json:"packages" yaml:"packages"`
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pnpmLockfileParser defines the interface for parsing different versions of pnpm lockfiles.
|
||||||
|
type pnpmLockfileParser interface {
|
||||||
|
Parse(version float64, data []byte) ([]pnpmPackage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pnpmV6LockYaml represents the structure of pnpm lockfiles for versions < 9.0.
|
||||||
|
type pnpmV6LockYaml struct {
|
||||||
|
Dependencies map[string]interface{} `yaml:"dependencies"`
|
||||||
|
Packages map[string]interface{} `yaml:"packages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pnpmV9LockYaml represents the structure of pnpm lockfiles for versions >= 9.0.
|
||||||
|
type pnpmV9LockYaml struct {
|
||||||
|
LockfileVersion string `yaml:"lockfileVersion"`
|
||||||
|
Importers map[string]interface{} `yaml:"importers"` // Using interface{} for forward compatibility
|
||||||
|
Packages map[string]interface{} `yaml:"packages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse implements the pnpmLockfileParser interface for v6-v8 lockfiles.
|
||||||
|
func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, error) {
|
||||||
|
if err := yaml.Unmarshal(data, p); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal pnpm v6 lockfile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
packages := make(map[string]pnpmPackage)
|
||||||
|
|
||||||
|
// Direct dependencies
|
||||||
|
for name, info := range p.Dependencies {
|
||||||
|
ver, err := parseVersionField(name, info)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := name + "@" + ver
|
||||||
|
packages[key] = pnpmPackage{Name: name, Version: ver}
|
||||||
|
}
|
||||||
|
|
||||||
|
splitChar := "/"
|
||||||
|
if version >= 6.0 {
|
||||||
|
splitChar = "@"
|
||||||
|
}
|
||||||
|
|
||||||
|
// All transitive dependencies
|
||||||
|
for key := range p.Packages {
|
||||||
|
name, ver, ok := parsePnpmPackageKey(key, splitChar)
|
||||||
|
if !ok {
|
||||||
|
log.WithFields("key", key).Trace("unable to parse pnpm package key")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgKey := name + "@" + ver
|
||||||
|
packages[pkgKey] = pnpmPackage{Name: name, Version: ver}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toSortedSlice(packages), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse implements the PnpmLockfileParser interface for v9+ lockfiles.
|
||||||
|
func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) {
|
||||||
|
if err := yaml.Unmarshal(data, p); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal pnpm v9 lockfile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
packages := make(map[string]pnpmPackage)
|
||||||
|
|
||||||
|
// 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 := range p.Packages {
|
||||||
|
// The separator for name and version is consistently '@' in v9+ keys.
|
||||||
|
name, ver, ok := parsePnpmPackageKey(key, "@")
|
||||||
|
if !ok {
|
||||||
|
log.WithFields("key", key).Trace("unable to parse pnpm v9 package key")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgKey := name + "@" + ver
|
||||||
|
packages[pkgKey] = pnpmPackage{Name: name, Version: ver}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toSortedSlice(packages), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPnpmLockfileParser is a factory function that returns the correct parser for the given lockfile version.
|
||||||
|
func newPnpmLockfileParser(version float64) pnpmLockfileParser {
|
||||||
|
if version >= 9.0 {
|
||||||
|
return &pnpmV9LockYaml{}
|
||||||
|
}
|
||||||
|
return &pnpmV6LockYaml{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePnpmLock is the main parser function for pnpm-lock.yaml files.
|
||||||
func parsePnpmLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
func parsePnpmLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||||
bytes, err := io.ReadAll(reader)
|
data, err := io.ReadAll(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to load pnpm-lock.yaml file: %w", err)
|
return nil, nil, fmt.Errorf("failed to load pnpm-lock.yaml file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pkgs []pkg.Package
|
var lockfile struct {
|
||||||
var lockFile pnpmLockYaml
|
Version string `yaml:"lockfileVersion"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &lockfile); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := yaml.Unmarshal(bytes, &lockFile); err != nil {
|
version, err := strconv.ParseFloat(lockfile.Version, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid lockfile version %q: %w", lockfile.Version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := newPnpmLockfileParser(version)
|
||||||
|
pnpmPkgs, err := parser.Parse(version, data)
|
||||||
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml file: %w", err)
|
return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
lockVersion, _ := strconv.ParseFloat(lockFile.Version, 64)
|
packages := make([]pkg.Package, len(pnpmPkgs))
|
||||||
|
for i, p := range pnpmPkgs {
|
||||||
for name, info := range lockFile.Dependencies {
|
packages[i] = newPnpmPackage(ctx, resolver, reader.Location, p.Name, p.Version)
|
||||||
version := ""
|
|
||||||
|
|
||||||
switch info := info.(type) {
|
|
||||||
case string:
|
|
||||||
version = info
|
|
||||||
case map[string]interface{}:
|
|
||||||
v, ok := info["version"]
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
ver, ok := v.(string)
|
|
||||||
if ok {
|
|
||||||
version = parseVersion(ver)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Tracef("unsupported pnpm dependency type: %+v", info)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasPkg(pkgs, name, version) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pkgs = append(pkgs, newPnpmPackage(ctx, resolver, reader.Location, name, version))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
packageNameRegex := regexp.MustCompile(`^/?([^(]*)(?:\(.*\))*$`)
|
return packages, nil, unknown.IfEmptyf(packages, "unable to determine packages")
|
||||||
splitChar := "/"
|
|
||||||
if lockVersion >= 6.0 {
|
|
||||||
splitChar = "@"
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse packages from packages section of pnpm-lock.yaml
|
|
||||||
for nameVersion := range lockFile.Packages {
|
|
||||||
nameVersion = packageNameRegex.ReplaceAllString(nameVersion, "$1")
|
|
||||||
nameVersionSplit := strings.Split(strings.TrimPrefix(nameVersion, "/"), splitChar)
|
|
||||||
|
|
||||||
// last element in split array is version
|
|
||||||
version := nameVersionSplit[len(nameVersionSplit)-1]
|
|
||||||
|
|
||||||
// construct name from all array items other than last item (version)
|
|
||||||
name := strings.Join(nameVersionSplit[:len(nameVersionSplit)-1], splitChar)
|
|
||||||
|
|
||||||
if hasPkg(pkgs, name, version) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pkgs = append(pkgs, newPnpmPackage(ctx, resolver, reader.Location, name, version))
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg.Sort(pkgs)
|
|
||||||
|
|
||||||
return pkgs, nil, unknown.IfEmptyf(pkgs, "unable to determine packages")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasPkg(pkgs []pkg.Package, name, version string) bool {
|
// parseVersionField extracts the version string from a dependency entry.
|
||||||
for _, p := range pkgs {
|
func parseVersionField(name string, info interface{}) (string, error) {
|
||||||
if p.Name == name && p.Version == version {
|
switch v := info.(type) {
|
||||||
return true
|
case string:
|
||||||
|
return v, nil
|
||||||
|
case map[string]interface{}:
|
||||||
|
if ver, ok := v["version"].(string); ok {
|
||||||
|
// e.g., "1.2.3(react@17.0.0)" -> "1.2.3"
|
||||||
|
return strings.SplitN(ver, "(", 2)[0], nil
|
||||||
}
|
}
|
||||||
|
return "", fmt.Errorf("version field is not a string for %q", name)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported dependency type %T for %q", info, name)
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseVersion(version string) string {
|
// parsePnpmPackageKey extracts the package name and version from a lockfile package key.
|
||||||
return strings.SplitN(version, "(", 2)[0]
|
// Handles formats like:
|
||||||
|
// - /@babel/runtime/7.16.7
|
||||||
|
// - /@types/node@14.18.12
|
||||||
|
// - /is-glob@4.0.3
|
||||||
|
// - /@babel/helper-plugin-utils@7.24.7(@babel/core@7.24.7)
|
||||||
|
func parsePnpmPackageKey(key, separator string) (name, version string, ok bool) {
|
||||||
|
// Strip peer dependency information, e.g., (...)
|
||||||
|
key = strings.SplitN(key, "(", 2)[0]
|
||||||
|
|
||||||
|
// Strip leading slash
|
||||||
|
key = strings.TrimPrefix(key, "/")
|
||||||
|
|
||||||
|
parts := strings.Split(key, separator)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
version = parts[len(parts)-1]
|
||||||
|
name = strings.Join(parts[:len(parts)-1], separator)
|
||||||
|
|
||||||
|
return name, version, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
for _, p := range packages {
|
||||||
|
pkgs = append(pkgs, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(pkgs, func(i, j int) bool {
|
||||||
|
if pkgs[i].Name == pkgs[j].Name {
|
||||||
|
return pkgs[i].Version < pkgs[j].Version
|
||||||
|
}
|
||||||
|
return pkgs[i].Name < pkgs[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return pkgs
|
||||||
}
|
}
|
||||||
|
|||||||
@ -145,6 +145,50 @@ func TestParsePnpmV6Lock(t *testing.T) {
|
|||||||
pkgtest.TestFileParser(t, fixture, parsePnpmLock, expectedPkgs, expectedRelationships)
|
pkgtest.TestFileParser(t, fixture, parsePnpmLock, expectedPkgs, expectedRelationships)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParsePnpmLockV9(t *testing.T) {
|
||||||
|
var expectedRelationships []artifact.Relationship
|
||||||
|
fixture := "test-fixtures/pnpm-v9/pnpm-lock.yaml"
|
||||||
|
locationSet := file.NewLocationSet(file.NewLocation(fixture))
|
||||||
|
|
||||||
|
expected := []pkg.Package{
|
||||||
|
{
|
||||||
|
Name: "@babel/core",
|
||||||
|
Version: "7.24.7",
|
||||||
|
PURL: "pkg:npm/%40babel/core@7.24.7",
|
||||||
|
Locations: locationSet,
|
||||||
|
Language: pkg.JavaScript,
|
||||||
|
Type: pkg.NpmPkg,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "@babel/helper-plugin-utils",
|
||||||
|
Version: "7.24.7",
|
||||||
|
PURL: "pkg:npm/%40babel/helper-plugin-utils@7.24.7",
|
||||||
|
Locations: locationSet,
|
||||||
|
Language: pkg.JavaScript,
|
||||||
|
Type: pkg.NpmPkg,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "is-positive",
|
||||||
|
Version: "3.1.0",
|
||||||
|
PURL: "pkg:npm/is-positive@3.1.0",
|
||||||
|
Locations: locationSet,
|
||||||
|
Language: pkg.JavaScript,
|
||||||
|
Type: pkg.NpmPkg,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "rollup",
|
||||||
|
Version: "4.18.0",
|
||||||
|
PURL: "pkg:npm/rollup@4.18.0",
|
||||||
|
Locations: locationSet,
|
||||||
|
Language: pkg.JavaScript,
|
||||||
|
Type: pkg.NpmPkg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: no relationships are under test
|
||||||
|
pkgtest.TestFileParser(t, fixture, parsePnpmLock, expected, expectedRelationships)
|
||||||
|
}
|
||||||
|
|
||||||
func Test_corruptPnpmLock(t *testing.T) {
|
func Test_corruptPnpmLock(t *testing.T) {
|
||||||
pkgtest.NewCatalogTester().
|
pkgtest.NewCatalogTester().
|
||||||
FromFile(t, "test-fixtures/corrupt/pnpm-lock.yaml").
|
FromFile(t, "test-fixtures/corrupt/pnpm-lock.yaml").
|
||||||
|
|||||||
31
syft/pkg/cataloger/javascript/test-fixtures/pnpm-v9/pnpm-lock.yaml
generated
Normal file
31
syft/pkg/cataloger/javascript/test-fixtures/pnpm-v9/pnpm-lock.yaml
generated
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
importers:
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
is-positive:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
|
rollup:
|
||||||
|
specifier: ^4.18.0
|
||||||
|
version: 4.18.0
|
||||||
|
|
||||||
|
packages:
|
||||||
|
/@babel/core@7.24.7:
|
||||||
|
resolution: {integrity: sha512-4RjkiFFI42+268iBv2nC+iMLTJGQW3u9P7YvA3x/6MDrJ9IYZ8I/xx5a2GIhY5gBTOcI4iC5S5in2fGjE+P4Yw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@babel/helper-plugin-utils@7.24.7(@babel/core@7.24.7):
|
||||||
|
resolution: {integrity: sha512-8A2+zKm53/3w4rwbX11FMW/yFS6c5Vam02P/dw01aK6KbwkKqBaIt3eEATiKtn9I2uS1itk8/aZ2yZ/kURee4Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/is-positive@3.1.0:
|
||||||
|
resolution: {integrity: sha512-9ffLCf_f5sopimAhg2g91a7b9Rw5A1aA9eI6S391S3VEzYw99I3iKjcZGxLp25s0cRxNBV5aL2mhn7421SSlA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/rollup@4.18.0:
|
||||||
|
resolution: {integrity: sha512-QpQY2Q5i0y0Q3RoAvoChE/R5iN2k05N//bNvQbC2XvRjHFT1qWJ2r3n1bNqE+gGRJaeuQf0BxE42D7CyuLh3ZQ==}
|
||||||
|
hasBin: true
|
||||||
|
dev: false
|
||||||
Loading…
x
Reference in New Issue
Block a user