Skip to content

Commit 8eb1cba

Browse files
authored
fix: pollish evidence gathering (#1312)
followup of #1309 - fixed some false-positives for license evidences. - refactored some functionality, so that it is much easier to add #1310 later Signed-off-by: Jan Kowalleck <[email protected]>
1 parent 18e7034 commit 8eb1cba

File tree

7 files changed

+78
-115
lines changed

7 files changed

+78
-115
lines changed

HISTORY.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ All notable changes to this project will be documented in this file.
77
<!-- unreleased changes go here -->
88

99
* Added
10-
* Feature for collecting (license) evidence ([#676] via [#1309])
10+
* Feature for collecting (license) evidence ([#676] via [#1309], [#1312])
1111
Controlled with option `collectEvidence`, disabled by default.
1212
* Build
1313
* 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.
1616
[#1302]: https://github.com/CycloneDX/cyclonedx-webpack-plugin/pull/1302
1717
[#1306]: https://github.com/CycloneDX/cyclonedx-webpack-plugin/pull/1306
1818
[#1309]: https://github.com/CycloneDX/cyclonedx-webpack-plugin/pull/1309
19+
[#1312]: https://github.com/CycloneDX/cyclonedx-webpack-plugin/pull/1312
1920

2021
## 3.13.0 - 2024-07-21
2122

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@ new CycloneDxWebpackPlugin(options?: object)
4949
| **`specVersion`** | `{string}`<br/>one of: `"1.2"`, `"1.3"`, `"1.4"`, `"1.5"`, `"1.6"` | `"1.4"` | Which version of [CycloneDX-spec] to use.<br/> Supported values depend on the installed dependency [CycloneDX-javascript-library]. |
5050
| **`reproducibleResults`** | `{boolean}` | `false` | Whether to go the extra mile and make the output reproducible.<br/> Reproducibility might result in loss of time- and random-based-values. |
5151
| **`validateResults`** | `{boolean}` | `true` | Whether to validate the BOM result.<br/>Validation is skipped, if requirements not met. Requires [transitive optional dependencies](https://github.com/CycloneDX/cyclonedx-javascript-library#optional-dependencies). |
52-
| **`collectEvidence`** | `{boolean}` | `false` | Look for common files that may provide licenses and attach them to the component as evidence. |
5352
| **`outputLocation`** | `{string}` | `"./cyclonedx"` | Path to write the output to. The path is relative to _webpack_'s overall output path. |
5453
| **`includeWellknown`** | `{boolean}` | `true` | Whether to write the Wellknowns. |
5554
| **`wellknownLocation`** | `{string}` | `"./.well-known"` | Path to write the Wellknowns to. The path is relative to _webpack_'s overall output path. |
5655
| **`rootComponentAutodetect`** | `{boolean}` | `true` | Whether to try auto-detection of the RootComponent.<br/> Tries to find the nearest `package.json` and build a CycloneDX component from it, so it can be assigned to `bom.metadata.component`. |
5756
| **`rootComponentType`** | `{string}` | `"application"` | Set the RootComponent's type.<br/>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`. |
5857
| **`rootComponentName`** | optional `{string}` | `undefined` | If `rootComponentAutodetect` is disabled, then this value is assumed as the "name" of the `package.json`. |
5958
| **`rootComponentVersion`** | optional `{string}` | `undefined` | If `rootComponentAutodetect` is disabled, then this value is assumed as the "version" of the `package.json`. |
59+
| **`collectEvidence`** | `{boolean}` | `false` | Whether to collect (license) evidence and attach them to the resulting SBOM. |
6060

6161
### Example
6262

src/_helpers.ts

+11-41
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ SPDX-License-Identifier: Apache-2.0
1717
Copyright (c) OWASP Foundation. All Rights Reserved.
1818
*/
1919

20-
import { existsSync, readdirSync, readFileSync } from 'fs'
20+
import { existsSync, readFileSync } from 'fs'
2121
import { dirname, extname, isAbsolute, join, sep } from 'path'
2222

2323
export function isNonNullable<T> (value: T): value is NonNullable<T> {
@@ -88,50 +88,20 @@ export function loadJsonFile (path: string): any {
8888
// see https://github.com/tc39/proposal-import-attributes
8989
}
9090

91-
const LICENSE_FILENAME_PATTERN = /^(?:UN)?LICEN[CS]E|NOTICE/i
92-
/**
93-
* Searches typical files in the package path which have typical a license notice text inside
94-
*
95-
* @param {string} searchFolder folder to look for common filenames
96-
*
97-
* @yields {{ filepath: string, contentType: string}} Next matching file containing path and MIME type
98-
*/
99-
export function * searchEvidenceSources (searchFolder: string): Generator<{
100-
filepath: string
101-
contentType: string
102-
}> {
103-
for (const dirent of readdirSync(searchFolder, { withFileTypes: true })) {
104-
if (
105-
!dirent.isFile() ||
106-
!LICENSE_FILENAME_PATTERN.test(dirent.name)
107-
) {
108-
continue
109-
}
91+
// region MIME
11092

111-
const contentType = determineContentType(dirent.name)
112-
if (contentType === undefined) {
113-
continue
114-
}
93+
export type MimeType = string
11594

116-
yield {
117-
filepath: `${dirent.parentPath}/${dirent.name}`,
118-
contentType
119-
}
120-
}
121-
}
122-
123-
// common file endings that are used for notice/license files
124-
const CONTENT_TYPE_MAP: Record<string, string> = {
95+
const MAP_TEXT_EXTENSION_MIME: Readonly<Record<string, MimeType>> = {
12596
'': 'text/plain',
126-
'.txt': 'text/plain',
12797
'.md': 'text/markdown',
128-
'.xml': 'text/xml'
98+
'.rst': 'text/prs.fallenstein.rst',
99+
'.txt': 'text/plain',
100+
'.xml': 'text/xml' // not `application/xml` -- our scope is text!
129101
} as const
130102

131-
/**
132-
* Returns the MIME type for the file or undefined if nothing was matched
133-
* @param {string} filename filename or complete filepath
134-
*/
135-
export function determineContentType (filename: string): string | undefined {
136-
return CONTENT_TYPE_MAP[extname(filename)]
103+
export function getMimeForTextFile (filename: string): MimeType | undefined {
104+
return MAP_TEXT_EXTENSION_MIME[extname(filename)]
137105
}
106+
107+
// endregion MIME

src/extractor.ts

+49-35
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ Copyright (c) OWASP Foundation. All Rights Reserved.
1818
*/
1919

2020
import * as CDX from '@cyclonedx/cyclonedx-library'
21-
import { readFileSync } from 'fs'
21+
import { readdirSync, readFileSync } from 'fs'
2222
import * as normalizePackageJson from 'normalize-package-data'
23-
import { basename, dirname } from 'path'
23+
import { dirname, join } from 'path'
2424
import type { Compilation, Module } from 'webpack'
2525

26-
import { getPackageDescription, isNonNullable, type PackageDescription, searchEvidenceSources, structuredClonePolyfill } from './_helpers'
26+
import { getMimeForTextFile, getPackageDescription, isNonNullable, type PackageDescription, structuredClonePolyfill } from './_helpers'
2727

2828
type WebpackLogger = Compilation['logger']
2929

@@ -42,7 +42,7 @@ export class Extractor {
4242
this.#purlFactory = purlFactory
4343
}
4444

45-
generateComponents (modules: Iterable<Module>, collectEvidence?: boolean, logger?: WebpackLogger): Iterable<CDX.Models.Component> {
45+
generateComponents (modules: Iterable<Module>, collectEvidence: boolean, logger?: WebpackLogger): Iterable<CDX.Models.Component> {
4646
const pkgs: Record<string, CDX.Models.Component | undefined> = {}
4747
const components = new Map<Module, CDX.Models.Component>()
4848

@@ -83,7 +83,7 @@ export class Extractor {
8383
/**
8484
* @throws {Error} when no component could be fetched
8585
*/
86-
makeComponent (pkg: PackageDescription, collectEvidence?: boolean, logger?: WebpackLogger): CDX.Models.Component {
86+
makeComponent (pkg: PackageDescription, collectEvidence: boolean, logger?: WebpackLogger): CDX.Models.Component {
8787
try {
8888
const _packageJson = structuredClonePolyfill(pkg.packageJson)
8989
normalizePackageJson(_packageJson as object /* add debug for warnings? */)
@@ -107,17 +107,15 @@ export class Extractor {
107107
l.acknowledgement = CDX.Enums.LicenseAcknowledgement.Declared
108108
})
109109

110+
if (collectEvidence) {
111+
component.evidence = new CDX.Models.ComponentEvidence({
112+
licenses: new CDX.Models.LicenseRepository(this.getLicenseEvidence(dirname(pkg.path), logger))
113+
})
114+
}
115+
110116
component.purl = this.#purlFactory.makeFromComponent(component)
111117
component.bomRef.value = component.purl?.toString()
112118

113-
if (collectEvidence === true) {
114-
try {
115-
component.evidence = this.makeComponentEvidence(pkg)
116-
} catch (e) {
117-
logger?.warn('collecting Evidence from PkgPath', pkg.path, 'failed:', e)
118-
}
119-
}
120-
121119
return component
122120
}
123121

@@ -132,29 +130,45 @@ export class Extractor {
132130
}
133131
}
134132

135-
/**
136-
* Look for common files that may provide licenses and attach them to the component as evidence
137-
* @param pkg
138-
*/
139-
makeComponentEvidence (pkg: PackageDescription): CDX.Models.ComponentEvidence {
140-
const cdxComponentEvidence = new CDX.Models.ComponentEvidence()
141-
142-
// Add license evidence
143-
for (const { contentType, filepath } of searchEvidenceSources(dirname(pkg.path))) {
144-
cdxComponentEvidence.licenses.add(new CDX.Models.NamedLicense(
145-
`file: ${basename(filepath)}`,
146-
{
147-
text: new CDX.Models.Attachment(
148-
readFileSync(filepath).toString('base64'),
149-
{
150-
contentType,
151-
encoding: CDX.Enums.AttachmentEncoding.Base64
152-
}
153-
)
154-
}
155-
))
133+
readonly #LICENSE_FILENAME_PATTERN = /^(?:UN)?LICEN[CS]E|^NOTICE$/i
134+
135+
public * getLicenseEvidence (packageDir: string, logger?: WebpackLogger): Generator<CDX.Models.License> {
136+
let pcis
137+
try {
138+
pcis = readdirSync(packageDir, { withFileTypes: true })
139+
} catch (e) {
140+
logger?.warn('collecting license evidence in', packageDir, 'failed:', e)
141+
return
156142
}
143+
for (const pci of pcis) {
144+
if (
145+
!pci.isFile() ||
146+
!this.#LICENSE_FILENAME_PATTERN.test(pci.name)
147+
) {
148+
continue
149+
}
150+
151+
const contentType = getMimeForTextFile(pci.name)
152+
if (contentType === undefined) {
153+
continue
154+
}
157155

158-
return cdxComponentEvidence
156+
const fp = join(packageDir, pci.name)
157+
try {
158+
yield new CDX.Models.NamedLicense(
159+
`file: ${pci.name}`,
160+
{
161+
text: new CDX.Models.Attachment(
162+
readFileSync(fp).toString('base64'),
163+
{
164+
contentType,
165+
encoding: CDX.Enums.AttachmentEncoding.Base64
166+
}
167+
)
168+
})
169+
} catch (e) { // may throw if `readFileSync()` fails
170+
logger?.warn('collecting license evidence from', fp, 'failed:', e)
171+
}
172+
}
159173
}
160174
}

src/plugin.ts

+12-11
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,6 @@ export interface CycloneDxWebpackPluginOptions {
5353
*/
5454
validateResults?: CycloneDxWebpackPlugin['validateResults']
5555

56-
/**
57-
* Look for common files that may provide licenses and attach them to the component as evidence
58-
*
59-
* @default false
60-
*/
61-
collectEvidence?: boolean
62-
6356
/**
6457
* Path to write the output to.
6558
* The path is relative to webpack's overall output path.
@@ -111,6 +104,13 @@ export interface CycloneDxWebpackPluginOptions {
111104
* @default undefined
112105
*/
113106
rootComponentVersion?: CycloneDxWebpackPlugin['rootComponentVersion']
107+
108+
/**
109+
* Whether to collect (license) evidence and attach them to the resulting SBOM.
110+
*
111+
* @default false
112+
*/
113+
collectEvidence?: boolean
114114
}
115115

116116
class ValidationError extends Error {
@@ -126,7 +126,6 @@ export class CycloneDxWebpackPlugin {
126126
specVersion: CDX.Spec.Version
127127
reproducibleResults: boolean
128128
validateResults: boolean
129-
collectEvidence: boolean
130129

131130
resultXml: string
132131
resultJson: string
@@ -137,23 +136,24 @@ export class CycloneDxWebpackPlugin {
137136
rootComponentName: CDX.Models.Component['name'] | undefined
138137
rootComponentVersion: CDX.Models.Component['version'] | undefined
139138

139+
collectEvidence: boolean
140+
140141
constructor ({
141142
specVersion = CDX.Spec.Version.v1dot4,
142143
reproducibleResults = false,
143144
validateResults = true,
144-
collectEvidence = false,
145145
outputLocation = './cyclonedx',
146146
includeWellknown = true,
147147
wellknownLocation = './.well-known',
148148
rootComponentAutodetect = true,
149149
rootComponentType = CDX.Enums.ComponentType.Application,
150150
rootComponentName = undefined,
151-
rootComponentVersion = undefined
151+
rootComponentVersion = undefined,
152+
collectEvidence = false
152153
}: CycloneDxWebpackPluginOptions = {}) {
153154
this.specVersion = specVersion
154155
this.reproducibleResults = reproducibleResults
155156
this.validateResults = validateResults
156-
this.collectEvidence = collectEvidence
157157
this.resultXml = joinPath(outputLocation, './bom.xml')
158158
this.resultJson = joinPath(outputLocation, './bom.json')
159159
this.resultWellknown = includeWellknown
@@ -163,6 +163,7 @@ export class CycloneDxWebpackPlugin {
163163
this.rootComponentType = rootComponentType
164164
this.rootComponentName = rootComponentName
165165
this.rootComponentVersion = rootComponentVersion
166+
this.collectEvidence = collectEvidence
166167
}
167168

168169
apply (compiler: Compiler): void {

tests/integration/__snapshots__/index.test.js.snap

-24
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/integration/feature-issue676/webpack-build.config.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ const { CycloneDxWebpackPlugin } = require('@cyclonedx/webpack-plugin')
44
const cycloneDxWebpackPluginOptions = new CycloneDxWebpackPlugin({
55
specVersion: '1.4',
66
outputLocation: '.bom',
7-
collectEvidence: true,
8-
reproducibleResults: true
7+
reproducibleResults: true,
8+
validateResults: true,
9+
collectEvidence: true
910
})
1011

1112
module.exports = {

0 commit comments

Comments
 (0)