feat(sbom): add pnpm sbom command (#10592)

* feat(sbom): add `pnpm sbom` command (#9088)

new command that generates SBOMs from the lockfile + store metadata.
supports CycloneDX 1.6 JSON and SPDX 2.3 JSON via `--sbom-format`.

two new packages following the existing `pnpm licenses` architecture:
- `@pnpm/sbom` — core library (lockfile walking, store reading, serializers)
- `@pnpm/plugin-commands-sbom` — CLI plugin wiring

uses the lockfile walker for dependency traversal and reads package.json
from the CAFS store for license/author/description metadata. `--lockfile-only`
skips the store entirely for faster CI runs where metadata isn't needed.

validated against official CycloneDX 1.6 and SPDX 2.3 JSON schemas.

* chore: add sbom-related words to cspell dictionary

* fix(sbom): address CycloneDX review feedback and bump to 1.7

Implements all 5 items from the CycloneDX maintainer review:
split scoped names into group/name, move hashes to
externalReferences distribution, use license.id for known SPDX
identifiers, switch to modern tools.components structure with
pnpm version, and bump specVersion to 1.7.

Also adds spdx-license-ids for proper license classification and
improves SPDX serializer test coverage.

* fix(sbom): fix CI bundle failure for spdx-license-ids

createRequire doesn't work in the esbuild bundle since it's a runtime
resolve, switched back to regular import which esbuild can inline.

* fix(sbom): use tarball URL for distribution externalReferences

Use actual tarball download URL instead of PURL for CycloneDX
distribution externalReferences, per review feedback.

* feat(sbom): add CycloneDX metadata and improve SBOM quality scores

adds $schema, timestamp, lifecycles (build/pre-build) to CycloneDX output
to match what npm does. also enriches both CycloneDX and SPDX with
metadata.authors, metadata.supplier, component supplier from author,
vcs externalReferences from repository, and root component details
(purl, license, description, author, vcs). SPDX now uses tarball URL
for downloadLocation instead of NOASSERTION.

renames CycloneDxToolInfo to CycloneDxOptions, passes lockfileOnly
through to the serializer for lifecycle phase selection. adds store-dir
to accepted CLI options.

* fix(sbom): address CycloneDX review feedback round 2

switches license classification from spdx-license-ids to
@cyclonedx/cyclonedx-library (SPDX.isSupportedSpdxId) for accurate
CycloneDX license ID validation per jkowalleck's feedback.

removes hardcoded metadata.authors and metadata.supplier — these are
not appropriate for a tool to set. adds --sbom-authors and
--sbom-supplier CLI flags so the SBOM consumer (e.g. ACME Corp) can
declare who they are.

removes supplier from components — supplier is the registry/distributor,
not the package author. also fixes distribution externalReference to
only emit when a real tarball URL exists, no PURL fallback.

* fix(sbom): use sub-path import for CycloneDX library to fix bundle

top-level import from @cyclonedx/cyclonedx-library drags in
validation/serialize layers with optional deps (ajv-formats, libxmljs2,
xmlbuilder2) that esbuild can't resolve during pnpm CLI bundling.

switch to @cyclonedx/cyclonedx-library/SPDX which only pulls in the
SPDX module we actually use — pure JS, no optional deps.

* chore: update manifests

* refactor: extract shared store-reading logic into @pnpm/store.pkg-finder

Both @pnpm/license-scanner and @pnpm/sbom independently implemented
nearly identical logic to read a package's file index from the
content-addressable store. This extracts that into a new shared package
that returns a uniform Map<string, string> (filename → absolute path),
simplifying both consumers.

Close #9088

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Allan Kimmer Jensen
2026-02-25 08:45:21 +01:00
committed by GitHub
parent 2ba8b2daf1
commit f92ac24c1b
48 changed files with 2599 additions and 206 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/sbom": minor
"@pnpm/plugin-commands-sbom": minor
"pnpm": minor
---
Added `pnpm sbom` command for generating Software Bill of Materials in CycloneDX 1.7 and SPDX 2.3 JSON formats [#9088](https://github.com/pnpm/pnpm/issues/9088).

View File

@@ -0,0 +1,5 @@
---
"@pnpm/store.pkg-finder": major
---
Initial release.

View File

@@ -42,6 +42,7 @@
"cowsay",
"cves",
"cwsay",
"cyclonedx",
"deburr",
"dedup",
"denoland",
@@ -137,6 +138,7 @@
"libzip",
"licence",
"licences",
"lifecycles",
"linuxstatic",
"localappdata",
"lockfiles",
@@ -165,6 +167,7 @@
"mytoken",
"ndjson",
"nerfed",
"NOASSERTION",
"nodetouch",
"noent",
"nonexec",
@@ -272,6 +275,8 @@
"sirv",
"soporan",
"sopts",
"spdxdocs",
"SPDXID",
"srcset",
"ssri",
"stackblitz",

257
pnpm-lock.yaml generated
View File

@@ -24,6 +24,9 @@ catalogs:
'@commitlint/prompt-cli':
specifier: ^20.4.1
version: 20.4.1
'@cyclonedx/cyclonedx-library':
specifier: 9.4.1
version: 9.4.1
'@eslint/js':
specifier: ^9.18.0
version: 9.39.2
@@ -6603,6 +6606,9 @@ importers:
'@pnpm/plugin-commands-rebuild':
specifier: workspace:*
version: link:../exec/plugin-commands-rebuild
'@pnpm/plugin-commands-sbom':
specifier: workspace:*
version: link:../reviewing/plugin-commands-sbom
'@pnpm/plugin-commands-script-runners':
specifier: workspace:*
version: link:../exec/plugin-commands-script-runners
@@ -7661,15 +7667,9 @@ importers:
'@pnpm/dependency-path':
specifier: workspace:*
version: link:../../packages/dependency-path
'@pnpm/directory-fetcher':
specifier: workspace:*
version: link:../../fetching/directory-fetcher
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
'@pnpm/fs.msgpack-file':
specifier: workspace:*
version: link:../../fs/msgpack-file
'@pnpm/lockfile.detect-dep-types':
specifier: workspace:*
version: link:../../lockfile/detect-dep-types
@@ -7691,9 +7691,9 @@ importers:
'@pnpm/read-package-json':
specifier: workspace:*
version: link:../../pkg-manifest/read-package-json
'@pnpm/store.cafs':
'@pnpm/store.pkg-finder':
specifier: workspace:*
version: link:../../store/cafs
version: link:../../store/pkg-finder
'@pnpm/types':
specifier: workspace:*
version: link:../../packages/types
@@ -8073,6 +8073,113 @@ importers:
specifier: 'catalog:'
version: '@types/table@6.0.0'
reviewing/plugin-commands-sbom:
dependencies:
'@pnpm/cli-meta':
specifier: workspace:*
version: link:../../cli/cli-meta
'@pnpm/cli-utils':
specifier: workspace:*
version: link:../../cli/cli-utils
'@pnpm/command':
specifier: workspace:*
version: link:../../cli/command
'@pnpm/common-cli-options-help':
specifier: workspace:*
version: link:../../cli/common-cli-options-help
'@pnpm/config':
specifier: workspace:*
version: link:../../config/config
'@pnpm/constants':
specifier: workspace:*
version: link:../../packages/constants
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../lockfile/fs
'@pnpm/sbom':
specifier: workspace:*
version: link:../sbom
'@pnpm/store-path':
specifier: workspace:*
version: link:../../store/store-path
ramda:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'
render-help:
specifier: 'catalog:'
version: 1.0.3
devDependencies:
'@pnpm/plugin-commands-installation':
specifier: workspace:*
version: link:../../pkg-manager/plugin-commands-installation
'@pnpm/plugin-commands-sbom':
specifier: workspace:*
version: 'link:'
'@pnpm/prepare':
specifier: workspace:*
version: link:../../__utils__/prepare
'@pnpm/read-package-json':
specifier: workspace:*
version: link:../../pkg-manifest/read-package-json
'@pnpm/registry-mock':
specifier: 'catalog:'
version: 5.2.1(verdaccio@6.2.5(encoding@0.1.13)(typanion@3.14.0))
'@pnpm/test-fixtures':
specifier: workspace:*
version: link:../../__utils__/test-fixtures
'@types/ramda':
specifier: 'catalog:'
version: 0.29.12
reviewing/sbom:
dependencies:
'@cyclonedx/cyclonedx-library':
specifier: 'catalog:'
version: 9.4.1(ajv@8.18.0)
'@pnpm/lockfile.detect-dep-types':
specifier: workspace:*
version: link:../../lockfile/detect-dep-types
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/lockfile.utils':
specifier: workspace:*
version: link:../../lockfile/utils
'@pnpm/lockfile.walker':
specifier: workspace:*
version: link:../../lockfile/walker
'@pnpm/read-package-json':
specifier: workspace:*
version: link:../../pkg-manifest/read-package-json
'@pnpm/store.pkg-finder':
specifier: workspace:*
version: link:../../store/pkg-finder
'@pnpm/types':
specifier: workspace:*
version: link:../../packages/types
p-limit:
specifier: 'catalog:'
version: 7.3.0
ssri:
specifier: 'catalog:'
version: 13.0.0
devDependencies:
'@jest/globals':
specifier: 'catalog:'
version: 30.0.5
'@pnpm/logger':
specifier: workspace:*
version: link:../../packages/logger
'@pnpm/sbom':
specifier: workspace:*
version: 'link:'
'@types/ssri':
specifier: 'catalog:'
version: 7.1.5
semver/peer-range:
dependencies:
semver:
@@ -8284,6 +8391,28 @@ importers:
specifier: 'catalog:'
version: 3.0.0
store/pkg-finder:
dependencies:
'@pnpm/dependency-path':
specifier: workspace:*
version: link:../../packages/dependency-path
'@pnpm/directory-fetcher':
specifier: workspace:*
version: link:../../fetching/directory-fetcher
'@pnpm/fs.msgpack-file':
specifier: workspace:*
version: link:../../fs/msgpack-file
'@pnpm/resolver-base':
specifier: workspace:*
version: link:../../resolving/resolver-base
'@pnpm/store.cafs':
specifier: workspace:*
version: link:../cafs
devDependencies:
'@pnpm/store.pkg-finder':
specifier: workspace:*
version: 'link:'
store/plugin-commands-store:
dependencies:
'@pnpm/cli-utils':
@@ -9619,6 +9748,27 @@ packages:
resolution: {integrity: sha512-plB0wwdAESqBl4xDAT2db2/K1FZHJXfYlJTiV6pkn0XffTGyg4UGLaSCm15NzUoPxdSmzqj5jQb7y+mB9kFK8g==}
engines: {node: '>=20'}
'@cyclonedx/cyclonedx-library@9.4.1':
resolution: {integrity: sha512-fY/ZEFXEKM4X/eC2vClPrpucgb4IsyKYT1q9SBFx2+ySJ0jA2NELIf1va+8SDlcv6yUgwyLqtJ92g2KivQp3eA==}
engines: {node: '>=20.18.0'}
peerDependencies:
ajv: ^8.12.0
ajv-formats: ^3.0.1
ajv-formats-draft2019: ^1.6.1
libxmljs2: ^0.35||^0.37
xmlbuilder2: ^3.0.2||^4.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv-formats:
optional: true
ajv-formats-draft2019:
optional: true
libxmljs2:
optional: true
xmlbuilder2:
optional: true
'@cypress/request@3.0.9':
resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==}
engines: {node: '>= 6'}
@@ -14683,6 +14833,9 @@ packages:
package-manager-detector@0.2.11:
resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==}
packageurl-js@2.0.1:
resolution: {integrity: sha512-N5ixXjzTy4QDQH0Q9YFjqIWd6zH6936Djpl2m9QNFmDv5Fum8q8BjkpAcHNMzOFE0IwQrFhJWex3AN6kS0OSwg==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
@@ -17049,6 +17202,13 @@ snapshots:
'@cspell/url@9.2.0': {}
'@cyclonedx/cyclonedx-library@9.4.1(ajv@8.18.0)':
dependencies:
packageurl-js: 2.0.1
spdx-expression-parse: 3.0.1
optionalDependencies:
ajv: 8.18.0
'@cypress/request@3.0.9':
dependencies:
aws-sign2: 0.7.0
@@ -17253,7 +17413,7 @@ snapshots:
'@jest/console@30.2.0':
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
chalk: 4.1.2
jest-message-util: 30.2.0
jest-util: 30.2.0
@@ -17267,14 +17427,14 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0(@babel/types@7.29.0)
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 4.4.0
exit-x: 0.2.2
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
jest-changed-files: 30.2.0
jest-config: 30.2.0(@babel/types@7.29.0)(@types/node@22.19.11)
jest-config: 30.2.0(@babel/types@7.29.0)(@types/node@25.2.3)
jest-haste-map: 30.2.0
jest-message-util: 30.2.0
jest-regex-util: 30.0.1
@@ -17309,7 +17469,7 @@ snapshots:
dependencies:
'@jest/fake-timers': 30.2.0
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
jest-mock: 30.2.0
'@jest/expect-utils@30.0.5':
@@ -17347,7 +17507,7 @@ snapshots:
dependencies:
'@jest/types': 30.2.0
'@sinonjs/fake-timers': 13.0.5
'@types/node': 22.19.11
'@types/node': 25.2.3
jest-message-util: 30.2.0
jest-mock: 30.2.0
jest-util: 30.2.0
@@ -17376,7 +17536,7 @@ snapshots:
'@jest/pattern@30.0.1':
dependencies:
'@types/node': 22.19.11
'@types/node': 25.2.3
jest-regex-util: 30.0.1
'@jest/reporters@30.2.0(@babel/types@7.29.0)':
@@ -17387,7 +17547,7 @@ snapshots:
'@jest/transform': 30.2.0(@babel/types@7.29.0)
'@jest/types': 30.2.0
'@jridgewell/trace-mapping': 0.3.31
'@types/node': 22.19.11
'@types/node': 25.2.3
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit-x: 0.2.2
@@ -17517,7 +17677,7 @@ snapshots:
'@jest/schemas': 30.0.5
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.11
'@types/node': 25.2.3
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -19227,7 +19387,7 @@ snapshots:
'@types/isexe@2.0.2':
dependencies:
'@types/node': 22.19.11
'@types/node': 25.2.3
'@types/istanbul-lib-coverage@2.0.6': {}
@@ -19329,7 +19489,7 @@ snapshots:
'@types/responselike@1.0.0':
dependencies:
'@types/node': 22.19.11
'@types/node': 25.2.3
'@types/responselike@1.0.3':
dependencies:
@@ -19366,7 +19526,7 @@ snapshots:
'@types/touch@3.1.5':
dependencies:
'@types/node': 22.19.11
'@types/node': 25.2.3
'@types/treeify@1.0.3': {}
@@ -20220,7 +20380,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.14.2
qs: 6.15.0
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@@ -22364,7 +22524,7 @@ snapshots:
'@jest/expect': 30.2.0
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
chalk: 4.1.2
co: 4.6.0
dedent: 1.7.1
@@ -22438,6 +22598,39 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@30.2.0(@babel/types@7.29.0)(@types/node@25.2.3):
dependencies:
'@babel/core': 7.29.0
'@jest/get-type': 30.1.0
'@jest/pattern': 30.0.1
'@jest/test-sequencer': 30.2.0
'@jest/types': 30.2.0
babel-jest: 30.2.0(@babel/core@7.29.0)(@babel/types@7.29.0)
chalk: 4.1.2
ci-info: 4.4.0
deepmerge: 4.3.1
glob: 11.1.0
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
jest-circus: 30.2.0(@babel/types@7.29.0)
jest-docblock: 30.2.0
jest-environment-node: 30.2.0
jest-regex-util: 30.0.1
jest-resolve: 30.2.0
jest-runner: 30.2.0(@babel/types@7.29.0)
jest-util: 30.2.0
jest-validate: 30.2.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 30.2.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 25.2.3
transitivePeerDependencies:
- '@babel/types'
- babel-plugin-macros
- supports-color
jest-diff@30.0.5:
dependencies:
'@jest/diff-sequences': 30.0.1
@@ -22469,7 +22662,7 @@ snapshots:
'@jest/environment': 30.2.0
'@jest/fake-timers': 30.2.0
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
jest-mock: 30.2.0
jest-util: 30.2.0
jest-validate: 30.2.0
@@ -22510,7 +22703,7 @@ snapshots:
jest-haste-map@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
@@ -22574,7 +22767,7 @@ snapshots:
jest-mock@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
jest-util: 30.2.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
@@ -22626,7 +22819,7 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0(@babel/types@7.29.0)
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
chalk: 4.1.2
emittery: 0.13.1
exit-x: 0.2.2
@@ -22656,7 +22849,7 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0(@babel/types@7.29.0)
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
chalk: 4.1.2
cjs-module-lexer: 2.2.0
collect-v8-coverage: 1.0.3
@@ -22748,7 +22941,7 @@ snapshots:
jest-util@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
@@ -22776,7 +22969,7 @@ snapshots:
dependencies:
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
'@types/node': 22.19.11
'@types/node': 25.2.3
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -22800,7 +22993,7 @@ snapshots:
jest-worker@30.2.0:
dependencies:
'@types/node': 22.19.11
'@types/node': 25.2.3
'@ungap/structured-clone': 1.3.0
jest-util: 30.2.0
merge-stream: 2.0.0
@@ -22876,7 +23069,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.3
semver: 7.7.4
jsprim@2.0.2:
dependencies:
@@ -23753,6 +23946,8 @@ snapshots:
dependencies:
quansync: 0.2.11
packageurl-js@2.0.1: {}
pako@0.2.9: {}
parent-module@1.0.1:
@@ -25491,7 +25686,7 @@ snapshots:
wide-align@1.1.5:
dependencies:
string-width: 1.0.2
string-width: 4.2.3
optional: true
widest-line@3.1.0:

View File

@@ -69,6 +69,7 @@ catalog:
'@commitlint/cli': ^20.4.1
'@commitlint/config-conventional': ^20.4.1
'@commitlint/prompt-cli': ^20.4.1
'@cyclonedx/cyclonedx-library': 9.4.1
'@eslint/js': ^9.18.0
'@jest/globals': 30.0.5
'@npm/types': ^2.1.0

View File

@@ -111,6 +111,7 @@
"@pnpm/plugin-commands-init": "workspace:*",
"@pnpm/plugin-commands-installation": "workspace:*",
"@pnpm/plugin-commands-licenses": "workspace:*",
"@pnpm/plugin-commands-sbom": "workspace:*",
"@pnpm/plugin-commands-listing": "workspace:*",
"@pnpm/plugin-commands-outdated": "workspace:*",
"@pnpm/plugin-commands-patching": "workspace:*",

View File

@@ -12,6 +12,7 @@ import { add, ci, dedupe, fetch, install, link, prune, remove, unlink, update, i
import { selfUpdate } from '@pnpm/tools.plugin-commands-self-updater'
import { list, ll, why } from '@pnpm/plugin-commands-listing'
import { licenses } from '@pnpm/plugin-commands-licenses'
import { sbom } from '@pnpm/plugin-commands-sbom'
import { outdated } from '@pnpm/plugin-commands-outdated'
import { pack, publish } from '@pnpm/plugin-commands-publishing'
import { patch, patchCommit, patchRemove } from '@pnpm/plugin-commands-patching'
@@ -152,6 +153,7 @@ const commands: CommandDefinition[] = [
restart,
root,
run,
sbom,
setup,
store,
catFile,

View File

@@ -149,6 +149,9 @@
{
"path": "../reviewing/plugin-commands-outdated"
},
{
"path": "../reviewing/plugin-commands-sbom"
},
{
"path": "../store/cafs"
},

View File

@@ -33,9 +33,7 @@
},
"dependencies": {
"@pnpm/dependency-path": "workspace:*",
"@pnpm/directory-fetcher": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fs.msgpack-file": "workspace:*",
"@pnpm/lockfile.detect-dep-types": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
@@ -43,7 +41,7 @@
"@pnpm/lockfile.walker": "workspace:*",
"@pnpm/package-is-installable": "workspace:*",
"@pnpm/read-package-json": "workspace:*",
"@pnpm/store.cafs": "workspace:*",
"@pnpm/store.pkg-finder": "workspace:*",
"@pnpm/types": "workspace:*",
"p-limit": "catalog:",
"path-absolute": "catalog:",

View File

@@ -2,21 +2,13 @@ import path from 'path'
import pathAbsolute from 'path-absolute'
import { readFile } from 'fs/promises'
import { readPackageJson } from '@pnpm/read-package-json'
import { depPathToFilename, parse } from '@pnpm/dependency-path'
import { readMsgpackFile } from '@pnpm/fs.msgpack-file'
import { depPathToFilename } from '@pnpm/dependency-path'
import pLimit from 'p-limit'
import { type PackageManifest, type Registries } from '@pnpm/types'
import {
getFilePathByModeInCafs,
getIndexFilePathInCafs,
type PackageFiles,
type PackageFileInfo,
type PackageFilesIndex,
} from '@pnpm/store.cafs'
import { readPackageFileMap } from '@pnpm/store.pkg-finder'
import { PnpmError } from '@pnpm/error'
import { type LicensePackage } from './licenses.js'
import { type DirectoryResolution, type PackageSnapshot, pkgSnapshotToResolution, type Resolution } from '@pnpm/lockfile.utils'
import { fetchFromDir } from '@pnpm/directory-fetcher'
import { type PackageSnapshot, pkgSnapshotToResolution } from '@pnpm/lockfile.utils'
const limitPkgReads = pLimit(4)
@@ -148,17 +140,13 @@ function parseLicenseManifestField (field: unknown): string {
* contents will be returned.
*
* @param {*} pkg the package to check
* @param {*} opts the options for parsing licenses
* @returns Promise<LicenseInfo>
*/
async function parseLicense (
pkg: {
manifest: PackageManifest
files:
| { local: true, files: Record<string, string> }
| { local: false, files: PackageFiles }
},
opts: { storeDir: string }
files: Map<string, string>
}
): Promise<LicenseInfo> {
let licenseField: unknown = pkg.manifest.license
if ('licenses' in pkg.manifest) {
@@ -172,30 +160,11 @@ async function parseLicense (
// check if we discovered a license, if not attempt to parse the LICENSE file
if (!license || /see license/i.test(license)) {
let licensePackageFileInfo: string | PackageFileInfo | undefined
let licenseFile: string | undefined
if (pkg.files.local) {
const filesRecord = pkg.files.files
licenseFile = LICENSE_FILES.find((f) => filesRecord[f])
if (licenseFile) {
licensePackageFileInfo = filesRecord[licenseFile]
}
} else {
const filesMap = pkg.files.files
licenseFile = LICENSE_FILES.find((f) => filesMap.has(f))
if (licenseFile) {
licensePackageFileInfo = filesMap.get(licenseFile)
}
}
if (licenseFile && licensePackageFileInfo) {
let licenseContents: Buffer | undefined
if (typeof licensePackageFileInfo === 'string') {
licenseContents = await readFile(licensePackageFileInfo)
} else {
licenseContents = await readLicenseFileFromCafs(opts.storeDir, licensePackageFileInfo)
}
const licenseContent = licenseContents?.toString('utf-8')
const licenseFileName = LICENSE_FILES.find((f) => pkg.files.has(f))
if (licenseFileName) {
const licenseFilePath = pkg.files.get(licenseFileName)!
const licenseContents = await readFile(licenseFilePath)
const licenseContent = licenseContents.toString('utf-8')
let name = 'Unknown'
if (licenseContent) {
// eslint-disable-next-line regexp/no-unused-capturing-group
@@ -215,97 +184,6 @@ async function parseLicense (
return { name: license ?? 'Unknown' }
}
/**
* Fetch a file by integrity id from the content-addressable store
* @param storeDir the cafs directory
* @param opts the options for reading file
* @returns Promise<Buffer>
*/
async function readLicenseFileFromCafs (storeDir: string, { digest, mode }: PackageFileInfo): Promise<Buffer> {
const fileName = getFilePathByModeInCafs(storeDir, digest, mode)
const fileContents = await readFile(fileName)
return fileContents
}
export type ReadPackageIndexFileResult =
| { local: false, files: PackageFiles }
| { local: true, files: Record<string, string> }
export interface ReadPackageIndexFileOptions {
storeDir: string
lockfileDir: string
virtualStoreDirMaxLength: number
}
/**
* Returns the index of files included in
* the package identified by the integrity id
* @param packageResolution the resolution package information
* @param depPath the package reference
* @param opts options for fetching package file index
*/
export async function readPackageIndexFile (
packageResolution: Resolution,
id: string,
opts: ReadPackageIndexFileOptions
): Promise<ReadPackageIndexFileResult> {
// If the package resolution is of type directory we need to do things
// differently and generate our own package index file
const isLocalPkg = packageResolution.type === 'directory'
if (isLocalPkg) {
const localInfo = await fetchFromDir(
path.join(opts.lockfileDir, (packageResolution as DirectoryResolution).directory),
{}
)
return {
local: true,
files: Object.fromEntries(localInfo.filesMap),
}
}
const isPackageWithIntegrity = 'integrity' in packageResolution
let pkgIndexFilePath
if (isPackageWithIntegrity) {
const parsedId = parse(id)
// Retrieve all the index file of all files included in the package
pkgIndexFilePath = getIndexFilePathInCafs(
opts.storeDir,
packageResolution.integrity as string,
parsedId.nonSemverVersion ?? `${parsedId.name}@${parsedId.version}`
)
} else if (!packageResolution.type && 'tarball' in packageResolution && packageResolution.tarball) {
const packageDirInStore = depPathToFilename(parse(id).nonSemverVersion ?? id, opts.virtualStoreDirMaxLength)
pkgIndexFilePath = path.join(
opts.storeDir,
packageDirInStore,
'integrity.mpk'
)
} else {
throw new PnpmError(
'UNSUPPORTED_PACKAGE_TYPE',
`Unsupported package resolution type for ${id}`
)
}
try {
const { files } = await readMsgpackFile<PackageFilesIndex>(pkgIndexFilePath)
return {
local: false,
files,
}
} catch (err: any) { // eslint-disable-line
if (err.code === 'ENOENT') {
throw new PnpmError(
'MISSING_PACKAGE_INDEX_FILE',
`Failed to find package index file for ${id} (at ${pkgIndexFilePath}), please consider running 'pnpm install'`
)
}
throw err
}
}
export interface PackageInfo {
id: string
name?: string
@@ -344,43 +222,43 @@ export async function getPkgInfo (
pkg.registries
)
const packageFileIndexInfo = await readPackageIndexFile(
packageResolution as Resolution,
pkg.id,
{
storeDir: opts.storeDir,
lockfileDir: opts.dir,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
}
)
// Fetch the package manifest
let packageManifestDir!: string
if (packageFileIndexInfo.local) {
packageManifestDir = packageFileIndexInfo.files['package.json'] as string
} else {
const packageFileIndex = packageFileIndexInfo.files
const packageManifestFile = packageFileIndex.get('package.json') as PackageFileInfo
packageManifestDir = getFilePathByModeInCafs(
opts.storeDir,
packageManifestFile.digest,
packageManifestFile.mode
)
}
let manifest
let files: Map<string, string>
try {
manifest = await readPkg(packageManifestDir)
} catch (err: any) { // eslint-disable-line
const result = await readPackageFileMap(
packageResolution,
pkg.id,
{
storeDir: opts.storeDir,
lockfileDir: opts.dir,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
}
)
if (!result) {
throw new PnpmError(
'UNSUPPORTED_PACKAGE_TYPE',
`Unsupported package resolution type for ${pkg.id}`
)
}
files = result
} catch (err: any) { // eslint-disable-line
if (err.code === 'ENOENT') {
throw new PnpmError(
'MISSING_PACKAGE_MANIFEST',
`Failed to find package manifest file at ${packageManifestDir}`
'MISSING_PACKAGE_INDEX_FILE',
`Failed to find package index file for ${pkg.id} (at ${err.path}), please consider running 'pnpm install'`
)
}
throw err
}
const manifestPath = files.get('package.json')
if (!manifestPath) {
throw new PnpmError(
'MISSING_PACKAGE_INDEX_FILE',
`Failed to find package.json in index for ${pkg.id}, please consider running 'pnpm install'`
)
}
const manifest = await readPackageJson(manifestPath)
// Determine the path to the package as known by the user
const modulesDir = opts.modulesDir ?? 'node_modules'
const virtualStoreDir = pathAbsolute(
@@ -397,8 +275,7 @@ export async function getPkgInfo (
)
const licenseInfo = await parseLicense(
{ manifest, files: packageFileIndexInfo },
{ storeDir: opts.storeDir }
{ manifest, files }
)
const packageInfo = {

View File

@@ -1,4 +1,3 @@
import path from 'path'
import { getPkgInfo } from '../lib/getPkgInfo.js'
export const DEFAULT_REGISTRIES = {
@@ -30,6 +29,6 @@ describe('licences', () => {
virtualStoreDirMaxLength: 120,
}
)
).rejects.toThrow(`Failed to find package index file for bogus-package@1.0.0 (at ${path.join('store-dir', 'index', 'b2', '16-bogus-package@1.0.0.mpk')}), please consider running 'pnpm install'`)
).rejects.toThrow(/Failed to find package index file for bogus-package@1\.0\.0 \(at .*16-bogus-package@1\.0\.0\.mpk\), please consider running 'pnpm install'/)
})
})

View File

@@ -12,12 +12,6 @@
{
"path": "../../config/package-is-installable"
},
{
"path": "../../fetching/directory-fetcher"
},
{
"path": "../../fs/msgpack-file"
},
{
"path": "../../lockfile/detect-dep-types"
},
@@ -52,7 +46,7 @@
"path": "../../pkg-manifest/read-package-json"
},
{
"path": "../../store/cafs"
"path": "../../store/pkg-finder"
}
]
}

View File

@@ -0,0 +1,63 @@
{
"name": "@pnpm/plugin-commands-sbom",
"version": "1000.0.0-0",
"description": "The sbom command of pnpm",
"keywords": [
"pnpm",
"pnpm11",
"sbom"
],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/reviewing/plugin-commands-sbom",
"homepage": "https://github.com/pnpm/pnpm/tree/main/reviewing/plugin-commands-sbom#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": "./lib/index.js"
},
"files": [
"lib",
"!*.map"
],
"scripts": {
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"_test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest",
"test": "pnpm run compile && pnpm run _test",
"prepublishOnly": "pnpm run compile",
"compile": "tsgo --build && pnpm run lint --fix"
},
"dependencies": {
"@pnpm/cli-meta": "workspace:*",
"@pnpm/cli-utils": "workspace:*",
"@pnpm/command": "workspace:*",
"@pnpm/common-cli-options-help": "workspace:*",
"@pnpm/config": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/sbom": "workspace:*",
"@pnpm/store-path": "workspace:*",
"ramda": "catalog:",
"render-help": "catalog:"
},
"devDependencies": {
"@pnpm/plugin-commands-installation": "workspace:*",
"@pnpm/plugin-commands-sbom": "workspace:*",
"@pnpm/prepare": "workspace:*",
"@pnpm/read-package-json": "workspace:*",
"@pnpm/registry-mock": "catalog:",
"@pnpm/test-fixtures": "workspace:*",
"@types/ramda": "catalog:"
},
"engines": {
"node": ">=22.13"
},
"jest": {
"preset": "@pnpm/jest-config"
}
}

View File

@@ -0,0 +1,3 @@
import * as sbom from './sbom.js'
export { sbom }

View File

@@ -0,0 +1,224 @@
import { docsUrl, readProjectManifestOnly } from '@pnpm/cli-utils'
import { type Config, types as allTypes } from '@pnpm/config'
import { FILTERING } from '@pnpm/common-cli-options-help'
import { WANTED_LOCKFILE } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import { getLockfileImporterId, readWantedLockfile } from '@pnpm/lockfile.fs'
import { getStorePath } from '@pnpm/store-path'
import { packageManager } from '@pnpm/cli-meta'
import {
collectSbomComponents,
serializeCycloneDx,
serializeSpdx,
type SbomFormat,
type SbomComponentType,
} from '@pnpm/sbom'
import { pick } from 'ramda'
import renderHelp from 'render-help'
export type SbomCommandOptions = {
sbomFormat?: string
sbomType?: string
lockfileOnly?: boolean
sbomAuthors?: string
sbomSupplier?: string
} & Pick<
Config,
| 'dev'
| 'dir'
| 'lockfileDir'
| 'registries'
| 'optional'
| 'production'
| 'storeDir'
| 'virtualStoreDir'
| 'modulesDir'
| 'pnpmHomeDir'
| 'selectedProjectsGraph'
| 'rootProjectManifest'
| 'rootProjectManifestDir'
| 'virtualStoreDirMaxLength'
> &
Partial<Pick<Config, 'userConfig'>>
export function rcOptionsTypes (): Record<string, unknown> {
return pick(
['dev', 'global-dir', 'global', 'optional', 'production', 'store-dir'],
allTypes
)
}
export const cliOptionsTypes = (): Record<string, unknown> => ({
...rcOptionsTypes(),
recursive: Boolean,
'sbom-format': String,
'sbom-type': String,
'sbom-authors': String,
'sbom-supplier': String,
'lockfile-only': Boolean,
})
export const shorthands: Record<string, string> = {
D: '--dev',
P: '--production',
}
export const commandNames = ['sbom']
export function help (): string {
return renderHelp({
description: 'Generate a Software Bill of Materials (SBOM) for the project.',
descriptionLists: [
{
title: 'Options',
list: [
{
description: 'The SBOM output format (required)',
name: '--sbom-format <cyclonedx|spdx>',
},
{
description: 'The component type for the root package (default: library)',
name: '--sbom-type <library|application>',
},
{
description: 'Only use lockfile data (skip reading from the store)',
name: '--lockfile-only',
},
{
description: 'Comma-separated list of SBOM authors (CycloneDX metadata.authors)',
name: '--sbom-authors <names>',
},
{
description: 'SBOM supplier name (CycloneDX metadata.supplier)',
name: '--sbom-supplier <name>',
},
{
description: 'Only include "dependencies" and "optionalDependencies"',
name: '--prod',
shortAlias: '-P',
},
{
description: 'Only include "devDependencies"',
name: '--dev',
shortAlias: '-D',
},
{
description: 'Don\'t include "optionalDependencies"',
name: '--no-optional',
},
],
},
FILTERING,
],
url: docsUrl('sbom'),
usages: [
'pnpm sbom --sbom-format cyclonedx',
'pnpm sbom --sbom-format spdx',
'pnpm sbom --sbom-format cyclonedx --lockfile-only',
'pnpm sbom --sbom-format spdx --prod',
],
})
}
export async function handler (
opts: SbomCommandOptions,
_params: string[] = []
): Promise<{ output: string, exitCode: number }> {
if (!opts.sbomFormat) {
throw new PnpmError(
'SBOM_NO_FORMAT',
'The --sbom-format option is required. Use --sbom-format cyclonedx or --sbom-format spdx.',
{ hint: help() }
)
}
const format = opts.sbomFormat.toLowerCase() as SbomFormat
if (format !== 'cyclonedx' && format !== 'spdx') {
throw new PnpmError(
'SBOM_INVALID_FORMAT',
`Invalid SBOM format "${opts.sbomFormat}". Use "cyclonedx" or "spdx".`
)
}
const sbomType = validateSbomType(opts.sbomType)
const lockfile = await readWantedLockfile(opts.lockfileDir ?? opts.dir, {
ignoreIncompatible: true,
})
if (lockfile == null) {
throw new PnpmError(
'SBOM_NO_LOCKFILE',
`No ${WANTED_LOCKFILE} found: Cannot generate SBOM without a lockfile`
)
}
const include = {
dependencies: opts.production !== false,
devDependencies: opts.dev !== false,
optionalDependencies: opts.optional !== false,
}
const manifest = await readProjectManifestOnly(opts.dir)
const rootName = manifest.name ?? 'unknown'
const rootVersion = manifest.version ?? '0.0.0'
const rootLicense = typeof manifest.license === 'string' ? manifest.license : undefined
const rootAuthor = typeof manifest.author === 'string'
? manifest.author
: (manifest.author as { name?: string } | undefined)?.name
const rootRepository = typeof manifest.repository === 'string'
? manifest.repository
: (manifest.repository as { url?: string } | undefined)?.url
const includedImporterIds = opts.selectedProjectsGraph
? Object.keys(opts.selectedProjectsGraph)
.map((p) => getLockfileImporterId(opts.lockfileDir ?? opts.dir, p))
: undefined
let storeDir: string | undefined
if (!opts.lockfileOnly) {
storeDir = await getStorePath({
pkgRoot: opts.dir,
storePath: opts.storeDir,
pnpmHomeDir: opts.pnpmHomeDir,
})
}
const result = await collectSbomComponents({
lockfile,
rootName,
rootVersion,
rootLicense,
rootDescription: manifest.description,
rootAuthor,
rootRepository,
sbomType,
include,
registries: opts.registries,
lockfileDir: opts.lockfileDir ?? opts.dir,
includedImporterIds,
lockfileOnly: opts.lockfileOnly,
storeDir,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
})
const output = format === 'cyclonedx'
? serializeCycloneDx(result, {
pnpmVersion: packageManager.version,
lockfileOnly: opts.lockfileOnly,
sbomAuthors: opts.sbomAuthors?.split(',').map((s) => s.trim()).filter(Boolean),
sbomSupplier: opts.sbomSupplier,
})
: serializeSpdx(result)
return { output, exitCode: 0 }
}
function validateSbomType (value: string | undefined): SbomComponentType {
if (!value || value === 'library') return 'library'
if (value === 'application') return 'application'
throw new PnpmError(
'SBOM_INVALID_TYPE',
`Invalid SBOM type "${value}". Use "library" or "application".`
)
}

View File

@@ -0,0 +1 @@
node_modules

View File

@@ -0,0 +1,9 @@
{
"name": "simple-sbom-test",
"version": "1.0.0",
"description": "Test fixture for SBOM generation",
"license": "ISC",
"dependencies": {
"is-positive": "^3.1.0"
}
}

View File

@@ -0,0 +1,24 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
is-positive:
specifier: ^3.1.0
version: 3.1.0
packages:
is-positive@3.1.0:
resolution: {integrity: sha512-8ND1j3y9/HP94TOvGzr69/FgbkX2ruOldhLEsTWwcJVfo4oRjwemJmJxt7RJkKYH8tz7vYBP9JcKQY8CLuJ90Q==}
engines: {node: '>=0.10.0'}
snapshots:
is-positive@3.1.0:
dev: false

View File

@@ -0,0 +1,12 @@
{
"name": "sbom-with-dev-dep",
"version": "2.0.0",
"description": "Test fixture with dev dependency",
"license": "MIT",
"dependencies": {
"is-positive": "^3.1.0"
},
"devDependencies": {
"typescript": "^4.8.4"
}
}

View File

@@ -0,0 +1,36 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
is-positive:
specifier: ^3.1.0
version: 3.1.0
devDependencies:
typescript:
specifier: ^4.8.4
version: 4.8.4
packages:
is-positive@3.1.0:
resolution: {integrity: sha512-8ND1j3y9/HP94TOvGzr69/FgbkX2ruOldhLEsTWwcJVfo4oRjwemJmJxt7RJkKYH8tz7vYBP9JcKQY8CLuJ90Q==}
engines: {node: '>=0.10.0'}
typescript@4.8.4:
resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==}
engines: {node: '>=4.2.0'}
hasBin: true
snapshots:
is-positive@3.1.0:
dev: false
typescript@4.8.4:
dev: true

View File

@@ -0,0 +1,221 @@
/// <reference path="../../../__typings__/index.d.ts" />
import path from 'path'
import { STORE_VERSION } from '@pnpm/constants'
import { sbom } from '@pnpm/plugin-commands-sbom'
import { install } from '@pnpm/plugin-commands-installation'
import { tempDir } from '@pnpm/prepare'
import { fixtures } from '@pnpm/test-fixtures'
import { DEFAULT_OPTS } from './utils/index.js'
const f = fixtures(import.meta.dirname)
test('pnpm sbom --sbom-format cyclonedx', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
const storeDir = path.join(workspaceDir, 'store')
await install.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
pnpmHomeDir: '',
storeDir,
})
const { output, exitCode } = await sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'cyclonedx',
storeDir: path.resolve(storeDir, STORE_VERSION),
})
expect(exitCode).toBe(0)
const parsed = JSON.parse(output)
expect(parsed.bomFormat).toBe('CycloneDX')
expect(parsed.specVersion).toBe('1.7')
expect(parsed.metadata.component.name).toBe('simple-sbom-test')
expect(parsed.components.length).toBeGreaterThan(0)
const isPositive = parsed.components.find(
(c: { name: string }) => c.name === 'is-positive'
)
expect(isPositive).toBeDefined()
expect(isPositive.purl).toBe('pkg:npm/is-positive@3.1.0')
expect(isPositive.version).toBe('3.1.0')
})
test('pnpm sbom --sbom-format spdx', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
const storeDir = path.join(workspaceDir, 'store')
await install.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
pnpmHomeDir: '',
storeDir,
})
const { output, exitCode } = await sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'spdx',
storeDir: path.resolve(storeDir, STORE_VERSION),
})
expect(exitCode).toBe(0)
const parsed = JSON.parse(output)
expect(parsed.spdxVersion).toBe('SPDX-2.3')
expect(parsed.dataLicense).toBe('CC0-1.0')
expect(parsed.packages.length).toBeGreaterThanOrEqual(2)
const isPositive = parsed.packages.find(
(p: { name: string }) => p.name === 'is-positive'
)
expect(isPositive).toBeDefined()
expect(isPositive.externalRefs[0].referenceLocator).toBe('pkg:npm/is-positive@3.1.0')
})
test('pnpm sbom --lockfile-only', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
// No install — just lockfile
const { output, exitCode } = await sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'cyclonedx',
lockfileOnly: true,
})
expect(exitCode).toBe(0)
const parsed = JSON.parse(output)
expect(parsed.bomFormat).toBe('CycloneDX')
expect(parsed.components.length).toBeGreaterThan(0)
const isPositive = parsed.components.find(
(c: { name: string }) => c.name === 'is-positive'
)
expect(isPositive).toBeDefined()
// In lockfile-only mode, license metadata is absent
expect(isPositive.licenses).toBeUndefined()
})
test('pnpm sbom missing --sbom-format throws', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
await expect(
sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
})
).rejects.toThrow('--sbom-format option is required')
})
test('pnpm sbom invalid --sbom-format throws', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
await expect(
sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'invalid',
})
).rejects.toThrow('Invalid SBOM format')
})
test('pnpm sbom with missing lockfile throws', async () => {
const workspaceDir = tempDir()
await expect(
sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'cyclonedx',
})
).rejects.toThrow('Cannot generate SBOM without a lockfile')
})
test('pnpm sbom --prod excludes devDependencies', async () => {
const workspaceDir = tempDir()
f.copy('with-dev-dependency', workspaceDir)
const storeDir = path.join(workspaceDir, 'store')
await install.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
pnpmHomeDir: '',
storeDir,
})
const { output, exitCode } = await sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'cyclonedx',
storeDir: path.resolve(storeDir, STORE_VERSION),
production: true,
dev: false,
})
expect(exitCode).toBe(0)
const parsed = JSON.parse(output)
const componentNames = parsed.components.map((c: { name: string }) => c.name)
expect(componentNames).toContain('is-positive')
expect(componentNames).not.toContain('typescript')
})
test('pnpm sbom invalid --sbom-type throws', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
await expect(
sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'cyclonedx',
sbomType: 'invalid',
lockfileOnly: true,
})
).rejects.toThrow('Invalid SBOM type')
})
test('pnpm sbom --sbom-type application', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
const { output, exitCode } = await sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'cyclonedx',
sbomType: 'application',
lockfileOnly: true,
})
expect(exitCode).toBe(0)
const parsed = JSON.parse(output)
expect(parsed.metadata.component.type).toBe('application')
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../node_modules/.test.lib",
"rootDir": "..",
"isolatedModules": true
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -0,0 +1,52 @@
const REGISTRY = 'https://registry.npmjs.org'
export const DEFAULT_OPTS = {
argv: {
original: [],
},
bail: true,
bin: 'node_modules/.bin',
ca: undefined,
cacheDir: '../cache',
cert: undefined,
excludeLinksFromLockfile: false,
extraEnv: {},
cliOptions: {},
fetchRetries: 2,
fetchRetryFactor: 90,
fetchRetryMaxtimeout: 90,
fetchRetryMintimeout: 10,
filter: [] as string[],
httpsProxy: undefined,
include: {
dependencies: true,
devDependencies: true,
optionalDependencies: true,
},
key: undefined,
linkWorkspacePackages: true,
localAddress: undefined,
lock: false,
lockStaleDuration: 90,
networkConcurrency: 16,
offline: false,
pending: false,
pnpmfile: ['./.pnpmfile.cjs'],
pnpmHomeDir: '',
preferWorkspacePackages: true,
proxy: undefined,
rawConfig: { registry: REGISTRY },
rawLocalConfig: {},
registries: { default: REGISTRY },
rootProjectManifestDir: '',
sort: true,
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
virtualStoreDir: 'node_modules/.pnpm',
workspaceConcurrency: 4,
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
}

View File

@@ -0,0 +1,55 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../__utils__/prepare"
},
{
"path": "../../__utils__/test-fixtures"
},
{
"path": "../../cli/cli-meta"
},
{
"path": "../../cli/cli-utils"
},
{
"path": "../../cli/command"
},
{
"path": "../../cli/common-cli-options-help"
},
{
"path": "../../config/config"
},
{
"path": "../../lockfile/fs"
},
{
"path": "../../packages/constants"
},
{
"path": "../../packages/error"
},
{
"path": "../../pkg-manager/plugin-commands-installation"
},
{
"path": "../../pkg-manifest/read-package-json"
},
{
"path": "../../store/store-path"
},
{
"path": "../sbom"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -0,0 +1,63 @@
{
"name": "@pnpm/sbom",
"version": "1000.0.0-0",
"description": "Generate SBOM from pnpm lockfile",
"keywords": [
"pnpm",
"pnpm11",
"cyclonedx",
"sbom",
"spdx"
],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/reviewing/sbom",
"homepage": "https://github.com/pnpm/pnpm/tree/main/reviewing/sbom#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": "./lib/index.js"
},
"files": [
"lib",
"!*.map"
],
"scripts": {
"test": "pnpm run compile && pnpm run _test",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"prepublishOnly": "pnpm run compile",
"_test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest",
"compile": "tsgo --build && pnpm run lint --fix"
},
"dependencies": {
"@cyclonedx/cyclonedx-library": "catalog:",
"@pnpm/lockfile.detect-dep-types": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/lockfile.utils": "workspace:*",
"@pnpm/lockfile.walker": "workspace:*",
"@pnpm/read-package-json": "workspace:*",
"@pnpm/store.pkg-finder": "workspace:*",
"@pnpm/types": "workspace:*",
"p-limit": "catalog:",
"ssri": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": "catalog:"
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/logger": "workspace:*",
"@pnpm/sbom": "workspace:*",
"@types/ssri": "catalog:"
},
"engines": {
"node": ">=22.13"
},
"jest": {
"preset": "@pnpm/jest-config"
}
}

View File

@@ -0,0 +1,131 @@
import { type LockfileObject, type TarballResolution } from '@pnpm/lockfile.types'
import { nameVerFromPkgSnapshot, pkgSnapshotToResolution } from '@pnpm/lockfile.utils'
import {
lockfileWalkerGroupImporterSteps,
type LockfileWalkerStep,
} from '@pnpm/lockfile.walker'
import { type DepTypes, DepType, detectDepTypes } from '@pnpm/lockfile.detect-dep-types'
import { type DependenciesField, type ProjectId, type Registries } from '@pnpm/types'
import { buildPurl, encodePurlName } from './purl.js'
import { getPkgMetadata, type GetPkgMetadataOptions } from './getPkgMetadata.js'
import { type SbomComponent, type SbomRelationship, type SbomResult, type SbomComponentType } from './types.js'
export interface CollectSbomComponentsOptions {
lockfile: LockfileObject
rootName: string
rootVersion: string
rootLicense?: string
rootDescription?: string
rootAuthor?: string
rootRepository?: string
sbomType?: SbomComponentType
include?: { [dependenciesField in DependenciesField]: boolean }
registries: Registries
lockfileDir: string
includedImporterIds?: ProjectId[]
lockfileOnly?: boolean
storeDir?: string
virtualStoreDirMaxLength?: number
}
export async function collectSbomComponents (opts: CollectSbomComponentsOptions): Promise<SbomResult> {
const depTypes = detectDepTypes(opts.lockfile)
const importerIds = opts.includedImporterIds ?? Object.keys(opts.lockfile.importers) as ProjectId[]
const importerWalkers = lockfileWalkerGroupImporterSteps(
opts.lockfile,
importerIds,
{ include: opts.include }
)
const componentsMap = new Map<string, SbomComponent>()
const relationships: SbomRelationship[] = []
const rootPurl = `pkg:npm/${encodePurlName(opts.rootName)}@${opts.rootVersion}`
const metadataOpts: GetPkgMetadataOptions | undefined = (!opts.lockfileOnly && opts.storeDir)
? {
storeDir: opts.storeDir,
lockfileDir: opts.lockfileDir,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength ?? 120,
}
: undefined
await Promise.all(
importerWalkers.map(async ({ step }) => {
await walkStep(
step,
rootPurl,
depTypes,
componentsMap,
relationships,
opts,
metadataOpts
)
})
)
return {
rootComponent: {
name: opts.rootName,
version: opts.rootVersion,
type: opts.sbomType ?? 'library',
license: opts.rootLicense,
description: opts.rootDescription,
author: opts.rootAuthor,
repository: opts.rootRepository,
},
components: Array.from(componentsMap.values()),
relationships,
}
}
async function walkStep (
step: LockfileWalkerStep,
parentPurl: string,
depTypes: DepTypes,
componentsMap: Map<string, SbomComponent>,
relationships: SbomRelationship[],
opts: CollectSbomComponentsOptions,
metadataOpts: GetPkgMetadataOptions | undefined
): Promise<void> {
await Promise.all(
step.dependencies.map(async (dep) => {
const { depPath, pkgSnapshot, next } = dep
const { name, version, nonSemverVersion } = nameVerFromPkgSnapshot(depPath, pkgSnapshot)
if (!name || !version) return
const purl = buildPurl({ name, version, nonSemverVersion: nonSemverVersion ?? undefined })
relationships.push({ from: parentPurl, to: purl })
if (componentsMap.has(purl)) return
const integrity = (pkgSnapshot.resolution as TarballResolution).integrity
const resolution = pkgSnapshotToResolution(depPath, pkgSnapshot, opts.registries)
const tarballUrl = (resolution as TarballResolution).tarball
let metadata: { license?: string, description?: string, author?: string, homepage?: string, repository?: string } = {}
if (metadataOpts) {
metadata = await getPkgMetadata(depPath, pkgSnapshot, opts.registries, metadataOpts)
}
const component: SbomComponent = {
name,
version,
purl,
depPath,
depType: depTypes[depPath] ?? DepType.ProdOnly,
integrity,
tarballUrl,
...metadata,
}
componentsMap.set(purl, component)
const subStep = next()
await walkStep(subStep, purl, depTypes, componentsMap, relationships, opts, metadataOpts)
})
)
}

View File

@@ -0,0 +1,96 @@
import { type PackageManifest, type Registries } from '@pnpm/types'
import { readPackageFileMap } from '@pnpm/store.pkg-finder'
import { readPackageJson } from '@pnpm/read-package-json'
import { type PackageSnapshot, pkgSnapshotToResolution } from '@pnpm/lockfile.utils'
import pLimit from 'p-limit'
const limitMetadataReads = pLimit(4)
export interface PkgMetadata {
license?: string
description?: string
author?: string
homepage?: string
repository?: string
}
export interface GetPkgMetadataOptions {
storeDir: string
lockfileDir: string
virtualStoreDirMaxLength: number
}
export async function getPkgMetadata (
depPath: string,
snapshot: PackageSnapshot,
registries: Registries,
opts: GetPkgMetadataOptions
): Promise<PkgMetadata> {
return limitMetadataReads(() => getPkgMetadataUnclamped(depPath, snapshot, registries, opts))
}
async function getPkgMetadataUnclamped (
depPath: string,
snapshot: PackageSnapshot,
registries: Registries,
opts: GetPkgMetadataOptions
): Promise<PkgMetadata> {
const id = snapshot.id ?? depPath
const resolution = pkgSnapshotToResolution(depPath, snapshot, registries)
let files: Map<string, string>
try {
const result = await readPackageFileMap(resolution, id, opts)
if (!result) return {}
files = result
} catch {
return {}
}
const manifestPath = files.get('package.json')
if (!manifestPath) return {}
const manifest = await readPackageJson(manifestPath)
return extractMetadata(manifest)
}
function extractMetadata (manifest: PackageManifest): PkgMetadata {
return {
license: parseLicenseField(manifest.license),
description: manifest.description,
author: parseAuthorField(manifest.author),
homepage: manifest.homepage,
repository: parseRepositoryField(manifest.repository),
}
}
function parseLicenseField (field: unknown): string | undefined {
if (typeof field === 'string') return field
if (field && typeof field === 'object' && 'type' in field) {
return (field as { type: string }).type
}
if (Array.isArray(field)) {
return field
.map((l: { type?: string }) => l.type)
.filter(Boolean)
.join(' OR ') || undefined
}
return undefined
}
function parseAuthorField (field: unknown): string | undefined {
if (!field) return undefined
if (typeof field === 'string') return field
if (typeof field === 'object' && 'name' in field) {
return (field as { name: string }).name
}
return undefined
}
function parseRepositoryField (field: unknown): string | undefined {
if (!field) return undefined
if (typeof field === 'string') return field
if (typeof field === 'object' && 'url' in field) {
return (field as { url: string }).url
}
return undefined
}

View File

@@ -0,0 +1,6 @@
export { collectSbomComponents, type CollectSbomComponentsOptions } from './collectComponents.js'
export { serializeCycloneDx, type CycloneDxOptions } from './serializeCycloneDx.js'
export { serializeSpdx } from './serializeSpdx.js'
export { buildPurl, encodePurlName } from './purl.js'
export { integrityToHashes } from './integrity.js'
export type { SbomComponent, SbomRelationship, SbomResult, SbomFormat, SbomComponentType } from './types.js'

View File

@@ -0,0 +1,45 @@
import ssri from 'ssri'
export interface HashDigest {
algorithm: string
digest: string
}
/**
* Convert an SRI integrity string to a list of algorithm + hex digest pairs.
* e.g. "sha512-abc123..." → [{ algorithm: "SHA-512", digest: "..." }]
*/
export function integrityToHashes (integrity: string | undefined): HashDigest[] {
if (!integrity) return []
const parsed = ssri.parse(integrity)
const hashes: HashDigest[] = []
for (const [algo, entries] of Object.entries(parsed)) {
if (!entries?.length) continue
for (const entry of entries) {
const hexDigest = Buffer.from(entry.digest, 'base64').toString('hex')
hashes.push({
algorithm: normalizeShaAlgorithm(algo),
digest: hexDigest,
})
}
}
return hashes
}
function normalizeShaAlgorithm (algo: string): string {
switch (algo) {
case 'sha1':
return 'SHA-1'
case 'sha256':
return 'SHA-256'
case 'sha384':
return 'SHA-384'
case 'sha512':
return 'SHA-512'
default:
return algo.toUpperCase()
}
}

View File

@@ -0,0 +1,18 @@
// Sub-path import to pull only the SPDX module — avoids dragging in the
// validation/serialize layers with optional native deps that break esbuild bundling.
import { isSupportedSpdxId, isValidSpdxLicenseExpression } from '@cyclonedx/cyclonedx-library/SPDX'
// Classifies a license string into the appropriate CycloneDX representation.
// Uses the CycloneDX library's own SPDX list rather than spdx-license-ids,
// since CycloneDX maintains its own subset of recognized IDs.
// Order matters: check ID first because "MIT" matches both isSupportedSpdxId
// and isValidSpdxLicenseExpression, but we prefer the more specific license.id form.
export function classifyLicense (license: string): { license: { id: string } } | { license: { name: string } } | { expression: string } {
if (isSupportedSpdxId(license)) {
return { license: { id: license } }
}
if (isValidSpdxLicenseExpression(license)) {
return { expression: license }
}
return { license: { name: license } }
}

View File

@@ -0,0 +1,27 @@
/**
* Encode a package name for use in a PURL.
* Scoped packages: @scope/name → %40scope/name
*/
export function encodePurlName (name: string): string {
if (name.startsWith('@')) {
return `%40${name.slice(1)}`
}
return name
}
/**
* Build a Package URL (PURL) for a given package.
* Spec: https://github.com/package-url/purl-spec
*/
export function buildPurl (opts: {
name: string
version: string
nonSemverVersion?: string
}): string {
if (opts.nonSemverVersion) {
// Git-hosted or tarball dep — encode the raw version as a qualifier
const encodedUrl = encodeURIComponent(opts.nonSemverVersion)
return `pkg:npm/${encodePurlName(opts.name)}@${encodeURIComponent(opts.version)}?vcs_url=${encodedUrl}`
}
return `pkg:npm/${encodePurlName(opts.name)}@${opts.version}`
}

View File

@@ -0,0 +1,179 @@
import crypto from 'crypto'
import { integrityToHashes } from './integrity.js'
import { classifyLicense } from './license.js'
import { encodePurlName } from './purl.js'
import { type SbomResult } from './types.js'
export interface CycloneDxOptions {
pnpmVersion?: string
lockfileOnly?: boolean
sbomAuthors?: string[]
sbomSupplier?: string
}
export function serializeCycloneDx (result: SbomResult, opts?: CycloneDxOptions): string {
const { rootComponent, components, relationships } = result
const rootBomRef = `pkg:npm/${encodePurlName(rootComponent.name)}@${rootComponent.version}`
const bomComponents = components.map((comp) => {
const { group, name } = splitScopedName(comp.name)
const cdxComp: Record<string, unknown> = {
type: 'library',
name,
version: comp.version,
purl: comp.purl,
'bom-ref': comp.purl,
}
if (group) {
cdxComp.group = group
}
if (comp.description) {
cdxComp.description = comp.description
}
// CycloneDX supplier is the registry/distributor, not the package author
if (comp.author) {
cdxComp.authors = [{ name: comp.author }]
}
if (comp.license) {
cdxComp.licenses = [classifyLicense(comp.license)]
}
const externalRefs: Array<Record<string, unknown>> = []
// Lockfile integrity is a tarball hash, not a source hash — belongs on the
// distribution reference, not component.hashes
if (comp.tarballUrl) {
const hashes = integrityToHashes(comp.integrity)
const distRef: Record<string, unknown> = {
type: 'distribution',
url: comp.tarballUrl,
}
if (hashes.length > 0) {
distRef.hashes = hashes.map((h) => ({
alg: h.algorithm,
content: h.digest,
}))
}
externalRefs.push(distRef)
}
if (comp.homepage) {
externalRefs.push({
type: 'website',
url: comp.homepage,
})
}
if (comp.repository) {
externalRefs.push({
type: 'vcs',
url: comp.repository,
})
}
if (externalRefs.length > 0) {
cdxComp.externalReferences = externalRefs
}
return cdxComp
})
// Group relationships by source
const depMap = new Map<string, string[]>()
depMap.set(rootBomRef, [])
for (const comp of components) {
depMap.set(comp.purl, [])
}
for (const rel of relationships) {
const deps = depMap.get(rel.from)
if (deps) {
deps.push(rel.to)
}
}
const bomDependencies = Array.from(depMap.entries()).map(([ref, dependsOn]) => ({
ref,
dependsOn: [...new Set(dependsOn)],
}))
const { group: rootGroup, name: rootName } = splitScopedName(rootComponent.name)
const rootCdxComponent: Record<string, unknown> = {
type: rootComponent.type,
name: rootName,
version: rootComponent.version,
purl: rootBomRef,
'bom-ref': rootBomRef,
}
if (rootGroup) {
rootCdxComponent.group = rootGroup
}
if (rootComponent.author) {
rootCdxComponent.authors = [{ name: rootComponent.author }]
}
if (rootComponent.license) {
rootCdxComponent.licenses = [classifyLicense(rootComponent.license)]
}
if (rootComponent.description) {
rootCdxComponent.description = rootComponent.description
}
if (rootComponent.repository) {
rootCdxComponent.externalReferences = [{
type: 'vcs',
url: rootComponent.repository,
}]
}
const toolComponents: Array<Record<string, unknown>> = []
if (opts?.pnpmVersion) {
toolComponents.push({
type: 'application',
name: 'pnpm',
version: opts.pnpmVersion,
})
}
const metadata: Record<string, unknown> = {
timestamp: new Date().toISOString(),
lifecycles: [{ phase: opts?.lockfileOnly ? 'pre-build' : 'build' }],
tools: { components: toolComponents },
component: rootCdxComponent,
}
// authors/supplier describe who authored/supplies the BOM document,
// not the tool — opt-in via --sbom-authors and --sbom-supplier
if (opts?.sbomAuthors?.length) {
metadata.authors = opts.sbomAuthors.map((name) => ({ name }))
}
if (opts?.sbomSupplier) {
metadata.supplier = { name: opts.sbomSupplier }
}
const bom: Record<string, unknown> = {
$schema: 'http://cyclonedx.org/schema/bom-1.7.schema.json',
bomFormat: 'CycloneDX',
specVersion: '1.7',
serialNumber: `urn:uuid:${crypto.randomUUID()}`,
version: 1,
metadata,
components: bomComponents,
dependencies: bomDependencies,
}
return JSON.stringify(bom, null, 2)
}
function splitScopedName (fullName: string): { group: string | undefined, name: string } {
if (fullName.startsWith('@')) {
const slashIdx = fullName.indexOf('/')
if (slashIdx > 0) {
return { group: fullName.slice(0, slashIdx), name: fullName.slice(slashIdx + 1) }
}
}
return { group: undefined, name: fullName }
}

View File

@@ -0,0 +1,167 @@
import crypto from 'crypto'
import { integrityToHashes } from './integrity.js'
import { encodePurlName } from './purl.js'
import { type SbomResult } from './types.js'
export function serializeSpdx (result: SbomResult): string {
const { rootComponent, components, relationships } = result
const rootSpdxId = 'SPDXRef-RootPackage'
const documentNamespace = `https://spdx.org/spdxdocs/${sanitizeSpdxId(rootComponent.name)}-${rootComponent.version}-${crypto.randomUUID()}`
const rootPurl = `pkg:npm/${encodePurlName(rootComponent.name)}@${rootComponent.version}`
const rootPackage: Record<string, unknown> = {
SPDXID: rootSpdxId,
name: rootComponent.name,
versionInfo: rootComponent.version,
downloadLocation: 'NOASSERTION',
filesAnalyzed: false,
primaryPackagePurpose: rootComponent.type === 'application' ? 'APPLICATION' : 'LIBRARY',
externalRefs: [
{
referenceCategory: 'PACKAGE-MANAGER',
referenceType: 'purl',
referenceLocator: rootPurl,
},
],
}
if (rootComponent.license) {
rootPackage.licenseConcluded = rootComponent.license
rootPackage.licenseDeclared = rootComponent.license
} else {
rootPackage.licenseConcluded = 'NOASSERTION'
rootPackage.licenseDeclared = 'NOASSERTION'
}
rootPackage.copyrightText = 'NOASSERTION'
if (rootComponent.description) {
rootPackage.description = rootComponent.description
}
if (rootComponent.author) {
rootPackage.supplier = `Person: ${rootComponent.author}`
}
if (rootComponent.repository) {
rootPackage.homepage = rootComponent.repository
}
const purlToSpdxId = new Map<string, string>()
purlToSpdxId.set(rootPurl, rootSpdxId)
const spdxPackages = components.map((comp, idx) => {
const spdxId = `SPDXRef-Package-${sanitizeSpdxId(comp.name)}-${sanitizeSpdxId(comp.version)}-${idx}`
purlToSpdxId.set(comp.purl, spdxId)
const pkg: Record<string, unknown> = {
SPDXID: spdxId,
name: comp.name,
versionInfo: comp.version,
downloadLocation: comp.tarballUrl ?? 'NOASSERTION',
filesAnalyzed: false,
externalRefs: [
{
referenceCategory: 'PACKAGE-MANAGER',
referenceType: 'purl',
referenceLocator: comp.purl,
},
],
}
if (comp.license) {
pkg.licenseConcluded = comp.license
pkg.licenseDeclared = comp.license
} else {
pkg.licenseConcluded = 'NOASSERTION'
pkg.licenseDeclared = 'NOASSERTION'
}
pkg.copyrightText = 'NOASSERTION'
if (comp.description) {
pkg.description = comp.description
}
if (comp.homepage) {
pkg.homepage = comp.homepage
}
if (comp.author) {
pkg.supplier = `Person: ${comp.author}`
}
const hashes = integrityToHashes(comp.integrity)
if (hashes.length > 0) {
pkg.checksums = hashes.map((h) => ({
algorithm: spdxHashAlgorithm(h.algorithm),
checksumValue: h.digest,
}))
}
return pkg
})
const spdxRelationships = [
{
spdxElementId: 'SPDXRef-DOCUMENT',
relatedSpdxElement: rootSpdxId,
relationshipType: 'DESCRIBES',
},
]
const seenRelationships = new Set<string>()
for (const rel of relationships) {
const fromId = purlToSpdxId.get(rel.from)
const toId = purlToSpdxId.get(rel.to)
if (fromId && toId) {
const key = `${fromId}|${toId}`
if (seenRelationships.has(key)) continue
seenRelationships.add(key)
spdxRelationships.push({
spdxElementId: fromId,
relatedSpdxElement: toId,
relationshipType: 'DEPENDS_ON',
})
}
}
const doc = {
spdxVersion: 'SPDX-2.3',
dataLicense: 'CC0-1.0',
SPDXID: 'SPDXRef-DOCUMENT',
name: rootComponent.name,
documentNamespace,
creationInfo: {
created: new Date().toISOString(),
creators: [
'Tool: pnpm',
],
},
packages: [rootPackage, ...spdxPackages],
relationships: spdxRelationships,
}
return JSON.stringify(doc, null, 2)
}
function sanitizeSpdxId (value: string): string {
return value.replace(/[^a-z0-9.-]/gi, '-')
}
function spdxHashAlgorithm (algo: string): string {
switch (algo) {
case 'SHA-1':
return 'SHA1'
case 'SHA-256':
return 'SHA256'
case 'SHA-384':
return 'SHA384'
case 'SHA-512':
return 'SHA512'
default:
return algo
}
}

View File

@@ -0,0 +1,38 @@
import { type DepType } from '@pnpm/lockfile.detect-dep-types'
export interface SbomComponent {
name: string
version: string
purl: string
depPath: string
depType: DepType
integrity?: string
tarballUrl?: string
license?: string
description?: string
author?: string
homepage?: string
repository?: string
}
export interface SbomRelationship {
from: string
to: string
}
export interface SbomResult {
rootComponent: {
name: string
version: string
type: 'library' | 'application'
license?: string
description?: string
author?: string
repository?: string
}
components: SbomComponent[]
relationships: SbomRelationship[]
}
export type SbomFormat = 'cyclonedx' | 'spdx'
export type SbomComponentType = 'library' | 'application'

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from '@jest/globals'
import { integrityToHashes } from '@pnpm/sbom'
describe('integrityToHashes', () => {
it('should return empty array for undefined', () => {
expect(integrityToHashes(undefined)).toEqual([])
})
it('should return empty array for empty string', () => {
expect(integrityToHashes('')).toEqual([])
})
it('should convert sha512 SRI to hex digest', () => {
// "hello" in sha512 base64
const base64Digest = 'm3HZHUS1gluA4SYbwmv9oKmjWpnOkVsNMODkieIEoW4yBFgVi/2ypgiJMOxRaA0MYkJMuxfb+Z1yDFkJUGFnOQ=='
const integrity = `sha512-${base64Digest}`
const result = integrityToHashes(integrity)
expect(result).toHaveLength(1)
expect(result[0].algorithm).toBe('SHA-512')
expect(result[0].digest).toMatch(/^[0-9a-f]+$/)
})
it('should convert sha256 SRI to hex digest', () => {
// Some arbitrary sha256 integrity
const base64Digest = 'LCt5klFGBqVfMfB1GL1o2Ll+0w/DeN2OZGR8U2/9fns='
const integrity = `sha256-${base64Digest}`
const result = integrityToHashes(integrity)
expect(result).toHaveLength(1)
expect(result[0].algorithm).toBe('SHA-256')
expect(result[0].digest).toMatch(/^[0-9a-f]+$/)
})
it('should handle multiple hash algorithms', () => {
const sha256 = 'LCt5klFGBqVfMfB1GL1o2Ll+0w/DeN2OZGR8U2/9fns='
const sha512 = 'm3HZHUS1gluA4SYbwmv9oKmjWpnOkVsNMODkieIEoW4yBFgVi/2ypgiJMOxRaA0MYkJMuxfb+Z1yDFkJUGFnOQ=='
const integrity = `sha256-${sha256} sha512-${sha512}`
const result = integrityToHashes(integrity)
expect(result).toHaveLength(2)
expect(result.map(h => h.algorithm)).toContain('SHA-256')
expect(result.map(h => h.algorithm)).toContain('SHA-512')
})
})

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from '@jest/globals'
import { classifyLicense } from '../src/license.js'
describe('classifyLicense', () => {
it('should return license.id for a known SPDX identifier', () => {
expect(classifyLicense('MIT')).toEqual({ license: { id: 'MIT' } })
expect(classifyLicense('Apache-2.0')).toEqual({ license: { id: 'Apache-2.0' } })
expect(classifyLicense('ISC')).toEqual({ license: { id: 'ISC' } })
expect(classifyLicense('BSD-3-Clause')).toEqual({ license: { id: 'BSD-3-Clause' } })
})
it('should return expression for compound SPDX expressions with OR', () => {
expect(classifyLicense('MIT OR Apache-2.0')).toEqual({ expression: 'MIT OR Apache-2.0' })
})
it('should return expression for compound SPDX expressions with AND', () => {
expect(classifyLicense('MIT AND ISC')).toEqual({ expression: 'MIT AND ISC' })
})
it('should return expression for compound SPDX expressions with WITH', () => {
expect(classifyLicense('Apache-2.0 WITH LLVM-exception')).toEqual({ expression: 'Apache-2.0 WITH LLVM-exception' })
})
it('should return license.name for non-SPDX license strings', () => {
expect(classifyLicense('SEE LICENSE IN LICENSE.md')).toEqual({ license: { name: 'SEE LICENSE IN LICENSE.md' } })
expect(classifyLicense('Custom License')).toEqual({ license: { name: 'Custom License' } })
})
it('should return license.name for unknown identifiers', () => {
expect(classifyLicense('WTFPL')).toEqual({ license: { id: 'WTFPL' } })
})
})

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from '@jest/globals'
import { buildPurl } from '@pnpm/sbom'
describe('buildPurl', () => {
it('should build a basic PURL for an unscoped package', () => {
expect(buildPurl({ name: 'lodash', version: '4.17.21' }))
.toBe('pkg:npm/lodash@4.17.21')
})
it('should build a PURL for a scoped package', () => {
expect(buildPurl({ name: '@babel/core', version: '7.23.0' }))
.toBe('pkg:npm/%40babel/core@7.23.0')
})
it('should include vcs_url for git deps', () => {
const result = buildPurl({
name: 'my-pkg',
version: '1.0.0',
nonSemverVersion: 'github.com/user/repo/abc123',
})
expect(result).toContain('pkg:npm/my-pkg@')
expect(result).toContain('?vcs_url=')
expect(result).toContain(encodeURIComponent('github.com/user/repo/abc123'))
})
it('should handle deeply scoped package names', () => {
expect(buildPurl({ name: '@pnpm/lockfile.types', version: '1.0.0' }))
.toBe('pkg:npm/%40pnpm/lockfile.types@1.0.0')
})
})

View File

@@ -0,0 +1,246 @@
import { describe, expect, it } from '@jest/globals'
import { serializeCycloneDx, type SbomResult } from '@pnpm/sbom'
import { DepType } from '@pnpm/lockfile.detect-dep-types'
function makeSbomResult (): SbomResult {
return {
rootComponent: {
name: '@acme/sbom-app',
version: '1.0.0',
type: 'application',
license: 'MIT',
description: 'ACME SBOM application',
author: 'ACME Corp',
repository: 'https://github.com/acme/sbom-app.git',
},
components: [
{
name: 'lodash',
version: '4.17.21',
purl: 'pkg:npm/lodash@4.17.21',
depPath: 'lodash@4.17.21',
depType: DepType.ProdOnly,
integrity: 'sha512-LCt5klFGBqVfMfB1GL1o2Ll+0w/DeN2OZGR8U2/9fns=',
tarballUrl: 'https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz',
license: 'MIT',
description: 'Lodash modular utilities',
author: 'Jane Doe',
homepage: 'https://lodash.com/',
repository: 'https://github.com/lodash/lodash.git',
},
{
name: '@babel/core',
version: '7.23.0',
purl: 'pkg:npm/%40babel/core@7.23.0',
depPath: '@babel/core@7.23.0',
depType: DepType.DevOnly,
license: 'MIT',
},
],
relationships: [
{ from: 'pkg:npm/%40acme/sbom-app@1.0.0', to: 'pkg:npm/lodash@4.17.21' },
{ from: 'pkg:npm/%40acme/sbom-app@1.0.0', to: 'pkg:npm/%40babel/core@7.23.0' },
],
}
}
describe('serializeCycloneDx', () => {
it('should produce valid CycloneDX 1.7 JSON', () => {
const result = makeSbomResult()
const json = serializeCycloneDx(result)
const parsed = JSON.parse(json)
expect(parsed.$schema).toBe('http://cyclonedx.org/schema/bom-1.7.schema.json')
expect(parsed.bomFormat).toBe('CycloneDX')
expect(parsed.specVersion).toBe('1.7')
expect(parsed.version).toBe(1)
expect(parsed.serialNumber).toMatch(/^urn:uuid:[0-9a-f-]+$/)
})
it('should include timestamp in metadata', () => {
const before = new Date().toISOString()
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
const after = new Date().toISOString()
expect(parsed.metadata.timestamp).toBeDefined()
expect(parsed.metadata.timestamp >= before).toBe(true)
expect(parsed.metadata.timestamp <= after).toBe(true)
})
it('should use build lifecycle by default', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.metadata.lifecycles).toEqual([{ phase: 'build' }])
})
it('should use pre-build lifecycle when lockfileOnly is true', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result, { lockfileOnly: true }))
expect(parsed.metadata.lifecycles).toEqual([{ phase: 'pre-build' }])
})
it('should split scoped root component into group and name', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.metadata.component.group).toBe('@acme')
expect(parsed.metadata.component.name).toBe('sbom-app')
expect(parsed.metadata.component.version).toBe('1.0.0')
expect(parsed.metadata.component.type).toBe('application')
expect(parsed.metadata.component.purl).toBe('pkg:npm/%40acme/sbom-app@1.0.0')
})
it('should include root component metadata', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
const root = parsed.metadata.component
expect(root.licenses).toEqual([{ license: { id: 'MIT' } }])
expect(root.description).toBe('ACME SBOM application')
expect(root.authors).toEqual([{ name: 'ACME Corp' }])
expect(root.supplier).toBeUndefined()
const vcsRef = root.externalReferences.find(
(r: { type: string }) => r.type === 'vcs'
)
expect(vcsRef.url).toBe('https://github.com/acme/sbom-app.git')
})
it('should not include metadata.authors or metadata.supplier by default', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.metadata.authors).toBeUndefined()
expect(parsed.metadata.supplier).toBeUndefined()
})
it('should include metadata.authors and metadata.supplier when provided via options', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result, {
sbomAuthors: ['Jane Doe', 'John Smith'],
sbomSupplier: 'ACME Corp',
}))
expect(parsed.metadata.authors).toEqual([{ name: 'Jane Doe' }, { name: 'John Smith' }])
expect(parsed.metadata.supplier).toEqual({ name: 'ACME Corp' })
})
it('should split scoped component names into group and name', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
const babel = parsed.components[1]
expect(babel.group).toBe('@babel')
expect(babel.name).toBe('core')
const lodash = parsed.components[0]
expect(lodash.group).toBeUndefined()
expect(lodash.name).toBe('lodash')
})
it('should include tools.components with versions when toolInfo is provided', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result, {
pnpmVersion: '11.0.0',
}))
const tools = parsed.metadata.tools.components
expect(tools).toHaveLength(1)
expect(tools[0]).toEqual({ type: 'application', name: 'pnpm', version: '11.0.0' })
})
it('should include all components with PURLs', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.components).toHaveLength(2)
expect(parsed.components[0].purl).toBe('pkg:npm/lodash@4.17.21')
expect(parsed.components[0].type).toBe('library')
expect(parsed.components[1].purl).toBe('pkg:npm/%40babel/core@7.23.0')
})
it('should place hashes in externalReferences with type distribution', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
const lodash = parsed.components[0]
expect(lodash.hashes).toBeUndefined()
const distRef = lodash.externalReferences.find(
(r: { type: string }) => r.type === 'distribution'
)
expect(distRef).toBeDefined()
expect(distRef.url).toBe('https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz')
expect(distRef.hashes.length).toBeGreaterThan(0)
expect(distRef.hashes[0].alg).toBeDefined()
expect(distRef.hashes[0].content).toBeDefined()
})
it('should use license.id for known SPDX identifiers', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.components[0].licenses).toEqual([
{ license: { id: 'MIT' } },
])
})
it('should use expression for compound SPDX licenses', () => {
const result = makeSbomResult()
result.components[0].license = 'MIT OR Apache-2.0'
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.components[0].licenses).toEqual([
{ expression: 'MIT OR Apache-2.0' },
])
})
it('should use license.name for non-SPDX license strings', () => {
const result = makeSbomResult()
result.components[0].license = 'SEE LICENSE IN LICENSE.md'
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.components[0].licenses).toEqual([
{ license: { name: 'SEE LICENSE IN LICENSE.md' } },
])
})
it('should include component authors without supplier', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.components[0].authors).toEqual([{ name: 'Jane Doe' }])
expect(parsed.components[0].supplier).toBeUndefined()
expect(parsed.components[1].authors).toBeUndefined()
})
it('should include vcs externalReference when repository is present', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
const lodash = parsed.components[0]
const vcsRef = lodash.externalReferences.find(
(r: { type: string }) => r.type === 'vcs'
)
expect(vcsRef).toBeDefined()
expect(vcsRef.url).toBe('https://github.com/lodash/lodash.git')
const babel = parsed.components[1]
expect(babel.externalReferences).toBeUndefined()
})
it('should include dependencies', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result))
expect(parsed.dependencies).toBeDefined()
const rootDep = parsed.dependencies.find(
(d: { ref: string }) => d.ref === 'pkg:npm/%40acme/sbom-app@1.0.0'
)
expect(rootDep).toBeDefined()
expect(rootDep.dependsOn).toContain('pkg:npm/lodash@4.17.21')
expect(rootDep.dependsOn).toContain('pkg:npm/%40babel/core@7.23.0')
})
})

View File

@@ -0,0 +1,198 @@
import { describe, expect, it } from '@jest/globals'
import { serializeSpdx, type SbomResult } from '@pnpm/sbom'
import { DepType } from '@pnpm/lockfile.detect-dep-types'
function makeSbomResult (): SbomResult {
return {
rootComponent: {
name: 'my-app',
version: '1.0.0',
type: 'library',
},
components: [
{
name: 'lodash',
version: '4.17.21',
purl: 'pkg:npm/lodash@4.17.21',
depPath: 'lodash@4.17.21',
depType: DepType.ProdOnly,
integrity: 'sha512-LCt5klFGBqVfMfB1GL1o2Ll+0w/DeN2OZGR8U2/9fns=',
license: 'MIT',
description: 'Lodash modular utilities',
homepage: 'https://lodash.com/',
author: 'John-David Dalton',
},
],
relationships: [
{ from: 'pkg:npm/my-app@1.0.0', to: 'pkg:npm/lodash@4.17.21' },
],
}
}
describe('serializeSpdx', () => {
it('should produce valid SPDX 2.3 JSON', () => {
const result = makeSbomResult()
const json = serializeSpdx(result)
const parsed = JSON.parse(json)
expect(parsed.spdxVersion).toBe('SPDX-2.3')
expect(parsed.dataLicense).toBe('CC0-1.0')
expect(parsed.SPDXID).toBe('SPDXRef-DOCUMENT')
})
it('should include creation info', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.creationInfo).toBeDefined()
expect(parsed.creationInfo.creators).toContain('Tool: pnpm')
expect(parsed.creationInfo.created).toBeDefined()
})
it('should include root package and dependency packages', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.packages).toHaveLength(2)
expect(parsed.packages[0].SPDXID).toBe('SPDXRef-RootPackage')
expect(parsed.packages[0].name).toBe('my-app')
})
it('should include PURL as external ref', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
const lodashPkg = parsed.packages[1]
expect(lodashPkg.externalRefs).toBeDefined()
expect(lodashPkg.externalRefs[0].referenceType).toBe('purl')
expect(lodashPkg.externalRefs[0].referenceLocator).toBe('pkg:npm/lodash@4.17.21')
})
it('should include license info', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
const lodashPkg = parsed.packages[1]
expect(lodashPkg.licenseConcluded).toBe('MIT')
expect(lodashPkg.licenseDeclared).toBe('MIT')
})
it('should use NOASSERTION for missing license', () => {
const result = makeSbomResult()
result.components[0].license = undefined
const parsed = JSON.parse(serializeSpdx(result))
const lodashPkg = parsed.packages[1]
expect(lodashPkg.licenseConcluded).toBe('NOASSERTION')
expect(lodashPkg.licenseDeclared).toBe('NOASSERTION')
})
it('should include DESCRIBES relationship from document to root', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
const describes = parsed.relationships.find(
(r: { relationshipType: string }) => r.relationshipType === 'DESCRIBES'
)
expect(describes).toBeDefined()
expect(describes.spdxElementId).toBe('SPDXRef-DOCUMENT')
expect(describes.relatedSpdxElement).toBe('SPDXRef-RootPackage')
})
it('should include DEPENDS_ON relationships', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
const dependsOn = parsed.relationships.filter(
(r: { relationshipType: string }) => r.relationshipType === 'DEPENDS_ON'
)
expect(dependsOn).toHaveLength(1)
expect(dependsOn[0].spdxElementId).toBe('SPDXRef-RootPackage')
})
it('should sanitize SPDX IDs', () => {
const result = makeSbomResult()
result.components[0].name = '@scope/pkg-name'
const parsed = JSON.parse(serializeSpdx(result))
const pkg = parsed.packages[1]
// SPDX IDs can only contain [a-zA-Z0-9.-]
expect(pkg.SPDXID).toMatch(/^SPDXRef-[a-zA-Z0-9.-]+$/)
})
it('should include document namespace', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.documentNamespace).toMatch(/^https:\/\/spdx\.org\/spdxdocs\//)
})
it('should include description when present', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.packages[1].description).toBe('Lodash modular utilities')
})
it('should include homepage when present', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.packages[1].homepage).toBe('https://lodash.com/')
})
it('should include supplier from author', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.packages[1].supplier).toBe('Person: John-David Dalton')
})
it('should omit supplier when author is absent', () => {
const result = makeSbomResult()
result.components[0].author = undefined
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.packages[1].supplier).toBeUndefined()
})
it('should include checksums from integrity', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
const lodashPkg = parsed.packages[1]
expect(lodashPkg.checksums).toBeDefined()
expect(lodashPkg.checksums.length).toBeGreaterThan(0)
expect(lodashPkg.checksums[0].algorithm).toBeDefined()
expect(lodashPkg.checksums[0].checksumValue).toBeDefined()
})
it('should deduplicate relationships', () => {
const result = makeSbomResult()
// Add a duplicate relationship
result.relationships.push(
{ from: 'pkg:npm/my-app@1.0.0', to: 'pkg:npm/lodash@4.17.21' }
)
const parsed = JSON.parse(serializeSpdx(result))
const dependsOn = parsed.relationships.filter(
(r: { relationshipType: string }) => r.relationshipType === 'DEPENDS_ON'
)
expect(dependsOn).toHaveLength(1)
})
it('should use APPLICATION for application root type', () => {
const result = makeSbomResult()
result.rootComponent.type = 'application'
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.packages[0].primaryPackagePurpose).toBe('APPLICATION')
})
it('should use LIBRARY for library root type', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeSpdx(result))
expect(parsed.packages[0].primaryPackagePurpose).toBe('LIBRARY')
})
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../node_modules/.test.lib",
"rootDir": "..",
"isolatedModules": true
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -0,0 +1,37 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../lockfile/detect-dep-types"
},
{
"path": "../../lockfile/types"
},
{
"path": "../../lockfile/utils"
},
{
"path": "../../lockfile/walker"
},
{
"path": "../../packages/logger"
},
{
"path": "../../packages/types"
},
{
"path": "../../pkg-manifest/read-package-json"
},
{
"path": "../../store/pkg-finder"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -0,0 +1,40 @@
# @pnpm/store.pkg-finder
Resolves a package's file index from the content-addressable store (CAFS) and returns a uniform `Map<string, string>` mapping filenames to absolute paths on disk.
## Usage
```ts
import { readPackageFileMap } from '@pnpm/store.pkg-finder'
const files = await readPackageFileMap(
resolution, // { integrity?, tarball?, directory?, type? }
packageId,
{
storeDir: '/home/user/.local/share/pnpm/store/v10',
lockfileDir: '/home/user/project',
virtualStoreDirMaxLength: 120,
}
)
if (files) {
const manifestPath = files.get('package.json')
const licensePath = files.get('LICENSE')
}
```
## Supported resolution types
- **Directory** (`type: 'directory'`): fetches the file list from the local directory.
- **Integrity** (`integrity` field): looks up the index file in CAFS by integrity hash.
- **Tarball** (`tarball` field): looks up the index file by package directory name.
Returns `undefined` for unsupported resolution types.
## Note on side effects
This function returns only the original package files. Files added or removed by post-install scripts (side effects) are not included. Use the raw `PackageFilesIndex` from `@pnpm/store.cafs` if you need side-effect files.
## License
MIT

View File

@@ -0,0 +1,48 @@
{
"name": "@pnpm/store.pkg-finder",
"version": "1000.0.0-0",
"description": "Read a package's file map from the content-addressable store",
"keywords": [
"pnpm",
"pnpm11"
],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/store/pkg-finder",
"homepage": "https://github.com/pnpm/pnpm/tree/main/store/pkg-finder#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": "./lib/index.js"
},
"files": [
"lib",
"!*.map"
],
"scripts": {
"lint": "eslint \"src/**/*.ts\"",
"compile": "tsgo --build && pnpm run lint --fix",
"prepublishOnly": "pnpm run compile",
"test": "pnpm run compile"
},
"dependencies": {
"@pnpm/dependency-path": "workspace:*",
"@pnpm/directory-fetcher": "workspace:*",
"@pnpm/fs.msgpack-file": "workspace:*",
"@pnpm/resolver-base": "workspace:*",
"@pnpm/store.cafs": "workspace:*"
},
"devDependencies": {
"@pnpm/store.pkg-finder": "workspace:*"
},
"engines": {
"node": ">=22.13"
},
"jest": {
"preset": "@pnpm/jest-config"
}
}

View File

@@ -0,0 +1,72 @@
import path from 'path'
import { depPathToFilename, parse } from '@pnpm/dependency-path'
import { fetchFromDir } from '@pnpm/directory-fetcher'
import { readMsgpackFile } from '@pnpm/fs.msgpack-file'
import { type Resolution } from '@pnpm/resolver-base'
import { getFilePathByModeInCafs, getIndexFilePathInCafs, type PackageFilesIndex } from '@pnpm/store.cafs'
export interface ReadPackageFileMapOptions {
storeDir: string
lockfileDir: string
virtualStoreDirMaxLength: number
}
/**
* Reads the file index for a package and returns a `Map<string, string>`
* mapping filenames to absolute paths on disk.
*
* Handles three types of package resolutions:
* - Directory packages: fetches the file list from the local directory
* - Packages with integrity: looks up the index file in the CAFS by integrity hash
* - Tarball packages: looks up the index file by package directory name
*
* For CAFS packages, the content-addressed digests are resolved to file
* paths upfront, so callers get a uniform map regardless of resolution type.
*
* Note: this function does not include files from side effects (post-install
* scripts). Use the raw `PackageFilesIndex` if you need side-effect files.
*
* Returns `undefined` if the resolution type is unsupported, letting callers
* decide how to handle this case. Throws if the index file cannot be read.
*/
export async function readPackageFileMap (
packageResolution: Resolution,
packageId: string,
opts: ReadPackageFileMapOptions
): Promise<Map<string, string> | undefined> {
if (packageResolution.type === 'directory') {
const localInfo = await fetchFromDir(
path.join(opts.lockfileDir, packageResolution.directory),
{}
)
return localInfo.filesMap
}
const isPackageWithIntegrity = 'integrity' in packageResolution
let pkgIndexFilePath: string
if (isPackageWithIntegrity) {
const parsedId = parse(packageId)
pkgIndexFilePath = getIndexFilePathInCafs(
opts.storeDir,
packageResolution.integrity as string,
parsedId.nonSemverVersion ?? `${parsedId.name}@${parsedId.version}`
)
} else if (!packageResolution.type && 'tarball' in packageResolution && packageResolution.tarball) {
const packageDirInStore = depPathToFilename(parse(packageId).nonSemverVersion ?? packageId, opts.virtualStoreDirMaxLength)
pkgIndexFilePath = path.join(
opts.storeDir,
packageDirInStore,
'integrity.mpk'
)
} else {
return undefined
}
const { files: indexFiles } = await readMsgpackFile<PackageFilesIndex>(pkgIndexFilePath)
const files = new Map<string, string>()
for (const [name, info] of indexFiles) {
files.set(name, getFilePathByModeInCafs(opts.storeDir, info.digest, info.mode))
}
return files
}

View File

@@ -0,0 +1,28 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../fetching/directory-fetcher"
},
{
"path": "../../fs/msgpack-file"
},
{
"path": "../../packages/dependency-path"
},
{
"path": "../../resolving/resolver-base"
},
{
"path": "../cafs"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}