mirror of
https://github.com/pnpm/pnpm.git
synced 2026-03-29 12:31:52 -04:00
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:
committed by
GitHub
parent
2ba8b2daf1
commit
f92ac24c1b
7
.changeset/add-sbom-command.md
Normal file
7
.changeset/add-sbom-command.md
Normal 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).
|
||||
5
.changeset/fruity-animals-sneeze.md
Normal file
5
.changeset/fruity-animals-sneeze.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@pnpm/store.pkg-finder": major
|
||||
---
|
||||
|
||||
Initial release.
|
||||
@@ -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
257
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -149,6 +149,9 @@
|
||||
{
|
||||
"path": "../reviewing/plugin-commands-outdated"
|
||||
},
|
||||
{
|
||||
"path": "../reviewing/plugin-commands-sbom"
|
||||
},
|
||||
{
|
||||
"path": "../store/cafs"
|
||||
},
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
63
reviewing/plugin-commands-sbom/package.json
Normal file
63
reviewing/plugin-commands-sbom/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
reviewing/plugin-commands-sbom/src/index.ts
Normal file
3
reviewing/plugin-commands-sbom/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as sbom from './sbom.js'
|
||||
|
||||
export { sbom }
|
||||
224
reviewing/plugin-commands-sbom/src/sbom.ts
Normal file
224
reviewing/plugin-commands-sbom/src/sbom.ts
Normal 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".`
|
||||
)
|
||||
}
|
||||
1
reviewing/plugin-commands-sbom/test/fixtures/.gitignore
vendored
Normal file
1
reviewing/plugin-commands-sbom/test/fixtures/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
9
reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/package.json
vendored
Normal file
9
reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
24
reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/pnpm-lock.yaml
generated
vendored
Normal file
24
reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/pnpm-lock.yaml
generated
vendored
Normal 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
|
||||
12
reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/package.json
vendored
Normal file
12
reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
36
reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/pnpm-lock.yaml
generated
vendored
Normal file
36
reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/pnpm-lock.yaml
generated
vendored
Normal 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
|
||||
221
reviewing/plugin-commands-sbom/test/index.ts
Normal file
221
reviewing/plugin-commands-sbom/test/index.ts
Normal 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')
|
||||
})
|
||||
18
reviewing/plugin-commands-sbom/test/tsconfig.json
Normal file
18
reviewing/plugin-commands-sbom/test/tsconfig.json
Normal 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": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
52
reviewing/plugin-commands-sbom/test/utils/index.ts
Normal file
52
reviewing/plugin-commands-sbom/test/utils/index.ts
Normal 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,
|
||||
}
|
||||
55
reviewing/plugin-commands-sbom/tsconfig.json
Normal file
55
reviewing/plugin-commands-sbom/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
reviewing/plugin-commands-sbom/tsconfig.lint.json
Normal file
8
reviewing/plugin-commands-sbom/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
63
reviewing/sbom/package.json
Normal file
63
reviewing/sbom/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
131
reviewing/sbom/src/collectComponents.ts
Normal file
131
reviewing/sbom/src/collectComponents.ts
Normal 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)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
96
reviewing/sbom/src/getPkgMetadata.ts
Normal file
96
reviewing/sbom/src/getPkgMetadata.ts
Normal 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
|
||||
}
|
||||
6
reviewing/sbom/src/index.ts
Normal file
6
reviewing/sbom/src/index.ts
Normal 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'
|
||||
45
reviewing/sbom/src/integrity.ts
Normal file
45
reviewing/sbom/src/integrity.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
18
reviewing/sbom/src/license.ts
Normal file
18
reviewing/sbom/src/license.ts
Normal 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 } }
|
||||
}
|
||||
27
reviewing/sbom/src/purl.ts
Normal file
27
reviewing/sbom/src/purl.ts
Normal 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}`
|
||||
}
|
||||
179
reviewing/sbom/src/serializeCycloneDx.ts
Normal file
179
reviewing/sbom/src/serializeCycloneDx.ts
Normal 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 }
|
||||
}
|
||||
167
reviewing/sbom/src/serializeSpdx.ts
Normal file
167
reviewing/sbom/src/serializeSpdx.ts
Normal 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
|
||||
}
|
||||
}
|
||||
38
reviewing/sbom/src/types.ts
Normal file
38
reviewing/sbom/src/types.ts
Normal 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'
|
||||
45
reviewing/sbom/test/integrity.test.ts
Normal file
45
reviewing/sbom/test/integrity.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
32
reviewing/sbom/test/license.test.ts
Normal file
32
reviewing/sbom/test/license.test.ts
Normal 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' } })
|
||||
})
|
||||
})
|
||||
30
reviewing/sbom/test/purl.test.ts
Normal file
30
reviewing/sbom/test/purl.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
246
reviewing/sbom/test/serializeCycloneDx.test.ts
Normal file
246
reviewing/sbom/test/serializeCycloneDx.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
198
reviewing/sbom/test/serializeSpdx.test.ts
Normal file
198
reviewing/sbom/test/serializeSpdx.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
18
reviewing/sbom/test/tsconfig.json
Normal file
18
reviewing/sbom/test/tsconfig.json
Normal 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": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
37
reviewing/sbom/tsconfig.json
Normal file
37
reviewing/sbom/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
reviewing/sbom/tsconfig.lint.json
Normal file
8
reviewing/sbom/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
40
store/pkg-finder/README.md
Normal file
40
store/pkg-finder/README.md
Normal 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
|
||||
48
store/pkg-finder/package.json
Normal file
48
store/pkg-finder/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
72
store/pkg-finder/src/index.ts
Normal file
72
store/pkg-finder/src/index.ts
Normal 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
|
||||
}
|
||||
28
store/pkg-finder/tsconfig.json
Normal file
28
store/pkg-finder/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
store/pkg-finder/tsconfig.lint.json
Normal file
8
store/pkg-finder/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user