Skip to content

Commit b94e000

Browse files
soul2zimateclaude
andcommitted
fix: exclude devDep transitive trees from yarn berry SBOM and use exact purl matching
Address two review comments: 1. Yarn berry addDependenciesToSbom() now builds a reachable set via BFS from root production deps, so devDependency nodes and their entire transitive trees are excluded from the SBOM — not just the root edges. 2. Replace substring-based checkIfPackageInsideDependsOnList() with purl-prefix matching in ensurePeerAndOptionalDeps() to avoid false positives (e.g. "minimist" matching "minimist-options") and false negatives (e.g. "@hapi/joi" not matching URL-encoded "%40hapi/joi"). Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
1 parent decf73e commit b94e000

4 files changed

Lines changed: 103 additions & 12 deletions

File tree

src/cyclone_dx_sbom.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,21 @@ export default class CycloneDxSbom {
282282
}
283283
}
284284

285+
/**
286+
* Checks if any entry in the dependsOn list of sourceRef starts with the given purl prefix.
287+
* @param {PackageURL} sourceRef - The source component
288+
* @param {string} purlPrefix - The purl prefix to match (e.g. "pkg:npm/minimist@")
289+
* @return {boolean}
290+
*/
291+
checkDependsOnByPurlPrefix(sourceRef, purlPrefix) {
292+
const sourcePurl = sourceRef.toString();
293+
const depIndex = this.getDependencyIndex(sourcePurl);
294+
if (depIndex < 0) {
295+
return false;
296+
}
297+
return this.dependencies[depIndex].dependsOn.some(dep => dep.startsWith(purlPrefix));
298+
}
299+
285300
/** Removes the root component from the sbom
286301
*/
287302
removeRootComponent() {

src/providers/base_javascript.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,13 +278,15 @@ export default class Base_javascript {
278278
*/
279279
#ensurePeerAndOptionalDeps(sbom) {
280280
const rootPurl = toPurl(purlType, this.#manifest.name, this.#manifest.version);
281-
const rootComponent = sbom.getRoot();
282281
const depSources = [this.#manifest.peerDependencies, this.#manifest.optionalDependencies];
283282
for (const source of depSources) {
284283
for (const [name, version] of Object.entries(source)) {
285-
if (!sbom.checkIfPackageInsideDependsOnList(rootComponent, name)) {
286-
const target = toPurl(purlType, name, version);
287-
sbom.addDependency(rootPurl, target);
284+
// Build the purl prefix for exact matching (e.g. "pkg:npm/minimist@"
285+
// or "pkg:npm/%40hapi/joi@") to avoid substring false positives
286+
const probe = toPurl(purlType, name, version);
287+
const purlPrefix = probe.toString().replace(/@[^@]*$/, '@');
288+
if (!sbom.checkDependsOnByPurlPrefix(rootPurl, purlPrefix)) {
289+
sbom.addDependency(rootPurl, probe);
288290
}
289291
}
290292
}

src/providers/processors/yarn_berry_processor.js

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,31 +93,95 @@ export default class Yarn_berry_processor extends Yarn_processor {
9393
return;
9494
}
9595

96-
// Collect names of production dependencies to filter out devDeps from root
96+
// Build index of nodes by their value for quick lookup
97+
const nodeIndex = new Map();
98+
depTree.forEach(n => nodeIndex.set(n.value, n));
99+
100+
// Determine the set of node values reachable from root via production deps
97101
const prodDeps = new Set(this._manifest.dependencies);
102+
const reachable = new Set();
103+
const queue = [];
104+
105+
// Seed with root's production dependencies
106+
const rootNode = depTree.find(n => this.#isRoot(n.value));
107+
if (rootNode?.children?.Dependencies) {
108+
for (const d of rootNode.children.Dependencies) {
109+
const to = this.#purlFromLocator(d.locator);
110+
if (to) {
111+
const fullName = to.namespace ? `${to.namespace}/${to.name}` : to.name;
112+
if (prodDeps.has(fullName)) {
113+
queue.push(d.locator);
114+
}
115+
}
116+
}
117+
}
98118

119+
// BFS to find all transitively reachable packages
120+
while (queue.length > 0) {
121+
const locator = queue.shift();
122+
if (reachable.has(locator)) {continue;}
123+
reachable.add(locator);
124+
125+
const node = nodeIndex.get(this.#nodeValueFromLocator(locator));
126+
if (node?.children?.Dependencies) {
127+
for (const d of node.children.Dependencies) {
128+
if (!reachable.has(d.locator)) {
129+
queue.push(d.locator);
130+
}
131+
}
132+
}
133+
}
134+
135+
// Only emit edges for root and reachable nodes
99136
depTree.forEach(n => {
100137
const depName = n.value;
101138
const isRoot = this.#isRoot(depName);
139+
if (!isRoot && !this.#isReachableNode(depName, reachable)) {return;}
140+
102141
const from = isRoot ? toPurlFromString(sbom.getRoot().purl) : this.#purlFromNode(depName, n);
103142
const deps = n.children?.Dependencies;
104143
if(!deps) {return;}
105144
deps.forEach(d => {
145+
if (!reachable.has(d.locator)) {return;}
106146
const to = this.#purlFromLocator(d.locator);
107147
if(to) {
108-
// For root node, only add production dependencies (exclude devDeps)
109-
if (isRoot) {
110-
const fullName = to.namespace ? `${to.namespace}/${to.name}` : to.name;
111-
if (!prodDeps.has(fullName)) {
112-
return;
113-
}
114-
}
115148
sbom.addDependency(from, to);
116149
}
117150
});
118151
})
119152
}
120153

154+
/**
155+
* Converts a locator to the node value format used in yarn info output
156+
* @param {string} locator - e.g. "express@npm:4.17.1"
157+
* @returns {string} The node value, same as locator for non-virtual
158+
* @private
159+
*/
160+
#nodeValueFromLocator(locator) {
161+
// Virtual locators: "@scope/name@virtual:hash#npm:version" → "@scope/name@npm:version"
162+
const virtualMatch = Yarn_berry_processor.VIRTUAL_LOCATOR_PATTERN.exec(locator);
163+
if (virtualMatch) {
164+
return `${virtualMatch[1]}@npm:${virtualMatch[2]}`;
165+
}
166+
return locator;
167+
}
168+
169+
/**
170+
* Checks if a node is in the reachable set by matching its value against reachable locators
171+
* @param {string} depName - The node value (e.g. "express@npm:4.17.1")
172+
* @param {Set<string>} reachable - Set of reachable locators
173+
* @returns {boolean}
174+
* @private
175+
*/
176+
#isReachableNode(depName, reachable) {
177+
if (reachable.has(depName)) {return true;}
178+
// Check if any reachable locator resolves to this node value
179+
for (const locator of reachable) {
180+
if (this.#nodeValueFromLocator(locator) === depName) {return true;}
181+
}
182+
return false;
183+
}
184+
121185
/**
122186
* Creates a PackageURL from a dependency locator
123187
* @param {string} locator - The dependency locator

src/sbom.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ export default class Sbom {
8686
return this.sbomModel.checkIfPackageInsideDependsOnList(component, name)
8787
}
8888

89+
/**
90+
* Checks if any entry in the dependsOn list of sourceRef starts with the given purl prefix.
91+
* @param {PackageURL} sourceRef - The source component to check
92+
* @param {string} purlPrefix - The purl prefix to match (e.g. "pkg:npm/minimist@")
93+
* @return {boolean}
94+
*/
95+
checkDependsOnByPurlPrefix(sourceRef, purlPrefix) {
96+
return this.sbomModel.checkDependsOnByPurlPrefix(sourceRef, purlPrefix)
97+
}
98+
8999
/** Removes the root component from the sbom
90100
*/
91101
removeRootComponent() {

0 commit comments

Comments
 (0)