Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
#!/usr/bin/env node

import fs from 'node:fs'
import * as path from "path";

import yargs from 'yargs'
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
Expand Down Expand Up @@ -361,15 +362,64 @@ const license = {
}
}

const sbom = {
command: 'sbom </path/to/manifest> [--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)
}
}
Comment thread
a-oren marked this conversation as resolved.
}

// 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)
Expand Down
19 changes: 18 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<object>} 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
*/
Expand Down
107 changes: 107 additions & 0 deletions test/generate_sbom.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import fs from 'node:fs'

Check warning on line 1 in test/generate_sbom.test.js

View workflow job for this annotation

GitHub Actions / Lint and test project (24)

There should be at least one empty line between import groups

Check warning on line 1 in test/generate_sbom.test.js

View workflow job for this annotation

GitHub Actions / Lint and test project (22)

There should be at least one empty line between import groups
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')
}
})
})
Loading