From f92ac24c1b2dcd3ece4ae06fed2092871273bc23 Mon Sep 17 00:00:00 2001 From: Allan Kimmer Jensen Date: Wed, 25 Feb 2026 08:45:21 +0100 Subject: [PATCH] feat(sbom): add pnpm sbom command (#10592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 (filename → absolute path), simplifying both consumers. Close #9088 --------- Co-authored-by: Zoltan Kochan --- .changeset/add-sbom-command.md | 7 + .changeset/fruity-animals-sneeze.md | 5 + cspell.json | 5 + pnpm-lock.yaml | 257 +++++++++++++++--- pnpm-workspace.yaml | 1 + pnpm/package.json | 1 + pnpm/src/cmd/index.ts | 2 + pnpm/tsconfig.json | 3 + reviewing/license-scanner/package.json | 4 +- reviewing/license-scanner/src/getPkgInfo.ts | 203 +++----------- .../license-scanner/test/getPkgInfo.spec.ts | 3 +- reviewing/license-scanner/tsconfig.json | 8 +- reviewing/plugin-commands-sbom/package.json | 63 +++++ reviewing/plugin-commands-sbom/src/index.ts | 3 + reviewing/plugin-commands-sbom/src/sbom.ts | 224 +++++++++++++++ .../test/fixtures/.gitignore | 1 + .../test/fixtures/simple-sbom/package.json | 9 + .../test/fixtures/simple-sbom/pnpm-lock.yaml | 24 ++ .../fixtures/with-dev-dependency/package.json | 12 + .../with-dev-dependency/pnpm-lock.yaml | 36 +++ reviewing/plugin-commands-sbom/test/index.ts | 221 +++++++++++++++ .../plugin-commands-sbom/test/tsconfig.json | 18 ++ .../plugin-commands-sbom/test/utils/index.ts | 52 ++++ reviewing/plugin-commands-sbom/tsconfig.json | 55 ++++ .../plugin-commands-sbom/tsconfig.lint.json | 8 + reviewing/sbom/package.json | 63 +++++ reviewing/sbom/src/collectComponents.ts | 131 +++++++++ reviewing/sbom/src/getPkgMetadata.ts | 96 +++++++ reviewing/sbom/src/index.ts | 6 + reviewing/sbom/src/integrity.ts | 45 +++ reviewing/sbom/src/license.ts | 18 ++ reviewing/sbom/src/purl.ts | 27 ++ reviewing/sbom/src/serializeCycloneDx.ts | 179 ++++++++++++ reviewing/sbom/src/serializeSpdx.ts | 167 ++++++++++++ reviewing/sbom/src/types.ts | 38 +++ reviewing/sbom/test/integrity.test.ts | 45 +++ reviewing/sbom/test/license.test.ts | 32 +++ reviewing/sbom/test/purl.test.ts | 30 ++ .../sbom/test/serializeCycloneDx.test.ts | 246 +++++++++++++++++ reviewing/sbom/test/serializeSpdx.test.ts | 198 ++++++++++++++ reviewing/sbom/test/tsconfig.json | 18 ++ reviewing/sbom/tsconfig.json | 37 +++ reviewing/sbom/tsconfig.lint.json | 8 + store/pkg-finder/README.md | 40 +++ store/pkg-finder/package.json | 48 ++++ store/pkg-finder/src/index.ts | 72 +++++ store/pkg-finder/tsconfig.json | 28 ++ store/pkg-finder/tsconfig.lint.json | 8 + 48 files changed, 2599 insertions(+), 206 deletions(-) create mode 100644 .changeset/add-sbom-command.md create mode 100644 .changeset/fruity-animals-sneeze.md create mode 100644 reviewing/plugin-commands-sbom/package.json create mode 100644 reviewing/plugin-commands-sbom/src/index.ts create mode 100644 reviewing/plugin-commands-sbom/src/sbom.ts create mode 100644 reviewing/plugin-commands-sbom/test/fixtures/.gitignore create mode 100644 reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/package.json create mode 100644 reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/pnpm-lock.yaml create mode 100644 reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/package.json create mode 100644 reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/pnpm-lock.yaml create mode 100644 reviewing/plugin-commands-sbom/test/index.ts create mode 100644 reviewing/plugin-commands-sbom/test/tsconfig.json create mode 100644 reviewing/plugin-commands-sbom/test/utils/index.ts create mode 100644 reviewing/plugin-commands-sbom/tsconfig.json create mode 100644 reviewing/plugin-commands-sbom/tsconfig.lint.json create mode 100644 reviewing/sbom/package.json create mode 100644 reviewing/sbom/src/collectComponents.ts create mode 100644 reviewing/sbom/src/getPkgMetadata.ts create mode 100644 reviewing/sbom/src/index.ts create mode 100644 reviewing/sbom/src/integrity.ts create mode 100644 reviewing/sbom/src/license.ts create mode 100644 reviewing/sbom/src/purl.ts create mode 100644 reviewing/sbom/src/serializeCycloneDx.ts create mode 100644 reviewing/sbom/src/serializeSpdx.ts create mode 100644 reviewing/sbom/src/types.ts create mode 100644 reviewing/sbom/test/integrity.test.ts create mode 100644 reviewing/sbom/test/license.test.ts create mode 100644 reviewing/sbom/test/purl.test.ts create mode 100644 reviewing/sbom/test/serializeCycloneDx.test.ts create mode 100644 reviewing/sbom/test/serializeSpdx.test.ts create mode 100644 reviewing/sbom/test/tsconfig.json create mode 100644 reviewing/sbom/tsconfig.json create mode 100644 reviewing/sbom/tsconfig.lint.json create mode 100644 store/pkg-finder/README.md create mode 100644 store/pkg-finder/package.json create mode 100644 store/pkg-finder/src/index.ts create mode 100644 store/pkg-finder/tsconfig.json create mode 100644 store/pkg-finder/tsconfig.lint.json diff --git a/.changeset/add-sbom-command.md b/.changeset/add-sbom-command.md new file mode 100644 index 0000000000..352f8fb715 --- /dev/null +++ b/.changeset/add-sbom-command.md @@ -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). diff --git a/.changeset/fruity-animals-sneeze.md b/.changeset/fruity-animals-sneeze.md new file mode 100644 index 0000000000..af999a6ae6 --- /dev/null +++ b/.changeset/fruity-animals-sneeze.md @@ -0,0 +1,5 @@ +--- +"@pnpm/store.pkg-finder": major +--- + +Initial release. diff --git a/cspell.json b/cspell.json index 71c065a557..623df2a79d 100644 --- a/cspell.json +++ b/cspell.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b490b0cb78..9ba52534d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2b78aa2767..b52436996b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/pnpm/package.json b/pnpm/package.json index 3d8018cdfc..265c0ef161 100644 --- a/pnpm/package.json +++ b/pnpm/package.json @@ -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:*", diff --git a/pnpm/src/cmd/index.ts b/pnpm/src/cmd/index.ts index 41665dcea3..243eb2c43f 100644 --- a/pnpm/src/cmd/index.ts +++ b/pnpm/src/cmd/index.ts @@ -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, diff --git a/pnpm/tsconfig.json b/pnpm/tsconfig.json index 150d583430..6175c5faf7 100644 --- a/pnpm/tsconfig.json +++ b/pnpm/tsconfig.json @@ -149,6 +149,9 @@ { "path": "../reviewing/plugin-commands-outdated" }, + { + "path": "../reviewing/plugin-commands-sbom" + }, { "path": "../store/cafs" }, diff --git a/reviewing/license-scanner/package.json b/reviewing/license-scanner/package.json index 8cc0953649..40f4652fda 100644 --- a/reviewing/license-scanner/package.json +++ b/reviewing/license-scanner/package.json @@ -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:", diff --git a/reviewing/license-scanner/src/getPkgInfo.ts b/reviewing/license-scanner/src/getPkgInfo.ts index 07e7ddad2c..487a0644dc 100644 --- a/reviewing/license-scanner/src/getPkgInfo.ts +++ b/reviewing/license-scanner/src/getPkgInfo.ts @@ -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 */ async function parseLicense ( pkg: { manifest: PackageManifest - files: - | { local: true, files: Record } - | { local: false, files: PackageFiles } - }, - opts: { storeDir: string } + files: Map + } ): Promise { 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 - */ -async function readLicenseFileFromCafs (storeDir: string, { digest, mode }: PackageFileInfo): Promise { - const fileName = getFilePathByModeInCafs(storeDir, digest, mode) - const fileContents = await readFile(fileName) - return fileContents -} - -export type ReadPackageIndexFileResult = - | { local: false, files: PackageFiles } - | { local: true, files: Record } - -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 { - // 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(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 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 = { diff --git a/reviewing/license-scanner/test/getPkgInfo.spec.ts b/reviewing/license-scanner/test/getPkgInfo.spec.ts index 802c50bc56..09328ce8b5 100644 --- a/reviewing/license-scanner/test/getPkgInfo.spec.ts +++ b/reviewing/license-scanner/test/getPkgInfo.spec.ts @@ -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'/) }) }) diff --git a/reviewing/license-scanner/tsconfig.json b/reviewing/license-scanner/tsconfig.json index f35ec32d96..7ec15f5806 100644 --- a/reviewing/license-scanner/tsconfig.json +++ b/reviewing/license-scanner/tsconfig.json @@ -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" } ] } diff --git a/reviewing/plugin-commands-sbom/package.json b/reviewing/plugin-commands-sbom/package.json new file mode 100644 index 0000000000..d7872a770e --- /dev/null +++ b/reviewing/plugin-commands-sbom/package.json @@ -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" + } +} diff --git a/reviewing/plugin-commands-sbom/src/index.ts b/reviewing/plugin-commands-sbom/src/index.ts new file mode 100644 index 0000000000..d408c23d17 --- /dev/null +++ b/reviewing/plugin-commands-sbom/src/index.ts @@ -0,0 +1,3 @@ +import * as sbom from './sbom.js' + +export { sbom } diff --git a/reviewing/plugin-commands-sbom/src/sbom.ts b/reviewing/plugin-commands-sbom/src/sbom.ts new file mode 100644 index 0000000000..233184a23d --- /dev/null +++ b/reviewing/plugin-commands-sbom/src/sbom.ts @@ -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> + +export function rcOptionsTypes (): Record { + return pick( + ['dev', 'global-dir', 'global', 'optional', 'production', 'store-dir'], + allTypes + ) +} + +export const cliOptionsTypes = (): Record => ({ + ...rcOptionsTypes(), + recursive: Boolean, + 'sbom-format': String, + 'sbom-type': String, + 'sbom-authors': String, + 'sbom-supplier': String, + 'lockfile-only': Boolean, +}) + +export const shorthands: Record = { + 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 ', + }, + { + description: 'The component type for the root package (default: library)', + name: '--sbom-type ', + }, + { + 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 ', + }, + { + description: 'SBOM supplier name (CycloneDX metadata.supplier)', + name: '--sbom-supplier ', + }, + { + 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".` + ) +} diff --git a/reviewing/plugin-commands-sbom/test/fixtures/.gitignore b/reviewing/plugin-commands-sbom/test/fixtures/.gitignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/reviewing/plugin-commands-sbom/test/fixtures/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/package.json b/reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/package.json new file mode 100644 index 0000000000..ada0694eac --- /dev/null +++ b/reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/package.json @@ -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" + } +} diff --git a/reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/pnpm-lock.yaml b/reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/pnpm-lock.yaml new file mode 100644 index 0000000000..c1d6d9dcad --- /dev/null +++ b/reviewing/plugin-commands-sbom/test/fixtures/simple-sbom/pnpm-lock.yaml @@ -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 diff --git a/reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/package.json b/reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/package.json new file mode 100644 index 0000000000..71c316167e --- /dev/null +++ b/reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/package.json @@ -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" + } +} diff --git a/reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/pnpm-lock.yaml b/reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/pnpm-lock.yaml new file mode 100644 index 0000000000..a0b59d4311 --- /dev/null +++ b/reviewing/plugin-commands-sbom/test/fixtures/with-dev-dependency/pnpm-lock.yaml @@ -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 diff --git a/reviewing/plugin-commands-sbom/test/index.ts b/reviewing/plugin-commands-sbom/test/index.ts new file mode 100644 index 0000000000..e5b50f58bb --- /dev/null +++ b/reviewing/plugin-commands-sbom/test/index.ts @@ -0,0 +1,221 @@ +/// +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') +}) diff --git a/reviewing/plugin-commands-sbom/test/tsconfig.json b/reviewing/plugin-commands-sbom/test/tsconfig.json new file mode 100644 index 0000000000..67ce5e1d0e --- /dev/null +++ b/reviewing/plugin-commands-sbom/test/tsconfig.json @@ -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": ".." + } + ] +} diff --git a/reviewing/plugin-commands-sbom/test/utils/index.ts b/reviewing/plugin-commands-sbom/test/utils/index.ts new file mode 100644 index 0000000000..2acabed942 --- /dev/null +++ b/reviewing/plugin-commands-sbom/test/utils/index.ts @@ -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, +} diff --git a/reviewing/plugin-commands-sbom/tsconfig.json b/reviewing/plugin-commands-sbom/tsconfig.json new file mode 100644 index 0000000000..b95ddd8ced --- /dev/null +++ b/reviewing/plugin-commands-sbom/tsconfig.json @@ -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" + } + ] +} diff --git a/reviewing/plugin-commands-sbom/tsconfig.lint.json b/reviewing/plugin-commands-sbom/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/reviewing/plugin-commands-sbom/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/reviewing/sbom/package.json b/reviewing/sbom/package.json new file mode 100644 index 0000000000..507906101b --- /dev/null +++ b/reviewing/sbom/package.json @@ -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" + } +} diff --git a/reviewing/sbom/src/collectComponents.ts b/reviewing/sbom/src/collectComponents.ts new file mode 100644 index 0000000000..dcf15dad07 --- /dev/null +++ b/reviewing/sbom/src/collectComponents.ts @@ -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 { + 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() + 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, + relationships: SbomRelationship[], + opts: CollectSbomComponentsOptions, + metadataOpts: GetPkgMetadataOptions | undefined +): Promise { + 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) + }) + ) +} + diff --git a/reviewing/sbom/src/getPkgMetadata.ts b/reviewing/sbom/src/getPkgMetadata.ts new file mode 100644 index 0000000000..f0d39fec07 --- /dev/null +++ b/reviewing/sbom/src/getPkgMetadata.ts @@ -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 { + return limitMetadataReads(() => getPkgMetadataUnclamped(depPath, snapshot, registries, opts)) +} + +async function getPkgMetadataUnclamped ( + depPath: string, + snapshot: PackageSnapshot, + registries: Registries, + opts: GetPkgMetadataOptions +): Promise { + const id = snapshot.id ?? depPath + const resolution = pkgSnapshotToResolution(depPath, snapshot, registries) + + let files: Map + 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 +} diff --git a/reviewing/sbom/src/index.ts b/reviewing/sbom/src/index.ts new file mode 100644 index 0000000000..482a32488f --- /dev/null +++ b/reviewing/sbom/src/index.ts @@ -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' diff --git a/reviewing/sbom/src/integrity.ts b/reviewing/sbom/src/integrity.ts new file mode 100644 index 0000000000..d87a9e6fb8 --- /dev/null +++ b/reviewing/sbom/src/integrity.ts @@ -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() + } +} diff --git a/reviewing/sbom/src/license.ts b/reviewing/sbom/src/license.ts new file mode 100644 index 0000000000..b4532faaee --- /dev/null +++ b/reviewing/sbom/src/license.ts @@ -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 } } +} diff --git a/reviewing/sbom/src/purl.ts b/reviewing/sbom/src/purl.ts new file mode 100644 index 0000000000..3d9807129e --- /dev/null +++ b/reviewing/sbom/src/purl.ts @@ -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}` +} diff --git a/reviewing/sbom/src/serializeCycloneDx.ts b/reviewing/sbom/src/serializeCycloneDx.ts new file mode 100644 index 0000000000..312982e88e --- /dev/null +++ b/reviewing/sbom/src/serializeCycloneDx.ts @@ -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 = { + 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> = [] + + // 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 = { + 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() + 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 = { + 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> = [] + if (opts?.pnpmVersion) { + toolComponents.push({ + type: 'application', + name: 'pnpm', + version: opts.pnpmVersion, + }) + } + + const metadata: Record = { + 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 = { + $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 } +} diff --git a/reviewing/sbom/src/serializeSpdx.ts b/reviewing/sbom/src/serializeSpdx.ts new file mode 100644 index 0000000000..ef3afaaf80 --- /dev/null +++ b/reviewing/sbom/src/serializeSpdx.ts @@ -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 = { + 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() + 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 = { + 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() + 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 + } +} diff --git a/reviewing/sbom/src/types.ts b/reviewing/sbom/src/types.ts new file mode 100644 index 0000000000..9fd64f56dc --- /dev/null +++ b/reviewing/sbom/src/types.ts @@ -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' diff --git a/reviewing/sbom/test/integrity.test.ts b/reviewing/sbom/test/integrity.test.ts new file mode 100644 index 0000000000..8c32b0fd17 --- /dev/null +++ b/reviewing/sbom/test/integrity.test.ts @@ -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') + }) +}) diff --git a/reviewing/sbom/test/license.test.ts b/reviewing/sbom/test/license.test.ts new file mode 100644 index 0000000000..c4ef5d8ce3 --- /dev/null +++ b/reviewing/sbom/test/license.test.ts @@ -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' } }) + }) +}) diff --git a/reviewing/sbom/test/purl.test.ts b/reviewing/sbom/test/purl.test.ts new file mode 100644 index 0000000000..55655b1438 --- /dev/null +++ b/reviewing/sbom/test/purl.test.ts @@ -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') + }) +}) diff --git a/reviewing/sbom/test/serializeCycloneDx.test.ts b/reviewing/sbom/test/serializeCycloneDx.test.ts new file mode 100644 index 0000000000..43b0b29cc8 --- /dev/null +++ b/reviewing/sbom/test/serializeCycloneDx.test.ts @@ -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') + }) +}) diff --git a/reviewing/sbom/test/serializeSpdx.test.ts b/reviewing/sbom/test/serializeSpdx.test.ts new file mode 100644 index 0000000000..1fce8cbdd4 --- /dev/null +++ b/reviewing/sbom/test/serializeSpdx.test.ts @@ -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') + }) +}) diff --git a/reviewing/sbom/test/tsconfig.json b/reviewing/sbom/test/tsconfig.json new file mode 100644 index 0000000000..67ce5e1d0e --- /dev/null +++ b/reviewing/sbom/test/tsconfig.json @@ -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": ".." + } + ] +} diff --git a/reviewing/sbom/tsconfig.json b/reviewing/sbom/tsconfig.json new file mode 100644 index 0000000000..e610fdba43 --- /dev/null +++ b/reviewing/sbom/tsconfig.json @@ -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" + } + ] +} diff --git a/reviewing/sbom/tsconfig.lint.json b/reviewing/sbom/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/reviewing/sbom/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/store/pkg-finder/README.md b/store/pkg-finder/README.md new file mode 100644 index 0000000000..c302e28ea5 --- /dev/null +++ b/store/pkg-finder/README.md @@ -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` 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 diff --git a/store/pkg-finder/package.json b/store/pkg-finder/package.json new file mode 100644 index 0000000000..f8ccd3e977 --- /dev/null +++ b/store/pkg-finder/package.json @@ -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" + } +} diff --git a/store/pkg-finder/src/index.ts b/store/pkg-finder/src/index.ts new file mode 100644 index 0000000000..6f8d98d9bb --- /dev/null +++ b/store/pkg-finder/src/index.ts @@ -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` + * 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 | 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(pkgIndexFilePath) + const files = new Map() + for (const [name, info] of indexFiles) { + files.set(name, getFilePathByModeInCafs(opts.storeDir, info.digest, info.mode)) + } + return files +} diff --git a/store/pkg-finder/tsconfig.json b/store/pkg-finder/tsconfig.json new file mode 100644 index 0000000000..2a4510ed93 --- /dev/null +++ b/store/pkg-finder/tsconfig.json @@ -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" + } + ] +} diff --git a/store/pkg-finder/tsconfig.lint.json b/store/pkg-finder/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/store/pkg-finder/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +}