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
126 changes: 78 additions & 48 deletions e2e/fixtures/npm/simple/sbom.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:c82634ad-0f4c-4bd7-b06c-e253f7f34fda",
"serialNumber": "urn:uuid:fdd23705-1780-4dd3-a72b-27c7f1fdb21c",
"version": 1,
"metadata": {
"timestamp": "2025-03-11T04:01:04Z",
"timestamp": "2025-07-22T14:43:41Z",
"tools": {
"components": [
{
"name": "@herodevs/cli",
"publisher": "HeroDevs, Inc.",
"version": "2.0.0-beta.4",
"type": "application"
},
{
"group": "@cyclonedx",
"name": "cdxgen",
"version": "11.2.0",
"purl": "pkg:npm/%40cyclonedx/cdxgen@11.2.0",
"version": "11.4.3",
"purl": "pkg:npm/%40cyclonedx/cdxgen@11.4.3",
"type": "application",
"bom-ref": "pkg:npm/@cyclonedx/cdxgen@11.2.0",
"bom-ref": "pkg:npm/@cyclonedx/cdxgen@11.4.3",
"publisher": "OWASP Foundation",
"authors": [
{
Expand All @@ -25,7 +31,7 @@
},
"authors": [
{
"name": "OWASP Foundation"
"name": "HeroDevs, Inc."
}
],
"lifecycles": [
Expand All @@ -50,17 +56,7 @@
}
}
]
},
"properties": [
{
"name": "cdx:bom:componentTypes",
"value": "npm"
},
{
"name": "cdx:bom:componentSrcFiles",
"value": "test/fixtures/npm/simple/package-lock.json"
}
]
}
},
"components": [
{
Expand All @@ -87,7 +83,7 @@
"properties": [
{
"name": "SrcFile",
"value": "test/fixtures/npm/simple/package-lock.json"
"value": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
},
{
"name": "ResolvedUrl",
Expand All @@ -107,14 +103,65 @@
{
"technique": "manifest-analysis",
"confidence": 1,
"value": "/Users/welch/Code/herodevs/cli/test/fixtures/npm/simple/package-lock.json"
"value": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
}
],
"concludedValue": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
}
]
}
},
{
"group": "",
"name": "vue",
"version": "3.5.13",
"hashes": [
{
"alg": "SHA-512",
"content": "c267a248cc6464249cf8f336c36551b0e600642f06762a4d1514ec2d27e8755a88f666de8ca797106afc49c92e2e7ad03c67b7a093797372b4be9a14f6aff009"
}
],
"licenses": [
{
"license": {
"id": "MIT",
"url": "https://opensource.org/licenses/MIT"
}
}
],
"purl": "pkg:npm/vue@3.5.13",
"type": "framework",
"bom-ref": "pkg:npm/vue@3.5.13",
"properties": [
{
"name": "SrcFile",
"value": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
},
{
"name": "ResolvedUrl",
"value": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz"
},
{
"name": "LocalNodeModulesPath",
"value": "node_modules/vue"
}
],
"evidence": {
"identity": [
{
"field": "purl",
"confidence": 1,
"methods": [
{
"technique": "manifest-analysis",
"confidence": 1,
"value": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
}
],
"concludedValue": "/Users/welch/Code/herodevs/cli/test/fixtures/npm/simple/package-lock.json"
"concludedValue": "/Users/rafael/dev/herodevs/cli/e2e/fixtures/npm/simple/package-lock.json"
}
]
},
"tags": ["registry"]
}
}
],
"services": [],
Expand All @@ -124,32 +171,15 @@
"dependsOn": []
},
{
"ref": "pkg:npm/simple@1.0.0",
"dependsOn": ["pkg:npm/bootstrap@3.1.1"]
}
],
"annotations": [
"ref": "pkg:npm/vue@3.5.13",
"dependsOn": []
},
{
"bom-ref": "metadata-annotations",
"subjects": ["pkg:npm/simple@1.0.0"],
"annotator": {
"component": {
"group": "@cyclonedx",
"name": "cdxgen",
"version": "11.2.0",
"purl": "pkg:npm/%40cyclonedx/cdxgen@11.2.0",
"type": "application",
"bom-ref": "pkg:npm/@cyclonedx/cdxgen@11.2.0",
"publisher": "OWASP Foundation",
"authors": [
{
"name": "OWASP Foundation"
}
]
}
},
"timestamp": "2025-03-11T04:01:04Z",
"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."
"ref": "pkg:npm/simple@1.0.0",
"dependsOn": [
"pkg:npm/bootstrap@3.1.1",
"pkg:npm/vue@3.5.13"
]
}
]
}
}
113 changes: 113 additions & 0 deletions e2e/scan/sbom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { doesNotThrow } from 'node:assert';
import { doesNotMatch, strictEqual } from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { describe, it } from 'node:test';
import { runCommand } from '@oclif/test';

describe('scan:sbom e2e', () => {
const fixturesDir = path.resolve(import.meta.dirname, '../fixtures');
const simpleDir = path.resolve(fixturesDir, 'npm/simple');

async function run(cmd: string) {
// Ensure fixtures directory exists and is clean
await mkdir(fixturesDir, { recursive: true });

const output = await runCommand(cmd);

// Log any errors for debugging
if (output.error) {
console.error('Command failed with error:', output.error);
console.error('Error details:', output.stderr);
}

// Verify command executed successfully
strictEqual(output.error, undefined, 'Command should execute without errors');

return output;
}

describe('SBOM generation and attribution', () => {
it('generates SBOM with correct HeroDevs attribution', async () => {
const cmd = `scan:sbom --dir ${simpleDir} --json`;
const { stdout } = await run(cmd);

// Verify JSON output is valid
doesNotThrow(() => JSON.parse(stdout), 'Output should be valid JSON');

const sbom = JSON.parse(stdout);

// Verify SBOM structure
strictEqual(sbom.bomFormat, 'CycloneDX', 'Should be CycloneDX format');
strictEqual(Array.isArray(sbom.components), true, 'Should have components array');

// Verify author attribution
strictEqual(Array.isArray(sbom.metadata?.authors), true, 'Should have authors array');
strictEqual(sbom.metadata.authors.length, 1, 'Should have exactly one author');
strictEqual(sbom.metadata.authors[0].name, 'HeroDevs, Inc.', 'Should have correct author name');

// Verify tools attribution (in CycloneDX, tools is an object with components array)
strictEqual(typeof sbom.metadata?.tools, 'object', 'Should have tools object');
strictEqual(Array.isArray(sbom.metadata.tools?.components), true, 'Should have tools components array');
strictEqual(sbom.metadata.tools.components.length > 0, true, 'Should have at least one tool');

// Find our CLI tool in the tools components
const cliTool = sbom.metadata.tools.components.find((tool: { name?: string }) => tool.name === '@herodevs/cli');

strictEqual(cliTool !== undefined, true, 'Should find @herodevs/cli tool');
strictEqual(cliTool.publisher, 'HeroDevs, Inc.', 'Should have correct tool publisher');

// Verify version is present (don't check exact value as it may vary)
strictEqual(typeof cliTool.version, 'string', 'Should have tool version as string');
strictEqual(cliTool.version.length > 0, true, 'Should have non-empty tool version');
});

it('outputs valid CycloneDX SBOM format', async () => {
const cmd = `scan:sbom --dir ${simpleDir} --json`;
const { stdout } = await run(cmd);

const sbom = JSON.parse(stdout);

// Verify SBOM format and spec version
strictEqual(sbom.bomFormat, 'CycloneDX', 'Should be CycloneDX format');
strictEqual(sbom.specVersion, '1.6', 'Should use CycloneDX spec version 1.6');

// Verify metadata structure
strictEqual(typeof sbom.metadata, 'object', 'Should have metadata object');
strictEqual(typeof sbom.serialNumber, 'string', 'Should have serial number');

// Verify components are detected
strictEqual(Array.isArray(sbom.components), true, 'Should have components array');
strictEqual(sbom.components.length > 0, true, 'Should detect at least one component');
});

it('does not show progress output when using --json flag', async () => {
const cmd = `scan:sbom --dir ${simpleDir} --json`;
const { stdout } = await run(cmd);

// Should not contain progress indicators or non-JSON output
doesNotMatch(stdout, /Generating SBOM/, 'Should not show progress messages');
doesNotMatch(stdout, /Scan results:/, 'Should not show results header');

// Verify output is pure JSON
doesNotThrow(() => JSON.parse(stdout), 'Output should be valid JSON');
});

it('detects npm packages in simple fixture', async () => {
const cmd = `scan:sbom --dir ${simpleDir} --json`;
const { stdout } = await run(cmd);

const sbom = JSON.parse(stdout);

// Verify components are detected
strictEqual(Array.isArray(sbom.components), true, 'Should have components array');
strictEqual(sbom.components.length > 0, true, 'Should detect components');

// Look for bootstrap package that should be in the simple fixture
const hasBootstrap = sbom.components.some((component: { purl?: string }) =>
component.purl?.includes('pkg:npm/bootstrap@'),
);
strictEqual(hasBootstrap, true, 'Should detect bootstrap package from package.json');
});
});
});
5 changes: 2 additions & 3 deletions src/commands/scan/sbom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export default class ScanSbom extends Command {

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

return sbom;
Expand Down Expand Up @@ -173,9 +175,6 @@ export default class ScanSbom extends Command {
try {
const outputPath = join(dir, `${filenamePrefix}.sbom.json`);
fs.writeFileSync(outputPath, JSON.stringify(sbom, null, 2));
if (!this.jsonEnabled()) {
this.log(`SBOM saved to ${outputPath}`);
}
} catch (error) {
this.error(`Failed to save SBOM: ${getErrorMessage(error)}`);
}
Expand Down
11 changes: 10 additions & 1 deletion src/service/eol/cdx.svc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface Sbom {
dependencies: SbomDependency[];
}

const author = process.env.npm_package_author ?? 'HeroDevs, Inc.';

export const SBOM_DEFAULT__OPTIONS = {
$0: 'cdxgen',
_: [],
Expand Down Expand Up @@ -51,7 +53,7 @@ export const SBOM_DEFAULT__OPTIONS = {
o: 'bom.json',
output: 'bom.json',
outputFormat: 'json', // or "xml"
// author: ['OWASP Foundation'],
author: [author],
profile: 'generic',
project: undefined,
'project-version': '',
Expand All @@ -69,6 +71,13 @@ export const SBOM_DEFAULT__OPTIONS = {
skipDtTlsCheck: true,
'spec-version': 1.6,
specVersion: 1.6,
tools: [
{
name: '@herodevs/cli',
publisher: author,
version: process.env.npm_package_version ?? 'unknown',
Comment thread
rlmestre marked this conversation as resolved.
Comment thread
rlmestre marked this conversation as resolved.
},
],
'usages-slices-file': 'usages.slices.json',
usagesSlicesFile: 'usages.slices.json',
validate: true,
Expand Down