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 7b204f56..cfca773f 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, @@ -274,6 +274,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') + } + }) +})