From 88c43a7f397e85d380e9ab7ef10e5ddce13949dd Mon Sep 17 00:00:00 2001 From: Venancio Orozco <4390221+v3nant@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:18:57 -0600 Subject: [PATCH] feat(scan): add scan sbom command --- README.md | 42 ++++++++ src/commands/scan/sbom.ts | 119 +++++++++++++++++++++++ test/commands/scan/sbom.test.ts | 164 ++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 src/commands/scan/sbom.ts create mode 100644 test/commands/scan/sbom.test.ts diff --git a/README.md b/README.md index 443fbd7a..9a5122e3 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ USAGE * [`hd help [COMMAND]`](#hd-help-command) * [`hd report committers`](#hd-report-committers) * [`hd scan eol`](#hd-scan-eol) +* [`hd scan sbom`](#hd-scan-sbom) * [`hd tracker init`](#hd-tracker-init) * [`hd tracker run`](#hd-tracker-run) * [`hd update [CHANNEL]`](#hd-update-channel) @@ -252,6 +253,47 @@ EXAMPLES _See code: [src/commands/scan/eol.ts](https://github.com/herodevs/cli/blob/v2.0.8/src/commands/scan/eol.ts)_ +### `hd scan sbom` + +Generate a CycloneDX SBOM for a directory + +``` +USAGE + $ hd scan sbom [-f | -d ] [-o ] + +FLAGS + -d, --dir= [default: ] The directory to scan in order to generate a CycloneDX SBOM + -f, --file= The file path of an existing SBOM to load (supports CycloneDX and SPDX 2.3 formats) + -o, --output= Save the SBOM to a file instead of printing it to stdout. Defaults to herodevs.sbom.json when + given a directory or omitted a filename + +DESCRIPTION + Generate a CycloneDX SBOM for a directory + +EXAMPLES + Default behavior (no command or flags specified) + + $ hd + + Equivalent to + + $ hd scan sbom --dir . + + Load and reformat an existing SBOM instead of generating one + + $ hd scan sbom --file /path/to/sbom.json + + Save the SBOM to a file instead of printing it to stdout + + $ hd scan sbom --output ./herodevs.sbom.json + + Generate an SBOM, then scan it in a separate step + + $ hd scan sbom --output sbom.json && hd scan eol --file sbom.json +``` + +_See code: [src/commands/scan/sbom.ts](https://github.com/herodevs/cli/blob/v2.0.8/src/commands/scan/sbom.ts)_ + ### `hd tracker init` Initialize the tracker configuration diff --git a/src/commands/scan/sbom.ts b/src/commands/scan/sbom.ts new file mode 100644 index 00000000..91aa48c1 --- /dev/null +++ b/src/commands/scan/sbom.ts @@ -0,0 +1,119 @@ +import type { CdxBom } from '@herodevs/eol-shared'; +import { Command, Flags } from '@oclif/core'; +import ora from 'ora'; +import { track } from '../../service/analytics.svc.ts'; +import { createSbom } from '../../service/cdx.svc.ts'; +import { readSbomFromFile, saveArtifactToFile, validateDirectory } from '../../service/file.svc.ts'; +import { getErrorMessage } from '../../service/log.svc.ts'; + +export default class ScanSbom extends Command { + static override description = 'Generate a CycloneDX SBOM for a directory'; + static override examples = [ + { description: 'Default behavior (no command or flags specified)', command: '<%= config.bin %>' }, + { description: 'Equivalent to', command: '<%= config.bin %> <%= command.id %> --dir .' }, + { + description: 'Load and reformat an existing SBOM instead of generating one', + command: '<%= config.bin %> <%= command.id %> --file /path/to/sbom.json', + }, + { + description: 'Save the SBOM to a file instead of printing it to stdout', + command: '<%= config.bin %> <%= command.id %> --output ./herodevs.sbom.json', + }, + { + description: 'Generate an SBOM, then scan it in a separate step', + command: '<%= config.bin %> <%= command.id %> --output sbom.json && <%= config.bin %> scan eol --file sbom.json', + }, + ]; + static override flags = { + file: Flags.string({ + char: 'f', + description: 'The file path of an existing SBOM to load (supports CycloneDX and SPDX 2.3 formats)', + exclusive: ['dir'], + }), + dir: Flags.string({ + char: 'd', + default: process.cwd(), + defaultHelp: async () => '', + description: 'The directory to scan in order to generate a CycloneDX SBOM', + exclusive: ['file'], + }), + output: Flags.string({ + char: 'o', + description: + 'Save the SBOM to a file instead of printing it to stdout. Defaults to herodevs.sbom.json when given a directory or omitted a filename', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(ScanSbom); + + const sbom = await this.loadSbom(flags.file, flags.dir); + + if (!flags.file) { + track('CLI SBOM Generated', (context) => ({ + command: context.command, + command_flags: context.command_flags, + })); + } + + if (flags.output !== undefined) { + const sbomPath = this.saveSbom(flags.dir, sbom, flags.output); + this.log(`SBOM saved to ${sbomPath}`); + track('CLI SBOM Output Saved', (context) => ({ + command: context.command, + command_flags: context.command_flags, + sbom_output_path: sbomPath, + })); + return sbom; + } + + this.log(JSON.stringify(sbom, null, 2)); + return sbom; + } + + private async loadSbom(file: string | undefined, dir: string): Promise { + const spinner = ora(); + spinner.start(file ? 'Loading SBOM file' : 'Generating SBOM'); + + const sbom = file ? this.getSbomFromFile(file) : await this.getSbomFromScan(dir); + + spinner.succeed(file ? 'Loaded SBOM file' : 'Generated SBOM'); + + return sbom; + } + + private async getSbomFromScan(dirPath: string): Promise { + try { + validateDirectory(dirPath); + const sbom = await createSbom(dirPath); + if (!sbom) { + this.error(`SBOM failed to generate for dir: ${dirPath}`); + } + return sbom; + } catch (error) { + const errorMessage = getErrorMessage(error); + track('CLI Error Encountered', () => ({ error: errorMessage })); + this.error(`Failed to scan directory: ${errorMessage}`); + } + } + + private getSbomFromFile(filePath: string): CdxBom { + try { + return readSbomFromFile(filePath); + } catch (error) { + const errorMessage = getErrorMessage(error); + track('CLI Error Encountered', () => ({ error: errorMessage })); + this.error(errorMessage); + } + } + + private saveSbom(dir: string, sbom: CdxBom, outputPath?: string): string { + try { + return saveArtifactToFile(dir, { kind: 'sbom', payload: sbom, outputPath }); + } catch (error) { + const errorMessage = getErrorMessage(error); + track('CLI Error Encountered', () => ({ error: errorMessage })); + this.error(errorMessage); + } + } +} diff --git a/test/commands/scan/sbom.test.ts b/test/commands/scan/sbom.test.ts new file mode 100644 index 00000000..bc247e2c --- /dev/null +++ b/test/commands/scan/sbom.test.ts @@ -0,0 +1,164 @@ +import type { CdxBom } from '@herodevs/eol-shared'; +import type { Config } from '@oclif/core'; +import ScanSbom from '../../../src/commands/scan/sbom.ts'; + +const { trackMock, createSbomMock, readSbomFromFileMock, saveArtifactToFileMock, validateDirectoryMock } = vi.hoisted( + () => ({ + trackMock: vi.fn(), + createSbomMock: vi.fn(), + readSbomFromFileMock: vi.fn(), + saveArtifactToFileMock: vi.fn(), + validateDirectoryMock: vi.fn(), + }), +); + +vi.mock('../../../src/service/analytics.svc.ts', () => ({ + track: trackMock, +})); + +vi.mock('../../../src/service/cdx.svc.ts', () => ({ + createSbom: createSbomMock, +})); + +vi.mock('../../../src/service/file.svc.ts', () => ({ + readSbomFromFile: readSbomFromFileMock, + saveArtifactToFile: saveArtifactToFileMock, + validateDirectory: validateDirectoryMock, +})); + +vi.mock('ora', () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + })), +})); + +type ParseFlags = { + dir?: string; + file?: string; + output?: string; +}; + +type ScanSbomInternals = { + parse: (...args: unknown[]) => Promise<{ flags: ParseFlags }>; + log: (message: string) => void; + error: (message: string) => never; + run: () => Promise; +}; + +function createCommand(): ScanSbomInternals { + return new ScanSbom([], {} as Config) as unknown as ScanSbomInternals; +} + +function getTrackProperties(eventName: string): Record { + const call = trackMock.mock.calls.find(([event]) => event === eventName); + if (!call) { + throw new Error(`Expected analytics event ${eventName} to be tracked`); + } + + const getProperties = call[1] as (context: Record) => Record; + return getProperties({ command: 'scan:sbom', command_flags: '--dir .' }); +} + +describe('scan:sbom', () => { + const sampleSbom = { + bomFormat: 'CycloneDX', + specVersion: '1.6', + metadata: {}, + components: [{ purl: 'pkg:npm/test@1.0.0' }], + } as unknown as CdxBom; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('generates an SBOM from a directory without requesting any credential', async () => { + createSbomMock.mockResolvedValue(sampleSbom); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: { dir: '/repo' } }); + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}); + + const result = await command.run(); + + expect(validateDirectoryMock).toHaveBeenCalledWith('/repo'); + expect(createSbomMock).toHaveBeenCalledWith('/repo'); + expect(result).toEqual(sampleSbom); + expect(logSpy).toHaveBeenCalledWith(JSON.stringify(sampleSbom, null, 2)); + + const properties = getTrackProperties('CLI SBOM Generated'); + expect(properties.command).toBe('scan:sbom'); + }); + + it('loads an existing SBOM from --file instead of generating one', async () => { + readSbomFromFileMock.mockReturnValue(sampleSbom); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: { file: '/tmp/sbom.json', dir: process.cwd() } }); + vi.spyOn(command, 'log').mockImplementation(() => {}); + + await command.run(); + + expect(readSbomFromFileMock).toHaveBeenCalledWith('/tmp/sbom.json'); + expect(createSbomMock).not.toHaveBeenCalled(); + expect(trackMock).not.toHaveBeenCalledWith('CLI SBOM Generated', expect.anything()); + }); + + it('saves the SBOM to a file when --output is provided, and does not print it to stdout', async () => { + createSbomMock.mockResolvedValue(sampleSbom); + saveArtifactToFileMock.mockReturnValue('/repo/herodevs.sbom.json'); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: { dir: '/repo', output: '/repo' } }); + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}); + + const result = await command.run(); + + expect(saveArtifactToFileMock).toHaveBeenCalledWith('/repo', { + kind: 'sbom', + payload: sampleSbom, + outputPath: '/repo', + }); + expect(logSpy).toHaveBeenCalledWith('SBOM saved to /repo/herodevs.sbom.json'); + expect(logSpy).not.toHaveBeenCalledWith(JSON.stringify(sampleSbom, null, 2)); + expect(result).toEqual(sampleSbom); + + const properties = getTrackProperties('CLI SBOM Output Saved'); + expect(properties.sbom_output_path).toBe('/repo/herodevs.sbom.json'); + }); + + it('tracks and surfaces an error when directory generation fails', async () => { + validateDirectoryMock.mockImplementation(() => { + throw new Error('Directory not found: /missing'); + }); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: { dir: '/missing' } }); + vi.spyOn(command, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.run()).rejects.toThrow('Failed to scan directory: Directory not found: /missing'); + + const properties = getTrackProperties('CLI Error Encountered'); + expect(properties.error).toBe('Directory not found: /missing'); + }); + + it('tracks and surfaces an error when loading an SBOM file fails', async () => { + readSbomFromFileMock.mockImplementation(() => { + throw new Error('SBOM file not found: /missing.json'); + }); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: { file: '/missing.json', dir: process.cwd() } }); + vi.spyOn(command, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.run()).rejects.toThrow('SBOM file not found: /missing.json'); + + const properties = getTrackProperties('CLI Error Encountered'); + expect(properties.error).toBe('SBOM file not found: /missing.json'); + }); +});