From 87b4bac2bd4dc74027ebcc4a4f23381c2a41347a Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Tue, 5 May 2026 03:12:51 +0200 Subject: [PATCH] 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 --- .changeset/honest-moose-grin.md | 6 ++ deps/compliance/commands/src/sbom/sbom.ts | 30 ++++++++++ deps/compliance/commands/test/sbom/index.ts | 55 +++++++++++++++++++ .../compliance/sbom/src/serializeCycloneDx.ts | 7 ++- .../sbom/test/serializeCycloneDx.test.ts | 8 +++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 .changeset/honest-moose-grin.md diff --git a/.changeset/honest-moose-grin.md b/.changeset/honest-moose-grin.md new file mode 100644 index 0000000000..8894ec0858 --- /dev/null +++ b/.changeset/honest-moose-grin.md @@ -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). diff --git a/deps/compliance/commands/src/sbom/sbom.ts b/deps/compliance/commands/src/sbom/sbom.ts index 37969ddbe9..240320fa85 100644 --- a/deps/compliance/commands/src/sbom/sbom.ts +++ b/deps/compliance/commands/src/sbom/sbom.ts @@ -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 => ({ 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 ', }, + { + description: 'The CycloneDX specification version (1.5, 1.6, or 1.7; default: 1.7)', + name: '--sbom-spec-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 +} diff --git a/deps/compliance/commands/test/sbom/index.ts b/deps/compliance/commands/test/sbom/index.ts index b55d72741c..2cf919692d 100644 --- a/deps/compliance/commands/test/sbom/index.ts +++ b/deps/compliance/commands/test/sbom/index.ts @@ -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) diff --git a/deps/compliance/sbom/src/serializeCycloneDx.ts b/deps/compliance/sbom/src/serializeCycloneDx.ts index abfa628e4e..03144b9801 100644 --- a/deps/compliance/sbom/src/serializeCycloneDx.ts +++ b/deps/compliance/sbom/src/serializeCycloneDx.ts @@ -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 = { - $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, diff --git a/deps/compliance/sbom/test/serializeCycloneDx.test.ts b/deps/compliance/sbom/test/serializeCycloneDx.test.ts index b2139d4db1..a485e4dc93 100644 --- a/deps/compliance/sbom/test/serializeCycloneDx.test.ts +++ b/deps/compliance/sbom/test/serializeCycloneDx.test.ts @@ -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()