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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,16 @@ Scan a given SBOM for EOL data

```
USAGE
$ hd scan eol [--json] [-f <value> | -d <value>] [-s] [--saveSbom] [--saveTrimmedSbom] [--hideReportUrl] [--version]
$ hd scan eol [--json] [-f <value> | -d <value>] [-s] [-o <value>] [--saveSbom] [--sbomOutput <value>] [--saveTrimmedSbom] [--hideReportUrl] [--version]

FLAGS
-d, --dir=<value> [default: <current directory>] The directory to scan in order to scan for EOL
-f, --file=<value> The file path of an existing SBOM to scan for EOL (supports CycloneDX and SPDX 2.3 formats)
-s, --save Save the generated report as herodevs.report.json in the scanned directory
-o, --output=<value> Save the generated report to a custom path (requires --save, defaults to herodevs.report.json when not provided)
--hideReportUrl Hide the generated web report URL for this scan
--saveSbom Save the generated SBOM as herodevs.sbom.json in the scanned directory
--sbomOutput=<value> Save the generated SBOM to a custom path (requires --saveSbom, defaults to herodevs.sbom.json when not provided)
--saveTrimmedSbom Save the trimmed SBOM as herodevs.sbom-trimmed.json in the scanned directory
--version Show CLI version.

Expand All @@ -146,6 +148,10 @@ EXAMPLES

$ hd scan eol --save --saveSbom

Save the report and SBOM to custom paths

$ hd scan eol --dir . --save --saveSbom --output ./reports/my-report.json --sbomOutput ./reports/my-sbom.json

Output the report in JSON format (for APIs, CI, etc.)

$ hd scan eol --json
Expand Down
179 changes: 156 additions & 23 deletions e2e/scan/eol.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { doesNotThrow } from 'node:assert';
import { doesNotMatch, match, notStrictEqual, strictEqual } from 'node:assert/strict';
import { doesNotMatch, match, notStrictEqual, ok, strictEqual } from 'node:assert/strict';
import { exec } from 'node:child_process';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { promisify } from 'node:util';
Expand Down Expand Up @@ -181,6 +183,43 @@ describe('scan:eol e2e', () => {
unlinkSync(reportPath);
});

it('warns and skips saving when --output is provided without --save', async () => {
const customDir = path.join(tmpdir(), 'scan-eol-report-output', randomUUID());
const customPath = path.join(customDir, 'custom-report.json');

const cmd = `scan:eol --dir ${simpleDir} --output ${customPath}`;
const { stderr } = await run(cmd);

const reportExists = existsSync(customPath);
strictEqual(reportExists, false, 'Custom report file should not be created without --save');

match(stderr, /--output requires --save to write the report/i, 'Should warn that --output needs --save');

if (existsSync(customDir)) {
rmSync(customDir, { recursive: true, force: true });
}
});

it('saves report to a custom path when --save and --output are provided', async () => {
const customDir = path.join(fixturesDir, 'outputs-save');
const customPath = path.join(customDir, 'custom-report.json');
await mkdir(customDir, { recursive: true });

const cmd = `scan:eol --dir ${simpleDir} --save --output ${customPath}`;
const { stderr } = await run(cmd);

const reportExists = existsSync(customPath);
strictEqual(reportExists, true, 'Custom report file should be created when --save is provided');

doesNotMatch(stderr, /--output requires --save to write the report/i, 'Should not warn when --save is provided');

const reportJson = JSON.parse(readFileSync(customPath, 'utf-8'));
ok(Array.isArray(reportJson.components), 'Report should have components array');

unlinkSync(customPath);
rmSync(customDir, { recursive: true, force: true });
});

it('outputs JSON only when using the --json flag', async () => {
const cmd = `scan:eol --file ${simpleSbom} --json`;
const { stdout } = await run(cmd);
Expand Down Expand Up @@ -236,6 +275,50 @@ describe('scan:eol e2e', () => {
unlinkSync(sbomPath);
});

it('warns and skips saving when --sbomOutput is provided without --saveSbom', async () => {
const customDir = path.join(fixturesDir, 'sbom-outputs');
const customPath = path.join(customDir, 'custom-sbom.json');
await mkdir(customDir, { recursive: true });

const cmd = `scan:eol --dir ${simpleDir} --sbomOutput ${customPath}`;
const { stderr } = await run(cmd);

const sbomExists = existsSync(customPath);
strictEqual(sbomExists, false, 'Custom SBOM file should not be created without --saveSbom');

match(
stderr,
/--sbomOutput requires --saveSbom to write the SBOM/i,
'Should warn that --sbomOutput needs --saveSbom',
);

rmSync(customDir, { recursive: true, force: true });
});

it('saves SBOM to a custom path when --sbomOutput is provided', async () => {
const customDir = path.join(fixturesDir, 'sbom-outputs');
const customPath = path.join(customDir, 'custom-sbom.json');
await mkdir(customDir, { recursive: true });

const cmd = `scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${customPath}`;
const { stderr } = await run(cmd);

const sbomExists = existsSync(customPath);
strictEqual(sbomExists, true, 'Custom SBOM file should be created');

doesNotMatch(
stderr,
/--sbomOutput requires --saveSbom to write the SBOM/i,
'Should not warn when --saveSbom is provided',
);

const sbomJson = JSON.parse(readFileSync(customPath, 'utf-8'));
strictEqual(sbomJson.bomFormat, 'CycloneDX', 'SBOM should be CycloneDX format');

unlinkSync(customPath);
rmSync(customDir, { recursive: true, force: true });
});

it('saves both report and SBOM when both --save and --saveSbom flags are used', async () => {
const reportPath = path.join(simpleDir, `${filenamePrefix}.report.json`);
const sbomPath = path.join(simpleDir, `${filenamePrefix}.sbom.json`);
Expand Down Expand Up @@ -342,6 +425,33 @@ describe('scan:eol e2e', () => {
doesNotMatch(stdout, /View your full EOL report/, 'Should not show web report text when hidden');
match(stdout, /To save your detailed JSON report, use the --save flag/, 'Should show save hint message');
});

it('omits save hint when --hideReportUrl is paired with custom outputs', async () => {
const customDir = path.join(fixturesDir, 'hide-report-output');
const customPath = path.join(customDir, 'custom-report.json');
await mkdir(customDir, { recursive: true });
const cmd = `scan:eol --file ${simpleSbom} --hideReportUrl --save --output ${customPath}`;
const { stdout, stderr } = await run(cmd);

doesNotMatch(
stdout,
/To save your detailed JSON report, use the --save flag/,
'Should not show save hint when custom outputs are provided',
);

doesNotMatch(
stderr,
/Warning: --output requires --save to write the report/i,
'Should not warn when --save is provided',
);

strictEqual(existsSync(customPath), true, 'Custom report file should be created');

if (existsSync(customPath)) {
unlinkSync(customPath);
}
rmSync(customDir, { recursive: true, force: true });
});
});

describe('privacy and transparency', () => {
Expand Down Expand Up @@ -415,18 +525,17 @@ describe('scan:eol e2e', () => {
return output;
}

function expectAny(output: { stdout: string; stderr: string; error?: Error }, patterns: RegExp[], message: string) {
const text = `${output.stderr}\n${output.stdout}\n${output.error?.message || ''}`;
const matched = patterns.some((re) => re.test(text));
strictEqual(matched, true, message);
function combinedOutputText(output: { stdout: string; stderr: string; error?: { message?: unknown } }) {
const errorText = typeof output?.error?.message === 'string' ? output.error.message : '';
return `${output.stderr}\n${output.stdout}\n${errorText}`;
}

it('fails when SBOM file does not exist', async () => {
const missing = path.join(fixturesDir, 'npm', 'does-not-exist.json');
const out = await runExpectFail(`scan:eol --file ${missing}`);
expectAny(
out,
[/SBOM file not found:/i, /Failed to read SBOM file/i, /Failed to load SBOM file/i, /Loading SBOM file/i],
match(
combinedOutputText(out),
/(SBOM file not found:|Failed to read SBOM file|Failed to load SBOM file|Loading SBOM file)/i,
'Should indicate missing SBOM file',
);
});
Expand All @@ -436,9 +545,9 @@ describe('scan:eol e2e', () => {
writeFileSync(badFile, '{not-json');
try {
const out = await runExpectFail(`scan:eol --file ${badFile}`);
expectAny(
out,
[/Failed to read SBOM file/i, /Failed to load SBOM file/i, /Loading SBOM file/i],
match(
combinedOutputText(out),
/(Failed to read SBOM file|Failed to load SBOM file|Loading SBOM file)/i,
'Should indicate invalid SBOM',
);
} finally {
Expand All @@ -451,9 +560,9 @@ describe('scan:eol e2e', () => {
writeFileSync(badFile, JSON.stringify({ invalid: 'format', notSpdx: true, notCdx: true }));
try {
const out = await runExpectFail(`scan:eol --file ${badFile}`);
expectAny(
out,
[/Failed to read SBOM file/i, /Invalid SBOM file format/i, /Expected SPDX 2\.3 or CycloneDX format./i],
match(
combinedOutputText(out),
/(Failed to read SBOM file|Invalid SBOM file format|Expected SPDX 2\.3 or CycloneDX format\.)/i,
'Should indicate invalid SBOM format',
);
} finally {
Expand All @@ -464,18 +573,18 @@ describe('scan:eol e2e', () => {
it('fails when directory does not exist', async () => {
const missingDir = path.join(fixturesDir, 'npm', 'no-such-dir');
const out = await runExpectFail(`scan:eol --dir ${missingDir}`);
expectAny(
out,
[/Directory not found:/i, /Failed to scan directory/i, /Generating SBOM/i],
match(
combinedOutputText(out),
/(Directory not found:|Failed to scan directory|Generating SBOM)/i,
'Should indicate missing directory',
);
});

it('fails when provided path is not a directory', async () => {
const out = await runExpectFail(`scan:eol --dir ${simpleSbom}`);
expectAny(
out,
[/Path is not a directory:/i, /Failed to scan directory/i, /Generating SBOM/i],
match(
combinedOutputText(out),
/(Path is not a directory:|Failed to scan directory|Generating SBOM)/i,
'Should indicate non-directory path',
);
});
Expand All @@ -485,7 +594,11 @@ describe('scan:eol e2e', () => {
fetchMock.restore();
fetchMock = new FetchMock().addGraphQL({ eol: { createReport: { success: false, id: null, totalRecords: 0 } } });
const out = await runExpectFail(`scan:eol --file ${simpleSbom}`);
expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate NES submission failure');
match(
combinedOutputText(out),
/(Failed to submit scan to NES|Scanning failed)/i,
'Should indicate NES submission failure',
);
});

it('fails when NES returns GraphQL errors', async () => {
Expand All @@ -494,7 +607,27 @@ describe('scan:eol e2e', () => {
{ message: 'Internal server error', path: ['eol', 'createReport'] },
]);
const out = await runExpectFail(`scan:eol --file ${simpleSbom}`);
expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate GraphQL errors from NES');
match(
combinedOutputText(out),
/(Failed to submit scan to NES|Scanning failed)/i,
'Should indicate GraphQL errors from NES',
);
});

it('shows a helpful error when report output directory is invalid', async () => {
const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-report.json');
const out = await runExpectFail(`scan:eol --dir ${simpleDir} --save --output ${invalidPath}`);
match(
combinedOutputText(out),
/Unable to save custom-report\.json/i,
'Should indicate report could not be saved',
);
});

it('shows a helpful error when SBOM output directory is invalid', async () => {
const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-sbom.json');
const out = await runExpectFail(`scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${invalidPath}`);
match(combinedOutputText(out), /Unable to save custom-sbom\.json/i, 'Should indicate SBOM could not be saved');
});
});
});
Loading