Skip to content

Commit 43efad7

Browse files
committed
feat: export generateSbom and add sbom CLI command
Add public generateSbom(manifestPath, opts) function that generates a CycloneDX SBOM locally without backend requests. Add 'sbom' CLI command with --output and --workspaceDir options. Implements TC-3993 Assisted-by: Claude Code
1 parent b8af0f8 commit 43efad7

3 files changed

Lines changed: 171 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,
@@ -272,6 +272,23 @@ function buildBatchAnalysisMetadata(root, ecosystem, totalSbomAttempts, successf
272272
}
273273
}
274274

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

test/generate_sbom.test.js

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

0 commit comments

Comments
 (0)