Skip to content

pnpm cataloger: a devEngines.runtime entry aborts the whole document, yielding an empty SBOM #5241

Description

@Brian-Bowyer

What happened:

A pnpm-lock.yaml that contains a devEngines.runtime entry (pnpm's Node runtime self-management) produces an empty SBOM. Syft exits 0 and writes a well-formed document, so nothing signals that every project dependency was dropped. On a real ~1200-package lockfile this silently yields 0 packages.

The cause is a type mismatch that is then treated as fatal for the whole document. pnpm records a provisioned runtime with a variations resolution, where variants is a sequence:

node@runtime:26.8.1:
  resolution:
    type: variations
    variants:
      - resolution:
          archive: tarball
          integrity: sha256-srdmYPpN7U4LKkHuPAxlHNUuqBcOrZHrrB4UesPVVkM=
          type: binary
          url: https://nodejs.org/download/release/v26.8.1/node-v26.8.1-linux-x64.tar.gz
        targets:
          - cpu: x64
            os: linux

pnpmV9PackageEntry.Resolution is declared as map[string]string in syft/pkg/cataloger/javascript/parse_pnpm_lock.go, so the sequence cannot be decoded:

failed to unmarshal pnpm v9 lockfile: yaml: unmarshal errors:
  line 29: cannot unmarshal !!seq into string

On its own that would be harmless — yaml.v3 reports this as a *yaml.TypeError and still populates every other entry. The problem is that Parse treats any error as fatal and discards the decoded result:

func (p *pnpmV9LockYaml) Parse(_ float64, doc *yaml.Node) ([]pnpmPackage, error) {
	if err := doc.Decode(p); err != nil {
		return nil, fmt.Errorf("failed to unmarshal pnpm v9 lockfile: %w", err)
	}

So one unrepresentable entry throws away all the others. The multi-document loop then records the error and moves on, leaving nothing behind.

What you expected to happen:

The entries that decode correctly should still be cataloged. An entry Syft cannot represent should be skipped (ideally with a warning), not cause the rest of the lockfile to be dropped.

Steps to reproduce the issue:

pnpm-lock.yaml:

lockfileVersion: '9.0'

settings:
  autoInstallPeers: true
  excludeLinksFromLockfile: false

importers:

  .:
    dependencies:
      is-odd:
        specifier: 3.0.1
        version: 3.0.1
    devDependencies:
      node:
        specifier: runtime:^26.8.1
        version: runtime:26.8.1

packages:

  is-odd@3.0.1:
    resolution: {integrity: sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==}
    engines: {node: '>=4'}

  node@runtime:26.8.1:
    resolution:
      type: variations
      variants:
        - resolution:
            archive: tarball
            bin:
              node: bin/node
            integrity: sha256-srdmYPpN7U4LKkHuPAxlHNUuqBcOrZHrrB4UesPVVkM=
            type: binary
            url: https://nodejs.org/download/release/v26.8.1/node-v26.8.1-linux-x64.tar.gz
          targets:
            - cpu: x64
              os: linux
    version: 26.8.1
    hasBin: true

snapshots:

  is-odd@3.0.1: {}

  node@runtime:26.8.1: {}
$ syft pnpm-lock.yaml -o json | jq '.artifacts | length'
0
$ echo $?
0

Deleting the node@runtime:26.8.1 entries yields the expected is-odd. The error is only visible at -vvv:

TRACE cataloger returned errors cataloger=javascript-lock-cataloger
  error=/pnpm-lock.yaml: failed to parse pnpm-lock.yaml document 0:
  failed to unmarshal pnpm v9 lockfile: yaml: unmarshal errors:
  line 29: cannot unmarshal !!seq into string

Anything else we need to know?:

A possible fix is to tolerate *yaml.TypeError in pnpmV9LockYaml.Parse (and pnpmV6LockYaml.Parse) — log it and keep the entries that decoded — rather than returning early. Typing Resolution as map[string]any would additionally let variations resolutions decode cleanly.

This compounds with #5168. A lockfile can hit both at once: pnpm writes its package-manager block as a separate leading YAML document and a devEngines.runtime entry in the main one. With the #5168 fix in 1.51.1, such a lockfile now parses document 0 (pnpm's own binaries) and fails document 1 (the actual dependency tree), so the SBOM contains only pnpm's release binaries. We hit exactly this on a real project: 1176 packages became 19.

Because the result is a valid-looking, non-empty SBOM, this is easy to ship into dependency-graph or vulnerability tooling without noticing that coverage is gone.

Environment:

  • Output of syft version: 1.51.1 (also reproduces conceptually on 1.33.0, which fails differently — it parses the entry into pkg:npm/node@runtime%3A26.7.0)
  • OS: Ubuntu (Linux 6.8.0, amd64)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    • Status
      No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions