Skip to content

Commit dcededc

Browse files
authored
feat(sbom): mark dev-only components with CycloneDX scope "excluded" (#12442)
Components reachable only through devDependencies now get `scope: "excluded"` plus the `cdx:npm:package:development` property in CycloneDX output. The `excluded` scope documents non-runtime/test usage (valid in every exported spec version, 1.5/1.6/1.7); the property is the CycloneDX npm-taxonomy marker emitted by `@cyclonedx/cyclonedx-npm`, so both modern and existing consumers are covered. Runtime-reachable components (ProdOnly, DevAndProd, and installed optionalDependencies) omit both and default to `required`.
1 parent 3188ae7 commit dcededc

4 files changed

Lines changed: 69 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/deps.compliance.sbom": minor
3+
"pnpm": minor
4+
---
5+
6+
`pnpm sbom` now marks components reachable only through `devDependencies` with CycloneDX `scope: "excluded"` and the `cdx:npm:package:development` property. The `excluded` scope documents "component usage for test and other non-runtime purposes", which matches the semantics of a devDependency; the property is the CycloneDX npm-taxonomy marker emitted by `@cyclonedx/cyclonedx-npm`, so both modern (scope) and existing (property) consumers are covered. Components reachable at runtime (including installed `optionalDependencies`) omit `scope` and default to `required`.

deps/compliance/commands/test/sbom/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,30 @@ test('pnpm sbom --prod excludes devDependencies', async () => {
186186
expect(componentNames).not.toContain('typescript')
187187
})
188188

189+
test('pnpm sbom marks dev-only components with scope "excluded" (cyclonedx)', async () => {
190+
const workspaceDir = tempDir()
191+
f.copy('with-dev-dependency', workspaceDir)
192+
193+
const { output, exitCode } = await sbom.handler({
194+
...DEFAULT_OPTS,
195+
dir: workspaceDir,
196+
lockfileDir: workspaceDir,
197+
pnpmHomeDir: '',
198+
sbomFormat: 'cyclonedx',
199+
lockfileOnly: true,
200+
})
201+
202+
expect(exitCode).toBe(0)
203+
204+
const parsed = JSON.parse(output)
205+
const typescript = parsed.components.find((c: { name: string }) => c.name === 'typescript')
206+
expect(typescript.scope).toBe('excluded')
207+
208+
// Prod components default to "required"; scope is omitted
209+
const isPositive = parsed.components.find((c: { name: string }) => c.name === 'is-positive')
210+
expect(isPositive.scope).toBeUndefined()
211+
})
212+
189213
test('pnpm sbom invalid --sbom-type throws', async () => {
190214
const workspaceDir = tempDir()
191215
f.copy('simple-sbom', workspaceDir)

deps/compliance/sbom/src/serializeCycloneDx.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import crypto from 'node:crypto'
22

3+
import { DepType } from '@pnpm/lockfile.detect-dep-types'
4+
35
import { integrityToHashes } from './integrity.js'
46
import { classifyLicense } from './license.js'
57
import { encodePurlName } from './purl.js'
@@ -29,6 +31,20 @@ export function serializeCycloneDx (result: SbomResult, opts?: CycloneDxOptions)
2931
'bom-ref': comp.purl,
3032
}
3133

34+
// CycloneDX `excluded` scope (valid in every exported spec version):
35+
// "component usage for test and other non-runtime purposes", which is the
36+
// semantics of a devDependency.
37+
// Components reachable through prod (ProdOnly/DevAndProd) omit scope and
38+
// default to `required`. Installed optionalDependencies are runtime-reachable,
39+
// so they stay `required` too, not `optional`.
40+
if (comp.depType === DepType.DevOnly) {
41+
cdxComp.scope = 'excluded'
42+
// Also emit the CycloneDX npm-taxonomy marker. `scope` is the modern
43+
// signal; `cdx:npm:package:development` is what @cyclonedx/cyclonedx-npm
44+
// emits and what older consumers read, so we provide both.
45+
cdxComp.properties = [{ name: 'cdx:npm:package:development', value: 'true' }]
46+
}
47+
3248
if (group) {
3349
cdxComp.group = group
3450
}

deps/compliance/sbom/test/serializeCycloneDx.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,29 @@ describe('serializeCycloneDx', () => {
101101
expect(parsed.metadata.component.purl).toBe('pkg:npm/%40acme/sbom-app@1.0.0')
102102
})
103103

104+
it('should mark dev-only components with scope "excluded" and leave prod components scopeless', () => {
105+
const result = makeSbomResult()
106+
const parsed = JSON.parse(serializeCycloneDx(result))
107+
108+
const babel = parsed.components.find((c: { name: string }) => c.name === 'core')
109+
expect(babel.scope).toBe('excluded')
110+
expect(babel.properties).toContainEqual({ name: 'cdx:npm:package:development', value: 'true' })
111+
112+
const lodash = parsed.components.find((c: { name: string }) => c.name === 'lodash')
113+
expect(lodash.scope).toBeUndefined()
114+
expect(lodash.properties).toBeUndefined()
115+
})
116+
117+
it('should leave dev-and-prod components scopeless without the development marker', () => {
118+
const result = makeSbomResult()
119+
result.components[1].depType = DepType.DevAndProd
120+
const parsed = JSON.parse(serializeCycloneDx(result))
121+
122+
const babel = parsed.components.find((c: { name: string }) => c.name === 'core')
123+
expect(babel.scope).toBeUndefined()
124+
expect(babel.properties).toBeUndefined()
125+
})
126+
104127
it('should include root component metadata', () => {
105128
const result = makeSbomResult()
106129
const parsed = JSON.parse(serializeCycloneDx(result))

0 commit comments

Comments
 (0)