Skip to content

Commit f0dce2d

Browse files
feat: add support for spdx sbom input file (#381)
1 parent bf45226 commit f0dce2d

6 files changed

Lines changed: 118 additions & 14 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ USAGE
115115
$ hd scan eol [--json] [-f <value> | -d <value>] [-s] [--saveSbom] [--saveTrimmedSbom] [--hideReportUrl] [--version]
116116
117117
FLAGS
118-
-d, --dir=<value> [default: <current directory>] The directory to scan in order to create a cyclonedx SBOM
119-
-f, --file=<value> The file path of an existing cyclonedx SBOM to scan for EOL
118+
-d, --dir=<value> [default: <current directory>] The directory to scan in order to scan for EOL
119+
-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
121121
--hideReportUrl Hide the generated web report URL for this scan
122122
--saveSbom Save the generated SBOM as herodevs.sbom.json in the scanned directory
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"spdxVersion": "SPDX-2.3",
3+
"dataLicense": "CC0-1.0",
4+
"SPDXID": "SPDXRef-DOCUMENT",
5+
"name": "simple-npm-project",
6+
"documentNamespace": "https://example.com/simple-npm-project",
7+
"creationInfo": {
8+
"created": "2024-01-01T00:00:00Z",
9+
"creators": ["Tool: test"]
10+
},
11+
"packages": [
12+
{
13+
"SPDXID": "SPDXRef-Package-bootstrap-3.1.1",
14+
"name": "bootstrap",
15+
"versionInfo": "3.1.1",
16+
"externalRefs": [
17+
{
18+
"referenceCategory": "PACKAGE-MANAGER",
19+
"referenceType": "purl",
20+
"referenceLocator": "pkg:npm/bootstrap@3.1.1"
21+
}
22+
]
23+
},
24+
{
25+
"SPDXID": "SPDXRef-Package-vue-3.5.13",
26+
"name": "vue",
27+
"versionInfo": "3.5.13",
28+
"externalRefs": [
29+
{
30+
"referenceCategory": "PACKAGE-MANAGER",
31+
"referenceType": "purl",
32+
"referenceLocator": "pkg:npm/vue@3.5.13"
33+
}
34+
]
35+
}
36+
]
37+
}

e2e/scan/eol.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const execAsync = promisify(exec);
1616
const fixturesDir = path.resolve(import.meta.dirname, '../fixtures');
1717
const simpleDir = path.resolve(fixturesDir, 'npm/simple');
1818
const simpleSbom = path.join(simpleDir, 'sbom.json');
19+
const simpleSpdxSbom = path.join(fixturesDir, 'npm/simple-spdx.sbom.json');
1920
const upToDateDir = path.resolve(fixturesDir, 'npm/up-to-date');
2021
const upToDateSbom = path.join(fixturesDir, 'npm/up-to-date.sbom.json');
2122
const noComponentsSbom = path.join(fixturesDir, 'npm/no-components.sbom.json');
@@ -135,6 +136,14 @@ describe('scan:eol e2e', () => {
135136
match(stdout, /2 total packages scanned/, 'Should show total packages scanned');
136137
});
137138

139+
it('scans existing SPDX SBOM file and converts to CycloneDX', async () => {
140+
const cmd = `scan:eol --file ${simpleSpdxSbom}`;
141+
const { stdout } = await run(cmd);
142+
match(stdout, /Scan results:/, 'Should show results header');
143+
match(stdout, /1( .*)End-of-Life \(EOL\)/, 'Should show EOL count');
144+
match(stdout, /2 total packages scanned/, 'Should show total packages scanned with SPDX input');
145+
});
146+
138147
it('shows warning and does not generate report when no components are found in scan', async () => {
139148
const cmd = `scan:eol --file ${noComponentsSbom}`;
140149
const { stdout } = await run(cmd);
@@ -406,8 +415,8 @@ describe('scan:eol e2e', () => {
406415
return output;
407416
}
408417

409-
function expectAny(output: { stdout: string; stderr: string }, patterns: RegExp[], message: string) {
410-
const text = `${output.stderr}\n${output.stdout}`;
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 || ''}`;
411420
const matched = patterns.some((re) => re.test(text));
412421
strictEqual(matched, true, message);
413422
}
@@ -437,6 +446,21 @@ describe('scan:eol e2e', () => {
437446
}
438447
});
439448

449+
it('fails when SBOM file is neither SPDX nor CycloneDX format', async () => {
450+
const badFile = path.join(fixturesDir, 'npm', 'invalid-format.json');
451+
writeFileSync(badFile, JSON.stringify({ invalid: 'format', notSpdx: true, notCdx: true }));
452+
try {
453+
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],
457+
'Should indicate invalid SBOM format',
458+
);
459+
} finally {
460+
unlinkSync(badFile);
461+
}
462+
});
463+
440464
it('fails when directory does not exist', async () => {
441465
const missingDir = path.join(fixturesDir, 'npm', 'no-such-dir');
442466
const out = await runExpectFail(`scan:eol --dir ${missingDir}`);

src/commands/scan/eol.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default class ScanEol extends Command {
4444
static override flags = {
4545
file: Flags.string({
4646
char: 'f',
47-
description: 'The file path of an existing cyclonedx SBOM to scan for EOL',
47+
description: 'The file path of an existing SBOM to scan for EOL (supports CycloneDX and SPDX 2.3 formats)',
4848
exclusive: ['dir'],
4949
}),
5050
dir: Flags.string({

src/service/file.svc.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'node:fs';
22
import path, { join, resolve } from 'node:path';
3-
import type { CdxBom, EolReport } from '@herodevs/eol-shared';
4-
import { isCdxBom } from '@herodevs/eol-shared';
3+
import type { CdxBom, EolReport, SPDX23 } from '@herodevs/eol-shared';
4+
import { isCdxBom, isSpdxBom, spdxToCdxBom } from '@herodevs/eol-shared';
55
import { filenamePrefix } from '../config/constants.ts';
66
import { getErrorMessage } from './log.svc.ts';
77

@@ -10,7 +10,8 @@ export interface FileError extends Error {
1010
}
1111

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

2223
try {
2324
const fileContent = fs.readFileSync(file, 'utf8');
24-
const sbom = JSON.parse(fileContent) as CdxBom;
25-
if (!isCdxBom(sbom)) {
26-
throw new Error(`Invalid SBOM file: ${file}`);
25+
const jsonContent = JSON.parse(fileContent);
26+
27+
if (isSpdxBom(jsonContent)) {
28+
return spdxToCdxBom(jsonContent as SPDX23);
29+
}
30+
31+
if (isCdxBom(jsonContent)) {
32+
return jsonContent as CdxBom;
2733
}
28-
return sbom;
34+
35+
throw new Error(`Invalid SBOM file format. Expected SPDX 2.3 or CycloneDX format.`);
2936
} catch (error) {
3037
throw new Error(`Failed to read SBOM file: ${getErrorMessage(error)}`);
3138
}

test/service/file.svc.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mkdtemp, writeFile } from 'node:fs/promises';
44
import { tmpdir } from 'node:os';
55
import { join } from 'node:path';
66
import { after, describe, it } from 'node:test';
7-
import type { CdxBom, EolReport } from '@herodevs/eol-shared';
7+
import type { CdxBom, EolReport, SPDX23 } from '@herodevs/eol-shared';
88
import {
99
readSbomFromFile,
1010
saveReportToFile,
@@ -23,6 +23,19 @@ describe('file.svc', () => {
2323
components: [],
2424
} as unknown as CdxBom;
2525

26+
const mockSpdxSbom: SPDX23 = {
27+
spdxVersion: 'SPDX-2.3',
28+
dataLicense: 'CC0-1.0',
29+
SPDXID: 'SPDXRef-DOCUMENT',
30+
name: 'test-sbom',
31+
documentNamespace: 'https://example.com/test',
32+
creationInfo: {
33+
created: '2024-01-01T00:00:00Z',
34+
creators: ['Tool: test'],
35+
},
36+
packages: [],
37+
};
38+
2639
const mockReport: EolReport = {
2740
id: 'test-id',
2841
createdOn: new Date().toISOString(),
@@ -40,7 +53,7 @@ describe('file.svc', () => {
4053
});
4154

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

65+
it('should read and convert a valid SPDX SBOM file to CycloneDX', async () => {
66+
tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-'));
67+
const filePath = join(tempDir, 'spdx-test.json');
68+
await writeFile(filePath, JSON.stringify(mockSpdxSbom));
69+
70+
const result = readSbomFromFile(filePath);
71+
72+
assert.strictEqual(result.bomFormat, 'CycloneDX');
73+
assert.ok(result.specVersion);
74+
assert.ok(Array.isArray(result.components));
75+
});
76+
5277
it('should throw error for non-existent file', () => {
5378
assert.throws(() => readSbomFromFile('/non/existent/path'), /SBOM file not found/);
5479
});
@@ -60,6 +85,17 @@ describe('file.svc', () => {
6085

6186
assert.throws(() => readSbomFromFile(filePath), /Failed to read SBOM file/);
6287
});
88+
89+
it('should throw error for invalid SBOM format (neither SPDX nor CycloneDX)', async () => {
90+
tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-'));
91+
const filePath = join(tempDir, 'invalid-format.json');
92+
await writeFile(filePath, JSON.stringify({ invalid: 'format' }));
93+
94+
assert.throws(
95+
() => readSbomFromFile(filePath),
96+
/Invalid SBOM file format\. Expected SPDX 2\.3 or CycloneDX format/,
97+
);
98+
});
6399
});
64100

65101
describe('validateDirectory', () => {

0 commit comments

Comments
 (0)