From d96b371b39d037a9bb82aac884b6a0af257981c0 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Tue, 22 Jul 2025 12:40:14 -0400 Subject: [PATCH 1/4] feat: add HeroDevs attribution to SBOM generation --- e2e/fixtures/npm/simple/sbom.json | 126 ++++++++++++++++++----------- e2e/scan/sbom.test.ts | 129 ++++++++++++++++++++++++++++++ src/commands/scan/sbom.ts | 5 +- src/service/eol/cdx.svc.ts | 9 ++- 4 files changed, 217 insertions(+), 52 deletions(-) create mode 100644 e2e/scan/sbom.test.ts 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..9fa046b6 --- /dev/null +++ b/e2e/scan/sbom.test.ts @@ -0,0 +1,129 @@ +import { doesNotThrow, notStrictEqual } from 'node:assert'; +import { doesNotMatch, match, strictEqual } from 'node:assert/strict'; +import { exec } from 'node:child_process'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { runCommand } from '@oclif/test'; +import { config } from '../../src/config/constants'; + +const execAsync = promisify(exec); + +describe('environment', () => { + it('should not be configured to run against the production environment', () => { + notStrictEqual(process.env.GRAPHQL_HOST, 'https://api.nes.herodevs.com'); + notStrictEqual(process.env.EOL_REPORT_URL, 'https://eol-report-card.apps.herodevs.com/reports'); + notStrictEqual(config.graphqlHost, 'https://api.nes.herodevs.com'); + notStrictEqual(config.eolReportUrl, 'https://eol-report-card.apps.herodevs.com/reports'); + }); +}); + +describe('scan:sbom e2e', () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const fixturesDir = path.resolve(__dirname, '../fixtures'); + const simpleDir = path.resolve(__dirname, '../fixtures/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..c49d0eca 100644 --- a/src/service/eol/cdx.svc.ts +++ b/src/service/eol/cdx.svc.ts @@ -51,7 +51,7 @@ export const SBOM_DEFAULT__OPTIONS = { o: 'bom.json', output: 'bom.json', outputFormat: 'json', // or "xml" - // author: ['OWASP Foundation'], + author: ['HeroDevs, Inc.'], profile: 'generic', project: undefined, 'project-version': '', @@ -69,6 +69,13 @@ export const SBOM_DEFAULT__OPTIONS = { skipDtTlsCheck: true, 'spec-version': 1.6, specVersion: 1.6, + tools: [ + { + name: '@herodevs/cli', + publisher: 'HeroDevs, Inc.', + version: process.env.npm_package_version ?? 'unknown', + }, + ], 'usages-slices-file': 'usages.slices.json', usagesSlicesFile: 'usages.slices.json', validate: true, From ed637cb503f74adb271b79e0539562f27c65c497 Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Tue, 22 Jul 2025 12:45:09 -0400 Subject: [PATCH 2/4] chore: remove unused code from reference test --- e2e/scan/sbom.test.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/e2e/scan/sbom.test.ts b/e2e/scan/sbom.test.ts index 9fa046b6..2c1d8265 100644 --- a/e2e/scan/sbom.test.ts +++ b/e2e/scan/sbom.test.ts @@ -1,24 +1,10 @@ -import { doesNotThrow, notStrictEqual } from 'node:assert'; -import { doesNotMatch, match, strictEqual } from 'node:assert/strict'; -import { exec } from 'node:child_process'; +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 { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; import { runCommand } from '@oclif/test'; -import { config } from '../../src/config/constants'; - -const execAsync = promisify(exec); - -describe('environment', () => { - it('should not be configured to run against the production environment', () => { - notStrictEqual(process.env.GRAPHQL_HOST, 'https://api.nes.herodevs.com'); - notStrictEqual(process.env.EOL_REPORT_URL, 'https://eol-report-card.apps.herodevs.com/reports'); - notStrictEqual(config.graphqlHost, 'https://api.nes.herodevs.com'); - notStrictEqual(config.eolReportUrl, 'https://eol-report-card.apps.herodevs.com/reports'); - }); -}); describe('scan:sbom e2e', () => { const __dirname = path.dirname(fileURLToPath(import.meta.url)); From 4c2eeaaba1205931d140b0d45777c11bb1879a0d Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Wed, 23 Jul 2025 12:47:57 -0400 Subject: [PATCH 3/4] chore: extract author variable --- src/service/eol/cdx.svc.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/service/eol/cdx.svc.ts b/src/service/eol/cdx.svc.ts index c49d0eca..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: ['HeroDevs, Inc.'], + author: [author], profile: 'generic', project: undefined, 'project-version': '', @@ -72,7 +74,7 @@ export const SBOM_DEFAULT__OPTIONS = { tools: [ { name: '@herodevs/cli', - publisher: 'HeroDevs, Inc.', + publisher: author, version: process.env.npm_package_version ?? 'unknown', }, ], From e86c0f8f53ac17aac4a8e58a9fb8eb23a9e2f09e Mon Sep 17 00:00:00 2001 From: Rafael Mestre Date: Wed, 23 Jul 2025 12:53:40 -0400 Subject: [PATCH 4/4] chore: address PR comment --- e2e/scan/sbom.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/e2e/scan/sbom.test.ts b/e2e/scan/sbom.test.ts index 2c1d8265..25a21ab6 100644 --- a/e2e/scan/sbom.test.ts +++ b/e2e/scan/sbom.test.ts @@ -3,13 +3,11 @@ 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 { fileURLToPath } from 'node:url'; import { runCommand } from '@oclif/test'; describe('scan:sbom e2e', () => { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const fixturesDir = path.resolve(__dirname, '../fixtures'); - const simpleDir = path.resolve(__dirname, '../fixtures/npm/simple'); + 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