Skip to content

Commit fd3bc30

Browse files
soul2zimateclaude
andcommitted
fix: handle peerDependencies and optionalDependencies consistently across JS providers
JavaScript providers (npm, pnpm, yarn classic, yarn berry) produced inconsistent dependency analysis results when package.json contains peerDependencies, optionalDependencies, and devDependencies. - pnpm: lodash (optionalDependency) was missing because pnpm outputs optional deps under a separate "optionalDependencies" key, but addDependenciesToSbom and getRootDependencies only read the "dependencies" key. - Yarn Berry: devDependencies were included in the scan because yarn info has no --prod flag and the processor did not filter at root level. - Yarn Classic: lodash was missing because Manifest.loadDependencies only read the "dependencies" key, causing the filter in addDependenciesToSbom to exclude it. - All yarn providers: minimist (peerDependency) was missing because yarn does not include peer deps in its output, and no backfill mechanism existed. Fixes applied (matching trustify-da-javascript-client approach): - Manifest: loadDependencies now includes optionalDependencies and peerDependencies names. Added peerDependencies and optionalDependencies as Map<String, String> fields. - JavaScriptProvider: addDependenciesToSbom and getRootDependencies now also read the "optionalDependencies" key from dep tree output. Added ensurePeerAndOptionalDeps to backfill missing peer/optional deps using declared versions from package.json. - YarnBerryProcessor: addDependenciesToSbom now filters root node dependencies to only include production deps (those in manifest.dependencies). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7d88afb commit fd3bc30

File tree

26 files changed

+829
-9
lines changed

26 files changed

+829
-9
lines changed

src/main/java/io/github/guacsec/trustifyda/providers/JavaScriptProvider.java

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,18 @@ private Sbom getDependencySbom() throws IOException {
159159
var sbom = SbomFactory.newInstance();
160160
sbom.addRoot(manifest.root, readLicenseFromManifest());
161161
addDependenciesToSbom(sbom, depTree);
162+
ensurePeerAndOptionalDeps(sbom);
162163
sbom.filterIgnoredDeps(manifest.ignored);
163164
return sbom;
164165
}
165166

166167
protected void addDependenciesToSbom(Sbom sbom, JsonNode depTree) {
167-
var deps = depTree.get("dependencies");
168+
addDependenciesFromKey(sbom, depTree, "dependencies");
169+
addDependenciesFromKey(sbom, depTree, "optionalDependencies");
170+
}
171+
172+
private void addDependenciesFromKey(Sbom sbom, JsonNode depTree, String key) {
173+
var deps = depTree.get(key);
168174
if (deps == null) {
169175
return;
170176
}
@@ -187,6 +193,7 @@ private Sbom getDirectDependencySbom() throws IOException {
187193
.filter(e -> manifest.dependencies.contains(e.getKey()))
188194
.map(Entry::getValue)
189195
.forEach(p -> sbom.addDependency(manifest.root, p, null));
196+
ensurePeerAndOptionalDeps(sbom);
190197
sbom.filterIgnoredDeps(manifest.ignored);
191198
return sbom;
192199
}
@@ -195,9 +202,18 @@ private Sbom getDirectDependencySbom() throws IOException {
195202
// axios -> pkg:npm/axios@0.19.2
196203
protected Map<String, PackageURL> getRootDependencies(JsonNode depTree) {
197204
Map<String, PackageURL> direct = new TreeMap<>();
198-
depTree
199-
.get("dependencies")
200-
.fields()
205+
addRootDependenciesFromKey(direct, depTree, "dependencies");
206+
addRootDependenciesFromKey(direct, depTree, "optionalDependencies");
207+
return direct;
208+
}
209+
210+
private void addRootDependenciesFromKey(
211+
Map<String, PackageURL> direct, JsonNode depTree, String key) {
212+
var node = depTree.get(key);
213+
if (node == null) {
214+
return;
215+
}
216+
node.fields()
201217
.forEachRemaining(
202218
e -> {
203219
String name = e.getKey();
@@ -208,7 +224,21 @@ protected Map<String, PackageURL> getRootDependencies(JsonNode depTree) {
208224
direct.put(name, purl);
209225
}
210226
});
211-
return direct;
227+
}
228+
229+
private void ensurePeerAndOptionalDeps(Sbom sbom) {
230+
var rootComponent = sbom.getRoot();
231+
var depSources = new Map[] {manifest.peerDependencies, manifest.optionalDependencies};
232+
for (var source : depSources) {
233+
@SuppressWarnings("unchecked")
234+
Map<String, String> deps = source;
235+
deps.forEach(
236+
(name, version) -> {
237+
if (!sbom.checkIfPackageInsideDependsOnList(rootComponent, name)) {
238+
sbom.addDependency(manifest.root, toPurl(name, version), null);
239+
}
240+
});
241+
}
212242
}
213243

214244
protected JsonNode buildDependencyTree(boolean includeTransitive) throws JsonProcessingException {

src/main/java/io/github/guacsec/trustifyda/providers/YarnBerryProcessor.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import io.github.guacsec.trustifyda.tools.Operations;
2525
import java.nio.file.Path;
2626
import java.util.Map;
27+
import java.util.Set;
2728
import java.util.TreeMap;
2829
import java.util.regex.Pattern;
2930

@@ -108,23 +109,38 @@ void addDependenciesToSbom(Sbom sbom, JsonNode depTree) {
108109
if (depTree == null) {
109110
return;
110111
}
112+
Set<String> prodDeps = manifest.dependencies;
111113
depTree.forEach(
112114
n -> {
113115
var depName = n.get("value").asText();
114-
var from = isRoot(depName) ? sbom.getRoot() : purlFromNode(depName, n);
116+
var isRootNode = isRoot(depName);
117+
var from = isRootNode ? sbom.getRoot() : purlFromNode(depName, n);
115118
var deps = (ArrayNode) n.get("children").get("Dependencies");
116119
if (deps != null && !deps.isEmpty()) {
117120
deps.forEach(
118121
d -> {
119122
var target = purlFromlocator(d.get("locator").asText());
120123
if (target != null) {
124+
// For root node, only include production dependencies
125+
if (isRootNode) {
126+
var fullName = purlToFullName(target);
127+
if (!prodDeps.contains(fullName)) {
128+
return;
129+
}
130+
}
121131
sbom.addDependency(from, target, null);
122132
}
123133
});
124134
}
125135
});
126136
}
127137

138+
private static String purlToFullName(PackageURL purl) {
139+
return purl.getNamespace() != null
140+
? purl.getNamespace() + "/" + purl.getName()
141+
: purl.getName();
142+
}
143+
128144
private PackageURL purlFromlocator(String locator) {
129145
if (locator == null) return null;
130146
var matcher = LOCATOR_PATTERN.matcher(locator);

src/main/java/io/github/guacsec/trustifyda/providers/javascript/model/Manifest.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
import java.nio.file.Files;
2626
import java.nio.file.Path;
2727
import java.util.Collections;
28+
import java.util.HashMap;
2829
import java.util.HashSet;
30+
import java.util.Map;
2931
import java.util.Set;
3032

3133
public class Manifest {
@@ -35,6 +37,8 @@ public class Manifest {
3537
public final String license;
3638
public final PackageURL root;
3739
public final Set<String> dependencies;
40+
public final Map<String, String> peerDependencies;
41+
public final Map<String, String> optionalDependencies;
3842
public final Set<String> ignored;
3943
public final Path path;
4044

@@ -45,6 +49,8 @@ public Manifest(Path manifestPath) throws IOException {
4549
}
4650
var content = loadManifest(manifestPath);
4751
this.dependencies = loadDependencies(content);
52+
this.peerDependencies = loadDependencyMap(content, "peerDependencies");
53+
this.optionalDependencies = loadDependencyMap(content, "optionalDependencies");
4854
this.name = content.get("name").asText();
4955
this.version = content.get("version").asText();
5056
this.license = loadLicense(content);
@@ -63,12 +69,29 @@ private JsonNode loadManifest(Path manifestPath) throws IOException {
6369

6470
private Set<String> loadDependencies(JsonNode content) {
6571
var names = new HashSet<String>();
66-
if (content != null && content.has("dependencies")) {
67-
content.get("dependencies").fieldNames().forEachRemaining(names::add);
72+
if (content != null) {
73+
addFieldNames(names, content, "dependencies");
74+
addFieldNames(names, content, "optionalDependencies");
75+
addFieldNames(names, content, "peerDependencies");
6876
}
6977
return Collections.unmodifiableSet(names);
7078
}
7179

80+
private void addFieldNames(Set<String> names, JsonNode content, String field) {
81+
if (content.has(field)) {
82+
content.get(field).fieldNames().forEachRemaining(names::add);
83+
}
84+
}
85+
86+
private Map<String, String> loadDependencyMap(JsonNode content, String field) {
87+
if (content == null || !content.has(field)) {
88+
return Collections.emptyMap();
89+
}
90+
var map = new HashMap<String, String>();
91+
content.get(field).fields().forEachRemaining(e -> map.put(e.getKey(), e.getValue().asText()));
92+
return Collections.unmodifiableMap(map);
93+
}
94+
7295
private String loadLicense(JsonNode content) {
7396
if (content == null) {
7497
return null;

src/test/java/io/github/guacsec/trustifyda/providers/Javascript_Provider_Test.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class Javascript_Provider_Test extends ExhortTest {
4545
// - package.json: the target manifest for testing
4646
// - expected_sbom.json: the SBOM expected to be provided
4747
static Stream<String> testFolders() {
48-
return Stream.of("deps_with_ignore", "deps_with_no_ignore");
48+
return Stream.of("deps_with_ignore", "deps_with_no_ignore", "deps_with_mixed_dep_types");
4949
}
5050

5151
static Stream<String> providers() {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2023-2025 Trustify Dependency Analytics Authors
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+
*
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package io.github.guacsec.trustifyda.providers;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import io.github.guacsec.trustifyda.ExhortTest;
22+
import io.github.guacsec.trustifyda.providers.javascript.model.Manifest;
23+
import java.io.IOException;
24+
import java.util.Map;
25+
import org.junit.jupiter.api.Test;
26+
27+
class ManifestTest extends ExhortTest {
28+
29+
@Test
30+
void loads_manifest_with_mixed_dependency_types() throws IOException {
31+
var manifestPath = resolveFile("tst_manifests/npm/deps_with_mixed_dep_types/package.json");
32+
var m = new Manifest(manifestPath);
33+
34+
assertThat(m.name).isEqualTo("mixed-deps-test");
35+
assertThat(m.version).isEqualTo("1.0.0");
36+
37+
// dependencies should include deps, peerDeps, and optionalDeps but NOT devDeps
38+
assertThat(m.dependencies).containsExactlyInAnyOrder("express", "axios", "minimist", "lodash");
39+
assertThat(m.dependencies).doesNotContain("jest", "eslint");
40+
41+
// peerDependencies and optionalDependencies maps
42+
assertThat(m.peerDependencies).isEqualTo(Map.of("minimist", "1.2.0"));
43+
assertThat(m.optionalDependencies).isEqualTo(Map.of("lodash", "4.17.19"));
44+
45+
assertThat(m.ignored).isEmpty();
46+
}
47+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"bomFormat" : "CycloneDX",
3+
"specVersion" : "1.4",
4+
"version" : 1,
5+
"metadata" : {
6+
"timestamp" : "2026-04-03T03:12:24Z",
7+
"component" : {
8+
"type" : "application",
9+
"bom-ref" : "pkg:npm/mixed-deps-test@1.0.0",
10+
"name" : "mixed-deps-test",
11+
"version" : "1.0.0",
12+
"purl" : "pkg:npm/mixed-deps-test@1.0.0"
13+
}
14+
},
15+
"components" : [
16+
{
17+
"type" : "library",
18+
"bom-ref" : "pkg:npm/axios@0.19.0",
19+
"name" : "axios",
20+
"version" : "0.19.0",
21+
"purl" : "pkg:npm/axios@0.19.0"
22+
},
23+
{
24+
"type" : "library",
25+
"bom-ref" : "pkg:npm/express@4.17.1",
26+
"name" : "express",
27+
"version" : "4.17.1",
28+
"purl" : "pkg:npm/express@4.17.1"
29+
},
30+
{
31+
"type" : "library",
32+
"bom-ref" : "pkg:npm/lodash@4.17.19",
33+
"name" : "lodash",
34+
"version" : "4.17.19",
35+
"purl" : "pkg:npm/lodash@4.17.19"
36+
},
37+
{
38+
"type" : "library",
39+
"bom-ref" : "pkg:npm/minimist@1.2.0",
40+
"name" : "minimist",
41+
"version" : "1.2.0",
42+
"purl" : "pkg:npm/minimist@1.2.0"
43+
}
44+
],
45+
"dependencies" : [
46+
{
47+
"ref" : "pkg:npm/mixed-deps-test@1.0.0",
48+
"dependsOn" : [
49+
"pkg:npm/axios@0.19.0",
50+
"pkg:npm/express@4.17.1",
51+
"pkg:npm/lodash@4.17.19",
52+
"pkg:npm/minimist@1.2.0"
53+
]
54+
},
55+
{
56+
"ref" : "pkg:npm/axios@0.19.0",
57+
"dependsOn" : [ ]
58+
},
59+
{
60+
"ref" : "pkg:npm/express@4.17.1",
61+
"dependsOn" : [ ]
62+
},
63+
{
64+
"ref" : "pkg:npm/lodash@4.17.19",
65+
"dependsOn" : [ ]
66+
},
67+
{
68+
"ref" : "pkg:npm/minimist@1.2.0",
69+
"dependsOn" : [ ]
70+
}
71+
]
72+
}

0 commit comments

Comments
 (0)