Skip to content

bug(pnpm): overlapping dependencies across importers cause non-deterministic resolution #10910

Description

@nikpivkin

pnpm workspaces allow the same package to be resolved to different versions across different workspace members (importers), whether as a dependency or devDependency. When parsing the lockfile's importers section, dependencies from all importers are merged into a single flat map keyed by package name, so when the same package name appears in more than one importer, later entries silently overwrite earlier ones.

Since map iteration order in Go is randomized, the version that "wins" is non-deterministic across runs.

This flat map is then used by the downstream relationship classification logic, which compares a resolved package version against deps[name]/devDeps[name] as a single value. Packages whose version doesn't match the one stored in the map fail this check and are not marked as direct dependencies, even though they are — so the scanner reports a different, incomplete set of direct dependencies and their vulnerabilities each run.

Steps to reproduce

Setup a project:

mkdir app-a app-b

cat > pnpm-workspace.yaml <<EOF
packages:
  - app-a
  - app-b
EOF

(cd app-a && pnpm init && pnpm add axios@0.21.4)
(cd app-b && pnpm init && pnpm add axios@1.7.7)

Run Trivy a few times:

trivy fs . -q -f cyclonedx -o report.json && jq -r '.components[] | select(.["bom-ref"] | startswith("pkg:npm/axios"))' report.json

Expected

Trivy detects both axios@0.21.4 and axios@1.7.7, reporting vulnerabilities for both versions consistently across runs.

Actual

Trivy reports vulnerabilities for only one of the two versions, chosen non-deterministically.

Suggested Fix

The aggregation logic should preserve all versions encountered across importers instead of collapsing them into a single value per package name:

deps := make(map[string]set.Set[string])    // name -> set of trimmed versions
devDeps := make(map[string]set.Set[string]) // name -> set of trimmed versions

for _, importer := range lockFile.Importers {
    for n, v := range importer.Dependencies {
        if deps[n] == nil {
            deps[n] = set.New[string]()
        }
        deps[n].Append(p.trimPeerDeps(v.Version, lockVer))
    }
    for n, v := range importer.DevDependencies {
        if devDeps[n] == nil {
            devDeps[n] = set.New[string]()
        }
        devDeps[n].Append(p.trimPeerDeps(v.Version, lockVer))
    }
}

...

// Check if this package matches a direct dev dependency
if versions, ok := devDeps[name]; ok && versions.Contains(version) {
    relationship = ftypes.RelationshipDirect
}
// Check if this package matches a direct production dependency
if versions, ok := deps[name]; ok && versions.Contains(version) {
    relationship = ftypes.RelationshipDirect
    dev = false // This is a production dependency, not a dev dependency
}

This ensures every version of a package resolved across any workspace importer is retained and scanned, rather than being overwritten depending on map iteration order.

Discussed in #10895

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/bugCategorizes issue or PR as related to a bug.scan/vulnerabilityIssues relating to vulnerability scanningtarget/filesystemIssues relating to filesystem scanning

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions