Skip to content

Commit 4aa48c4

Browse files
committed
fix(go): distinguish direct from indirect dependencies
Add go mod edit -json parsing to GoModulesProvider to identify which dependencies in go.mod are direct vs indirect. Since Go 1.17, go mod tidy records all transitively-imported modules as require entries with an // indirect marker, causing go mod graph to emit root-level edges for all of them. Previously the Java client treated all root-level edges as direct dependencies, inflating component analysis SBOMs. The fix adds getDirectDependencyPaths() which runs go mod edit -json and builds a Set of module paths where Indirect is false/absent. Both buildSbomFromGraph (stack analysis) and buildSbomFromList (component analysis) now filter root-level edges to only include direct deps. Also adds getModulePath() helper to strip version suffixes from go mod graph entries for comparison with go mod edit -json output. Implements TC-4300 Assisted-by: Claude Code
1 parent 601178a commit 4aa48c4

8 files changed

Lines changed: 956 additions & 1972 deletions

File tree

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

Lines changed: 76 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,58 @@ 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+
directDepPaths.add(req.get("Path").asText());
470+
}
471+
}
472+
}
473+
} catch (JsonProcessingException e) {
474+
throw new IllegalStateException(
475+
"Failed to parse go mod edit -json output: " + e.getMessage(), e);
476+
}
477+
if (debugLoggingIsNeeded()) {
478+
log.info(String.format("Direct dependency paths from go mod edit -json: %s", directDepPaths));
479+
}
480+
return directDepPaths;
481+
}
482+
483+
/**
484+
* Extracts the module path from a go mod graph entry by stripping the version suffix. For
485+
* example, {@code "github.com/foo/bar@v1.2.3"} returns {@code "github.com/foo/bar"}.
486+
*/
487+
private static String getModulePath(String graphEntry) {
488+
int atIndex = graphEntry.indexOf("@");
489+
if (atIndex == -1) {
490+
return graphEntry;
491+
}
492+
return graphEntry.substring(0, atIndex);
493+
}
494+
495+
private Sbom buildSbomFromList(
496+
String golangDeps, List<PackageURL> ignoredDeps, Set<String> directDepPaths) {
426497
String[] allModulesFlat = golangDeps.split(Operations.GENERIC_LINE_SEPARATOR);
427498
String parentVertex = getParentVertex(allModulesFlat[0]);
428499
PackageURL root = toPurl(parentVertex, "@");
@@ -433,6 +504,7 @@ private Sbom buildSbomFromList(String golangDeps, List<PackageURL> ignoredDeps)
433504
sbom.addRoot(root, readLicenseFromManifest());
434505
deps.stream()
435506
.filter(dep -> !isGoToolchainEntry(dep))
507+
.filter(dep -> directDepPaths.contains(getModulePath(dep)))
436508
.forEach(
437509
dep -> {
438510
PackageURL targetPurl = toPurl(dep, "@");

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)