diff --git a/HISTORY.md b/HISTORY.md index 790aa5dc..7a13eaf1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7,7 +7,7 @@ All notable changes to this project will be documented in this file. * Added - * Feature for collecting (license) evidence ([#676] via [#1309]) + * Feature for collecting (license) evidence ([#676] via [#1309], [#1312]) Controlled with option `collectEvidence`, disabled by default. * Build * Use _TypeScript_ `v5.6.2` now, was `v5.5.3` (via [#1302], [#1306]) @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. [#1302]: https://github.com/CycloneDX/cyclonedx-webpack-plugin/pull/1302 [#1306]: https://github.com/CycloneDX/cyclonedx-webpack-plugin/pull/1306 [#1309]: https://github.com/CycloneDX/cyclonedx-webpack-plugin/pull/1309 +[#1312]: https://github.com/CycloneDX/cyclonedx-webpack-plugin/pull/1312 ## 3.13.0 - 2024-07-21 diff --git a/README.md b/README.md index 5fa9e24e..50c0c764 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ new CycloneDxWebpackPlugin(options?: object) | **`specVersion`** | `{string}`
one of: `"1.2"`, `"1.3"`, `"1.4"`, `"1.5"`, `"1.6"` | `"1.4"` | Which version of [CycloneDX-spec] to use.
Supported values depend on the installed dependency [CycloneDX-javascript-library]. | | **`reproducibleResults`** | `{boolean}` | `false` | Whether to go the extra mile and make the output reproducible.
Reproducibility might result in loss of time- and random-based-values. | | **`validateResults`** | `{boolean}` | `true` | Whether to validate the BOM result.
Validation is skipped, if requirements not met. Requires [transitive optional dependencies](https://github.com/CycloneDX/cyclonedx-javascript-library#optional-dependencies). | -| **`collectEvidence`** | `{boolean}` | `false` | Look for common files that may provide licenses and attach them to the component as evidence. | | **`outputLocation`** | `{string}` | `"./cyclonedx"` | Path to write the output to. The path is relative to _webpack_'s overall output path. | | **`includeWellknown`** | `{boolean}` | `true` | Whether to write the Wellknowns. | | **`wellknownLocation`** | `{string}` | `"./.well-known"` | Path to write the Wellknowns to. The path is relative to _webpack_'s overall output path. | @@ -57,6 +56,7 @@ new CycloneDxWebpackPlugin(options?: object) | **`rootComponentType`** | `{string}` | `"application"` | Set the RootComponent's type.
See [the list of valid values](https://cyclonedx.org/docs/1.4/json/#metadata_component_type). Supported values depend on [CycloneDX-javascript-library]'s enum `ComponentType`. | | **`rootComponentName`** | optional `{string}` | `undefined` | If `rootComponentAutodetect` is disabled, then this value is assumed as the "name" of the `package.json`. | | **`rootComponentVersion`** | optional `{string}` | `undefined` | If `rootComponentAutodetect` is disabled, then this value is assumed as the "version" of the `package.json`. | +| **`collectEvidence`** | `{boolean}` | `false` | Whether to collect (license) evidence and attach them to the resulting SBOM. | ### Example diff --git a/src/_helpers.ts b/src/_helpers.ts index 83a8de10..3bebb204 100644 --- a/src/_helpers.ts +++ b/src/_helpers.ts @@ -17,7 +17,7 @@ SPDX-License-Identifier: Apache-2.0 Copyright (c) OWASP Foundation. All Rights Reserved. */ -import { existsSync, readdirSync, readFileSync } from 'fs' +import { existsSync, readFileSync } from 'fs' import { dirname, extname, isAbsolute, join, sep } from 'path' export function isNonNullable (value: T): value is NonNullable { @@ -88,50 +88,20 @@ export function loadJsonFile (path: string): any { // see https://github.com/tc39/proposal-import-attributes } -const LICENSE_FILENAME_PATTERN = /^(?:UN)?LICEN[CS]E|NOTICE/i -/** - * Searches typical files in the package path which have typical a license notice text inside - * - * @param {string} searchFolder folder to look for common filenames - * - * @yields {{ filepath: string, contentType: string}} Next matching file containing path and MIME type - */ -export function * searchEvidenceSources (searchFolder: string): Generator<{ - filepath: string - contentType: string -}> { - for (const dirent of readdirSync(searchFolder, { withFileTypes: true })) { - if ( - !dirent.isFile() || - !LICENSE_FILENAME_PATTERN.test(dirent.name) - ) { - continue - } +// region MIME - const contentType = determineContentType(dirent.name) - if (contentType === undefined) { - continue - } +export type MimeType = string - yield { - filepath: `${dirent.parentPath}/${dirent.name}`, - contentType - } - } -} - -// common file endings that are used for notice/license files -const CONTENT_TYPE_MAP: Record = { +const MAP_TEXT_EXTENSION_MIME: Readonly> = { '': 'text/plain', - '.txt': 'text/plain', '.md': 'text/markdown', - '.xml': 'text/xml' + '.rst': 'text/prs.fallenstein.rst', + '.txt': 'text/plain', + '.xml': 'text/xml' // not `application/xml` -- our scope is text! } as const -/** - * Returns the MIME type for the file or undefined if nothing was matched - * @param {string} filename filename or complete filepath - */ -export function determineContentType (filename: string): string | undefined { - return CONTENT_TYPE_MAP[extname(filename)] +export function getMimeForTextFile (filename: string): MimeType | undefined { + return MAP_TEXT_EXTENSION_MIME[extname(filename)] } + +// endregion MIME diff --git a/src/extractor.ts b/src/extractor.ts index 42f114fb..bdc58049 100644 --- a/src/extractor.ts +++ b/src/extractor.ts @@ -18,12 +18,12 @@ Copyright (c) OWASP Foundation. All Rights Reserved. */ import * as CDX from '@cyclonedx/cyclonedx-library' -import { readFileSync } from 'fs' +import { readdirSync, readFileSync } from 'fs' import * as normalizePackageJson from 'normalize-package-data' -import { basename, dirname } from 'path' +import { dirname, join } from 'path' import type { Compilation, Module } from 'webpack' -import { getPackageDescription, isNonNullable, type PackageDescription, searchEvidenceSources, structuredClonePolyfill } from './_helpers' +import { getMimeForTextFile, getPackageDescription, isNonNullable, type PackageDescription, structuredClonePolyfill } from './_helpers' type WebpackLogger = Compilation['logger'] @@ -42,7 +42,7 @@ export class Extractor { this.#purlFactory = purlFactory } - generateComponents (modules: Iterable, collectEvidence?: boolean, logger?: WebpackLogger): Iterable { + generateComponents (modules: Iterable, collectEvidence: boolean, logger?: WebpackLogger): Iterable { const pkgs: Record = {} const components = new Map() @@ -83,7 +83,7 @@ export class Extractor { /** * @throws {Error} when no component could be fetched */ - makeComponent (pkg: PackageDescription, collectEvidence?: boolean, logger?: WebpackLogger): CDX.Models.Component { + makeComponent (pkg: PackageDescription, collectEvidence: boolean, logger?: WebpackLogger): CDX.Models.Component { try { const _packageJson = structuredClonePolyfill(pkg.packageJson) normalizePackageJson(_packageJson as object /* add debug for warnings? */) @@ -110,12 +110,10 @@ export class Extractor { component.purl = this.#purlFactory.makeFromComponent(component) component.bomRef.value = component.purl?.toString() - if (collectEvidence === true) { - try { - component.evidence = this.makeComponentEvidence(pkg) - } catch (e) { - logger?.warn('collecting Evidence from PkgPath', pkg.path, 'failed:', e) - } + if (collectEvidence) { + component.evidence = new CDX.Models.ComponentEvidence({ + licenses: new CDX.Models.LicenseRepository(this.getLicenseEvidence(dirname(pkg.path), logger)) + }) } return component @@ -132,29 +130,48 @@ export class Extractor { } } - /** - * Look for common files that may provide licenses and attach them to the component as evidence - * @param pkg - */ - makeComponentEvidence (pkg: PackageDescription): CDX.Models.ComponentEvidence { - const cdxComponentEvidence = new CDX.Models.ComponentEvidence() - - // Add license evidence - for (const { contentType, filepath } of searchEvidenceSources(dirname(pkg.path))) { - cdxComponentEvidence.licenses.add(new CDX.Models.NamedLicense( - `file: ${basename(filepath)}`, - { - text: new CDX.Models.Attachment( - readFileSync(filepath).toString('base64'), - { - contentType, - encoding: CDX.Enums.AttachmentEncoding.Base64 - } - ) - } - )) + readonly #LICENSE_FILENAME_PATTERN = /^(?:UN)?LICEN[CS]E|^NOTICE$/i + + public * getLicenseEvidence ( + packageDir: string, + logger?: WebpackLogger + ): Generator { + let pcis + try { + pcis = readdirSync(packageDir, { withFileTypes: true }) + } catch (e) { + logger?.warn('collecting license evidence in', packageDir, 'failed:', e) + return } + for (const pci of pcis) { + if ( + !pci.isFile() || + !this.#LICENSE_FILENAME_PATTERN.test(pci.name) + ) { + continue + } + + const contentType = getMimeForTextFile(pci.name) + if (contentType === undefined) { + continue + } - return cdxComponentEvidence + const fp = join(packageDir, pci.name) + try { + yield new CDX.Models.NamedLicense( + `file: ${pci.name}`, + { + text: new CDX.Models.Attachment( + readFileSync(fp).toString('base64'), + { + contentType, + encoding: CDX.Enums.AttachmentEncoding.Base64 + } + ) + }) + } catch (e) { // may throw if `readFileSync()` fails + logger?.warn('collecting license evidence from', fp, 'failed:', e) + } + } } } diff --git a/src/plugin.ts b/src/plugin.ts index c941fe32..e4137c8a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -53,13 +53,6 @@ export interface CycloneDxWebpackPluginOptions { */ validateResults?: CycloneDxWebpackPlugin['validateResults'] - /** - * Look for common files that may provide licenses and attach them to the component as evidence - * - * @default false - */ - collectEvidence?: boolean - /** * Path to write the output to. * The path is relative to webpack's overall output path. @@ -111,6 +104,13 @@ export interface CycloneDxWebpackPluginOptions { * @default undefined */ rootComponentVersion?: CycloneDxWebpackPlugin['rootComponentVersion'] + + /** + * Whether to collect (license) evidence and attach them to the resulting SBOM. + * + * @default false + */ + collectEvidence?: boolean } class ValidationError extends Error { @@ -126,7 +126,6 @@ export class CycloneDxWebpackPlugin { specVersion: CDX.Spec.Version reproducibleResults: boolean validateResults: boolean - collectEvidence: boolean resultXml: string resultJson: string @@ -137,23 +136,24 @@ export class CycloneDxWebpackPlugin { rootComponentName: CDX.Models.Component['name'] | undefined rootComponentVersion: CDX.Models.Component['version'] | undefined + collectEvidence: boolean + constructor ({ specVersion = CDX.Spec.Version.v1dot4, reproducibleResults = false, validateResults = true, - collectEvidence = false, outputLocation = './cyclonedx', includeWellknown = true, wellknownLocation = './.well-known', rootComponentAutodetect = true, rootComponentType = CDX.Enums.ComponentType.Application, rootComponentName = undefined, - rootComponentVersion = undefined + rootComponentVersion = undefined, + collectEvidence = false }: CycloneDxWebpackPluginOptions = {}) { this.specVersion = specVersion this.reproducibleResults = reproducibleResults this.validateResults = validateResults - this.collectEvidence = collectEvidence this.resultXml = joinPath(outputLocation, './bom.xml') this.resultJson = joinPath(outputLocation, './bom.json') this.resultWellknown = includeWellknown @@ -163,6 +163,7 @@ export class CycloneDxWebpackPlugin { this.rootComponentType = rootComponentType this.rootComponentName = rootComponentName this.rootComponentVersion = rootComponentVersion + this.collectEvidence = collectEvidence } apply (compiler: Compiler): void { diff --git a/tests/integration/__snapshots__/index.test.js.snap b/tests/integration/__snapshots__/index.test.js.snap index eb155a39..095712df 100644 --- a/tests/integration/__snapshots__/index.test.js.snap +++ b/tests/integration/__snapshots__/index.test.js.snap @@ -476,16 +476,6 @@ exports[`integration feature: component evidence generated json file: dist/.bom/ ], "evidence": { "licenses": [ - { - "license": { - "name": "file: CopyrightNotice.txt", - "text": { - "content": "LyoqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKg0KQ29weXJpZ2h0IChjKSBNaWNyb3NvZnQgQ29ycG9yYXRpb24uDQoNClBlcm1pc3Npb24gdG8gdXNlLCBjb3B5LCBtb2RpZnksIGFuZC9vciBkaXN0cmlidXRlIHRoaXMgc29mdHdhcmUgZm9yIGFueQ0KcHVycG9zZSB3aXRoIG9yIHdpdGhvdXQgZmVlIGlzIGhlcmVieSBncmFudGVkLg0KDQpUSEUgU09GVFdBUkUgSVMgUFJPVklERUQgIkFTIElTIiBBTkQgVEhFIEFVVEhPUiBESVNDTEFJTVMgQUxMIFdBUlJBTlRJRVMgV0lUSA0KUkVHQVJEIFRPIFRISVMgU09GVFdBUkUgSU5DTFVESU5HIEFMTCBJTVBMSUVEIFdBUlJBTlRJRVMgT0YgTUVSQ0hBTlRBQklMSVRZDQpBTkQgRklUTkVTUy4gSU4gTk8gRVZFTlQgU0hBTEwgVEhFIEFVVEhPUiBCRSBMSUFCTEUgRk9SIEFOWSBTUEVDSUFMLCBESVJFQ1QsDQpJTkRJUkVDVCwgT1IgQ09OU0VRVUVOVElBTCBEQU1BR0VTIE9SIEFOWSBEQU1BR0VTIFdIQVRTT0VWRVIgUkVTVUxUSU5HIEZST00NCkxPU1MgT0YgVVNFLCBEQVRBIE9SIFBST0ZJVFMsIFdIRVRIRVIgSU4gQU4gQUNUSU9OIE9GIENPTlRSQUNULCBORUdMSUdFTkNFIE9SDQpPVEhFUiBUT1JUSU9VUyBBQ1RJT04sIEFSSVNJTkcgT1VUIE9GIE9SIElOIENPTk5FQ1RJT04gV0lUSCBUSEUgVVNFIE9SDQpQRVJGT1JNQU5DRSBPRiBUSElTIFNPRlRXQVJFLg0KKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKiogKi8NCg0K", - "contentType": "text/plain", - "encoding": "base64" - } - } - }, { "license": { "name": "file: LICENSE.txt", @@ -1085,16 +1075,6 @@ exports[`integration feature: component evidence generated json file: dist/.well ], "evidence": { "licenses": [ - { - "license": { - "name": "file: CopyrightNotice.txt", - "text": { - "content": "LyoqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKg0KQ29weXJpZ2h0IChjKSBNaWNyb3NvZnQgQ29ycG9yYXRpb24uDQoNClBlcm1pc3Npb24gdG8gdXNlLCBjb3B5LCBtb2RpZnksIGFuZC9vciBkaXN0cmlidXRlIHRoaXMgc29mdHdhcmUgZm9yIGFueQ0KcHVycG9zZSB3aXRoIG9yIHdpdGhvdXQgZmVlIGlzIGhlcmVieSBncmFudGVkLg0KDQpUSEUgU09GVFdBUkUgSVMgUFJPVklERUQgIkFTIElTIiBBTkQgVEhFIEFVVEhPUiBESVNDTEFJTVMgQUxMIFdBUlJBTlRJRVMgV0lUSA0KUkVHQVJEIFRPIFRISVMgU09GVFdBUkUgSU5DTFVESU5HIEFMTCBJTVBMSUVEIFdBUlJBTlRJRVMgT0YgTUVSQ0hBTlRBQklMSVRZDQpBTkQgRklUTkVTUy4gSU4gTk8gRVZFTlQgU0hBTEwgVEhFIEFVVEhPUiBCRSBMSUFCTEUgRk9SIEFOWSBTUEVDSUFMLCBESVJFQ1QsDQpJTkRJUkVDVCwgT1IgQ09OU0VRVUVOVElBTCBEQU1BR0VTIE9SIEFOWSBEQU1BR0VTIFdIQVRTT0VWRVIgUkVTVUxUSU5HIEZST00NCkxPU1MgT0YgVVNFLCBEQVRBIE9SIFBST0ZJVFMsIFdIRVRIRVIgSU4gQU4gQUNUSU9OIE9GIENPTlRSQUNULCBORUdMSUdFTkNFIE9SDQpPVEhFUiBUT1JUSU9VUyBBQ1RJT04sIEFSSVNJTkcgT1VUIE9GIE9SIElOIENPTk5FQ1RJT04gV0lUSCBUSEUgVVNFIE9SDQpQRVJGT1JNQU5DRSBPRiBUSElTIFNPRlRXQVJFLg0KKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKiogKi8NCg0K", - "contentType": "text/plain", - "encoding": "base64" - } - } - }, { "license": { "name": "file: LICENSE.txt", @@ -1569,10 +1549,6 @@ exports[`integration feature: component evidence generated xml file: dist/.bom/b - - file: CopyrightNotice.txt - LyoqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKg0KQ29weXJpZ2h0IChjKSBNaWNyb3NvZnQgQ29ycG9yYXRpb24uDQoNClBlcm1pc3Npb24gdG8gdXNlLCBjb3B5LCBtb2RpZnksIGFuZC9vciBkaXN0cmlidXRlIHRoaXMgc29mdHdhcmUgZm9yIGFueQ0KcHVycG9zZSB3aXRoIG9yIHdpdGhvdXQgZmVlIGlzIGhlcmVieSBncmFudGVkLg0KDQpUSEUgU09GVFdBUkUgSVMgUFJPVklERUQgIkFTIElTIiBBTkQgVEhFIEFVVEhPUiBESVNDTEFJTVMgQUxMIFdBUlJBTlRJRVMgV0lUSA0KUkVHQVJEIFRPIFRISVMgU09GVFdBUkUgSU5DTFVESU5HIEFMTCBJTVBMSUVEIFdBUlJBTlRJRVMgT0YgTUVSQ0hBTlRBQklMSVRZDQpBTkQgRklUTkVTUy4gSU4gTk8gRVZFTlQgU0hBTEwgVEhFIEFVVEhPUiBCRSBMSUFCTEUgRk9SIEFOWSBTUEVDSUFMLCBESVJFQ1QsDQpJTkRJUkVDVCwgT1IgQ09OU0VRVUVOVElBTCBEQU1BR0VTIE9SIEFOWSBEQU1BR0VTIFdIQVRTT0VWRVIgUkVTVUxUSU5HIEZST00NCkxPU1MgT0YgVVNFLCBEQVRBIE9SIFBST0ZJVFMsIFdIRVRIRVIgSU4gQU4gQUNUSU9OIE9GIENPTlRSQUNULCBORUdMSUdFTkNFIE9SDQpPVEhFUiBUT1JUSU9VUyBBQ1RJT04sIEFSSVNJTkcgT1VUIE9GIE9SIElOIENPTk5FQ1RJT04gV0lUSCBUSEUgVVNFIE9SDQpQRVJGT1JNQU5DRSBPRiBUSElTIFNPRlRXQVJFLg0KKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKiogKi8NCg0K - file: LICENSE.txt Q29weXJpZ2h0IChjKSBNaWNyb3NvZnQgQ29ycG9yYXRpb24uDQoNClBlcm1pc3Npb24gdG8gdXNlLCBjb3B5LCBtb2RpZnksIGFuZC9vciBkaXN0cmlidXRlIHRoaXMgc29mdHdhcmUgZm9yIGFueQ0KcHVycG9zZSB3aXRoIG9yIHdpdGhvdXQgZmVlIGlzIGhlcmVieSBncmFudGVkLg0KDQpUSEUgU09GVFdBUkUgSVMgUFJPVklERUQgIkFTIElTIiBBTkQgVEhFIEFVVEhPUiBESVNDTEFJTVMgQUxMIFdBUlJBTlRJRVMgV0lUSA0KUkVHQVJEIFRPIFRISVMgU09GVFdBUkUgSU5DTFVESU5HIEFMTCBJTVBMSUVEIFdBUlJBTlRJRVMgT0YgTUVSQ0hBTlRBQklMSVRZDQpBTkQgRklUTkVTUy4gSU4gTk8gRVZFTlQgU0hBTEwgVEhFIEFVVEhPUiBCRSBMSUFCTEUgRk9SIEFOWSBTUEVDSUFMLCBESVJFQ1QsDQpJTkRJUkVDVCwgT1IgQ09OU0VRVUVOVElBTCBEQU1BR0VTIE9SIEFOWSBEQU1BR0VTIFdIQVRTT0VWRVIgUkVTVUxUSU5HIEZST00NCkxPU1MgT0YgVVNFLCBEQVRBIE9SIFBST0ZJVFMsIFdIRVRIRVIgSU4gQU4gQUNUSU9OIE9GIENPTlRSQUNULCBORUdMSUdFTkNFIE9SDQpPVEhFUiBUT1JUSU9VUyBBQ1RJT04sIEFSSVNJTkcgT1VUIE9GIE9SIElOIENPTk5FQ1RJT04gV0lUSCBUSEUgVVNFIE9SDQpQRVJGT1JNQU5DRSBPRiBUSElTIFNPRlRXQVJFLg== diff --git a/tests/integration/feature-issue676/webpack-build.config.js b/tests/integration/feature-issue676/webpack-build.config.js index 44d9d4df..713db613 100644 --- a/tests/integration/feature-issue676/webpack-build.config.js +++ b/tests/integration/feature-issue676/webpack-build.config.js @@ -4,8 +4,9 @@ const { CycloneDxWebpackPlugin } = require('@cyclonedx/webpack-plugin') const cycloneDxWebpackPluginOptions = new CycloneDxWebpackPlugin({ specVersion: '1.4', outputLocation: '.bom', - collectEvidence: true, - reproducibleResults: true + reproducibleResults: true, + validateResults: true, + collectEvidence: true }) module.exports = {