Skip to content

Commit e8921c8

Browse files
ruromeroclaude
andauthored
fix(go): distinguish direct from indirect dependencies (guacsec#449)
## Summary - Fix Go provider to correctly distinguish direct from indirect dependencies using `go mod edit -json` parsing, matching the JS client behavior - Since Go 1.17, `go mod tidy` records all transitively-imported modules in `go.mod` as `require` entries with `// indirect`, and `go mod graph` emits root-level edges for all of them — the Java client was treating all root-level edges as direct dependencies - Match JS client empty SBOM structure: ensure `"components": []` is always present and `"dependencies": []` is empty when all components are filtered out ## Changes **`GoModulesProvider.java`** - Add `getDirectDependencyPaths()` — runs `go mod edit -json`, parses the `Require` array, returns the set of non-indirect module paths - Filter root-level edges in both `buildSbomFromGraph()` and `buildSbomFromList()` to only include direct dependencies **`CycloneDXSbom.java`** - Clear dependencies list when all components are filtered out (matches JS client) - Post-process JSON to ensure `"components"` key is always present (CycloneDX library omits empty collections) ## Test plan - [x] Updated expected SBOM fixtures for `go_mod_light_no_ignore`, `go_mod_no_ignore`, `go_mod_with_ignore`, `go_mod_with_all_ignore`, and `msc/golang/mvs_logic` - [x] Verified fixture parity with JS client test expectations - [ ] Unit tests (blocked by pre-existing `NoClassDefFoundError` in `cyclonedx-core-java` 12.1.0 JPMS module setup — exists on `main`) Closes [TC-4300](https://redhat.atlassian.net/browse/TC-4300) Related to [TC-3818](https://redhat.atlassian.net/browse/TC-3818) 🤖 Generated with [Claude Code](https://claude.com/claude-code) [TC-4300]: https://redhat.atlassian.net/browse/TC-4300?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ ## Summary by Sourcery Correct Go module SBOM generation to only include direct dependencies and align CycloneDX JSON output structure with other clients, while updating automation config and documentation. Bug Fixes: - Ensure Go SBOMs treat only non-indirect Go modules as direct dependencies when building both list and graph-based dependency views. - Make generated CycloneDX JSON SBOMs always include an empty components array and clear dependencies when all components are filtered out, matching expected consumer behavior. Enhancements: - Add parsing of `go mod edit -json` output to derive direct dependency module paths for Go projects. - Introduce helper utilities to normalize Go module graph entries by stripping version suffixes before filtering. Build: - Reconfigure Dependabot ecosystems and explicitly scope Maven test fixture directories to prevent unintended dependency updates while keeping production Maven dependencies updatable. Documentation: - Add a CONVENTIONS.md documenting project-specific coding standards, structure, testing practices, and dependency management guidelines. Tests: - Update Go SBOM JSON fixtures to reflect the new handling of direct dependencies and empty component sets in generated SBOMs. --------- Signed-off-by: Ruben Romero Montes <rromerom@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2786440 commit e8921c8

18 files changed

Lines changed: 1119 additions & 2120 deletions

File tree

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

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
import static io.github.guacsec.trustifyda.impl.ExhortApi.debugLoggingIsNeeded;
2020

21+
import com.fasterxml.jackson.core.JsonProcessingException;
22+
import com.fasterxml.jackson.databind.JsonNode;
23+
import com.fasterxml.jackson.databind.ObjectMapper;
2124
import com.github.packageurl.MalformedPackageURLException;
2225
import com.github.packageurl.PackageURL;
2326
import io.github.guacsec.trustifyda.Api;
@@ -40,9 +43,11 @@
4043
import java.util.ArrayList;
4144
import java.util.Arrays;
4245
import java.util.HashMap;
46+
import java.util.HashSet;
4347
import java.util.List;
4448
import java.util.Map;
4549
import java.util.Objects;
50+
import java.util.Set;
4651
import java.util.logging.Logger;
4752
import java.util.regex.Pattern;
4853
import java.util.stream.Collectors;
@@ -136,16 +141,17 @@ Sbom getDependenciesSbom(Path manifestPath, boolean buildTree) throws IOExceptio
136141
determineMainModuleVersion(manifestPath.getParent());
137142
Sbom sbom;
138143
List<PackageURL> ignoredDeps = getIgnoredDeps(manifestPath);
144+
Set<String> directDepPaths = getDirectDependencyPaths(manifestPath);
139145
boolean matchManifestVersions =
140146
Environment.getBoolean(Provider.PROP_MATCH_MANIFEST_VERSIONS, false);
141147
if (matchManifestVersions) {
142148
String[] goModGraphLines = goModulesResult.split(Operations.GENERIC_LINE_SEPARATOR);
143149
performManifestVersionsCheck(goModGraphLines, manifestPath);
144150
}
145151
if (!buildTree) {
146-
sbom = buildSbomFromList(goModulesResult, ignoredDeps);
152+
sbom = buildSbomFromList(goModulesResult, ignoredDeps, directDepPaths);
147153
} else {
148-
sbom = buildSbomFromGraph(goModulesResult, ignoredDeps, manifestPath);
154+
sbom = buildSbomFromGraph(goModulesResult, ignoredDeps, manifestPath, directDepPaths);
149155
}
150156
return sbom;
151157
}
@@ -264,7 +270,10 @@ public void determineMainModuleVersion(Path directory) {
264270
}
265271

266272
private Sbom buildSbomFromGraph(
267-
String goModulesResult, List<PackageURL> ignoredDeps, Path manifestPath) {
273+
String goModulesResult,
274+
List<PackageURL> ignoredDeps,
275+
Path manifestPath,
276+
Set<String> directDepPaths) {
268277
// Each entry contains a key of the module, and the list represents the module direct
269278
// dependencies , so
270279
// pairing of the key with each of the dependencies in a list is basically an edge in the graph.
@@ -300,6 +309,17 @@ private Sbom buildSbomFromGraph(
300309
PackageURL source = toPurl(key, "@");
301310
value.stream()
302311
.filter(dep -> !isGoToolchainEntry(dep))
312+
.filter(
313+
dep -> {
314+
// When processing root-level edges, skip indirect dependencies.
315+
// In Go 1.17+, go mod graph emits edges for all modules in go.mod,
316+
// including those marked // indirect. Only keep edges whose child
317+
// module path is in the direct dependency set.
318+
if (getModulePath(key).equals(getModulePath(rootPackage))) {
319+
return directDepPaths.contains(getModulePath(dep));
320+
}
321+
return true;
322+
})
303323
.forEach(
304324
dep -> {
305325
PackageURL targetPurl = toPurl(dep, "@");
@@ -422,7 +442,59 @@ private String buildGoModulesDependencies(Path manifestPath) {
422442
return goModulesOutput;
423443
}
424444

425-
private Sbom buildSbomFromList(String golangDeps, List<PackageURL> ignoredDeps) {
445+
/**
446+
* Runs {@code go mod edit -json} and returns the set of module paths that are direct dependencies
447+
* (i.e., entries in the Require array where {@code Indirect} is false or absent). This
448+
* distinguishes direct from indirect dependencies in Go 1.17+ modules, where {@code go mod tidy}
449+
* records all transitively-imported modules in go.mod with an {@code // indirect} marker.
450+
*/
451+
private Set<String> getDirectDependencyPaths(Path manifestPath) {
452+
String[] goModEditCmd = new String[] {goExecutable, "mod", "edit", "-json"};
453+
String goModEditOutput = Operations.runProcessGetOutput(manifestPath.getParent(), goModEditCmd);
454+
if (debugLoggingIsNeeded()) {
455+
log.info(
456+
String.format(
457+
"Package Manager Go Mod Edit -json output : %s%s",
458+
System.lineSeparator(), goModEditOutput));
459+
}
460+
Set<String> directDepPaths = new HashSet<>();
461+
try {
462+
ObjectMapper mapper = new ObjectMapper();
463+
JsonNode root = mapper.readTree(goModEditOutput);
464+
JsonNode requireArray = root.get("Require");
465+
if (requireArray != null && requireArray.isArray()) {
466+
for (JsonNode req : requireArray) {
467+
JsonNode indirectNode = req.get("Indirect");
468+
if (indirectNode == null || !indirectNode.asBoolean()) {
469+
if (req.get("Path") != null) {
470+
directDepPaths.add(req.get("Path").asText());
471+
}
472+
}
473+
}
474+
}
475+
} catch (JsonProcessingException e) {
476+
throw new IllegalStateException("Failed to parse go mod edit -json output", e);
477+
}
478+
if (debugLoggingIsNeeded()) {
479+
log.info(String.format("Direct dependency paths from go mod edit -json: %s", directDepPaths));
480+
}
481+
return directDepPaths;
482+
}
483+
484+
/**
485+
* Extracts the module path from a go mod graph entry by stripping the version suffix. For
486+
* example, {@code "github.com/foo/bar@v1.2.3"} returns {@code "github.com/foo/bar"}.
487+
*/
488+
private static String getModulePath(String graphEntry) {
489+
int atIndex = graphEntry.indexOf("@");
490+
if (atIndex == -1) {
491+
return graphEntry;
492+
}
493+
return graphEntry.substring(0, atIndex);
494+
}
495+
496+
private Sbom buildSbomFromList(
497+
String golangDeps, List<PackageURL> ignoredDeps, Set<String> directDepPaths) {
426498
String[] allModulesFlat = golangDeps.split(Operations.GENERIC_LINE_SEPARATOR);
427499
String parentVertex = getParentVertex(allModulesFlat[0]);
428500
PackageURL root = toPurl(parentVertex, "@");
@@ -433,6 +505,7 @@ private Sbom buildSbomFromList(String golangDeps, List<PackageURL> ignoredDeps)
433505
sbom.addRoot(root, readLicenseFromManifest());
434506
deps.stream()
435507
.filter(dep -> !isGoToolchainEntry(dep))
508+
.filter(dep -> directDepPaths.contains(getModulePath(dep)))
436509
.forEach(
437510
dep -> {
438511
PackageURL targetPurl = toPurl(dep, "@");

src/main/java/io/github/guacsec/trustifyda/sbom/CycloneDXSbom.java

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
import static io.github.guacsec.trustifyda.impl.ExhortApi.debugLoggingIsNeeded;
2020

21+
import com.fasterxml.jackson.core.JsonProcessingException;
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import com.fasterxml.jackson.databind.node.ObjectNode;
2124
import com.github.packageurl.MalformedPackageURLException;
2225
import com.github.packageurl.PackageURL;
2326
import io.github.guacsec.trustifyda.logging.LoggersFactory;
@@ -67,8 +70,8 @@ public CycloneDXSbom() {
6770
Metadata metadata = new Metadata();
6871
metadata.setTimestamp(new Date());
6972
bom.setMetadata(metadata);
70-
bom.setComponents(new ArrayList<>());
7173
bom.setDependencies(new ArrayList<>());
74+
bom.setComponents(new ArrayList<>());
7275
belongingCriteriaBinaryAlgorithm = getBelongingConditionByName();
7376
this.exhortIgnoreMethod = "insensitive";
7477
}
@@ -230,6 +233,9 @@ private Sbom removeIgnoredDepsFromSbom(List<String> refsToIgnore) {
230233
d.setDependencies(filteredDeps);
231234
}
232235
});
236+
if (bom.getComponents().isEmpty()) {
237+
bom.setDependencies(new ArrayList<>());
238+
}
233239
return this;
234240
}
235241

@@ -302,12 +308,36 @@ public Sbom addDependency(PackageURL sourceRef, PackageURL targetRef, String s)
302308
public String getAsJsonString() {
303309
try {
304310
var jsonString = BomGeneratorFactory.createJson(VERSION, bom).toJsonString();
311+
jsonString = ensureComponentsField(jsonString);
305312
if (debugLoggingIsNeeded()) {
306313
log.info("Generated Sbom Json:" + System.lineSeparator() + jsonString);
307314
}
308315
return jsonString;
309316
} catch (GeneratorException e) {
310-
throw new RuntimeException("Unable to genenerate JSON from SBOM", e);
317+
throw new RuntimeException("Unable to generate JSON from SBOM", e);
318+
}
319+
}
320+
321+
private String ensureComponentsField(String json) throws GeneratorException {
322+
try {
323+
var mapper = new ObjectMapper();
324+
var rootNode = (ObjectNode) mapper.readTree(json);
325+
if (!rootNode.has("components")) {
326+
var ordered = mapper.createObjectNode();
327+
rootNode
328+
.properties()
329+
.forEach(
330+
field -> {
331+
ordered.set(field.getKey(), field.getValue());
332+
if (field.getKey().equals("metadata")) {
333+
ordered.set("components", mapper.createArrayNode());
334+
}
335+
});
336+
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(ordered);
337+
}
338+
return json;
339+
} catch (JsonProcessingException e) {
340+
throw new GeneratorException("Unable to ensure components field in JSON", e);
311341
}
312342
}
313343

src/test/resources/msc/golang/mvs_logic/expected_sbom_stack_analysis.json

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3989,51 +3989,13 @@
39893989
{
39903990
"ref": "pkg:golang/github.com/rhecosystemappeng/saasi/deployer@v0.0.0",
39913991
"dependsOn": [
3992-
"pkg:golang/github.com/davecgh/go-spew@v1.1.1",
3993-
"pkg:golang/github.com/emicklei/go-restful/v3@v3.9.0",
39943992
"pkg:golang/github.com/gin-gonic/gin@v1.9.1",
3995-
"pkg:golang/github.com/go-logr/logr@v1.2.3",
3996-
"pkg:golang/github.com/go-openapi/jsonpointer@v0.19.5",
3997-
"pkg:golang/github.com/go-openapi/jsonreference@v0.20.0",
3998-
"pkg:golang/github.com/go-openapi/swag@v0.19.14",
3999-
"pkg:golang/github.com/gogo/protobuf@v1.3.2",
4000-
"pkg:golang/github.com/golang/protobuf@v1.5.2",
4001-
"pkg:golang/github.com/google/gnostic@v0.5.7-v3refs",
4002-
"pkg:golang/github.com/google/go-cmp@v0.5.9",
4003-
"pkg:golang/github.com/google/gofuzz@v1.1.0",
40043993
"pkg:golang/github.com/google/uuid@v1.1.2",
4005-
"pkg:golang/github.com/imdario/mergo@v0.3.6",
40063994
"pkg:golang/github.com/jessevdk/go-flags@v1.5.0",
4007-
"pkg:golang/github.com/josharian/intern@v1.0.0",
4008-
"pkg:golang/github.com/json-iterator/go@v1.1.12",
40093995
"pkg:golang/github.com/kr/pretty@v0.3.1",
4010-
"pkg:golang/github.com/kr/text@v0.2.0",
4011-
"pkg:golang/github.com/mailru/easyjson@v0.7.6",
4012-
"pkg:golang/github.com/modern-go/concurrent@v0.0.0-20180306012644-bacd9c7ef1dd",
4013-
"pkg:golang/github.com/modern-go/reflect2@v1.0.2",
4014-
"pkg:golang/github.com/munnerz/goautoneg@v0.0.0-20191010083416-a7dc8b61c822",
4015-
"pkg:golang/github.com/rogpeppe/go-internal@v1.9.0",
4016-
"pkg:golang/github.com/spf13/pflag@v1.0.5",
4017-
"pkg:golang/golang.org/x/net@v0.10.0",
4018-
"pkg:golang/golang.org/x/oauth2@v0.0.0-20220223155221-ee480838109b",
4019-
"pkg:golang/golang.org/x/sys@v0.8.0",
4020-
"pkg:golang/golang.org/x/term@v0.8.0",
4021-
"pkg:golang/golang.org/x/text@v0.9.0",
4022-
"pkg:golang/golang.org/x/time@v0.0.0-20220210224613-90d013bbcef8",
4023-
"pkg:golang/google.golang.org/appengine@v1.6.7",
4024-
"pkg:golang/google.golang.org/protobuf@v1.30.0",
4025-
"pkg:golang/gopkg.in/inf.v0@v0.9.1",
40263996
"pkg:golang/gopkg.in/yaml.v2@v2.4.0",
4027-
"pkg:golang/gopkg.in/yaml.v3@v3.0.1",
4028-
"pkg:golang/k8s.io/api@v0.26.1",
40293997
"pkg:golang/k8s.io/apimachinery@v0.26.1",
4030-
"pkg:golang/k8s.io/client-go@v0.26.1",
4031-
"pkg:golang/k8s.io/klog/v2@v2.80.1",
4032-
"pkg:golang/k8s.io/kube-openapi@v0.0.0-20221012153701-172d655c2280",
4033-
"pkg:golang/k8s.io/utils@v0.0.0-20221107191617-1a15be271d1d",
4034-
"pkg:golang/sigs.k8s.io/json@v0.0.0-20220713155537-f223a00ba0e2",
4035-
"pkg:golang/sigs.k8s.io/structured-merge-diff/v4@v4.2.3",
4036-
"pkg:golang/sigs.k8s.io/yaml@v1.3.0"
3998+
"pkg:golang/k8s.io/client-go@v0.26.1"
40373999
]
40384000
},
40394001
{

src/test/resources/tst_manifests/golang/go_mod_light_no_ignore/expected_sbom_component_analysis.json

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,14 @@
2929
"name" : "tools",
3030
"version" : "v0.0.0-20210112183307-1e6ecd4bf1b0",
3131
"purl" : "pkg:golang/golang.org/x/tools@v0.0.0-20210112183307-1e6ecd4bf1b0"
32-
},
33-
{
34-
"type" : "library",
35-
"bom-ref" : "pkg:golang/gopkg.in/yaml.v3@v3.0.1",
36-
"group" : "gopkg.in",
37-
"name" : "yaml.v3",
38-
"version" : "v3.0.1",
39-
"purl" : "pkg:golang/gopkg.in/yaml.v3@v3.0.1"
4032
}
4133
],
4234
"dependencies" : [
4335
{
4436
"ref" : "pkg:golang/golang.org/x/example@v0.0.0",
4537
"dependsOn" : [
4638
"pkg:golang/github.com/spf13/cobra@v0.0.5",
47-
"pkg:golang/golang.org/x/tools@v0.0.0-20210112183307-1e6ecd4bf1b0",
48-
"pkg:golang/gopkg.in/yaml.v3@v3.0.1"
39+
"pkg:golang/golang.org/x/tools@v0.0.0-20210112183307-1e6ecd4bf1b0"
4940
]
5041
},
5142
{
@@ -55,10 +46,6 @@
5546
{
5647
"ref" : "pkg:golang/golang.org/x/tools@v0.0.0-20210112183307-1e6ecd4bf1b0",
5748
"dependsOn" : [ ]
58-
},
59-
{
60-
"ref" : "pkg:golang/gopkg.in/yaml.v3@v3.0.1",
61-
"dependsOn" : [ ]
6249
}
6350
]
64-
}
51+
}

src/test/resources/tst_manifests/golang/go_mod_light_no_ignore/expected_sbom_stack_analysis.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,7 @@
315315
"ref": "pkg:golang/golang.org/x/example@v0.0.0",
316316
"dependsOn": [
317317
"pkg:golang/github.com/spf13/cobra@v0.0.5",
318-
"pkg:golang/golang.org/x/tools@v0.0.0-20210112183307-1e6ecd4bf1b0",
319-
"pkg:golang/gopkg.in/yaml.v3@v3.0.1"
318+
"pkg:golang/golang.org/x/tools@v0.0.0-20210112183307-1e6ecd4bf1b0"
320319
]
321320
},
322321
{

0 commit comments

Comments
 (0)