feat(sbom): add --exclude-peers to omit peer dependencies#12443
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a ChangesSBOM peer-dependency exclusion
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code Review by Qodo
1. Peers leak via workspace links
|
PR Summary by Qodofeat(sbom): add --exclude-peers to omit peer dependencies WalkthroughsDescription• Add pnpm sbom --exclude-peers to omit peer dependencies from generated SBOMs. • Prune any transitive dependency subtrees reachable only through omitted peers. • Add fixtures and tests covering default behavior, single-project, and workspace importers. Diagramgraph TD
A["pnpm sbom CLI"] --> B["sbom.ts handler"] --> C[("pnpm-lock.yaml")]
B -. "--exclude-peers" .-> D["Read manifests & build peer-name map"] --> E["collectSbomComponents()"] --> F["Serialize CycloneDX/SPDX"] --> G["SBOM output"]
C --> E
High-Level AssessmentThe following are alternative approaches to this PR: 1. Post-filter peers at serialization time
2. Persist peer markers in the lockfile format
3. Model peers explicitly via SBOM metadata/extensions
Recommendation: The implemented approach—deriving peers from manifests and filtering them at each importer’s root step—is the best fit for CycloneDX 1.7 constraints. Filtering at the root naturally prunes exclusive peer subtrees while preserving packages also reachable via real dependencies, and avoids generating non-standard SBOM semantics. File ChangesEnhancement (2)
Tests (7)
Documentation (1)
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@deps/compliance/commands/src/sbom/sbom.ts`:
- Around line 226-227: The importerId values from lockfile.importers are
untrusted user-controlled input that are directly concatenated into a filesystem
path via path.join(lockfileDir, importerId) without validation, creating a path
traversal vulnerability. Before using importerId in the path.join call, validate
that the importerId does not contain path traversal sequences (such as `..` or
absolute paths) and ensure the resolved path remains within the intended
lockfileDir boundary. Consider using path.resolve() and checking that the final
path is still within lockfileDir, or normalize and validate the importerId to
reject any parent directory references or absolute paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 083d4640-5d7c-463e-b9ba-d7cc0fa28579
⛔ Files ignored due to path filters (2)
deps/compliance/commands/test/sbom/fixtures/with-peer-dependency/pnpm-lock.yamlis excluded by!**/pnpm-lock.yamldeps/compliance/commands/test/sbom/fixtures/with-peer-workspace/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (8)
.changeset/sbom-exclude-peers.mddeps/compliance/commands/src/sbom/sbom.tsdeps/compliance/commands/test/sbom/fixtures/with-peer-dependency/package.jsondeps/compliance/commands/test/sbom/fixtures/with-peer-workspace/package.jsondeps/compliance/commands/test/sbom/fixtures/with-peer-workspace/packages/pkg-a/package.jsondeps/compliance/commands/test/sbom/fixtures/with-peer-workspace/pnpm-workspace.yamldeps/compliance/commands/test/sbom/index.tsdeps/compliance/sbom/src/collectComponents.ts
5dcd70c to
2938d2b
Compare
|
Code review by qodo was updated up to the latest commit 2938d2b |
2938d2b to
c11b631
Compare
|
Code review by qodo was updated up to the latest commit c11b631 |
|
Code review by qodo was updated up to the latest commit c0c0fbe |
|
Code review by qodo was updated up to the latest commit 577dcd7 |
Integrated-Benchmark Report (Linux)Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD. Scenario: Isolated linker: fresh restore, cold cache + cold store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 3.8771818994200005,
"stddev": 0.19132658616800066,
"median": 3.8908016551199998,
"user": 3.72067974,
"system": 3.294670600000001,
"min": 3.65467230362,
"max": 4.2440559676200005,
"times": [
4.03132368462,
3.8747459636199997,
3.6641170016199998,
4.2440559676200005,
3.90685734662,
3.65467230362,
3.6851574176199997,
4.03214460662,
3.76335660162,
3.91538810062
]
},
{
"command": "pacquet@main",
"mean": 3.82638520002,
"stddev": 0.13911839516955748,
"median": 3.82110589712,
"user": 3.7134392399999996,
"system": 3.2945211,
"min": 3.6409695576199996,
"max": 4.05983800062,
"times": [
3.82970993062,
3.7244031766199996,
3.81250186362,
3.6409695576199996,
4.05983800062,
3.76435478162,
3.6875266556199997,
3.86179961162,
4.05062953662,
3.83211888562
]
},
{
"command": "pnpr@HEAD",
"mean": 2.1605805230199997,
"stddev": 0.1295399342889556,
"median": 2.1502036236199995,
"user": 2.73290714,
"system": 2.8985735,
"min": 1.93707991862,
"max": 2.3823141456199997,
"times": [
2.1601854226199997,
2.3305520976199996,
2.18958454162,
2.1977496246199997,
1.93707991862,
2.1402218246199998,
2.3823141456199997,
2.0976179386199996,
2.13557670162,
2.03492301462
]
},
{
"command": "pnpr@main",
"mean": 2.1409734233199997,
"stddev": 0.09964787562666778,
"median": 2.1329728276199997,
"user": 2.72221864,
"system": 2.8749508,
"min": 1.93855521162,
"max": 2.2877872186199997,
"times": [
2.24222738162,
2.21415475262,
2.09640031962,
2.18517495362,
2.11892101562,
2.14326984462,
2.1226758106199997,
2.06056772462,
1.93855521162,
2.2877872186199997
]
}
]
}Scenario: Isolated linker: fresh restore, hot cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.6551662101200001,
"stddev": 0.012668461411135258,
"median": 0.6545348279200001,
"user": 0.39927836,
"system": 1.3264312,
"min": 0.63925856392,
"max": 0.6802232929200001,
"times": [
0.6444768619200001,
0.65690770292,
0.6453114479200001,
0.65216195292,
0.64756391792,
0.63925856392,
0.67176606392,
0.65691123092,
0.65708106592,
0.6802232929200001
]
},
{
"command": "pacquet@main",
"mean": 0.67070147422,
"stddev": 0.03814846376323452,
"median": 0.6606652044200001,
"user": 0.40160076,
"system": 1.3403623,
"min": 0.64363387192,
"max": 0.77401236192,
"times": [
0.66260246992,
0.64363387192,
0.6442485839200001,
0.64941134192,
0.67044207792,
0.65872793892,
0.6792593049200001,
0.77401236192,
0.65469697892,
0.66997981192
]
},
{
"command": "pnpr@HEAD",
"mean": 0.76453283022,
"stddev": 0.03535497633176031,
"median": 0.7573952419200001,
"user": 0.4204175600000001,
"system": 1.416814,
"min": 0.7259610189200001,
"max": 0.85507469292,
"times": [
0.73484744792,
0.77588107892,
0.75141612692,
0.74978315892,
0.75875962192,
0.76541531992,
0.7259610189200001,
0.75603086192,
0.77215897392,
0.85507469292
]
},
{
"command": "pnpr@main",
"mean": 0.7403419258199999,
"stddev": 0.08112485985221674,
"median": 0.7106749934200001,
"user": 0.40275216,
"system": 1.3669529999999999,
"min": 0.6911921079200001,
"max": 0.96458236592,
"times": [
0.7501824889200001,
0.70122129292,
0.7069867279200001,
0.6911921079200001,
0.71946338992,
0.70252946092,
0.70564923292,
0.7472489319200001,
0.96458236592,
0.7143632589200001
]
}
]
}Scenario: Isolated linker: fresh install, cold cache + cold store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 4.306930792359999,
"stddev": 0.06301262527906629,
"median": 4.317674954059999,
"user": 3.87723234,
"system": 3.32025418,
"min": 4.22008505256,
"max": 4.38257837656,
"times": [
4.3489618515599995,
4.22008505256,
4.381729237559999,
4.27023510956,
4.3008865385599995,
4.38257837656,
4.24423158556,
4.33446336956,
4.35884259956,
4.2272942025599995
]
},
{
"command": "pacquet@main",
"mean": 4.35422847936,
"stddev": 0.044474104524407455,
"median": 4.35809049606,
"user": 3.91893594,
"system": 3.3388936799999995,
"min": 4.271066429559999,
"max": 4.42497500056,
"times": [
4.383454759559999,
4.31944848956,
4.33195580456,
4.271066429559999,
4.38514310356,
4.35065408956,
4.42497500056,
4.3883707125599996,
4.36552690256,
4.32168950156
]
},
{
"command": "pnpr@HEAD",
"mean": 2.2216045899599997,
"stddev": 0.12006813583144445,
"median": 2.19313280856,
"user": 2.58389384,
"system": 2.8502824799999997,
"min": 2.08384679456,
"max": 2.43502399056,
"times": [
2.19440614156,
2.43502399056,
2.13552700556,
2.14147658556,
2.25201281356,
2.34235337656,
2.08384679456,
2.1918594755600003,
2.35064408656,
2.08889562956
]
},
{
"command": "pnpr@main",
"mean": 2.19553341856,
"stddev": 0.12332112881635343,
"median": 2.1636911435600004,
"user": 2.57516194,
"system": 2.83668058,
"min": 2.05827288356,
"max": 2.4330972545600003,
"times": [
2.4330972545600003,
2.07073787356,
2.1437336025600002,
2.07080540656,
2.2606010375600003,
2.05827288356,
2.32270473556,
2.26799910456,
2.15675662156,
2.1706256655600003
]
}
]
}Scenario: Isolated linker: fresh install, hot cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 1.40835069774,
"stddev": 0.016347953904528957,
"median": 1.40270106334,
"user": 1.40808898,
"system": 1.7350784799999999,
"min": 1.38892332634,
"max": 1.43167121834,
"times": [
1.40167890534,
1.42783682634,
1.43058867034,
1.43167121834,
1.39684780934,
1.39028018734,
1.38892332634,
1.41205099334,
1.39990581934,
1.4037232213400002
]
},
{
"command": "pacquet@main",
"mean": 1.3951979574400002,
"stddev": 0.0169237626025575,
"median": 1.3910786158400001,
"user": 1.3819017800000002,
"system": 1.72898418,
"min": 1.3703710853400002,
"max": 1.42700109634,
"times": [
1.37980336634,
1.38640978934,
1.41757441934,
1.39181288034,
1.3960312803400001,
1.42700109634,
1.39034435134,
1.4032715823400002,
1.3893597233400001,
1.3703710853400002
]
},
{
"command": "pnpr@HEAD",
"mean": 0.66322219924,
"stddev": 0.004037665618183177,
"median": 0.6629570838400001,
"user": 0.34062467999999996,
"system": 1.29610078,
"min": 0.6592119323400001,
"max": 0.6726939563400001,
"times": [
0.6637917063400001,
0.6592119323400001,
0.65942357734,
0.6649205693400001,
0.6612810073400001,
0.6594703193400001,
0.6726939563400001,
0.66438867134,
0.6649177913400001,
0.6621224613400001
]
},
{
"command": "pnpr@main",
"mean": 0.67165940124,
"stddev": 0.03356372295961125,
"median": 0.6572060423400001,
"user": 0.33613928000000004,
"system": 1.28068378,
"min": 0.6495663903400001,
"max": 0.75651856234,
"times": [
0.70137415134,
0.67385844634,
0.65904996934,
0.6532645883400001,
0.6553621153400001,
0.6495663903400001,
0.6543319793400001,
0.6614811663400001,
0.65178664334,
0.75651856234
]
}
]
}Scenario: Isolated linker: fresh install, cold cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 3.0619094478199997,
"stddev": 0.06478268757153575,
"median": 3.04130406082,
"user": 1.7859942600000003,
"system": 1.98966836,
"min": 2.99691426482,
"max": 3.2011891018200003,
"times": [
3.0473517288200003,
3.14342437582,
3.02077653182,
3.07485386882,
3.0162117088200002,
3.03525639282,
3.07159886782,
3.0115176368200003,
3.2011891018200003,
2.99691426482
]
},
{
"command": "pacquet@main",
"mean": 3.1247117832200004,
"stddev": 0.043429851059313414,
"median": 3.11386366182,
"user": 1.8512819600000001,
"system": 2.03305726,
"min": 3.0650307588200003,
"max": 3.20661986582,
"times": [
3.0650307588200003,
3.10985631482,
3.0820906258200003,
3.1178710088200003,
3.1056892208200004,
3.10003126882,
3.1427681568200003,
3.1812672278200003,
3.20661986582,
3.13589338382
]
},
{
"command": "pnpr@HEAD",
"mean": 0.6946072974199999,
"stddev": 0.007706132167413201,
"median": 0.6935387078199999,
"user": 0.35597656,
"system": 1.3326521599999999,
"min": 0.68447040882,
"max": 0.70970764882,
"times": [
0.70970764882,
0.68447040882,
0.6986277818200001,
0.70111411782,
0.68604552982,
0.69492159982,
0.69920470082,
0.69043843782,
0.69215581582,
0.68938693282
]
},
{
"command": "pnpr@main",
"mean": 0.68918460702,
"stddev": 0.01651673644437011,
"median": 0.68655845332,
"user": 0.35251765999999995,
"system": 1.3075549599999996,
"min": 0.66183009382,
"max": 0.71001798782,
"times": [
0.6817736238200001,
0.70651399182,
0.68233293482,
0.70480096882,
0.68929637282,
0.70267410582,
0.68382053382,
0.66878545682,
0.66183009382,
0.71001798782
]
}
]
} |
|
| Branch | pr/12443 |
| Testbed | pacquet |
Click to view all benchmark results
| Benchmark | Latency | Benchmark Result milliseconds (ms) (Result Δ%) | Upper Boundary milliseconds (ms) (Limit %) |
|---|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot 🚷 view threshold | 4,306.93 ms(+2.35%)Baseline: 4,207.89 ms | 5,049.47 ms (85.29%) |
| isolated-linker.fresh-install.cold-cache.hot-store | 📈 view plot 🚷 view threshold | 3,061.91 ms(+2.20%)Baseline: 2,996.01 ms | 3,595.21 ms (85.17%) |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot 🚷 view threshold | 1,408.35 ms(+6.54%)Baseline: 1,321.92 ms | 1,586.30 ms (88.78%) |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot 🚷 view threshold | 3,877.18 ms(-6.35%)Baseline: 4,139.95 ms | 4,967.94 ms (78.04%) |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot 🚷 view threshold | 655.17 ms(+6.88%)Baseline: 612.97 ms | 735.57 ms (89.07%) |
|
| Branch | pr/12443 |
| Testbed | pnpr |
⚠️ WARNING: No Threshold found!Without a Threshold, no Alerts will ever be generated.
Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the--ci-only-thresholdsflag.
Click to view all benchmark results
| Benchmark | Latency | milliseconds (ms) |
|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot | 2,221.60 ms |
| isolated-linker.fresh-install.cold-cache.hot-store | 📈 view plot | 694.61 ms |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot | 663.22 ms |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot | 2,160.58 ms |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot | 764.53 ms |
|
Code review by qodo was updated up to the latest commit 3bdede8 |
|
Code review by qodo was updated up to the latest commit 7c110d8 |
With auto-install-peers (the default), peer dependencies resolve into the lockfile and are indistinguishable from a package's own deps. The manifest's peerDependencies is the only signal, so the flag drops those names (and any transitive subtree reachable only through them) from the SBOM. CycloneDX 1.7 has no scope or relationship for consumer-provided peers, so omission is the only spec-clean handling. When no project is filtered, every importer in the lockfile is walked, so each importer's own manifest is resolved (via safeReadProjectManifestOnly, which tolerates a missing manifest) to catch peers declared in workspace sub-packages too. Importer dirs are canonicalized with realpath and checked against the project root, so a crafted lockfile cannot read a manifest outside the tree via `..` or a symlinked directory. The flag name matches pnpm list --exclude-peers; the SBOM flag is stricter, pruning a peer's exclusive subtree rather than only hiding leaf peers.
Without a filter, the peer scan reads every importer manifest in the lockfile. Cap the fan-out with p-limit(16) so a large workspace does not realpath and open every manifest at once. Addresses the review note about the unbounded realpath fan-out.
- Do not abort the whole SBOM when one importer package.json is malformed. The no-filter peer scan reads every importer manifest, and safeReadProjectManifestOnly tolerates a missing manifest but still throws on a parse error; in an untrusted clone a single junk package.json would reject the Promise.all and fail the command. Catch it per importer and skip (fail-open: that importer peers are not filtered, which only over-includes, never deletes). - Key the per-importer peer set by a Map rather than a plain object, so an attacker-controlled importer id like `__proto__` cannot reassign the container prototype.
The grouped lockfile walker shares one walked set across importers, so each importer direct deps are claimed up front. With --exclude-peers, a package that is a peer of one importer (filtered out) but a real dependency of another was dropped: the first importer marked it walked, so the second skipped it. Walk each importer with its own walked set when excluding peers so one importer peer cannot suppress another importer real dependency; componentsMap still deduplicates components across importers. Regression test: pkg-a declares is-odd as a peer, pkg-b as a real dependency; is-odd must survive --exclude-peers (verified failing before the fix).
7c110d8 to
edd3fee
Compare
|
Code review by qodo was updated up to the latest commit edd3fee |
Drop stray blank lines and restore the comments explaining why a workspace importer with no resolved package info is skipped and why peer entries are filtered from the importer's root step before walking.
|
Code review by qodo was updated up to the latest commit 17453cd |
…runs With --exclude-peers and a --filter, peer names were collected only for the selected importers, but collectSbomComponents also walks the workspace packages those importers reach through `link:` deps. Peers declared in the linked packages therefore leaked into the SBOM. Derive peer names from the in-memory project graph(s), reading from both allProjectsGraph and selectedProjectsGraph (as workspaceManifestsByImporterId already does), so every importer that may be walked is covered. This also drops the redundant per-importer realpath + manifest disk scan whenever a graph is available; the disk scan now runs only when no project graph exists.
|
Code review by qodo was updated up to the latest commit b35ac65 |
With
auto-install-peers(default since v8), peer dependencies resolve intothe lockfile indistinguishably from a package's own deps, so
pnpm sbomliststhem as the package's components. For a published-library SBOM that's wrong —
peers are supplied by the consumer.
--exclude-peersdrops them, plus anytransitive subtree reachable only through them.
peerDependencies come from the manifest (the lockfile carries no marker). The
collector filters those names at each importer's top level, so a peer's
exclusive subtree is never walked while a package also reached via a real dep
stays. With no
--filter, every importer is walked, so each importer's ownmanifest is resolved (via
safeReadProjectManifestOnly, which tolerates amissing manifest) and peers in workspace sub-packages are dropped too.
The flag name matches
pnpm list --exclude-peers; the SBOM behavior isstricter, pruning the exclusive subtree rather than only hiding leaf peers.
CycloneDX 1.7 has no scope or relationship for "consumer-provided peer", so
omission is the only spec-clean handling.
Known limitation: an aliased peer (
"x": "npm:real@1"inpeerDependencies)is not excluded, since matching is by resolved package name. Aliased peer deps
are vanishingly rare in practice.
Tests
Single-project (peer + exclusive subtree dropped, absent from
dependsOn,real deps kept), workspace (peer declared in a sub-package dropped when run at
the root with no filter), and default-includes-peers.
Written by an agent (Claude Code, claude-fable-5).
Summary by CodeRabbit
New Features
--exclude-peersoption topnpm sbomto omit peer dependencies (and any subtrees reachable only through them) from the generated SBOM.Bug Fixes
--exclude-peersis enabled.Tests