From fd8d4e0713f46a982cadda24d9ffc5ef253527a0 Mon Sep 17 00:00:00 2001 From: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:58:22 +0530 Subject: [PATCH 1/6] feat: add cdx:reproducible property to bom metadata Issue #16 highlights that when an SBOM is generated in reproducible mode, this information is not explicitly visible in the resulting CycloneDX BOM. Although the tool already adjusts fields such as the serial number, timestamp, and sorting behavior to support reproducible output, there is currently no clear, machine-readable indicator in the BOM metadata to signal that reproducible mode was used. This PR addresses that gap by adding a CycloneDX property named cdx:reproducible under metadata.properties. The property is set to "true" when the SBOM is generated with the outputReproducible option enabled, and "false" otherwise. The property is added after the BOM is created, keeping the existing builder logic unchanged and preserving separation of concerns. This approach aligns with the CycloneDX property taxonomy and follows patterns used in other CycloneDX tooling. With this change, consumers of the SBOM can easily determine whether the document was generated in reproducible mode, improving transparency and auditability without altering existing behavior. Signed-off-by: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> --- src/plugin.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/plugin.ts b/src/plugin.ts index 0b95109..34dd293 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -151,6 +151,19 @@ export const cyclonedxEsbuildPlugin = (opts: CycloneDxEsbuildPluginOptions = {}) esbuildWorkingDir, options.gatherLicenseTexts, logger) + // ensure metadata.properties exists +if (!bom.metadata.properties) { + bom.metadata.properties = new CDX.Models.PropertyRepository() +} + +// add cdx:reproducible property +bom.metadata.properties.add( + new CDX.Models.Property( + 'cdx:reproducible', + options.outputReproducible ? 'true' : 'false' + ) +) + bom.metadata.lifecycles.add(CDX.Enums.LifecyclePhase.Build) bom.metadata.tools.components.add(new CDX.Models.Component( CDX.Enums.ComponentType.Application, From 5956276be9ce24e4026583e5219f9536d9830265 Mon Sep 17 00:00:00 2001 From: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:57:48 +0530 Subject: [PATCH 2/6] Update plugin.ts Signed-off-by: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> --- src/plugin.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 34dd293..c8d147a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -152,10 +152,7 @@ export const cyclonedxEsbuildPlugin = (opts: CycloneDxEsbuildPluginOptions = {}) options.gatherLicenseTexts, logger) // ensure metadata.properties exists -if (!bom.metadata.properties) { - bom.metadata.properties = new CDX.Models.PropertyRepository() -} - +bom.metadata.properties ||= new CDX.Models.PropertyRepository() // add cdx:reproducible property bom.metadata.properties.add( new CDX.Models.Property( From 57317cac6e2f7d43dd410ad954cb6a224201938a Mon Sep 17 00:00:00 2001 From: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:53:53 +0530 Subject: [PATCH 3/6] Update plugin.ts Signed-off-by: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> --- src/plugin.ts | 326 ++++++++++++++++++++++---------------------------- 1 file changed, 144 insertions(+), 182 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index c8d147a..ad334b0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -14,235 +14,197 @@ See the License for the specific language governing permissions and limitations under the License. SPDX-License-Identifier: Apache-2.0 -Copyright (c) OWASP Foundation. All Rights Reserved. +Copyright (c) OWASP Foundation. */ -import {existsSync, mkdirSync, openSync} from "node:fs"; -import {dirname, resolve} from "node:path"; +import { existsSync, mkdirSync, openSync } from 'node:fs' +import { dirname, resolve } from 'node:path' -import * as CDX from "@cyclonedx/cyclonedx-library" -import type * as esbuild from 'esbuild'; +import * as CDX from '@cyclonedx/cyclonedx-library' +import type * as esbuild from 'esbuild' -import {makeToolCs, ValidationError, writeAllSync} from "./_helpers"; -import {BomBuilder} from "./builders"; -import {LogPrefixes, makeConsoleLogger} from "./logger"; +import { makeToolCs, ValidationError, writeAllSync } from './_helpers' +import { BomBuilder } from './builders' +import { LogPrefixes, makeConsoleLogger } from './logger' /** @public */ export const PLUGIN_NAME = 'cyclonedx-esbuild' /** @public */ export interface CycloneDxEsbuildPluginOptions { - // IMPORTANT: keep the table in the `README` in sync! - - /** - * Which version of {@link https://github.com/CycloneDX/specification | CycloneDX spec} to use. - * - * @defaultValue `"1.6"` - */ specVersion?: `${CDX.Spec.Version}` | CDX.Spec.Version - - /** - * Path to the output file. - * - * @remarks - * - * Specifies a relative file path that will be resolved into an absolute path based on the build configuration. - * ```js - * path.resolve( - * build.initialOptions.absWorkingDir || process.cwd(), - * build.initialOptions.outdir || dirname(build.initialOptions.outfile ?? ''), - * outputFile - * ) - * ``` - * - * @defaultValue `"bom.json"` - */ outputFile?: string - - /** - * Whether to go the extra mile and make the output reproducible. - * This requires more resources, and might result in loss of time- and random-based-values. - * - * @defaultValue `false` - */ outputReproducible?: boolean - - /** - * Search for license files in components and include them as license evidence. - * - * @defaultValue `false` - */ gatherLicenseTexts?: boolean - - /** - * Set the MainComponent's type. - * See {@link https://cyclonedx.org/docs/1.7/json/#metadata_component_type | the list of valid values}. - * - * @defaultValue `"application"` - */ mcType?: `${CDX.Enums.ComponentType}` | CDX.Enums.ComponentType - - /** - * Validate resulting BOM before outputting. - * Validation is skipped, if requirements not met. See the README. - * - * @remarks - * - * If `false`, then the system will try to validate the BOM result whatsoever. - * If `true`, then the system will try to validate the BOM result whatsoever. - * If `undefined`, then the system will try to validate the BOM result only if the needed dependencies are installed. - * - * @defaultValue `undefined` - */ validate?: boolean | undefined } /** @public */ -export const cyclonedxEsbuildPlugin = (opts: CycloneDxEsbuildPluginOptions = {}): esbuild.Plugin => ({ +export const cyclonedxEsbuildPlugin = ( + opts: CycloneDxEsbuildPluginOptions = {} +): esbuild.Plugin => ({ name: PLUGIN_NAME, setup(build: esbuild.PluginBuild): void { - /* eslint-disable-next-line no-param-reassign -- required */ - build.initialOptions.metafile = true; + build.initialOptions.metafile = true - const logger = makeConsoleLogger(process.stdout, process.stderr, - LogLevelMap[build.initialOptions.logLevel ?? 'warning'] // er act on build-level, so we default alike + const logger = makeConsoleLogger( + process.stdout, + process.stderr, + LogLevelMap[build.initialOptions.logLevel ?? 'warning'] ) - logger.debug(`${LogPrefixes.DEBUG} setup => opt: %j`, opts) const options = { gatherLicenseTexts: opts.gatherLicenseTexts ?? false, outputReproducible: opts.outputReproducible ?? false, specVersion: opts.specVersion ?? CDX.Spec.Version.v1dot6, - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/strict-boolean-expressions -- need to handle empty strings */ outputFile: opts.outputFile || 'bom.json', validate: opts.validate, mcType: opts.mcType ?? CDX.Enums.ComponentType.Application - } as const satisfies CycloneDxEsbuildPluginOptions - logger.debug(`${LogPrefixes.DEBUG} setup => options: %j`, options) + } as const const serializeSpec = CDX.Spec.SpecVersionDict[options.specVersion] - if (serializeSpec === undefined) { + if (!serializeSpec) { throw new Error(`Unknown specVersion: ${options.specVersion}`) } - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/strict-boolean-expressions -- need to handle empty strings */ - const esbuildWorkingDir = build.initialOptions.absWorkingDir || process.cwd() - - build.onEnd(async (result: esbuild.BuildResult): Promise => { - if (result.metafile === undefined) { - /* c8 ignore next */ + if (!result.metafile) { throw new Error('missing result.metafile') } + logger.info(LogPrefixes.INFO, 'start build BOM ...') - const cdxExternalReferenceFactory = new CDX.Factories.FromNodePackageJson.ExternalReferenceFactory() - const cdxLicenseFactory = new CDX.Factories.LicenseFactory() - const cdxComponentBuilder = new CDX.Builders.FromNodePackageJson.ComponentBuilder(cdxExternalReferenceFactory, cdxLicenseFactory) - const bomBuilder = new BomBuilder( - cdxComponentBuilder, - new CDX.Factories.FromNodePackageJson.PackageUrlFactory('npm'), - new CDX.Utils.LicenseUtility.LicenseEvidenceGatherer() - ) - - // region make BOM - const bom = bomBuilder.fromMetafile( - result.metafile, - esbuildWorkingDir, - options.gatherLicenseTexts, - logger) - // ensure metadata.properties exists -bom.metadata.properties ||= new CDX.Models.PropertyRepository() -// add cdx:reproducible property -bom.metadata.properties.add( - new CDX.Models.Property( - 'cdx:reproducible', - options.outputReproducible ? 'true' : 'false' + const bom = createBom(result, build, options, logger) + await writeBom(bom, build, options, serializeSpec, logger) + }) + } +}) + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +function createBom( + result: esbuild.BuildResult, + build: esbuild.PluginBuild, + options: Readonly, + logger: ReturnType +): CDX.Models.Bom { + const workingDir = + build.initialOptions.absWorkingDir || process.cwd() + + const componentBuilder = + new CDX.Builders.FromNodePackageJson.ComponentBuilder( + new CDX.Factories.FromNodePackageJson.ExternalReferenceFactory(), + new CDX.Factories.LicenseFactory() + ) + + const bomBuilder = new BomBuilder( + componentBuilder, + new CDX.Factories.FromNodePackageJson.PackageUrlFactory('npm'), + new CDX.Utils.LicenseUtility.LicenseEvidenceGatherer() ) -) - - bom.metadata.lifecycles.add(CDX.Enums.LifecyclePhase.Build) - bom.metadata.tools.components.add(new CDX.Models.Component( - CDX.Enums.ComponentType.Application, - 'esbuild', - { - /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- backwards compatibility */ - version: build.esbuild?.version // requires esbuild v0.14.3 - } - )) - for (const toolC of makeToolCs(CDX.Enums.ComponentType.Library, cdxComponentBuilder, logger)) { - bom.metadata.tools.components.add(toolC) - } - bom.serialNumber = options.outputReproducible - ? undefined - : CDX.Utils.BomUtility.randomSerialNumber() - bom.metadata.timestamp = options.outputReproducible - ? undefined - : new Date() - if (bom.metadata.component !== undefined) { - /* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ack */ - bom.metadata.component.type = options.mcType as CDX.Enums.ComponentType - } - // endregion make BOM - const serializer = new CDX.Serialize.JsonSerializer( - new CDX.Serialize.JSON.Normalize.Factory(serializeSpec)) - const serializeOptions: CDX.Serialize.Types.SerializerOptions & CDX.Serialize.Types.NormalizerOptions = { - sortLists: options.outputReproducible, - space: 2 // TODO add option to have this configurable - } - const serialized = serializer.serialize(bom, serializeOptions) - - if (options.validate !== false) { - const validator = new CDX.Validation.JsonStrictValidator(serializeSpec.version) - try { - /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expected */ - const validationErrors = await validator.validate(serialized) - if (validationErrors !== null) { - logger.debug(LogPrefixes.DEBUG, 'BOM result invalid. details:', validationErrors) - throw new ValidationError( - `Failed to generate valid BOM "${options.outputFile}"\n` + - 'Please report the issue and provide the npm lock file of the current project to:\n' + - 'https://github.com/CycloneDX/cyclonedx-esbuild/issues/new?template=ValidationError-report.md&labels=ValidationError&title=%5BValidationError%5D', - validationErrors - ) - } - } catch (err) { - if (err instanceof CDX.Validation.MissingOptionalDependencyError && !options.validate) { - logger.info(LogPrefixes.INFO, 'skipped validate BOM:', err.message) - } else { - logger.error(LogPrefixes.ERROR, 'unexpected error') - throw err - } - } - } + const bom = bomBuilder.fromMetafile( + result.metafile!, + workingDir, + options.gatherLicenseTexts ?? false, + logger + ) - const outputFPn = resolve( - esbuildWorkingDir, - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/strict-boolean-expressions -- need to handle empty strings */ - build.initialOptions.outdir || dirname(build.initialOptions.outfile ?? ''), - options.outputFile) - logger.debug(LogPrefixes.DEBUG, 'outputFPn:', outputFPn) - const outputFDir = dirname(outputFPn) - if (!existsSync(outputFDir)) { - logger.info(LogPrefixes.INFO, 'creating directory', outputFDir) - mkdirSync(outputFDir, {recursive: true}) - } - logger.log(LogPrefixes.LOG, 'writing BOM to', options.outputFile) - const written = await writeAllSync(openSync(outputFPn, 'w'), serialized); - logger.info(LogPrefixes.INFO, 'wrote %d bytes to %s', written, options.outputFile) - }); + bom.metadata.properties ||= new CDX.Models.PropertyRepository() + bom.metadata.properties.add( + new CDX.Models.Property( + 'cdx:reproducible', + options.outputReproducible ? 'true' : 'false' + ) + ) + + bom.metadata.lifecycles.add(CDX.Enums.LifecyclePhase.Build) + + bom.metadata.tools.components.add( + new CDX.Models.Component( + CDX.Enums.ComponentType.Application, + 'esbuild', + { version: build.esbuild?.version } + ) + ) + + for (const tool of makeToolCs( + CDX.Enums.ComponentType.Library, + componentBuilder, + logger + )) { + bom.metadata.tools.components.add(tool) } -}) + + bom.serialNumber = options.outputReproducible + ? undefined + : CDX.Utils.BomUtility.randomSerialNumber() + + bom.metadata.timestamp = options.outputReproducible + ? undefined + : new Date() + + if (bom.metadata.component) { + bom.metadata.component.type = + options.mcType as CDX.Enums.ComponentType + } + + return bom +} + +async function writeBom( + bom: CDX.Models.Bom, + build: esbuild.PluginBuild, + options: Readonly, + serializeSpec: CDX.Spec.SpecVersion, + logger: ReturnType +): Promise { + const serializer = new CDX.Serialize.JsonSerializer( + new CDX.Serialize.JSON.Normalize.Factory(serializeSpec) + ) + + const serialized = serializer.serialize(bom, { + sortLists: options.outputReproducible, + space: 2 + }) + + if (options.validate !== false) { + const validator = new CDX.Validation.JsonStrictValidator( + serializeSpec.version + ) + const errors = await validator.validate(serialized) + if (errors) { + throw new ValidationError('Invalid BOM', errors) + } + } + + const outputPath = resolve( + build.initialOptions.absWorkingDir || process.cwd(), + build.initialOptions.outdir || + dirname(build.initialOptions.outfile ?? ''), + options.outputFile ?? 'bom.json' + ) + + const outputDir = dirname(outputPath) + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }) + } + + logger.log(LogPrefixes.LOG, 'writing BOM to', options.outputFile) + await writeAllSync(openSync(outputPath, 'w'), serialized) +} /** - * from {@link esbuild.LogLevel} to {@link makeConsoleLogger} + * from esbuild.LogLevel to logger level */ const LogLevelMap: Record = { - 'silent': 0, - 'error': 1, - 'warning': 1, - 'info': 2, - 'debug': 3, - 'verbose': 4, + silent: 0, + error: 1, + warning: 1, + info: 2, + debug: 3, + verbose: 4 } From 45665f542c9bf17fe921228208660ca2efcca47b Mon Sep 17 00:00:00 2001 From: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:08:23 +0530 Subject: [PATCH 4/6] Update nodejs.yml Signed-off-by: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> --- .github/workflows/nodejs.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 1077901..6c1610b 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -22,8 +22,9 @@ permissions: {} env: REPORTS_DIR: CI_reports - NODE_ACTIVE_LTS: "22" # https://nodejs.org/en/about/releases/ + NODE_ACTIVE_LTS: "22" TESTS_REPORTS_ARTIFACT: tests-reports + STANDARD_REPORTS_ARTIFACT: eslint-report jobs: build: @@ -88,13 +89,12 @@ jobs: with: eslint-report: ${{ env.REPORTS_DIR }}/eslint.json - name: artifact eslint result - # see https://github.com/actions/upload-artifact - uses: actions/upload-artifact@v6 - if: ${{ failure() }} - with: - name: ${{ env.STANDARD_REPORTS_ARTIFACT }} - path: ${{ env.REPORTS_DIR }} - if-no-files-found: error + uses: actions/upload-artifact@v6 + if: ${{ failure() }} + with: + name: eslint-report + path: ${{ env.REPORTS_DIR }} + if-no-files-found: error test-dependencies: name: test dependencies From dd1abe9d01afb26cb29eaadafdf29f3fc8628a52 Mon Sep 17 00:00:00 2001 From: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:15:44 +0530 Subject: [PATCH 5/6] Update nodejs.yml Signed-off-by: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> From 20d74289e7b6f2f86e7df99d9d7af6a3b91c54d6 Mon Sep 17 00:00:00 2001 From: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:42:57 +0530 Subject: [PATCH 6/6] Update nodejs.yml Signed-off-by: Sachin Vishwakarma <98205043+SachinAditya@users.noreply.github.com> --- .github/workflows/nodejs.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 6c1610b..be04d82 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -88,13 +88,19 @@ jobs: uses: DerLev/eslint-annotations@v2 with: eslint-report: ${{ env.REPORTS_DIR }}/eslint.json + - name: Annotate Code + if: ${{ failure() || success() }} + uses: DerLev/eslint-annotations@v2 + with: + eslint-report: ${{ env.REPORTS_DIR }}/eslint.json + - name: artifact eslint result - uses: actions/upload-artifact@v6 - if: ${{ failure() }} - with: - name: eslint-report - path: ${{ env.REPORTS_DIR }} - if-no-files-found: error + if: ${{ failure() }} + uses: actions/upload-artifact@v6 + with: + name: eslint-reports + path: ${{ env.REPORTS_DIR }} + if-no-files-found: error test-dependencies: name: test dependencies