Skip to content

Commit 933a024

Browse files
committed
fix: replace auto-detected root component with user-provided one
Previously, disabling root component auto-detection while specifying a root component left the auto-detected root in the BOM, with dependencies still attached to it (issue #1418). This change ensures that all instances of the auto-detected root are replaced by the user-provided component using a `componentSubstitutionMap` during component generation. Also introduced a new `RichComponentBuilder` to centralize handling of component creation, BOM reference initialization, and PURL assignment, moving relevant logic out of `Extractor`. Regression tests were added for that particular case Signed-off-by: Maxim Bagryantsev <[email protected]>
1 parent dc16c7e commit 933a024

File tree

14 files changed

+6800
-194
lines changed

14 files changed

+6800
-194
lines changed

src/extractor.ts

Lines changed: 20 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -17,39 +17,31 @@ SPDX-License-Identifier: Apache-2.0
1717
Copyright (c) OWASP Foundation. All Rights Reserved.
1818
*/
1919

20-
import { dirname } from 'node:path'
21-
22-
import * as CDX from '@cyclonedx/cyclonedx-library'
20+
import type * as CDX from '@cyclonedx/cyclonedx-library'
2321
import type { Compilation, Module } from 'webpack'
2422

23+
import type {
24+
PackageDescription} from './_helpers'
2525
import {
2626
getPackageDescription,
27-
isNonNullable,
28-
normalizePackageManifest,
29-
type PackageDescription,
30-
structuredClonePolyfill} from './_helpers'
27+
isNonNullable} from './_helpers'
28+
import type {RichComponentBuilder} from './richComponentBuilder'
3129

3230
type WebpackLogger = Compilation['logger']
3331

3432
export class Extractor {
3533
readonly #compilation: Compilation
36-
readonly #componentBuilder: CDX.Builders.FromNodePackageJson.ComponentBuilder
37-
readonly #purlFactory: CDX.Factories.FromNodePackageJson.PackageUrlFactory
38-
readonly #leGatherer: CDX.Utils.LicenseUtility.LicenseEvidenceGatherer
34+
readonly #componentBuilder: RichComponentBuilder
3935

4036
constructor (
4137
compilation: Compilation,
42-
componentBuilder: CDX.Builders.FromNodePackageJson.ComponentBuilder,
43-
purlFactory: CDX.Factories.FromNodePackageJson.PackageUrlFactory,
44-
leFetcher: CDX.Utils.LicenseUtility.LicenseEvidenceGatherer
38+
componentBuilder: RichComponentBuilder,
4539
) {
4640
this.#compilation = compilation
4741
this.#componentBuilder = componentBuilder
48-
this.#purlFactory = purlFactory
49-
this.#leGatherer = leFetcher
5042
}
5143

52-
generateComponents (modules: Iterable<Module>, collectEvidence: boolean, logger?: WebpackLogger): Iterable<CDX.Models.Component> {
44+
generateComponents (modules: Iterable<Module>, componentSubstitutionMap: Map<string, CDX.Models.Component>, collectEvidence: boolean, logger?: WebpackLogger): Iterable<CDX.Models.Component> {
5345
const pkgs: Record<string, CDX.Models.Component | undefined> = {}
5446
const components = new Map<Module, CDX.Models.Component>()
5547

@@ -68,7 +60,7 @@ export class Extractor {
6860
if (component === undefined) {
6961
logger?.log('try to build new Component from PkgPath:', pkg.path)
7062
try {
71-
component = this.makeComponent(pkg, collectEvidence, logger)
63+
component = this.#makeComponent(pkg, componentSubstitutionMap, collectEvidence, logger)
7264
} catch (err) {
7365
logger?.debug('unexpected error:', err)
7466
logger?.warn('skipped Component from PkgPath', pkg.path)
@@ -90,39 +82,21 @@ export class Extractor {
9082
/**
9183
* @throws {@link Error} when no component could be fetched
9284
*/
93-
makeComponent (pkg: PackageDescription, collectEvidence: boolean, logger?: WebpackLogger): CDX.Models.Component {
94-
try {
95-
// work with a deep copy, because `normalizePackageManifest()` might modify the data
96-
/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ach */
97-
const _packageJson = structuredClonePolyfill(pkg.packageJson)
98-
normalizePackageManifest(_packageJson)
99-
pkg.packageJson = _packageJson
100-
} catch (e) {
101-
logger?.warn('normalizePackageJson from PkgPath', pkg.path, 'failed:', e)
102-
}
103-
104-
const component = this.#componentBuilder.makeComponent(
105-
/* @ts-expect-error TS2559 */
106-
pkg.packageJson as PackageDescription) /* eslint-disable-line @typescript-eslint/no-unsafe-type-assertion -- ack */
107-
108-
if (component === undefined) {
109-
throw new Error(`failed building Component from PkgPath ${pkg.path}`)
85+
#makeComponent(pkg: PackageDescription, componentSubstitutionMap: Map<string, CDX.Models.Component>, collectEvidence: boolean, logger?: WebpackLogger): CDX.Models.Component
86+
{
87+
const newComponent = this.#componentBuilder.makeComponent(pkg, collectEvidence, logger)
88+
if(newComponent === undefined) {
89+
throw Error(`failed building Component from PkgPath ${pkg.path}`)
11090
}
11191

112-
component.licenses.forEach(l => {
113-
l.acknowledgement = CDX.Enums.LicenseAcknowledgement.Declared
114-
})
115-
116-
if (collectEvidence) {
117-
component.evidence = new CDX.Models.ComponentEvidence({
118-
licenses: new CDX.Models.LicenseRepository(this.getLicenseEvidence(dirname(pkg.path), logger))
119-
})
92+
if(newComponent.bomRef.value !== undefined) {
93+
const remappedComponent = componentSubstitutionMap.get(newComponent.bomRef.value)
94+
if(remappedComponent !== undefined) {
95+
return remappedComponent
96+
}
12097
}
12198

122-
component.purl = this.#purlFactory.makeFromComponent(component)
123-
component.bomRef.value = component.purl?.toString()
124-
125-
return component
99+
return newComponent
126100
}
127101

128102
#linkDependencies (modulesComponents: Map<Module, CDX.Models.Component>): void {
@@ -135,25 +109,4 @@ export class Extractor {
135109
}
136110
}
137111
}
138-
139-
public * getLicenseEvidence (packageDir: string, logger?: WebpackLogger): Generator<CDX.Models.License> {
140-
const files = this.#leGatherer.getFileAttachments(
141-
packageDir,
142-
(error: Error): void => {
143-
/* c8 ignore next 2 */
144-
logger?.info(error.message)
145-
logger?.debug(error.message, error)
146-
}
147-
)
148-
try {
149-
for (const {file, text} of files) {
150-
yield new CDX.Models.NamedLicense(`file: ${file}`, { text })
151-
}
152-
}
153-
/* c8 ignore next 3 */
154-
catch (e) {
155-
// generator will not throw before first `.nest()` is called ...
156-
logger?.warn('collecting license evidence in', packageDir, 'failed:', e)
157-
}
158-
}
159112
}

src/plugin.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type PackageDescription
3232
} from './_helpers'
3333
import { Extractor } from './extractor'
34+
import { RichComponentBuilder } from './richComponentBuilder'
3435

3536
type WebpackLogger = Compilation['logger']
3637

@@ -211,10 +212,12 @@ export class CycloneDxWebpackPlugin {
211212
const cdxLicenseFactory = new CDX.Factories.LicenseFactory()
212213
const cdxPurlFactory = new CDX.Factories.FromNodePackageJson.PackageUrlFactory('npm')
213214
const cdxComponentBuilder = new CDX.Builders.FromNodePackageJson.ComponentBuilder(cdxExternalReferenceFactory, cdxLicenseFactory)
215+
const richComponentBuilder = new RichComponentBuilder(cdxComponentBuilder, cdxPurlFactory, new CDX.Utils.LicenseUtility.LicenseEvidenceGatherer())
214216

215217
const bom = new CDX.Models.Bom()
216218
bom.metadata.lifecycles.add(CDX.Enums.LifecyclePhase.Build)
217-
bom.metadata.component = this.#makeRootComponent(compilation.compiler.context, cdxComponentBuilder, logger.getChildLogger('RootComponentBuilder'))
219+
const rootComponents = this.#makeRootComponents(compilation.compiler.context, richComponentBuilder, logger.getChildLogger('RootComponentBuilder'))
220+
bom.metadata.component = rootComponents?.rootComponent
218221

219222
const serializeOptions: CDX.Serialize.Types.SerializerOptions & CDX.Serialize.Types.NormalizerOptions = {
220223
sortLists: this.reproducibleResults,
@@ -255,23 +258,22 @@ export class CycloneDxWebpackPlugin {
255258
}
256259
}
257260

261+
const componentSubstitutionMap = new Map<string, CDX.Models.Component>()
262+
if(rootComponents !== undefined && rootComponents.autoDetectedRootComponent !== rootComponents.rootComponent && rootComponents.autoDetectedRootComponent.bomRef.value !== undefined) {
263+
componentSubstitutionMap.set(rootComponents.autoDetectedRootComponent.bomRef.value, rootComponents.rootComponent)
264+
}
258265
compilation.hooks.afterOptimizeTree.tap(
259266
pluginName,
260267
(_, modules) => {
261268
const thisLogger = logger.getChildLogger('ComponentFetcher')
262269
const extractor = new Extractor(
263270
compilation,
264-
cdxComponentBuilder,
265-
cdxPurlFactory,
266-
new CDX.Utils.LicenseUtility.LicenseEvidenceGatherer()
271+
richComponentBuilder
267272
)
268273

269274
thisLogger.log('generating components...')
270-
for (const component of extractor.generateComponents(modules, this.collectEvidence, thisLogger.getChildLogger('Extractor'))) {
271-
if (bom.metadata.component !== undefined &&
272-
bom.metadata.component.group === component.group &&
273-
bom.metadata.component.name === component.name &&
274-
bom.metadata.component.version === component.version
275+
for (const component of extractor.generateComponents(modules, componentSubstitutionMap, this.collectEvidence, thisLogger.getChildLogger('Extractor'))) {
276+
if (bom.metadata.component !== undefined && component.bomRef.value !== undefined && bom.metadata.component.bomRef.value === component.bomRef.value
275277
) {
276278
// metadata matches this exact component.
277279
// -> so the component is actually treated as the root component.
@@ -376,23 +378,30 @@ export class CycloneDxWebpackPlugin {
376378
}
377379
}
378380

379-
#makeRootComponent (
381+
#makeRootComponents (
380382
path: string,
381-
builder: CDX.Builders.FromNodePackageJson.ComponentBuilder,
383+
builder: RichComponentBuilder,
382384
logger: WebpackLogger
383-
): CDX.Models.Component | undefined {
384-
/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expected */
385+
): {rootComponent: CDX.Models.Component, autoDetectedRootComponent: CDX.Models.Component} | undefined {
386+
const autoDetectedComponent = getPackageDescription(path)
385387
const thisPackageJson = this.rootComponentAutodetect
386-
? getPackageDescription(path)?.packageJson
387-
: { name: this.rootComponentName, version: this.rootComponentVersion }
388+
? autoDetectedComponent
389+
: { path, packageJson: { name: this.rootComponentName, version: this.rootComponentVersion } }
388390
if (thisPackageJson === undefined) { return undefined }
389391
normalizePackageManifest(
390-
391392
thisPackageJson,
392393
w => { logger.debug('normalizePackageJson from PkgPath', path, 'caused:', w) }
393394
)
394-
395-
return builder.makeComponent(thisPackageJson)
395+
const rootComponent = builder.makeComponent(thisPackageJson, false, logger)
396+
const autoDetectedRootComponent =
397+
autoDetectedComponent === thisPackageJson ?
398+
rootComponent :
399+
autoDetectedComponent !== undefined ?
400+
builder.makeComponent(autoDetectedComponent, false, logger) :
401+
undefined
402+
403+
if(rootComponent === undefined || autoDetectedRootComponent === undefined) { return undefined }
404+
return { rootComponent, autoDetectedRootComponent }
396405
}
397406

398407
#finalizeBom (

src/richComponentBuilder.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*!
2+
This file is part of CycloneDX Webpack plugin.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
SPDX-License-Identifier: Apache-2.0
17+
Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
20+
import { dirname } from 'node:path'
21+
22+
import * as CDX from '@cyclonedx/cyclonedx-library'
23+
import type { Compilation } from 'webpack'
24+
25+
import type { PackageDescription} from './_helpers'
26+
import { normalizePackageManifest, structuredClonePolyfill } from './_helpers'
27+
28+
29+
type WebpackLogger = Compilation['logger']
30+
31+
export class RichComponentBuilder
32+
{
33+
readonly #componentBuilder : CDX.Builders.FromNodePackageJson.ComponentBuilder
34+
readonly #purlFactory : CDX.Factories.FromNodePackageJson.PackageUrlFactory
35+
readonly #leGatherer: CDX.Utils.LicenseUtility.LicenseEvidenceGatherer
36+
37+
constructor(
38+
componentBuilder: CDX.Builders.FromNodePackageJson.ComponentBuilder,
39+
purlFactory: CDX.Factories.FromNodePackageJson.PackageUrlFactory,
40+
leFetcher: CDX.Utils.LicenseUtility.LicenseEvidenceGatherer
41+
)
42+
{
43+
this.#componentBuilder = componentBuilder
44+
this.#purlFactory = purlFactory
45+
this.#leGatherer = leFetcher
46+
}
47+
48+
makeComponent (pkg: PackageDescription, collectEvidence: boolean, logger?: WebpackLogger): CDX.Models.Component | undefined {
49+
try {
50+
// work with a deep copy, because `normalizePackageManifest()` might modify the data
51+
/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ach */
52+
const _packageJson = structuredClonePolyfill(pkg.packageJson)
53+
normalizePackageManifest(_packageJson)
54+
pkg.packageJson = _packageJson
55+
/* c8 ignore next 3 */
56+
} catch (e) {
57+
logger?.warn('normalizePackageJson from PkgPath', pkg.path, 'failed:', e)
58+
}
59+
60+
const component = this.#componentBuilder.makeComponent(
61+
/* @ts-expect-error TS2559 */
62+
pkg.packageJson as PackageDescription) /* eslint-disable-line @typescript-eslint/no-unsafe-type-assertion -- ack */
63+
64+
if (component === undefined) {
65+
return undefined
66+
}
67+
68+
component.licenses.forEach(l => {
69+
l.acknowledgement = CDX.Enums.LicenseAcknowledgement.Declared
70+
})
71+
72+
if (collectEvidence) {
73+
component.evidence = new CDX.Models.ComponentEvidence({
74+
licenses: new CDX.Models.LicenseRepository(this.getLicenseEvidence(dirname(pkg.path), logger))
75+
})
76+
}
77+
78+
component.purl = this.#purlFactory.makeFromComponent(component)
79+
component.bomRef.value = component.purl?.toString()
80+
81+
return component
82+
}
83+
84+
85+
private * getLicenseEvidence (packageDir: string, logger?: WebpackLogger): Generator<CDX.Models.License> {
86+
const files = this.#leGatherer.getFileAttachments(
87+
packageDir,
88+
(error: Error): void => {
89+
/* c8 ignore next 2 */
90+
logger?.info(error.message)
91+
logger?.debug(error.message, error)
92+
}
93+
)
94+
try {
95+
for (const {file, text} of files) {
96+
yield new CDX.Models.NamedLicense(`file: ${file}`, { text })
97+
}
98+
}
99+
/* c8 ignore next 3 */
100+
catch (e) {
101+
// generator will not throw before first `.nest()` is called ...
102+
logger?.warn('collecting license evidence in', packageDir, 'failed:', e)
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)