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
pnpm workspaces allow the same package to be resolved to different versions across different workspace members (importers), whether as a
dependencyordevDependency. 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:
Run Trivy a few times:
Expected
Trivy detects both
axios@0.21.4andaxios@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:
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