From 1abd8f87207099b298c46b40f7e19d78ec1b6867 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Sun, 12 Apr 2026 10:50:07 +0300 Subject: [PATCH] feat: export generateSbom and add sbom CLI command 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 --- src/cli.js | 54 ++++++++++++++++++- src/index.js | 19 ++++++- test/generate_sbom.test.js | 107 +++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 test/generate_sbom.test.js diff --git a/src/cli.js b/src/cli.js index 1550c4aa..ec0d21df 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +import fs from 'node:fs' import * as path from "path"; import yargs from 'yargs' @@ -7,7 +8,7 @@ import { hideBin } from 'yargs/helpers' import { getProjectLicense, getLicenseDetails } from './license/index.js' -import client, { selectTrustifyDABackend } from './index.js' +import client, { selectTrustifyDABackend, generateSbom } from './index.js' // command for component analysis take manifest type and content @@ -361,15 +362,64 @@ const license = { } } +const sbom = { + command: 'sbom [--output]', + desc: 'generate a CycloneDX SBOM from a manifest file', + builder: yargs => yargs.positional( + '/path/to/manifest', + { + desc: 'manifest path for SBOM generation', + type: 'string', + normalize: true, + } + ).options({ + output: { + alias: 'o', + desc: 'Write SBOM JSON to a file instead of stdout', + type: 'string', + normalize: true, + }, + workspaceDir: { + alias: 'w', + desc: 'Workspace root directory (for monorepos; lock file is expected here)', + type: 'string', + normalize: true, + } + }), + handler: async args => { + let manifest = args['/path/to/manifest'] + const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {} + let result + try { + result = await generateSbom(manifest, opts) + } catch (err) { + console.error(JSON.stringify({ error: `Failed to generate SBOM: ${err.message}` }, null, 2)) + process.exit(1) + } + const json = JSON.stringify(result, null, 2) + if (args.output) { + try { + fs.writeFileSync(args.output, json) + } catch (err) { + console.error(JSON.stringify({ error: `Failed to write output file: ${err.message}` }, null, 2)) + process.exit(1) + } + } else { + console.log(json) + } + } +} + // parse and invoke the command yargs(hideBin(process.argv)) - .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}`) + .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}`) .command(stack) .command(stackBatch) .command(component) .command(image) .command(validateToken) .command(license) + .command(sbom) .scriptName('') .version(false) .demandCommand(1) diff --git a/src/index.js b/src/index.js index a3980992..d4e520b2 100644 --- a/src/index.js +++ b/src/index.js @@ -21,7 +21,7 @@ export { parseImageRef } from "./oci_image/utils.js"; export { ImageRef } from "./oci_image/images.js"; export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js"; -export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken } +export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom } export { discoverWorkspacePackages, discoverWorkspaceCrates, @@ -272,6 +272,23 @@ function buildBatchAnalysisMetadata(root, ecosystem, totalSbomAttempts, successf } } +/** + * Generate a CycloneDX SBOM from a manifest file. No backend HTTP request is made. + * + * @param {string} manifestPath - path to the manifest file (e.g. pom.xml, package.json) + * @param {Options} [opts={}] - optional options (e.g. workspace dir, tool paths) + * @returns {Promise} parsed CycloneDX SBOM JSON object + * @throws {Error} if the manifest is unsupported or SBOM generation fails + */ +export async function generateSbom(manifestPath, opts = {}) { + fs.accessSync(manifestPath, fs.constants.R_OK) + const result = await generateOneSbom(manifestPath, opts) + if (!result.ok) { + throw new Error(`Failed to generate SBOM for ${result.manifestPath}: ${result.reason}`) + } + return result.sbom +} + /** * @typedef {{ ok: true, purl: string, sbom: object } | { ok: false, manifestPath: string, reason: string }} SbomResult */ diff --git a/test/generate_sbom.test.js b/test/generate_sbom.test.js new file mode 100644 index 00000000..20d1834a --- /dev/null +++ b/test/generate_sbom.test.js @@ -0,0 +1,107 @@ +import fs from 'node:fs' +import { expect } from 'chai' +import esmock from 'esmock' + +function makeSbom(name, version) { + return { + metadata: { + component: { + purl: `pkg:npm/${name}@${version}`, + 'bom-ref': `pkg:npm/${name}@${version}`, + }, + }, + components: [ + { name: 'dep-a', version: '1.0.0', purl: 'pkg:npm/dep-a@1.0.0' }, + ], + } +} + +function buildMockProviders(sbomMap) { + const mockProvider = { + isSupported: () => true, + validateLockFile: () => true, + provideStack: (manifestPath) => { + const sbom = sbomMap[manifestPath] + if (!sbom) { + throw new Error(`Unsupported manifest: ${manifestPath}`) + } + return { + ecosystem: 'npm', + content: JSON.stringify(sbom), + contentType: 'application/vnd.cyclonedx+json', + } + }, + } + return { + availableProviders: [mockProvider], + match: () => mockProvider, + } +} + +async function createMockClient(sbomMap) { + return esmock('../src/index.js', { + '../src/provider.js': buildMockProviders(sbomMap), + 'node:fs': { + ...fs, + default: { ...fs, accessSync: () => {} }, + accessSync: () => {}, + }, + }) +} + +suite('testing generateSbom', () => { + test('returns valid CycloneDX JSON for a pom.xml manifest', async () => { + const expected = makeSbom('my-app', '1.0.0') + const client = await createMockClient({ '/fake/pom.xml': expected }) + const result = await client.generateSbom('/fake/pom.xml') + expect(result).to.deep.equal(expected) + expect(result.metadata.component.purl).to.equal('pkg:npm/my-app@1.0.0') + }) + + test('returns valid CycloneDX JSON for a package.json manifest', async () => { + const expected = makeSbom('my-js-app', '2.0.0') + const client = await createMockClient({ '/fake/package.json': expected }) + const result = await client.generateSbom('/fake/package.json') + expect(result).to.deep.equal(expected) + expect(result.metadata.component.purl).to.equal('pkg:npm/my-js-app@2.0.0') + }) + + test('throws for unsupported manifest types', async () => { + const client = await createMockClient({}) + try { + await client.generateSbom('/fake/unsupported.txt') + expect.fail('should have thrown') + } catch (err) { + expect(err.message).to.include('Unsupported manifest') + } + }) + + test('generated SBOM contains metadata.component with expected purl', async () => { + const expected = makeSbom('test-pkg', '3.5.1') + const client = await createMockClient({ '/project/pom.xml': expected }) + const result = await client.generateSbom('/project/pom.xml') + expect(result.metadata).to.exist + expect(result.metadata.component).to.exist + expect(result.metadata.component.purl).to.equal('pkg:npm/test-pkg@3.5.1') + expect(result.components).to.be.an('array').with.lengthOf(1) + expect(result.components[0].name).to.equal('dep-a') + }) + + test('throws when SBOM is missing purl', async () => { + const sbomNoPurl = { + metadata: { + component: { + name: 'no-purl-app', + }, + }, + components: [], + } + const client = await createMockClient({ '/fake/pom.xml': sbomNoPurl }) + try { + await client.generateSbom('/fake/pom.xml') + expect.fail('should have thrown') + } catch (err) { + expect(err.message).to.include('missing purl in SBOM') + } + }) +})