@pnpm/deps.compliance.sbom 1100.1.8 → 1100.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,14 @@ import type { LockfileObject } from '@pnpm/lockfile.types';
2
2
  import type { Resolution } from '@pnpm/resolving.resolver-base';
3
3
  import type { DependenciesField, ProjectId, Registries } from '@pnpm/types';
4
4
  import type { SbomComponentType, SbomResult } from './types.js';
5
+ export interface WorkspacePackageInfo {
6
+ name: string;
7
+ version: string;
8
+ license?: string;
9
+ description?: string;
10
+ author?: string;
11
+ repository?: string;
12
+ }
5
13
  export interface CollectSbomComponentsOptions {
6
14
  lockfile: LockfileObject;
7
15
  rootName: string;
@@ -20,6 +28,21 @@ export interface CollectSbomComponentsOptions {
20
28
  lockfileOnly?: boolean;
21
29
  storeDir?: string;
22
30
  virtualStoreDirMaxLength?: number;
31
+ workspacePackages?: Record<ProjectId, WorkspacePackageInfo>;
32
+ resolvedWorkspaceDeps?: ReturnType<typeof resolveWorkspaceDeps>;
23
33
  }
24
34
  export declare function collectSbomComponents(opts: CollectSbomComponentsOptions): Promise<SbomResult>;
25
35
  export declare function gitDownloadUrl(resolution: Resolution): string | undefined;
36
+ interface WorkspaceLink {
37
+ sourceImporterId: ProjectId;
38
+ targetImporterId: ProjectId;
39
+ depName: string;
40
+ devOnly: boolean;
41
+ }
42
+ export declare function resolveWorkspaceDeps(lockfile: LockfileObject, importerIds: ProjectId[], include?: {
43
+ [dependenciesField in DependenciesField]: boolean;
44
+ }): {
45
+ links: WorkspaceLink[];
46
+ additionalImporterIds: ProjectId[];
47
+ };
48
+ export {};
@@ -1,16 +1,71 @@
1
+ import path from 'node:path';
1
2
  import { DepType, detectDepTypes } from '@pnpm/lockfile.detect-dep-types';
2
3
  import { nameVerFromPkgSnapshot, pkgSnapshotToResolution } from '@pnpm/lockfile.utils';
3
4
  import { lockfileWalkerGroupImporterSteps, } from '@pnpm/lockfile.walker';
4
5
  import { StoreIndex } from '@pnpm/store.index';
6
+ import pLimit from 'p-limit';
5
7
  import { getPkgMetadata } from './getPkgMetadata.js';
6
8
  import { buildPurl, encodePurlName } from './purl.js';
9
+ const IMPORTER_WALK_CONCURRENCY = 8;
7
10
  export async function collectSbomComponents(opts) {
8
11
  const depTypes = detectDepTypes(opts.lockfile);
9
12
  const importerIds = opts.includedImporterIds ?? Object.keys(opts.lockfile.importers);
10
- const importerWalkers = lockfileWalkerGroupImporterSteps(opts.lockfile, importerIds, { include: opts.include });
11
13
  const componentsMap = new Map();
12
14
  const relationships = [];
13
15
  const rootPurl = `pkg:npm/${encodePurlName(opts.rootName)}@${opts.rootVersion}`;
16
+ const workspaceDeps = opts.resolvedWorkspaceDeps
17
+ ?? (opts.lockfileOnly
18
+ ? { links: [], additionalImporterIds: [] }
19
+ : resolveWorkspaceDeps(opts.lockfile, importerIds, opts.include));
20
+ const allImporterIds = [...importerIds, ...workspaceDeps.additionalImporterIds];
21
+ const importerWalkers = lockfileWalkerGroupImporterSteps(opts.lockfile, allImporterIds, { include: opts.include });
22
+ const importerIdSet = new Set(importerIds);
23
+ if (opts.workspacePackages) {
24
+ const workspaceDepTypes = new Map();
25
+ for (const dep of workspaceDeps.links) {
26
+ const info = opts.workspacePackages[dep.targetImporterId];
27
+ if (!info)
28
+ continue;
29
+ const purl = buildPurl({ name: info.name, version: info.version });
30
+ const current = workspaceDepTypes.get(purl);
31
+ if (!dep.devOnly) {
32
+ workspaceDepTypes.set(purl, DepType.ProdOnly);
33
+ }
34
+ else if (current === undefined) {
35
+ workspaceDepTypes.set(purl, DepType.DevOnly);
36
+ }
37
+ }
38
+ for (const dep of workspaceDeps.links) {
39
+ const info = opts.workspacePackages[dep.targetImporterId];
40
+ if (!info)
41
+ continue;
42
+ const purl = buildPurl({ name: info.name, version: info.version });
43
+ let parentPurl;
44
+ if (importerIdSet.has(dep.sourceImporterId)) {
45
+ parentPurl = rootPurl;
46
+ }
47
+ else {
48
+ const sourceInfo = opts.workspacePackages[dep.sourceImporterId];
49
+ parentPurl = sourceInfo
50
+ ? buildPurl({ name: sourceInfo.name, version: sourceInfo.version })
51
+ : rootPurl;
52
+ }
53
+ relationships.push({ from: parentPurl, to: purl });
54
+ if (!componentsMap.has(purl)) {
55
+ componentsMap.set(purl, {
56
+ name: info.name,
57
+ version: info.version,
58
+ purl,
59
+ depPath: `link:${dep.targetImporterId}`,
60
+ depType: workspaceDepTypes.get(purl) ?? DepType.ProdOnly,
61
+ license: info.license,
62
+ description: info.description,
63
+ author: info.author,
64
+ repository: info.repository,
65
+ });
66
+ }
67
+ }
68
+ }
14
69
  const storeIndex = (!opts.lockfileOnly && opts.storeDir)
15
70
  ? new StoreIndex(opts.storeDir)
16
71
  : undefined;
@@ -22,9 +77,20 @@ export async function collectSbomComponents(opts) {
22
77
  virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength ?? 120,
23
78
  }
24
79
  : undefined;
25
- await Promise.all(importerWalkers.map(async ({ step }) => {
26
- await walkStep(step, rootPurl, depTypes, componentsMap, relationships, opts, metadataOpts);
27
- }));
80
+ const walkImporter = pLimit(IMPORTER_WALK_CONCURRENCY);
81
+ await Promise.all(importerWalkers.map(({ importerId, step }) => walkImporter(async () => {
82
+ let parentPurl = rootPurl;
83
+ if (!importerIdSet.has(importerId)) {
84
+ const info = opts.workspacePackages?.[importerId];
85
+ // A reachable workspace importer with no resolved package info (e.g. its
86
+ // manifest could not be read) is skipped entirely; walking it would
87
+ // misattribute its dependencies to the root component.
88
+ if (!info)
89
+ return;
90
+ parentPurl = buildPurl({ name: info.name, version: info.version });
91
+ }
92
+ await walkStep(step, parentPurl, depTypes, componentsMap, relationships, opts, metadataOpts);
93
+ })));
28
94
  storeIndex?.close();
29
95
  return {
30
96
  rootComponent: {
@@ -79,4 +145,47 @@ export function gitDownloadUrl(resolution) {
79
145
  const prefix = needsGitPlusPrefix ? 'git+' : '';
80
146
  return `${prefix}${resolution.repo}#${resolution.commit}`;
81
147
  }
148
+ export function resolveWorkspaceDeps(lockfile, importerIds, include) {
149
+ const links = [];
150
+ const visited = new Set(importerIds);
151
+ const queue = [...importerIds];
152
+ const additionalImporterIds = [];
153
+ for (let head = 0; head < queue.length; head++) {
154
+ const importerId = queue[head];
155
+ const snapshot = lockfile.importers[importerId];
156
+ if (!snapshot)
157
+ continue;
158
+ const devDepNames = new Set(Object.keys(snapshot.devDependencies ?? {}));
159
+ const prodDeps = {
160
+ ...(include?.dependencies !== false ? snapshot.dependencies : {}),
161
+ ...(include?.optionalDependencies !== false ? snapshot.optionalDependencies : {}),
162
+ };
163
+ const allDeps = {
164
+ ...prodDeps,
165
+ ...(include?.devDependencies !== false ? snapshot.devDependencies : {}),
166
+ };
167
+ for (const [depName, reference] of Object.entries(allDeps)) {
168
+ if (!reference.startsWith('link:'))
169
+ continue;
170
+ const linkPath = reference.slice(5);
171
+ const targetId = path.posix.normalize(importerId === '.' ? linkPath : path.posix.join(importerId, linkPath));
172
+ // A crafted lockfile can point a `link:` target outside the workspace root;
173
+ // such importer IDs must never be followed, as they later become filesystem reads.
174
+ if (path.posix.isAbsolute(targetId) || targetId === '..' || targetId.startsWith('../'))
175
+ continue;
176
+ // `in` would also match inherited keys (e.g. "toString"); a crafted lockfile
177
+ // must not be able to enqueue importer IDs that are not actually present.
178
+ if (!Object.prototype.hasOwnProperty.call(lockfile.importers, targetId))
179
+ continue;
180
+ const devOnly = devDepNames.has(depName) && !(depName in prodDeps);
181
+ links.push({ sourceImporterId: importerId, targetImporterId: targetId, depName, devOnly });
182
+ if (!visited.has(targetId)) {
183
+ visited.add(targetId);
184
+ additionalImporterIds.push(targetId);
185
+ queue.push(targetId);
186
+ }
187
+ }
188
+ }
189
+ return { links, additionalImporterIds };
190
+ }
82
191
  //# sourceMappingURL=collectComponents.js.map
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { collectSbomComponents, type CollectSbomComponentsOptions, gitDownloadUrl } from './collectComponents.js';
1
+ export { collectSbomComponents, type CollectSbomComponentsOptions, gitDownloadUrl, resolveWorkspaceDeps, type WorkspacePackageInfo } from './collectComponents.js';
2
2
  export { integrityToHashes } from './integrity.js';
3
3
  export { buildPurl, encodePurlName } from './purl.js';
4
4
  export { type CycloneDxOptions, serializeCycloneDx } from './serializeCycloneDx.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { collectSbomComponents, gitDownloadUrl } from './collectComponents.js';
1
+ export { collectSbomComponents, gitDownloadUrl, resolveWorkspaceDeps } from './collectComponents.js';
2
2
  export { integrityToHashes } from './integrity.js';
3
3
  export { buildPurl, encodePurlName } from './purl.js';
4
4
  export { serializeCycloneDx } from './serializeCycloneDx.js';
@@ -5,5 +5,6 @@ export interface CycloneDxOptions {
5
5
  sbomAuthors?: string[];
6
6
  sbomSupplier?: string;
7
7
  specVersion?: string;
8
+ compact?: boolean;
8
9
  }
9
10
  export declare function serializeCycloneDx(result: SbomResult, opts?: CycloneDxOptions): string;
@@ -1,4 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
+ import { DepType } from '@pnpm/lockfile.detect-dep-types';
2
3
  import { integrityToHashes } from './integrity.js';
3
4
  import { classifyLicense } from './license.js';
4
5
  import { encodePurlName } from './purl.js';
@@ -14,6 +15,19 @@ export function serializeCycloneDx(result, opts) {
14
15
  purl: comp.purl,
15
16
  'bom-ref': comp.purl,
16
17
  };
18
+ // CycloneDX `excluded` scope (valid in every exported spec version):
19
+ // "component usage for test and other non-runtime purposes", which is the
20
+ // semantics of a devDependency.
21
+ // Components reachable through prod (ProdOnly/DevAndProd) omit scope and
22
+ // default to `required`. Installed optionalDependencies are runtime-reachable,
23
+ // so they stay `required` too, not `optional`.
24
+ if (comp.depType === DepType.DevOnly) {
25
+ cdxComp.scope = 'excluded';
26
+ // Also emit the CycloneDX npm-taxonomy marker. `scope` is the modern
27
+ // signal; `cdx:npm:package:development` is what @cyclonedx/cyclonedx-npm
28
+ // emits and what older consumers read, so we provide both.
29
+ cdxComp.properties = [{ name: 'cdx:npm:package:development', value: 'true' }];
30
+ }
17
31
  if (group) {
18
32
  cdxComp.group = group;
19
33
  }
@@ -136,7 +150,7 @@ export function serializeCycloneDx(result, opts) {
136
150
  components: bomComponents,
137
151
  dependencies: bomDependencies,
138
152
  };
139
- return JSON.stringify(bom, null, 2);
153
+ return JSON.stringify(bom, null, opts?.compact ? undefined : 2);
140
154
  }
141
155
  function splitScopedName(fullName) {
142
156
  if (fullName.startsWith('@')) {
@@ -1,2 +1,5 @@
1
1
  import type { SbomResult } from './types.js';
2
- export declare function serializeSpdx(result: SbomResult): string;
2
+ export interface SpdxOptions {
3
+ compact?: boolean;
4
+ }
5
+ export declare function serializeSpdx(result: SbomResult, opts?: SpdxOptions): string;
@@ -1,7 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { integrityToHashes } from './integrity.js';
3
3
  import { encodePurlName } from './purl.js';
4
- export function serializeSpdx(result) {
4
+ export function serializeSpdx(result, opts) {
5
5
  const { rootComponent, components, relationships } = result;
6
6
  const rootSpdxId = 'SPDXRef-RootPackage';
7
7
  const documentNamespace = `https://spdx.org/spdxdocs/${sanitizeSpdxId(rootComponent.name)}-${rootComponent.version}-${crypto.randomUUID()}`;
@@ -123,7 +123,7 @@ export function serializeSpdx(result) {
123
123
  packages: [rootPackage, ...spdxPackages],
124
124
  relationships: spdxRelationships,
125
125
  };
126
- return JSON.stringify(doc, null, 2);
126
+ return JSON.stringify(doc, null, opts?.compact ? undefined : 2);
127
127
  }
128
128
  function sanitizeSpdxId(value) {
129
129
  return value.replace(/[^a-z0-9.-]/gi, '-');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/deps.compliance.sbom",
3
- "version": "1100.1.8",
3
+ "version": "1100.2.0",
4
4
  "description": "Generate SBOM from pnpm lockfile",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -30,29 +30,29 @@
30
30
  "!*.map"
31
31
  ],
32
32
  "dependencies": {
33
- "@cyclonedx/cyclonedx-library": "10.0.0",
33
+ "@cyclonedx/cyclonedx-library": "10.1.0",
34
34
  "p-limit": "^7.3.0",
35
35
  "ssri": "13.0.1",
36
- "@pnpm/lockfile.types": "1100.0.10",
36
+ "@pnpm/lockfile.detect-dep-types": "1100.0.11",
37
+ "@pnpm/lockfile.walker": "1100.0.11",
38
+ "@pnpm/lockfile.utils": "1100.0.13",
37
39
  "@pnpm/deps.compliance.license-resolver": "1100.0.0",
38
- "@pnpm/lockfile.utils": "1100.0.12",
39
- "@pnpm/lockfile.walker": "1100.0.10",
40
- "@pnpm/lockfile.detect-dep-types": "1100.0.10",
41
- "@pnpm/pkg-manifest.reader": "1100.0.7",
42
- "@pnpm/resolving.resolver-base": "1100.4.1",
43
- "@pnpm/store.pkg-finder": "1100.0.16",
44
- "@pnpm/types": "1101.3.1",
45
- "@pnpm/store.index": "1100.1.0"
40
+ "@pnpm/resolving.resolver-base": "1100.4.2",
41
+ "@pnpm/store.index": "1100.2.0",
42
+ "@pnpm/lockfile.types": "1100.0.11",
43
+ "@pnpm/types": "1101.3.2",
44
+ "@pnpm/store.pkg-finder": "1100.0.17",
45
+ "@pnpm/pkg-manifest.reader": "1100.0.8"
46
46
  },
47
47
  "peerDependencies": {
48
- "@pnpm/logger": "^1001.0.1"
48
+ "@pnpm/logger": "^1100.0.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@jest/globals": "30.3.0",
51
+ "@jest/globals": "30.4.1",
52
52
  "@types/ssri": "^7.1.5",
53
- "@pnpm/deps.compliance.sbom": "1100.1.8",
54
- "@pnpm/logger": "1100.0.0",
55
- "@pnpm/store.cafs": "1100.1.9"
53
+ "@pnpm/store.cafs": "1100.1.10",
54
+ "@pnpm/deps.compliance.sbom": "1100.2.0",
55
+ "@pnpm/logger": "1100.0.0"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=22.13"