Skip to content

Commit 8c46ca3

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 8c46ca3

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,
@@ -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: 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)