Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nodejs): add v9 pnpm lock file support #6617

Merged
merged 15 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/docs/coverage/language/nodejs.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ The following scanners are supported.

The following table provides an outline of the features Trivy offers.

| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position |
|:---------------:|-------------------|:-----------------------:|:-----------------:|:------------------------------------:|:--------:|
| npm | package-lock.json | ✓ | [Excluded](#npm) | ✓ | ✓ |
| Yarn | yarn.lock | ✓ | [Excluded](#yarn) | ✓ | ✓ |
| pnpm | pnpm-lock.yaml | ✓ | Excluded | ✓ | - |
| Bun | yarn.lock | ✓ | [Excluded](#yarn) | ✓ | ✓ |
| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position |
|:---------------:|-------------------|:-----------------------:|:---------------------------------:|:------------------------------------:|:--------:|
| npm | package-lock.json | ✓ | [Excluded](#npm) | ✓ | ✓ |
| Yarn | yarn.lock | ✓ | [Excluded](#yarn) | ✓ | ✓ |
| pnpm | pnpm-lock.yaml | ✓ | [Excluded](#lock-file-v9-version) | ✓ | - |
| Bun | yarn.lock | ✓ | [Excluded](#yarn) | ✓ | ✓ |

In addition, Trivy scans installed packages with `package.json`.

Expand Down Expand Up @@ -55,8 +55,8 @@ By default, Trivy doesn't report development dependencies. Use the `--include-de
### pnpm
Trivy parses `pnpm-lock.yaml`, then finds production dependencies and builds a [tree][dependency-graph] of dependencies with vulnerabilities.

!!! note
Trivy currently only supports Lockfile [v6][pnpm-lockfile-v6] or earlier.
#### lock file v9 version
Trivy supports `Dev` field for `pnpm-lock.yaml` v9 or later. Use the `--include-dev-deps` flag to include the developer's dependencies in the result.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not seen any requests to use Dev field for version 6.0 or earlier lock files.
But if users want, we can add this in another PR.


### Bun
Trivy supports scanning `yarn.lock` files generated by [Bun](https://bun.sh/docs/install/lockfile#how-do-i-inspect-bun-s-lockfile). You can use the command `bun install -y` to generate a Yarn-compatible `yarn.lock`.
Expand Down
278 changes: 236 additions & 42 deletions pkg/dependency/parser/nodejs/pnpm/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package pnpm

import (
"fmt"
"sort"
"strconv"
"strings"

"github.com/samber/lo"
"golang.org/x/exp/maps"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"golang.org/x/exp/maps"
"maps"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is weird, but maps package doesn't have Values and Keys functions:
https://pkg.go.dev/maps

"golang.org/x/xerrors"
"gopkg.in/yaml.v3"

Expand Down Expand Up @@ -34,6 +36,28 @@ type LockFile struct {
Dependencies map[string]any `yaml:"dependencies,omitempty"`
DevDependencies map[string]any `yaml:"devDependencies,omitempty"`
Packages map[string]PackageInfo `yaml:"packages,omitempty"`

// V9
Importers Importer `yaml:"importers,omitempty"`
Snapshots map[string]Snapshot `yaml:"snapshots,omitempty"`
}

type Importer struct {
Root RootImporter `yaml:".,omitempty"`
}

type RootImporter struct {
Dependencies map[string]ImporterDepVersion `yaml:"dependencies,omitempty"`
DevDependencies map[string]ImporterDepVersion `yaml:"devDependencies,omitempty"`
}

type ImporterDepVersion struct {
Version string `yaml:"version,omitempty"`
}

type Snapshot struct {
Dependencies map[string]string `yaml:"dependencies,omitempty"`
OptionalDependencies map[string]string `yaml:"optionalDependencies,omitempty"`
}

type Parser struct {
Expand All @@ -57,8 +81,16 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
return nil, nil, nil
}

pkgs, deps := p.parse(lockVer, lockFile)
var pkgs []ftypes.Package
var deps []ftypes.Dependency
if lockVer >= 9 {
pkgs, deps = p.parseV9(lockFile)
} else {
pkgs, deps = p.parse(lockVer, lockFile)
}

sort.Sort(ftypes.Packages(pkgs))
sort.Sort(ftypes.Dependencies(deps))
return pkgs, deps, nil
}

Expand All @@ -78,9 +110,11 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
// cf. https://github.com/pnpm/spec/blob/274ff02de23376ad59773a9f25ecfedd03a41f64/lockfile/6.0.md#packagesdependencypathname
name := info.Name
version := info.Version
var ref string

if name == "" {
name, version = p.parsePackage(depPath, lockVer)
name, version, ref = p.parseDepPath(depPath, lockVer)
version = p.parseVersion(depPath, version, lockVer)
}
pkgID := packageID(name, version)

Expand All @@ -90,13 +124,15 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
}

pkgs = append(pkgs, ftypes.Package{
ID: pkgID,
Name: name,
Version: version,
Relationship: lo.Ternary(isDirectPkg(name, lockFile.Dependencies), ftypes.RelationshipDirect, ftypes.RelationshipIndirect),
ID: pkgID,
Name: name,
Version: version,
Relationship: lo.Ternary(isDirectPkg(name, lockFile.Dependencies), ftypes.RelationshipDirect, ftypes.RelationshipIndirect),
ExternalReferences: toExternalRefs(ref),
})

if len(dependencies) > 0 {
sort.Strings(dependencies)
deps = append(deps, ftypes.Dependency{
ID: pkgID,
DependsOn: dependencies,
Expand All @@ -107,6 +143,98 @@ func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []
return pkgs, deps
}

func (p *Parser) parseV9(lockFile LockFile) ([]ftypes.Package, []ftypes.Dependency) {
lockVer := 9.0
resolvedPkgs := make(map[string]ftypes.Package)
resolvedDeps := make(map[string]ftypes.Dependency)

// Check all snapshots and save with resolved versions
resolvedSnapshots := make(map[string][]string)
for depPath, snapshot := range lockFile.Snapshots {
name, version, _ := p.parseDepPath(depPath, lockVer)

var dependsOn []string
for depName, depVer := range lo.Assign(snapshot.OptionalDependencies, snapshot.Dependencies) {
depVer = p.trimPeerDeps(depVer, lockVer) // pnpm has already separated dep name. therefore, we only need to separate peer deps.
depVer = p.parseVersion(depPath, depVer, lockVer)
id := packageID(depName, depVer)
if _, ok := lockFile.Packages[id]; ok {
dependsOn = append(dependsOn, id)
}
}
if len(dependsOn) > 0 {
resolvedSnapshots[packageID(name, version)] = dependsOn
}

}

for depPath, pkgInfo := range lockFile.Packages {
name, ver, ref := p.parseDepPath(depPath, lockVer)
parsedVer := p.parseVersion(depPath, ver, lockVer)

if pkgInfo.Version != "" {
parsedVer = pkgInfo.Version
}

// By default, pkg is dev pkg.
// We will update `Dev` field later.
dev := true
relationship := ftypes.RelationshipIndirect
if dep, ok := lockFile.Importers.Root.DevDependencies[name]; ok && dep.Version == ver {
relationship = ftypes.RelationshipDirect
}
if dep, ok := lockFile.Importers.Root.Dependencies[name]; ok && dep.Version == ver {
relationship = ftypes.RelationshipDirect
dev = false // mark root direct deps to update `dev` field of their child deps.
}

id := packageID(name, parsedVer)
resolvedPkgs[id] = ftypes.Package{
ID: id,
Name: name,
Version: parsedVer,
Relationship: relationship,
Dev: dev,
ExternalReferences: toExternalRefs(ref),
}

// Save child deps
if dependsOn, ok := resolvedSnapshots[depPath]; ok {
sort.Strings(dependsOn)
resolvedDeps[id] = ftypes.Dependency{
ID: id,
DependsOn: dependsOn, // Deps from dependsOn has been resolved when parsing snapshots
}
}
}

// Overwrite the `Dev` field for dev deps and their child dependencies.
for _, pkg := range resolvedPkgs {
if !pkg.Dev {
p.markRootPkgs(pkg.ID, resolvedPkgs, resolvedDeps)
}
}

return maps.Values(resolvedPkgs), maps.Values(resolvedDeps)
}

// markRootPkgs sets `Dev` to false for non dev dependency.
func (p *Parser) markRootPkgs(id string, pkgs map[string]ftypes.Package, deps map[string]ftypes.Dependency) {
pkg, ok := pkgs[id]
if !ok {
return
}

pkg.Dev = false
pkgs[id] = pkg

// Update child deps
for _, depID := range deps[id].DependsOn {
p.markRootPkgs(depID, pkgs, deps)
}
return
}

func (p *Parser) parseLockfileVersion(lockFile LockFile) float64 {
switch v := lockFile.LockfileVersion.(type) {
// v5
Expand All @@ -127,55 +255,109 @@ func (p *Parser) parseLockfileVersion(lockFile LockFile) float64 {
}
}

// cf. https://github.com/pnpm/pnpm/blob/ce61f8d3c29eee46cee38d56ced45aea8a439a53/packages/dependency-path/src/index.ts#L112-L163
func (p *Parser) parsePackage(depPath string, lockFileVersion float64) (string, string) {
// The version separator is different between v5 and v6+.
versionSep := "@"
if lockFileVersion < 6 {
versionSep = "/"
func (p *Parser) parseDepPath(depPath string, lockVer float64) (string, string, string) {
dPath, nonDefaultRegistry := p.trimRegistry(depPath, lockVer)

var scope string
scope, dPath = p.separateScope(dPath)

var name string
name, dPath = p.separateName(dPath, lockVer)

// add scope to pkg name
if scope != "" {
name = fmt.Sprintf("%s/%s", scope, name)
}
return p.parseDepPath(depPath, versionSep)

ver := p.trimPeerDeps(dPath, lockVer)

return name, ver, lo.Ternary(nonDefaultRegistry, depPath, "")
}

func (p *Parser) parseDepPath(depPath, versionSep string) (string, string) {
// Skip registry
// e.g.
// - "registry.npmjs.org/lodash/4.17.10" => "lodash/4.17.10"
// - "registry.npmjs.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9"
// - "/lodash/4.17.10" => "lodash/4.17.10"
_, depPath, _ = strings.Cut(depPath, "/")
// trimRegistry trims registry (or `/` prefix) for depPath.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PURL has vcs_url. I think we should keep this information if it is not registry.npmjs.org.
https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#npm

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if using registry URL in package name is correct.
I suggest keeping depPath in ExternalReferences
and check ExternalReferences in purl for npm:

trivy/pkg/purl/purl.go

Lines 425 to 429 in bbaf595

func parseNpm(pkgName string) (string, string) {
// the name must be lowercased
name := strings.ToLower(pkgName)
return parsePkgName(name)
}

e.g.
private.npmjs.org/@babel/runtime@7.18.3 =>

ID:           "@babel/runtime@7.18.3",
Name:         "@babel/runtime",
Version:      "7.18.3",
Relationship: ftypes.RelationshipIndirect,
ExternalReferences: []ftypes.ExternalRef{
	{
		Type: ftypes.RefVCS,
		URL:  "private.npmjs.org/@babel/runtime@7.18.3",
	},
},

@knqyf263 wdyt?

Copy link
Collaborator

@knqyf263 knqyf263 May 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I meant to suggest using ExternalReferences. I didn't explain clearly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I update this logic - 2d50522
take a look, when you have time, please

// It returns true if non-default registry has been trimmed.
// e.g.
// - "registry.npmjs.org/lodash/4.17.10" => "lodash/4.17.10", false
// - "registry.npmjs.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9", false
// - "private.npm.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9", true
// - "/lodash/4.17.10" => "lodash/4.17.10", false
// - "/asap@2.0.6" => "asap@2.0.6", false
func (p *Parser) trimRegistry(depPath string, lockVer float64) (string, bool) {
var nonDefaultRegistry bool
// lock file v9 doesn't use registry prefix
if lockVer < 9 {
var registry string
registry, depPath, _ = strings.Cut(depPath, "/")
if registry != "" && registry != "registry.npmjs.org" {
nonDefaultRegistry = true
}
}
return depPath, nonDefaultRegistry
}

// Parse scope
// e.g.
// - v5: "@babel/generator/7.21.9" => {"babel", "generator/7.21.9"}
// - v6+: "@babel/helper-annotate-as-pure@7.18.6" => "{"babel", "helper-annotate-as-pure@7.18.6"}
// separateScope separates the scope (if set) from the rest of the depPath.
// e.g.
// - v5: "@babel/generator/7.21.9" => {"babel", "generator/7.21.9"}
// - v6+: "@babel/helper-annotate-as-pure@7.18.6" => "{"babel", "helper-annotate-as-pure@7.18.6"}
func (p *Parser) separateScope(depPath string) (string, string) {
var scope string
if strings.HasPrefix(depPath, "@") {
scope, depPath, _ = strings.Cut(depPath, "/")
}
return scope, depPath
}

// Parse package name
// e.g.
// - v5: "generator/7.21.9" => {"generator", "7.21.9"}
// - v6+: "helper-annotate-as-pure@7.18.6" => {"helper-annotate-as-pure", "7.18.6"}
var name, version string
name, version, _ = strings.Cut(depPath, versionSep)
if scope != "" {
name = fmt.Sprintf("%s/%s", scope, name)
// separateName separates pkg name and version.
// e.g.
// - v5: "generator/7.21.9" => {"generator", "7.21.9"}
// - v6+: "7.21.5(@babel/core@7.20.7)" => "7.21.5"
//
// for v9+ version can be filePath or link:
// - "package1@file:package1:"
// - "is-negative@https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5"
//
// Also version can contain peer deps:
// - "debug@4.3.4(supports-color@8.1.1)"
func (p *Parser) separateName(depPath string, lockVer float64) (string, string) {
sep := "@"
if lockVer < 6 {
sep = "/"
}
name, version, _ := strings.Cut(depPath, sep)
return name, version
}

// Trim peer deps
// e.g.
// - v5: "7.21.5_@babel+core@7.21.8" => "7.21.5"
// - v6+: "7.21.5(@babel/core@7.20.7)" => "7.21.5"
func (p *Parser) trimPeerDeps(depPath string, lockVer float64) string {
sep := "("
if lockVer < 6 {
sep = "_"
}
// Trim peer deps
// e.g.
// - v5: "7.21.5_@babel+core@7.21.8" => "7.21.5"
// - v6+: "7.21.5(@babel/core@7.20.7)" => "7.21.5"
if idx := strings.IndexAny(version, "_("); idx != -1 {
version = version[:idx]
version, _, _ := strings.Cut(depPath, sep)
return version
}

// parseVersion parses version.
// v9 can use filePath or link as version - we need to clear these versions.
// e.g.
// - "package1@file:package1:"
// - "is-negative@https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5"
//
// Other versions should be semver valid.
func (p *Parser) parseVersion(depPath, ver string, lockVer float64) string {
if lockVer < 9 && (strings.HasPrefix(ver, "file:") || strings.HasPrefix(ver, "http")) {
return ""
}
if _, err := semver.Parse(version); err != nil {
if _, err := semver.Parse(ver); err != nil {
p.logger.Debug("Skip non-semver package", log.String("pkg_path", depPath),
log.String("version", version), log.Err(err))
return "", ""
log.String("version", ver), log.Err(err))
return ""
}
return name, version

return ver
}

func isDirectPkg(name string, directDeps map[string]interface{}) bool {
Expand All @@ -186,3 +368,15 @@ func isDirectPkg(name string, directDeps map[string]interface{}) bool {
func packageID(name, version string) string {
return dependency.ID(ftypes.Pnpm, name, version)
}

func toExternalRefs(ref string) []ftypes.ExternalRef {
if ref == "" {
return nil
}
return []ftypes.ExternalRef{
{
Type: ftypes.RefVCS,
URL: ref,
},
}
}
Loading