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" + ] +}