feat(sbom): added support for specifying --sbom-spec-version (#11389)

Currently only supported for cyclonedx. Allowed values are 1.5, 1.6, and 1.7 (defaulting to 1.7); the lower bound is 1.5 because the generated JSON uses fields (e.g. `metadata.lifecycles`) that are only valid from CycloneDX 1.5+.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Sebastian Qvarfordt
2026-05-05 03:12:51 +02:00
committed by GitHub
parent 6d7903a8b7
commit 87b4bac2bd
5 changed files with 104 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/deps.compliance.sbom": minor
pnpm: minor
---
Allow setting sbom spec version using `--sbom-spec-version` [#11389](https://github.com/pnpm/pnpm/pull/11389).

View File

@@ -20,6 +20,7 @@ import { renderHelp } from 'render-help'
export type SbomCommandOptions = {
sbomFormat?: string
sbomType?: string
sbomSpecVersion?: string
lockfileOnly?: boolean
sbomAuthors?: string
sbomSupplier?: string
@@ -55,6 +56,7 @@ export const cliOptionsTypes = (): Record<string, unknown> => ({
recursive: Boolean,
'sbom-format': String,
'sbom-type': String,
'sbom-spec-version': String,
'sbom-authors': String,
'sbom-supplier': String,
'lockfile-only': Boolean,
@@ -82,6 +84,10 @@ export function help (): string {
description: 'The component type for the root package (default: library)',
name: '--sbom-type <library|application>',
},
{
description: 'The CycloneDX specification version (1.5, 1.6, or 1.7; default: 1.7)',
name: '--sbom-spec-version <version>',
},
{
description: 'Only use lockfile data (skip reading from the store)',
name: '--lockfile-only',
@@ -143,6 +149,7 @@ export async function handler (
}
const sbomType = validateSbomType(opts.sbomType)
const sbomSpecVersion = validateSbomSpecVersion(opts.sbomSpecVersion, format)
const lockfile = await readWantedLockfile(opts.lockfileDir ?? opts.dir, {
ignoreIncompatible: true,
@@ -217,6 +224,7 @@ export async function handler (
lockfileOnly: opts.lockfileOnly,
sbomAuthors: opts.sbomAuthors?.split(',').map((s) => s.trim()).filter(Boolean),
sbomSupplier: opts.sbomSupplier,
specVersion: sbomSpecVersion,
})
: serializeSpdx(result)
@@ -231,3 +239,25 @@ function validateSbomType (value: string | undefined): SbomComponentType {
`Invalid SBOM type "${value}". Use "library" or "application".`
)
}
// Versions whose schema is fully covered by what we currently emit
// (e.g. metadata.lifecycles requires CycloneDX 1.5+).
const SUPPORTED_CYCLONEDX_SPEC_VERSIONS = ['1.5', '1.6', '1.7']
function validateSbomSpecVersion (value: string | undefined, format: SbomFormat): string | undefined {
if (value == null) return undefined
if (format !== 'cyclonedx') {
throw new PnpmError(
'SBOM_SPEC_VERSION_UNSUPPORTED_FORMAT',
'The --sbom-spec-version option is only supported with --sbom-format cyclonedx.'
)
}
const normalized = value.trim()
if (!SUPPORTED_CYCLONEDX_SPEC_VERSIONS.includes(normalized)) {
throw new PnpmError(
'SBOM_INVALID_SPEC_VERSION',
`Invalid CycloneDX spec version "${value}". Supported versions: ${SUPPORTED_CYCLONEDX_SPEC_VERSIONS.join(', ')}.`
)
}
return normalized
}

View File

@@ -203,6 +203,61 @@ test('pnpm sbom invalid --sbom-type throws', async () => {
).rejects.toThrow('Invalid SBOM type')
})
test('pnpm sbom --sbom-spec-version 1.6', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
const { output, exitCode } = await sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'cyclonedx',
sbomSpecVersion: '1.6',
lockfileOnly: true,
})
expect(exitCode).toBe(0)
const parsed = JSON.parse(output)
expect(parsed.specVersion).toBe('1.6')
expect(parsed.$schema).toBe('http://cyclonedx.org/schema/bom-1.6.schema.json')
})
test('pnpm sbom invalid --sbom-spec-version throws', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
await expect(
sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'cyclonedx',
sbomSpecVersion: '1.4',
lockfileOnly: true,
})
).rejects.toThrow('Invalid CycloneDX spec version')
})
test('pnpm sbom --sbom-spec-version with spdx format throws', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)
await expect(
sbom.handler({
...DEFAULT_OPTS,
dir: workspaceDir,
lockfileDir: workspaceDir,
pnpmHomeDir: '',
sbomFormat: 'spdx',
sbomSpecVersion: '1.6',
lockfileOnly: true,
})
).rejects.toThrow('only supported with --sbom-format cyclonedx')
})
test('pnpm sbom --sbom-type application', async () => {
const workspaceDir = tempDir()
f.copy('simple-sbom', workspaceDir)

View File

@@ -10,6 +10,7 @@ export interface CycloneDxOptions {
lockfileOnly?: boolean
sbomAuthors?: string[]
sbomSupplier?: string
specVersion?: string
}
export function serializeCycloneDx (result: SbomResult, opts?: CycloneDxOptions): string {
@@ -155,10 +156,12 @@ export function serializeCycloneDx (result: SbomResult, opts?: CycloneDxOptions)
metadata.supplier = { name: opts.sbomSupplier }
}
const version = opts?.specVersion || '1.7'
const bom: Record<string, unknown> = {
$schema: 'http://cyclonedx.org/schema/bom-1.7.schema.json',
$schema: `http://cyclonedx.org/schema/bom-${version}.schema.json`,
bomFormat: 'CycloneDX',
specVersion: '1.7',
specVersion: version,
serialNumber: `urn:uuid:${crypto.randomUUID()}`,
version: 1,
metadata,

View File

@@ -57,6 +57,14 @@ describe('serializeCycloneDx', () => {
expect(parsed.serialNumber).toMatch(/^urn:uuid:[0-9a-f-]+$/)
})
it('should honor specVersion option in $schema and specVersion fields', () => {
const result = makeSbomResult()
const parsed = JSON.parse(serializeCycloneDx(result, { specVersion: '1.6' }))
expect(parsed.$schema).toBe('http://cyclonedx.org/schema/bom-1.6.schema.json')
expect(parsed.specVersion).toBe('1.6')
})
it('should include timestamp in metadata', () => {
const before = new Date().toISOString()
const result = makeSbomResult()