diff --git a/README.md b/README.md index 3a3dde97..355a3f08 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,8 @@ USAGE $ hd scan eol [--json] [-f | -d ] [-s] [--saveSbom] [--saveTrimmedSbom] [--hideReportUrl] [--version] FLAGS - -d, --dir= [default: ] The directory to scan in order to create a cyclonedx SBOM - -f, --file= The file path of an existing cyclonedx SBOM to scan for EOL + -d, --dir= [default: ] The directory to scan in order to scan for EOL + -f, --file= 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 diff --git a/e2e/fixtures/npm/simple-spdx.sbom.json b/e2e/fixtures/npm/simple-spdx.sbom.json new file mode 100644 index 00000000..501b3031 --- /dev/null +++ b/e2e/fixtures/npm/simple-spdx.sbom.json @@ -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" + } + ] + } + ] +} diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 4fea3eed..6596004a 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -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'); @@ -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); @@ -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); } @@ -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}`); diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 05cde001..03b3b656 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -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({ diff --git a/src/service/file.svc.ts b/src/service/file.svc.ts index dcd3ffe8..a257a5c3 100644 --- a/src/service/file.svc.ts +++ b/src/service/file.svc.ts @@ -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'; @@ -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); @@ -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)}`); } diff --git a/test/service/file.svc.test.ts b/test/service/file.svc.test.ts index 24f3e6f5..5dcdbf51 100644 --- a/test/service/file.svc.test.ts +++ b/test/service/file.svc.test.ts @@ -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, @@ -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(), @@ -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)); @@ -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/); }); @@ -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', () => {