Skip to content

Commit 75c61ca

Browse files
authored
feat: adds file --output and --sbomOutput flags to specify custom file paths (#387)
1 parent f0dce2d commit 75c61ca

6 files changed

Lines changed: 413 additions & 123 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,16 @@ Scan a given SBOM for EOL data
112112

113113
```
114114
USAGE
115-
$ hd scan eol [--json] [-f <value> | -d <value>] [-s] [--saveSbom] [--saveTrimmedSbom] [--hideReportUrl] [--version]
115+
$ hd scan eol [--json] [-f <value> | -d <value>] [-s] [-o <value>] [--saveSbom] [--sbomOutput <value>] [--saveTrimmedSbom] [--hideReportUrl] [--version]
116116
117117
FLAGS
118118
-d, --dir=<value> [default: <current directory>] The directory to scan in order to scan for EOL
119119
-f, --file=<value> The file path of an existing SBOM to scan for EOL (supports CycloneDX and SPDX 2.3 formats)
120120
-s, --save Save the generated report as herodevs.report.json in the scanned directory
121+
-o, --output=<value> Save the generated report to a custom path (requires --save, defaults to herodevs.report.json when not provided)
121122
--hideReportUrl Hide the generated web report URL for this scan
122123
--saveSbom Save the generated SBOM as herodevs.sbom.json in the scanned directory
124+
--sbomOutput=<value> Save the generated SBOM to a custom path (requires --saveSbom, defaults to herodevs.sbom.json when not provided)
123125
--saveTrimmedSbom Save the trimmed SBOM as herodevs.sbom-trimmed.json in the scanned directory
124126
--version Show CLI version.
125127
@@ -146,6 +148,10 @@ EXAMPLES
146148
147149
$ hd scan eol --save --saveSbom
148150
151+
Save the report and SBOM to custom paths
152+
153+
$ hd scan eol --dir . --save --saveSbom --output ./reports/my-report.json --sbomOutput ./reports/my-sbom.json
154+
149155
Output the report in JSON format (for APIs, CI, etc.)
150156
151157
$ hd scan eol --json

e2e/scan/eol.test.ts

Lines changed: 156 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { doesNotThrow } from 'node:assert';
2-
import { doesNotMatch, match, notStrictEqual, strictEqual } from 'node:assert/strict';
2+
import { doesNotMatch, match, notStrictEqual, ok, strictEqual } from 'node:assert/strict';
33
import { exec } from 'node:child_process';
4-
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4+
import { randomUUID } from 'node:crypto';
5+
import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
56
import { mkdir } from 'node:fs/promises';
7+
import { tmpdir } from 'node:os';
68
import path from 'node:path';
79
import { afterEach, beforeEach, describe, it } from 'node:test';
810
import { promisify } from 'node:util';
@@ -181,6 +183,43 @@ describe('scan:eol e2e', () => {
181183
unlinkSync(reportPath);
182184
});
183185

186+
it('warns and skips saving when --output is provided without --save', async () => {
187+
const customDir = path.join(tmpdir(), 'scan-eol-report-output', randomUUID());
188+
const customPath = path.join(customDir, 'custom-report.json');
189+
190+
const cmd = `scan:eol --dir ${simpleDir} --output ${customPath}`;
191+
const { stderr } = await run(cmd);
192+
193+
const reportExists = existsSync(customPath);
194+
strictEqual(reportExists, false, 'Custom report file should not be created without --save');
195+
196+
match(stderr, /--output requires --save to write the report/i, 'Should warn that --output needs --save');
197+
198+
if (existsSync(customDir)) {
199+
rmSync(customDir, { recursive: true, force: true });
200+
}
201+
});
202+
203+
it('saves report to a custom path when --save and --output are provided', async () => {
204+
const customDir = path.join(fixturesDir, 'outputs-save');
205+
const customPath = path.join(customDir, 'custom-report.json');
206+
await mkdir(customDir, { recursive: true });
207+
208+
const cmd = `scan:eol --dir ${simpleDir} --save --output ${customPath}`;
209+
const { stderr } = await run(cmd);
210+
211+
const reportExists = existsSync(customPath);
212+
strictEqual(reportExists, true, 'Custom report file should be created when --save is provided');
213+
214+
doesNotMatch(stderr, /--output requires --save to write the report/i, 'Should not warn when --save is provided');
215+
216+
const reportJson = JSON.parse(readFileSync(customPath, 'utf-8'));
217+
ok(Array.isArray(reportJson.components), 'Report should have components array');
218+
219+
unlinkSync(customPath);
220+
rmSync(customDir, { recursive: true, force: true });
221+
});
222+
184223
it('outputs JSON only when using the --json flag', async () => {
185224
const cmd = `scan:eol --file ${simpleSbom} --json`;
186225
const { stdout } = await run(cmd);
@@ -236,6 +275,50 @@ describe('scan:eol e2e', () => {
236275
unlinkSync(sbomPath);
237276
});
238277

278+
it('warns and skips saving when --sbomOutput is provided without --saveSbom', async () => {
279+
const customDir = path.join(fixturesDir, 'sbom-outputs');
280+
const customPath = path.join(customDir, 'custom-sbom.json');
281+
await mkdir(customDir, { recursive: true });
282+
283+
const cmd = `scan:eol --dir ${simpleDir} --sbomOutput ${customPath}`;
284+
const { stderr } = await run(cmd);
285+
286+
const sbomExists = existsSync(customPath);
287+
strictEqual(sbomExists, false, 'Custom SBOM file should not be created without --saveSbom');
288+
289+
match(
290+
stderr,
291+
/--sbomOutput requires --saveSbom to write the SBOM/i,
292+
'Should warn that --sbomOutput needs --saveSbom',
293+
);
294+
295+
rmSync(customDir, { recursive: true, force: true });
296+
});
297+
298+
it('saves SBOM to a custom path when --sbomOutput is provided', async () => {
299+
const customDir = path.join(fixturesDir, 'sbom-outputs');
300+
const customPath = path.join(customDir, 'custom-sbom.json');
301+
await mkdir(customDir, { recursive: true });
302+
303+
const cmd = `scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${customPath}`;
304+
const { stderr } = await run(cmd);
305+
306+
const sbomExists = existsSync(customPath);
307+
strictEqual(sbomExists, true, 'Custom SBOM file should be created');
308+
309+
doesNotMatch(
310+
stderr,
311+
/--sbomOutput requires --saveSbom to write the SBOM/i,
312+
'Should not warn when --saveSbom is provided',
313+
);
314+
315+
const sbomJson = JSON.parse(readFileSync(customPath, 'utf-8'));
316+
strictEqual(sbomJson.bomFormat, 'CycloneDX', 'SBOM should be CycloneDX format');
317+
318+
unlinkSync(customPath);
319+
rmSync(customDir, { recursive: true, force: true });
320+
});
321+
239322
it('saves both report and SBOM when both --save and --saveSbom flags are used', async () => {
240323
const reportPath = path.join(simpleDir, `${filenamePrefix}.report.json`);
241324
const sbomPath = path.join(simpleDir, `${filenamePrefix}.sbom.json`);
@@ -342,6 +425,33 @@ describe('scan:eol e2e', () => {
342425
doesNotMatch(stdout, /View your full EOL report/, 'Should not show web report text when hidden');
343426
match(stdout, /To save your detailed JSON report, use the --save flag/, 'Should show save hint message');
344427
});
428+
429+
it('omits save hint when --hideReportUrl is paired with custom outputs', async () => {
430+
const customDir = path.join(fixturesDir, 'hide-report-output');
431+
const customPath = path.join(customDir, 'custom-report.json');
432+
await mkdir(customDir, { recursive: true });
433+
const cmd = `scan:eol --file ${simpleSbom} --hideReportUrl --save --output ${customPath}`;
434+
const { stdout, stderr } = await run(cmd);
435+
436+
doesNotMatch(
437+
stdout,
438+
/To save your detailed JSON report, use the --save flag/,
439+
'Should not show save hint when custom outputs are provided',
440+
);
441+
442+
doesNotMatch(
443+
stderr,
444+
/Warning: --output requires --save to write the report/i,
445+
'Should not warn when --save is provided',
446+
);
447+
448+
strictEqual(existsSync(customPath), true, 'Custom report file should be created');
449+
450+
if (existsSync(customPath)) {
451+
unlinkSync(customPath);
452+
}
453+
rmSync(customDir, { recursive: true, force: true });
454+
});
345455
});
346456

347457
describe('privacy and transparency', () => {
@@ -415,18 +525,17 @@ describe('scan:eol e2e', () => {
415525
return output;
416526
}
417527

418-
function expectAny(output: { stdout: string; stderr: string; error?: Error }, patterns: RegExp[], message: string) {
419-
const text = `${output.stderr}\n${output.stdout}\n${output.error?.message || ''}`;
420-
const matched = patterns.some((re) => re.test(text));
421-
strictEqual(matched, true, message);
528+
function combinedOutputText(output: { stdout: string; stderr: string; error?: { message?: unknown } }) {
529+
const errorText = typeof output?.error?.message === 'string' ? output.error.message : '';
530+
return `${output.stderr}\n${output.stdout}\n${errorText}`;
422531
}
423532

424533
it('fails when SBOM file does not exist', async () => {
425534
const missing = path.join(fixturesDir, 'npm', 'does-not-exist.json');
426535
const out = await runExpectFail(`scan:eol --file ${missing}`);
427-
expectAny(
428-
out,
429-
[/SBOM file not found:/i, /Failed to read SBOM file/i, /Failed to load SBOM file/i, /Loading SBOM file/i],
536+
match(
537+
combinedOutputText(out),
538+
/(SBOM file not found:|Failed to read SBOM file|Failed to load SBOM file|Loading SBOM file)/i,
430539
'Should indicate missing SBOM file',
431540
);
432541
});
@@ -436,9 +545,9 @@ describe('scan:eol e2e', () => {
436545
writeFileSync(badFile, '{not-json');
437546
try {
438547
const out = await runExpectFail(`scan:eol --file ${badFile}`);
439-
expectAny(
440-
out,
441-
[/Failed to read SBOM file/i, /Failed to load SBOM file/i, /Loading SBOM file/i],
548+
match(
549+
combinedOutputText(out),
550+
/(Failed to read SBOM file|Failed to load SBOM file|Loading SBOM file)/i,
442551
'Should indicate invalid SBOM',
443552
);
444553
} finally {
@@ -451,9 +560,9 @@ describe('scan:eol e2e', () => {
451560
writeFileSync(badFile, JSON.stringify({ invalid: 'format', notSpdx: true, notCdx: true }));
452561
try {
453562
const out = await runExpectFail(`scan:eol --file ${badFile}`);
454-
expectAny(
455-
out,
456-
[/Failed to read SBOM file/i, /Invalid SBOM file format/i, /Expected SPDX 2\.3 or CycloneDX format./i],
563+
match(
564+
combinedOutputText(out),
565+
/(Failed to read SBOM file|Invalid SBOM file format|Expected SPDX 2\.3 or CycloneDX format\.)/i,
457566
'Should indicate invalid SBOM format',
458567
);
459568
} finally {
@@ -464,18 +573,18 @@ describe('scan:eol e2e', () => {
464573
it('fails when directory does not exist', async () => {
465574
const missingDir = path.join(fixturesDir, 'npm', 'no-such-dir');
466575
const out = await runExpectFail(`scan:eol --dir ${missingDir}`);
467-
expectAny(
468-
out,
469-
[/Directory not found:/i, /Failed to scan directory/i, /Generating SBOM/i],
576+
match(
577+
combinedOutputText(out),
578+
/(Directory not found:|Failed to scan directory|Generating SBOM)/i,
470579
'Should indicate missing directory',
471580
);
472581
});
473582

474583
it('fails when provided path is not a directory', async () => {
475584
const out = await runExpectFail(`scan:eol --dir ${simpleSbom}`);
476-
expectAny(
477-
out,
478-
[/Path is not a directory:/i, /Failed to scan directory/i, /Generating SBOM/i],
585+
match(
586+
combinedOutputText(out),
587+
/(Path is not a directory:|Failed to scan directory|Generating SBOM)/i,
479588
'Should indicate non-directory path',
480589
);
481590
});
@@ -485,7 +594,11 @@ describe('scan:eol e2e', () => {
485594
fetchMock.restore();
486595
fetchMock = new FetchMock().addGraphQL({ eol: { createReport: { success: false, id: null, totalRecords: 0 } } });
487596
const out = await runExpectFail(`scan:eol --file ${simpleSbom}`);
488-
expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate NES submission failure');
597+
match(
598+
combinedOutputText(out),
599+
/(Failed to submit scan to NES|Scanning failed)/i,
600+
'Should indicate NES submission failure',
601+
);
489602
});
490603

491604
it('fails when NES returns GraphQL errors', async () => {
@@ -494,7 +607,27 @@ describe('scan:eol e2e', () => {
494607
{ message: 'Internal server error', path: ['eol', 'createReport'] },
495608
]);
496609
const out = await runExpectFail(`scan:eol --file ${simpleSbom}`);
497-
expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate GraphQL errors from NES');
610+
match(
611+
combinedOutputText(out),
612+
/(Failed to submit scan to NES|Scanning failed)/i,
613+
'Should indicate GraphQL errors from NES',
614+
);
615+
});
616+
617+
it('shows a helpful error when report output directory is invalid', async () => {
618+
const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-report.json');
619+
const out = await runExpectFail(`scan:eol --dir ${simpleDir} --save --output ${invalidPath}`);
620+
match(
621+
combinedOutputText(out),
622+
/Unable to save custom-report\.json/i,
623+
'Should indicate report could not be saved',
624+
);
625+
});
626+
627+
it('shows a helpful error when SBOM output directory is invalid', async () => {
628+
const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-sbom.json');
629+
const out = await runExpectFail(`scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${invalidPath}`);
630+
match(combinedOutputText(out), /Unable to save custom-sbom\.json/i, 'Should indicate SBOM could not be saved');
498631
});
499632
});
500633
});

0 commit comments

Comments
 (0)