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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <value> | -d <value>] [-o <value>]

FLAGS
-d, --dir=<value> [default: <current directory>] The directory to scan in order to generate a CycloneDX SBOM
-f, --file=<value> The file path of an existing SBOM to load (supports CycloneDX and SPDX 2.3 formats)
-o, --output=<value> 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
Expand Down
119 changes: 119 additions & 0 deletions src/commands/scan/sbom.ts
Original file line number Diff line number Diff line change
@@ -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 () => '<current directory>',
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<CdxBom> {
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<CdxBom> {
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<CdxBom> {
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);
}
}
}
164 changes: 164 additions & 0 deletions test/commands/scan/sbom.test.ts
Original file line number Diff line number Diff line change
@@ -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<CdxBom>;
};

function createCommand(): ScanSbomInternals {
return new ScanSbom([], {} as Config) as unknown as ScanSbomInternals;
}

function getTrackProperties(eventName: string): Record<string, unknown> {
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<string, unknown>) => Record<string, unknown>;
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');
});
});
Loading