Skip to content

Commit 790da15

Browse files
authored
feat: add HeroDevs attribution to SBOM generation (#289)
1 parent 5a45adf commit 790da15

File tree

4 files changed

+203
-52
lines changed

4 files changed

+203
-52
lines changed

e2e/fixtures/npm/simple/sbom.json

Lines changed: 78 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
{
22
"bomFormat": "CycloneDX",
33
"specVersion": "1.6",
4-
"serialNumber": "urn:uuid:c82634ad-0f4c-4bd7-b06c-e253f7f34fda",
4+
"serialNumber": "urn:uuid:fdd23705-1780-4dd3-a72b-27c7f1fdb21c",
55
"version": 1,
66
"metadata": {
7-
"timestamp": "2025-03-11T04:01:04Z",
7+
"timestamp": "2025-07-22T14:43:41Z",
88
"tools": {
99
"components": [
10+
{
11+
"name": "@herodevs/cli",
12+
"publisher": "HeroDevs, Inc.",
13+
"version": "2.0.0-beta.4",
14+
"type": "application"
15+
},
1016
{
1117
"group": "@cyclonedx",
1218
"name": "cdxgen",
13-
"version": "11.2.0",
14-
"purl": "pkg:npm/%40cyclonedx/cdxgen@11.2.0",
19+
"version": "11.4.3",
20+
"purl": "pkg:npm/%40cyclonedx/cdxgen@11.4.3",
1521
"type": "application",
16-
"bom-ref": "pkg:npm/@cyclonedx/cdxgen@11.2.0",
22+
"bom-ref": "pkg:npm/@cyclonedx/cdxgen@11.4.3",
1723
"publisher": "OWASP Foundation",
1824
"authors": [
1925
{
@@ -25,7 +31,7 @@
2531
},
2632
"authors": [
2733
{
28-
"name": "OWASP Foundation"
34+
"name": "HeroDevs, Inc."
2935
}
3036
],
3137
"lifecycles": [
@@ -50,17 +56,7 @@
5056
}
5157
}
5258
]
53-
},
54-
"properties": [
55-
{
56-
"name": "cdx:bom:componentTypes",
57-
"value": "npm"
58-
},
59-
{
60-
"name": "cdx:bom:componentSrcFiles",
61-
"value": "test/fixtures/npm/simple/package-lock.json"
62-
}
63-
]
59+
}
6460
},
6561
"components": [
6662
{
@@ -87,7 +83,7 @@
8783
"properties": [
8884
{
8985
"name": "SrcFile",
90-
"value": "test/fixtures/npm/simple/package-lock.json"
86+
"value": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
9187
},
9288
{
9389
"name": "ResolvedUrl",
@@ -107,14 +103,65 @@
107103
{
108104
"technique": "manifest-analysis",
109105
"confidence": 1,
110-
"value": "/Users/welch/Code/herodevs/cli/test/fixtures/npm/simple/package-lock.json"
106+
"value": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
107+
}
108+
],
109+
"concludedValue": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
110+
}
111+
]
112+
}
113+
},
114+
{
115+
"group": "",
116+
"name": "vue",
117+
"version": "3.5.13",
118+
"hashes": [
119+
{
120+
"alg": "SHA-512",
121+
"content": "c267a248cc6464249cf8f336c36551b0e600642f06762a4d1514ec2d27e8755a88f666de8ca797106afc49c92e2e7ad03c67b7a093797372b4be9a14f6aff009"
122+
}
123+
],
124+
"licenses": [
125+
{
126+
"license": {
127+
"id": "MIT",
128+
"url": "https://opensource.org/licenses/MIT"
129+
}
130+
}
131+
],
132+
"purl": "pkg:npm/[email protected]",
133+
"type": "framework",
134+
"bom-ref": "pkg:npm/[email protected]",
135+
"properties": [
136+
{
137+
"name": "SrcFile",
138+
"value": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
139+
},
140+
{
141+
"name": "ResolvedUrl",
142+
"value": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz"
143+
},
144+
{
145+
"name": "LocalNodeModulesPath",
146+
"value": "node_modules/vue"
147+
}
148+
],
149+
"evidence": {
150+
"identity": [
151+
{
152+
"field": "purl",
153+
"confidence": 1,
154+
"methods": [
155+
{
156+
"technique": "manifest-analysis",
157+
"confidence": 1,
158+
"value": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
111159
}
112160
],
113-
"concludedValue": "/Users/welch/Code/herodevs/cli/test/fixtures/npm/simple/package-lock.json"
161+
"concludedValue": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
114162
}
115163
]
116-
},
117-
"tags": ["registry"]
164+
}
118165
}
119166
],
120167
"services": [],
@@ -124,32 +171,15 @@
124171
"dependsOn": []
125172
},
126173
{
127-
"ref": "pkg:npm/[email protected]",
128-
"dependsOn": ["pkg:npm/[email protected]"]
129-
}
130-
],
131-
"annotations": [
174+
"ref": "pkg:npm/[email protected]",
175+
"dependsOn": []
176+
},
132177
{
133-
"bom-ref": "metadata-annotations",
134-
"subjects": ["pkg:npm/[email protected]"],
135-
"annotator": {
136-
"component": {
137-
"group": "@cyclonedx",
138-
"name": "cdxgen",
139-
"version": "11.2.0",
140-
"purl": "pkg:npm/%40cyclonedx/[email protected]",
141-
"type": "application",
142-
"bom-ref": "pkg:npm/@cyclonedx/[email protected]",
143-
"publisher": "OWASP Foundation",
144-
"authors": [
145-
{
146-
"name": "OWASP Foundation"
147-
}
148-
]
149-
}
150-
},
151-
"timestamp": "2025-03-11T04:01:04Z",
152-
"text": "This Software Bill-of-Materials (SBOM) document was created on Monday, March 10, 2025 with cdxgen. The data was captured during the pre-build lifecycle phase without building the application. The document describes an application named 'simple' with version '1.0.0'. There are 1 components."
178+
"ref": "pkg:npm/[email protected]",
179+
"dependsOn": [
180+
"pkg:npm/[email protected]",
181+
182+
]
153183
}
154184
]
155-
}
185+
}

e2e/scan/sbom.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { doesNotThrow } from 'node:assert';
2+
import { doesNotMatch, strictEqual } from 'node:assert/strict';
3+
import { mkdir } from 'node:fs/promises';
4+
import path from 'node:path';
5+
import { describe, it } from 'node:test';
6+
import { runCommand } from '@oclif/test';
7+
8+
describe('scan:sbom e2e', () => {
9+
const fixturesDir = path.resolve(import.meta.dirname, '../fixtures');
10+
const simpleDir = path.resolve(fixturesDir, 'npm/simple');
11+
12+
async function run(cmd: string) {
13+
// Ensure fixtures directory exists and is clean
14+
await mkdir(fixturesDir, { recursive: true });
15+
16+
const output = await runCommand(cmd);
17+
18+
// Log any errors for debugging
19+
if (output.error) {
20+
console.error('Command failed with error:', output.error);
21+
console.error('Error details:', output.stderr);
22+
}
23+
24+
// Verify command executed successfully
25+
strictEqual(output.error, undefined, 'Command should execute without errors');
26+
27+
return output;
28+
}
29+
30+
describe('SBOM generation and attribution', () => {
31+
it('generates SBOM with correct HeroDevs attribution', async () => {
32+
const cmd = `scan:sbom --dir ${simpleDir} --json`;
33+
const { stdout } = await run(cmd);
34+
35+
// Verify JSON output is valid
36+
doesNotThrow(() => JSON.parse(stdout), 'Output should be valid JSON');
37+
38+
const sbom = JSON.parse(stdout);
39+
40+
// Verify SBOM structure
41+
strictEqual(sbom.bomFormat, 'CycloneDX', 'Should be CycloneDX format');
42+
strictEqual(Array.isArray(sbom.components), true, 'Should have components array');
43+
44+
// Verify author attribution
45+
strictEqual(Array.isArray(sbom.metadata?.authors), true, 'Should have authors array');
46+
strictEqual(sbom.metadata.authors.length, 1, 'Should have exactly one author');
47+
strictEqual(sbom.metadata.authors[0].name, 'HeroDevs, Inc.', 'Should have correct author name');
48+
49+
// Verify tools attribution (in CycloneDX, tools is an object with components array)
50+
strictEqual(typeof sbom.metadata?.tools, 'object', 'Should have tools object');
51+
strictEqual(Array.isArray(sbom.metadata.tools?.components), true, 'Should have tools components array');
52+
strictEqual(sbom.metadata.tools.components.length > 0, true, 'Should have at least one tool');
53+
54+
// Find our CLI tool in the tools components
55+
const cliTool = sbom.metadata.tools.components.find((tool: { name?: string }) => tool.name === '@herodevs/cli');
56+
57+
strictEqual(cliTool !== undefined, true, 'Should find @herodevs/cli tool');
58+
strictEqual(cliTool.publisher, 'HeroDevs, Inc.', 'Should have correct tool publisher');
59+
60+
// Verify version is present (don't check exact value as it may vary)
61+
strictEqual(typeof cliTool.version, 'string', 'Should have tool version as string');
62+
strictEqual(cliTool.version.length > 0, true, 'Should have non-empty tool version');
63+
});
64+
65+
it('outputs valid CycloneDX SBOM format', async () => {
66+
const cmd = `scan:sbom --dir ${simpleDir} --json`;
67+
const { stdout } = await run(cmd);
68+
69+
const sbom = JSON.parse(stdout);
70+
71+
// Verify SBOM format and spec version
72+
strictEqual(sbom.bomFormat, 'CycloneDX', 'Should be CycloneDX format');
73+
strictEqual(sbom.specVersion, '1.6', 'Should use CycloneDX spec version 1.6');
74+
75+
// Verify metadata structure
76+
strictEqual(typeof sbom.metadata, 'object', 'Should have metadata object');
77+
strictEqual(typeof sbom.serialNumber, 'string', 'Should have serial number');
78+
79+
// Verify components are detected
80+
strictEqual(Array.isArray(sbom.components), true, 'Should have components array');
81+
strictEqual(sbom.components.length > 0, true, 'Should detect at least one component');
82+
});
83+
84+
it('does not show progress output when using --json flag', async () => {
85+
const cmd = `scan:sbom --dir ${simpleDir} --json`;
86+
const { stdout } = await run(cmd);
87+
88+
// Should not contain progress indicators or non-JSON output
89+
doesNotMatch(stdout, /Generating SBOM/, 'Should not show progress messages');
90+
doesNotMatch(stdout, /Scan results:/, 'Should not show results header');
91+
92+
// Verify output is pure JSON
93+
doesNotThrow(() => JSON.parse(stdout), 'Output should be valid JSON');
94+
});
95+
96+
it('detects npm packages in simple fixture', async () => {
97+
const cmd = `scan:sbom --dir ${simpleDir} --json`;
98+
const { stdout } = await run(cmd);
99+
100+
const sbom = JSON.parse(stdout);
101+
102+
// Verify components are detected
103+
strictEqual(Array.isArray(sbom.components), true, 'Should have components array');
104+
strictEqual(sbom.components.length > 0, true, 'Should detect components');
105+
106+
// Look for bootstrap package that should be in the simple fixture
107+
const hasBootstrap = sbom.components.some((component: { purl?: string }) =>
108+
component.purl?.includes('pkg:npm/bootstrap@'),
109+
);
110+
strictEqual(hasBootstrap, true, 'Should detect bootstrap package from package.json');
111+
});
112+
});
113+
});

src/commands/scan/sbom.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ export default class ScanSbom extends Command {
101101

102102
if (!save) {
103103
this.log(JSON.stringify(sbom, null, 2));
104+
} else if (sbom && !this.jsonEnabled()) {
105+
this.log(`SBOM saved to ${path}/${filenamePrefix}.sbom.json`);
104106
}
105107

106108
return sbom;
@@ -173,9 +175,6 @@ export default class ScanSbom extends Command {
173175
try {
174176
const outputPath = join(dir, `${filenamePrefix}.sbom.json`);
175177
fs.writeFileSync(outputPath, JSON.stringify(sbom, null, 2));
176-
if (!this.jsonEnabled()) {
177-
this.log(`SBOM saved to ${outputPath}`);
178-
}
179178
} catch (error) {
180179
this.error(`Failed to save SBOM: ${getErrorMessage(error)}`);
181180
}

src/service/eol/cdx.svc.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface Sbom {
1919
dependencies: SbomDependency[];
2020
}
2121

22+
const author = process.env.npm_package_author ?? 'HeroDevs, Inc.';
23+
2224
export const SBOM_DEFAULT__OPTIONS = {
2325
$0: 'cdxgen',
2426
_: [],
@@ -51,7 +53,7 @@ export const SBOM_DEFAULT__OPTIONS = {
5153
o: 'bom.json',
5254
output: 'bom.json',
5355
outputFormat: 'json', // or "xml"
54-
// author: ['OWASP Foundation'],
56+
author: [author],
5557
profile: 'generic',
5658
project: undefined,
5759
'project-version': '',
@@ -69,6 +71,13 @@ export const SBOM_DEFAULT__OPTIONS = {
6971
skipDtTlsCheck: true,
7072
'spec-version': 1.6,
7173
specVersion: 1.6,
74+
tools: [
75+
{
76+
name: '@herodevs/cli',
77+
publisher: author,
78+
version: process.env.npm_package_version ?? 'unknown',
79+
},
80+
],
7281
'usages-slices-file': 'usages.slices.json',
7382
usagesSlicesFile: 'usages.slices.json',
7483
validate: true,

0 commit comments

Comments
 (0)