Skip to content

Commit c2f6c64

Browse files
authored
feat: export generateSbom and add sbom CLI command (#472)
Add public generateSbom(manifestPath, opts) function that generates a CycloneDX SBOM locally without backend HTTP requests. The function validates manifest readability upfront (consistent with stackAnalysis and componentAnalysis), delegates to the existing provider pipeline, and throws descriptive errors on failure. Add 'sbom' CLI command with --output flag to write SBOM JSON to file and --workspaceDir flag for monorepo lock file resolution. CLI handler includes structured error handling consistent with other commands. Comprehensive test suite covers pom.xml and package.json manifests, unsupported manifest error paths, SBOM structure validation, and missing purl detection. Implements TC-3993 Assisted-by: Claude Code
1 parent 38515a7 commit c2f6c64

3 files changed

Lines changed: 177 additions & 3 deletions

File tree

src/cli.js

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
#!/usr/bin/env node
22

3+
import fs from 'node:fs'
34
import * as path from "path";
45

56
import yargs from 'yargs'
67
import { hideBin } from 'yargs/helpers'
78

89
import { getProjectLicense, getLicenseDetails } from './license/index.js'
910

10-
import client, { selectTrustifyDABackend } from './index.js'
11+
import client, { selectTrustifyDABackend, generateSbom } from './index.js'
1112

1213

1314
// command for component analysis take manifest type and content
@@ -361,15 +362,64 @@ const license = {
361362
}
362363
}
363364

365+
const sbom = {
366+
command: 'sbom </path/to/manifest> [--output]',
367+
desc: 'generate a CycloneDX SBOM from a manifest file',
368+
builder: yargs => yargs.positional(
369+
'/path/to/manifest',
370+
{
371+
desc: 'manifest path for SBOM generation',
372+
type: 'string',
373+
normalize: true,
374+
}
375+
).options({
376+
output: {
377+
alias: 'o',
378+
desc: 'Write SBOM JSON to a file instead of stdout',
379+
type: 'string',
380+
normalize: true,
381+
},
382+
workspaceDir: {
383+
alias: 'w',
384+
desc: 'Workspace root directory (for monorepos; lock file is expected here)',
385+
type: 'string',
386+
normalize: true,
387+
}
388+
}),
389+
handler: async args => {
390+
let manifest = args['/path/to/manifest']
391+
const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {}
392+
let result
393+
try {
394+
result = await generateSbom(manifest, opts)
395+
} catch (err) {
396+
console.error(JSON.stringify({ error: `Failed to generate SBOM: ${err.message}` }, null, 2))
397+
process.exit(1)
398+
}
399+
const json = JSON.stringify(result, null, 2)
400+
if (args.output) {
401+
try {
402+
fs.writeFileSync(args.output, json)
403+
} catch (err) {
404+
console.error(JSON.stringify({ error: `Failed to write output file: ${err.message}` }, null, 2))
405+
process.exit(1)
406+
}
407+
} else {
408+
console.log(json)
409+
}
410+
}
411+
}
412+
364413
// parse and invoke the command
365414
yargs(hideBin(process.argv))
366-
.usage(`Usage: ${process.argv[0].includes("node") ? path.parse(process.argv[1]).base : path.parse(process.argv[0]).base} {component|stack|stack-batch|image|validate-token|license}`)
415+
.usage(`Usage: ${process.argv[0].includes("node") ? path.parse(process.argv[1]).base : path.parse(process.argv[0]).base} {component|stack|stack-batch|image|validate-token|license|sbom}`)
367416
.command(stack)
368417
.command(stackBatch)
369418
.command(component)
370419
.command(image)
371420
.command(validateToken)
372421
.command(license)
422+
.command(sbom)
373423
.scriptName('')
374424
.version(false)
375425
.demandCommand(1)

src/index.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export { parseImageRef } from "./oci_image/utils.js";
2121
export { ImageRef } from "./oci_image/images.js";
2222
export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js";
2323

24-
export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken }
24+
export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom }
2525
export {
2626
discoverWorkspacePackages,
2727
discoverWorkspaceCrates,
@@ -274,6 +274,23 @@ function buildBatchAnalysisMetadata(root, ecosystem, totalSbomAttempts, successf
274274
}
275275
}
276276

277+
/**
278+
* Generate a CycloneDX SBOM from a manifest file. No backend HTTP request is made.
279+
*
280+
* @param {string} manifestPath - path to the manifest file (e.g. pom.xml, package.json)
281+
* @param {Options} [opts={}] - optional options (e.g. workspace dir, tool paths)
282+
* @returns {Promise<object>} parsed CycloneDX SBOM JSON object
283+
* @throws {Error} if the manifest is unsupported or SBOM generation fails
284+
*/
285+
export async function generateSbom(manifestPath, opts = {}) {
286+
fs.accessSync(manifestPath, fs.constants.R_OK)
287+
const result = await generateOneSbom(manifestPath, opts)
288+
if (!result.ok) {
289+
throw new Error(`Failed to generate SBOM for ${result.manifestPath}: ${result.reason}`)
290+
}
291+
return result.sbom
292+
}
293+
277294
/**
278295
* @typedef {{ ok: true, purl: string, sbom: object } | { ok: false, manifestPath: string, reason: string }} SbomResult
279296
*/

test/generate_sbom.test.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import fs from 'node:fs'
2+
import { expect } from 'chai'
3+
import esmock from 'esmock'
4+
5+
function makeSbom(name, version) {
6+
return {
7+
metadata: {
8+
component: {
9+
purl: `pkg:npm/${name}@${version}`,
10+
'bom-ref': `pkg:npm/${name}@${version}`,
11+
},
12+
},
13+
components: [
14+
{ name: 'dep-a', version: '1.0.0', purl: 'pkg:npm/dep-a@1.0.0' },
15+
],
16+
}
17+
}
18+
19+
function buildMockProviders(sbomMap) {
20+
const mockProvider = {
21+
isSupported: () => true,
22+
validateLockFile: () => true,
23+
provideStack: (manifestPath) => {
24+
const sbom = sbomMap[manifestPath]
25+
if (!sbom) {
26+
throw new Error(`Unsupported manifest: ${manifestPath}`)
27+
}
28+
return {
29+
ecosystem: 'npm',
30+
content: JSON.stringify(sbom),
31+
contentType: 'application/vnd.cyclonedx+json',
32+
}
33+
},
34+
}
35+
return {
36+
availableProviders: [mockProvider],
37+
match: () => mockProvider,
38+
}
39+
}
40+
41+
async function createMockClient(sbomMap) {
42+
return esmock('../src/index.js', {
43+
'../src/provider.js': buildMockProviders(sbomMap),
44+
'node:fs': {
45+
...fs,
46+
default: { ...fs, accessSync: () => {} },
47+
accessSync: () => {},
48+
},
49+
})
50+
}
51+
52+
suite('testing generateSbom', () => {
53+
test('returns valid CycloneDX JSON for a pom.xml manifest', async () => {
54+
const expected = makeSbom('my-app', '1.0.0')
55+
const client = await createMockClient({ '/fake/pom.xml': expected })
56+
const result = await client.generateSbom('/fake/pom.xml')
57+
expect(result).to.deep.equal(expected)
58+
expect(result.metadata.component.purl).to.equal('pkg:npm/my-app@1.0.0')
59+
})
60+
61+
test('returns valid CycloneDX JSON for a package.json manifest', async () => {
62+
const expected = makeSbom('my-js-app', '2.0.0')
63+
const client = await createMockClient({ '/fake/package.json': expected })
64+
const result = await client.generateSbom('/fake/package.json')
65+
expect(result).to.deep.equal(expected)
66+
expect(result.metadata.component.purl).to.equal('pkg:npm/my-js-app@2.0.0')
67+
})
68+
69+
test('throws for unsupported manifest types', async () => {
70+
const client = await createMockClient({})
71+
try {
72+
await client.generateSbom('/fake/unsupported.txt')
73+
expect.fail('should have thrown')
74+
} catch (err) {
75+
expect(err.message).to.include('Unsupported manifest')
76+
}
77+
})
78+
79+
test('generated SBOM contains metadata.component with expected purl', async () => {
80+
const expected = makeSbom('test-pkg', '3.5.1')
81+
const client = await createMockClient({ '/project/pom.xml': expected })
82+
const result = await client.generateSbom('/project/pom.xml')
83+
expect(result.metadata).to.exist
84+
expect(result.metadata.component).to.exist
85+
expect(result.metadata.component.purl).to.equal('pkg:npm/test-pkg@3.5.1')
86+
expect(result.components).to.be.an('array').with.lengthOf(1)
87+
expect(result.components[0].name).to.equal('dep-a')
88+
})
89+
90+
test('throws when SBOM is missing purl', async () => {
91+
const sbomNoPurl = {
92+
metadata: {
93+
component: {
94+
name: 'no-purl-app',
95+
},
96+
},
97+
components: [],
98+
}
99+
const client = await createMockClient({ '/fake/pom.xml': sbomNoPurl })
100+
try {
101+
await client.generateSbom('/fake/pom.xml')
102+
expect.fail('should have thrown')
103+
} catch (err) {
104+
expect(err.message).to.include('missing purl in SBOM')
105+
}
106+
})
107+
})

0 commit comments

Comments
 (0)