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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ USAGE
$ hd scan eol [--json] [-f <value> | -d <value>] [-s] [--saveSbom] [--saveTrimmedSbom] [--hideReportUrl] [--version]

FLAGS
-d, --dir=<value> [default: <current directory>] The directory to scan in order to create a cyclonedx SBOM
-f, --file=<value> The file path of an existing cyclonedx SBOM to scan for EOL
-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
--hideReportUrl Hide the generated web report URL for this scan
--saveSbom Save the generated SBOM as herodevs.sbom.json in the scanned directory
Expand Down
37 changes: 37 additions & 0 deletions e2e/fixtures/npm/simple-spdx.sbom.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "simple-npm-project",
"documentNamespace": "https://example.com/simple-npm-project",
"creationInfo": {
"created": "2024-01-01T00:00:00Z",
"creators": ["Tool: test"]
},
"packages": [
{
"SPDXID": "SPDXRef-Package-bootstrap-3.1.1",
"name": "bootstrap",
"versionInfo": "3.1.1",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/bootstrap@3.1.1"
}
]
},
{
"SPDXID": "SPDXRef-Package-vue-3.5.13",
"name": "vue",
"versionInfo": "3.5.13",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/vue@3.5.13"
}
]
}
]
}
28 changes: 26 additions & 2 deletions e2e/scan/eol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const execAsync = promisify(exec);
const fixturesDir = path.resolve(import.meta.dirname, '../fixtures');
const simpleDir = path.resolve(fixturesDir, 'npm/simple');
const simpleSbom = path.join(simpleDir, 'sbom.json');
const simpleSpdxSbom = path.join(fixturesDir, 'npm/simple-spdx.sbom.json');
const upToDateDir = path.resolve(fixturesDir, 'npm/up-to-date');
const upToDateSbom = path.join(fixturesDir, 'npm/up-to-date.sbom.json');
const noComponentsSbom = path.join(fixturesDir, 'npm/no-components.sbom.json');
Expand Down Expand Up @@ -135,6 +136,14 @@ describe('scan:eol e2e', () => {
match(stdout, /2 total packages scanned/, 'Should show total packages scanned');
});

it('scans existing SPDX SBOM file and converts to CycloneDX', async () => {
const cmd = `scan:eol --file ${simpleSpdxSbom}`;
const { stdout } = await run(cmd);
match(stdout, /Scan results:/, 'Should show results header');
match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count');
match(stdout, /2 total packages scanned/, 'Should show total packages scanned with SPDX input');
});

it('shows warning and does not generate report when no components are found in scan', async () => {
const cmd = `scan:eol --file ${noComponentsSbom}`;
const { stdout } = await run(cmd);
Expand Down Expand Up @@ -406,8 +415,8 @@ describe('scan:eol e2e', () => {
return output;
}

function expectAny(output: { stdout: string; stderr: string }, patterns: RegExp[], message: string) {
const text = `${output.stderr}\n${output.stdout}`;
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);
}
Expand Down Expand Up @@ -437,6 +446,21 @@ describe('scan:eol e2e', () => {
}
});

it('fails when SBOM file is neither SPDX nor CycloneDX format', async () => {
const badFile = path.join(fixturesDir, 'npm', 'invalid-format.json');
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],
'Should indicate invalid SBOM format',
);
} finally {
unlinkSync(badFile);
}
});

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}`);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/scan/eol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default class ScanEol extends Command {
static override flags = {
file: Flags.string({
char: 'f',
description: 'The file path of an existing cyclonedx SBOM to scan for EOL',
description: 'The file path of an existing SBOM to scan for EOL (supports CycloneDX and SPDX 2.3 formats)',
exclusive: ['dir'],
}),
dir: Flags.string({
Expand Down
21 changes: 14 additions & 7 deletions src/service/file.svc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path, { join, resolve } from 'node:path';
import type { CdxBom, EolReport } from '@herodevs/eol-shared';
import { isCdxBom } from '@herodevs/eol-shared';
import type { CdxBom, EolReport, SPDX23 } from '@herodevs/eol-shared';
import { isCdxBom, isSpdxBom, spdxToCdxBom } from '@herodevs/eol-shared';
import { filenamePrefix } from '../config/constants.ts';
import { getErrorMessage } from './log.svc.ts';

Expand All @@ -10,7 +10,8 @@ export interface FileError extends Error {
}

/**
* Reads an SBOM from a file path
* Reads an SBOM from a file path and converts it to CycloneDX format
* Supports both SPDX 2.3 and CycloneDX formats
*/
export function readSbomFromFile(filePath: string): CdxBom {
const file = resolve(filePath);
Expand All @@ -21,11 +22,17 @@ export function readSbomFromFile(filePath: string): CdxBom {

try {
const fileContent = fs.readFileSync(file, 'utf8');
const sbom = JSON.parse(fileContent) as CdxBom;
if (!isCdxBom(sbom)) {
throw new Error(`Invalid SBOM file: ${file}`);
const jsonContent = JSON.parse(fileContent);

if (isSpdxBom(jsonContent)) {
return spdxToCdxBom(jsonContent as SPDX23);
}

if (isCdxBom(jsonContent)) {
return jsonContent as CdxBom;
}
return sbom;

throw new Error(`Invalid SBOM file format. Expected SPDX 2.3 or CycloneDX format.`);
} catch (error) {
throw new Error(`Failed to read SBOM file: ${getErrorMessage(error)}`);
}
Expand Down
40 changes: 38 additions & 2 deletions test/service/file.svc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, describe, it } from 'node:test';
import type { CdxBom, EolReport } from '@herodevs/eol-shared';
import type { CdxBom, EolReport, SPDX23 } from '@herodevs/eol-shared';
import {
readSbomFromFile,
saveReportToFile,
Expand All @@ -23,6 +23,19 @@ describe('file.svc', () => {
components: [],
} as unknown as CdxBom;

const mockSpdxSbom: SPDX23 = {
spdxVersion: 'SPDX-2.3',
dataLicense: 'CC0-1.0',
SPDXID: 'SPDXRef-DOCUMENT',
name: 'test-sbom',
documentNamespace: 'https://example.com/test',
creationInfo: {
created: '2024-01-01T00:00:00Z',
creators: ['Tool: test'],
},
packages: [],
};

const mockReport: EolReport = {
id: 'test-id',
createdOn: new Date().toISOString(),
Expand All @@ -40,7 +53,7 @@ describe('file.svc', () => {
});

describe('readSbomFromFile', () => {
it('should read and parse a valid SBOM file', async () => {
it('should read and parse a valid CycloneDX SBOM file', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-'));
const filePath = join(tempDir, 'test.json');
await writeFile(filePath, JSON.stringify(mockSbom));
Expand All @@ -49,6 +62,18 @@ describe('file.svc', () => {
assert.deepStrictEqual(result, mockSbom);
});

it('should read and convert a valid SPDX SBOM file to CycloneDX', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-'));
const filePath = join(tempDir, 'spdx-test.json');
await writeFile(filePath, JSON.stringify(mockSpdxSbom));

const result = readSbomFromFile(filePath);

assert.strictEqual(result.bomFormat, 'CycloneDX');
assert.ok(result.specVersion);
assert.ok(Array.isArray(result.components));
});

it('should throw error for non-existent file', () => {
assert.throws(() => readSbomFromFile('/non/existent/path'), /SBOM file not found/);
});
Expand All @@ -60,6 +85,17 @@ describe('file.svc', () => {

assert.throws(() => readSbomFromFile(filePath), /Failed to read SBOM file/);
});

it('should throw error for invalid SBOM format (neither SPDX nor CycloneDX)', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-'));
const filePath = join(tempDir, 'invalid-format.json');
await writeFile(filePath, JSON.stringify({ invalid: 'format' }));

assert.throws(
() => readSbomFromFile(filePath),
/Invalid SBOM file format\. Expected SPDX 2\.3 or CycloneDX format/,
);
});
});

describe('validateDirectory', () => {
Expand Down