diff --git a/e2e/fixtures/npm/simple/sbom.json b/e2e/fixtures/npm/simple/sbom.json index 308a8a21..d4916958 100644 --- a/e2e/fixtures/npm/simple/sbom.json +++ b/e2e/fixtures/npm/simple/sbom.json @@ -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": [ { @@ -25,7 +31,7 @@ }, "authors": [ { - "name": "OWASP Foundation" + "name": "HeroDevs, Inc." } ], "lifecycles": [ @@ -50,17 +56,7 @@ } } ] - }, - "properties": [ - { - "name": "cdx:bom:componentTypes", - "value": "npm" - }, - { - "name": "cdx:bom:componentSrcFiles", - "value": "test/fixtures/npm/simple/package-lock.json" - } - ] + } }, "components": [ { @@ -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", @@ -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": [], @@ -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" + ] } ] -} +} \ No newline at end of file diff --git a/e2e/scan/sbom.test.ts b/e2e/scan/sbom.test.ts new file mode 100644 index 00000000..25a21ab6 --- /dev/null +++ b/e2e/scan/sbom.test.ts @@ -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'); + }); + }); +}); diff --git a/src/commands/scan/sbom.ts b/src/commands/scan/sbom.ts index fc9bae1c..893a5d8f 100644 --- a/src/commands/scan/sbom.ts +++ b/src/commands/scan/sbom.ts @@ -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; @@ -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)}`); } diff --git a/src/service/eol/cdx.svc.ts b/src/service/eol/cdx.svc.ts index ee6d40f8..8ec9e5dc 100644 --- a/src/service/eol/cdx.svc.ts +++ b/src/service/eol/cdx.svc.ts @@ -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', _: [], @@ -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': '', @@ -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', + }, + ], 'usages-slices-file': 'usages.slices.json', usagesSlicesFile: 'usages.slices.json', validate: true,