import { path, FileSystem } from '@servicenow/sdk-build-core' import type { RecordContext } from '@servicenow/sdk-build-core' import { JsonSerializer, JSON as CycloneDXJSON } from '@cyclonedx/cyclonedx-library/Serialize' import { ExternalReference, type Metadata } from '@cyclonedx/cyclonedx-library/Models' import { ExternalReferenceType } from '@cyclonedx/cyclonedx-library/Enums' import * as CDXUtils from '@cyclonedx/cyclonedx-library/Utils' import * as Builders from '@cyclonedx/cyclonedx-library/Builders' import * as Enums from '@cyclonedx/cyclonedx-library/Enums' import * as Factories from '@cyclonedx/cyclonedx-library/Factories' import * as Models from '@cyclonedx/cyclonedx-library/Models' import * as Spec from '@cyclonedx/cyclonedx-library/Spec' import { PackageURL } from 'packageurl-js' export class SBOMBuilder { /** * The set of package.json files that are used to generate the SBOM. * @private */ private packageJsonFiles: Map = new Map() /** * The package.json of the SDK build plugins. This is used to generate the tool component in the SBOM. */ private toolPkgJsonDefault = { name: '@servicenow/sdk-build-plugins', version: '0.0.1', author: 'ServiceNow', } /** * Add a package.json file to the set of package.json files that are used to generate the SBOM for the application. * @param file the path to the package.json file */ public addPackageJson(file: string, content: string) { //We don't care about the package.json path but it least keeps the content unique this.packageJsonFiles.set(file, content) } /** * Generate a CycloneDX SBOM for the application. * @param context the build context */ public generateSBOM(context: RecordContext): string { const cdxExternalReferenceFactory = new Factories.FromNodePackageJson.ExternalReferenceFactory() const cdxLicenseFactory = new Factories.LicenseFactory() const cdxPurlFactory = new Factories.FromNodePackageJson.PackageUrlFactory('npm') const cdxComponentBuilder = new Builders.FromNodePackageJson.ComponentBuilder( cdxExternalReferenceFactory, cdxLicenseFactory ) const jsonSerializer = new JsonSerializer(new CycloneDXJSON.Normalize.Factory(Spec.Spec1dot6)) const sdkJson = this.getSdkInfo(context) const toolComponent = cdxComponentBuilder.makeComponent(sdkJson, Enums.ComponentType.Library) const rootComponent = cdxComponentBuilder.makeComponent(context.packageJson, Enums.ComponentType.Application) let qualifiers: Record | undefined if (context.config.scopeId) { qualifiers = { sys_scope_id: context.config.scopeId, } } const purl = new PackageURL( 'servicenow', undefined, context.packageJson.name, context.packageJson.version, qualifiers, undefined ) if (rootComponent && purl) { rootComponent.purl = purl rootComponent.bomRef.value = purl.toString() } const metadata = new Models.Metadata({ timestamp: new Date(), component: rootComponent, }) toolComponent && metadata.tools.components.add(toolComponent) this.addExternalReferences(metadata) const bom = new Models.Bom({ metadata, serialNumber: CDXUtils.BomUtility.randomSerialNumber(), }) for (const [, content] of this.packageJsonFiles.entries()) { const data = content const json = JSON.parse(data) if (json) { const { name, version } = json if (!(context.packageJson.name === name && context.packageJson.version === version)) { const component = cdxComponentBuilder.makeComponent(json, Enums.ComponentType.Application) if (component) { this.registerPackageUrlOnComponent(component, cdxPurlFactory) bom.components.add(component) rootComponent?.dependencies.add(component.bomRef) } } } } const nowDir: string = path.resolve(context.project.getRootDir(), '.now') FileSystem.ensureDirSync(context.fs, nowDir) const bomPath = path.resolve(nowDir, 'bom.json') const bomContent: string = jsonSerializer.serialize(bom, { space: 2, sortLists: true }) context.fs.writeFileSync(bomPath, bomContent) return bomContent } /** * Helper method to get the JSON needed to build the tool component for the @servicenow/sdk-build-plugins package. * @param context the build context * @private */ public getSdkInfo(context: RecordContext): { name: string; version: string; author: string } { try { //TODO can we retrieve the package.json's name and version from the context for the sdk-build-plugins package? const packageJsonPath = path.join( context.project.getRootDir(), 'node_modules', '@servicenow/sdk-build-plugins', 'package.json' ) const packageJson = JSON.parse(context.fs.readFileSync(packageJsonPath, { encoding: 'utf-8' })) return { name: packageJson.name, version: packageJson.version, author: 'ServiceNow', } } catch (error) { // ignore } return this.toolPkgJsonDefault } /** * Helper method to generate a package url from a component and register it on the component. No idea why the base * component builder doesn't do this automatically. * @param component the component to register the package url on * @param purlFactory the package url factory */ private registerPackageUrlOnComponent( component: Models.Component | undefined, purlFactory: Factories.FromNodePackageJson.PackageUrlFactory ) { if (component) { const purl = purlFactory.makeFromComponent(component) if (purl) { component.purl = purl component.bomRef.value = purl.toString() } } } /** * Add external references to the root component that are retrieved from common Jenkins environment variables. * @param metadata the bom metadata reference * @private */ private addExternalReferences(metadata: Metadata) { const buildUrl = process.env['BUILD_URL'] if (buildUrl) { const buildSystemExRef = new ExternalReference(buildUrl, ExternalReferenceType.BuildSystem, { comment: 'As obtained from the environment variable BUILD_URL', }) if (metadata.component && metadata.component.externalReferences) { metadata.component.externalReferences.add(buildSystemExRef) } } const vcsUrl = process.env['GIT_URL'] if (vcsUrl) { const vcsExRef = new ExternalReference(vcsUrl, ExternalReferenceType.VCS, { comment: 'As obtained from the environment variable GIT_URL', }) if (metadata.component && metadata.component.externalReferences) { metadata.component.externalReferences.add(vcsExRef) } } } }