Files
pnpm/deps
Allan Kimmer Jensen 1495cb080c feat(sbom): per-package SBOM generation with --out and --split (#12097)
* feat(sbom): add monorepo workspace support for SBOM generation

When --filter selects a single workspace, the SBOM root component now
uses that workspace's name, version, description, license, and author
instead of the workspace root's metadata. Author, repository, and
license fall back to the root manifest when the workspace package
doesn't define them.

Workspace inter-dependencies (workspace: protocol) and their transitive
dependencies are now included in the SBOM. Dev-only workspace deps are
correctly excluded when --prod is used, and lockfile-only mode skips
workspace resolution entirely to avoid unexpected disk reads.

* fix(sbom): add recursiveByDefault so --filter works in workspaces

Without recursiveByDefault, the sbom command in a workspace didn't
enter the recursive code path that populates selectedProjectsGraph from
--filter. The handler received no filter info and always used the
workspace root manifest for the root component.

* feat(sbom): add --out and --split for per-package SBOM generation

Add --out <path> to write SBOM to a file instead of stdout. Supports
%s (package name) and %v (version) placeholders. When %s is present,
generates one SBOM per workspace package automatically.

Add --split to output NDJSON (one compact JSON per line) to stdout,
for piping into tooling that processes multiple SBOMs.

Add compact option to CycloneDX and SPDX serializers so NDJSON lines
are single-line JSON without re-parsing.

Sanitize %s and %v values to prevent path traversal in output paths.

* fix: use sbom-out instead of sboms to pass spellcheck

* fix(sbom): expand %v in single-output path and fix workspace dep parent relationships

Two bugs found by CodeRabbit:

1. --out with %v but no %s wrote a literal %v filename. Now both %s
   and %v are expanded in the single-output path using the SBOM root
   component's name and version.

2. The lockfile walker used rootPurl as parent for every importer,
   including additional workspace dep importers. This caused
   shared-lib's external deps (like is-odd) to appear as direct
   deps of the root package. Now each importer's walk uses the
   correct parent PURL based on whether it's an original or
   workspace dep importer.

* fix(sbom): fall back to workspace root manifest for filtered package metadata

Read the root manifest from rootProjectManifest/rootProjectManifestDir instead
of opts.dir so license/author/repository/description fall back to the workspace
root when a filtered package omits them. Add the missing rootDescription
fallback. Fix the per-package CycloneDX test assertions to match the standard
group/name split for scoped names.

* fix(sbom): harden path handling and fix lockfile-only/versionless workspace deps

- Neutralize '.', '..' and blank segments in --out path templates so a crafted
  package name/version cannot escape the output directory.
- Skip lockfile link: targets that resolve outside the workspace root, and guard
  the workspace manifest reads with a containment check, preventing arbitrary
  package.json reads from a malicious lockfile.
- Honor --lockfile-only inside collectSbomComponents so workspace links and their
  transitive deps are no longer traversed.
- Include workspace packages that omit a version (default to 0.0.0, matching the
  root component).
- Bound workspace manifest reads with p-limit to avoid EMFILE on large monorepos.

* fix(sbom): error on per-package output path collisions and use O(n) workspace BFS

- Throw SBOM_OUT_PATH_COLLISION when two workspace packages sanitize to the same
  --out path instead of silently overwriting one SBOM with another.
- Replace the resolveWorkspaceDeps BFS queue.shift() (O(n) per dequeue) with a
  moving head index for O(n) traversal on large workspaces.

* fix(sbom): strip control chars from output paths and skip unreadable workspace importers

- Strip ASCII control characters (incl. newlines) in sanitizePathSegment so a
  crafted package name/version cannot inject lines into the --split --out summary
  or produce confusing filenames.
- Skip walking a reachable workspace importer when its package info is missing
  (e.g. unreadable manifest) instead of misattributing its deps to the root.
- Bound importer-walker fan-out with p-limit to avoid resource pressure on large
  workspaces.

* perf(sbom): reuse workspace manifests across split outputs and use a Set for path collisions

- Read workspace package manifests once into SharedContext (keyed by importer id)
  from the project graph, so split mode no longer re-reads them from disk for
  every emitted SBOM.
- Track written --out paths in a Set instead of Array#includes to make collision
  detection O(1) per package rather than O(n2) overall.

* fix(sbom): support %s in single-project --out, harden importer lookup, cache root license

- Only treat %s in --out as per-package (split) mode when a workspace project
  graph is present; in a single-project repo %s/%v interpolate from the root
  component on the single-output path. Adds a regression test.
- Use an own-property check for lockfile importer existence so a crafted link:
  target cannot follow inherited keys (e.g. toString) via the prototype chain.
- Fast-path resolveRootLicense when the manifest already declares an SPDX license
  and cache the workspace-root license in SharedContext, avoiding redundant
  on-disk LICENSE probing per emitted SBOM.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-06-17 09:43:27 +02:00
..
2026-06-15 08:37:08 +02:00