Skip to content

Commit 6e1d270

Browse files
authored
Go CLI detector enhancement (go list -m all) (#105)
* Go CLI enhancement, include only modules in build list
1 parent 164770f commit 6e1d270

4 files changed

Lines changed: 158 additions & 26 deletions

File tree

docs/detectors/go.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ Improved go detection depends on the following to successfully run:
2222

2323
- Go v1.11+.
2424

25-
Go detection is performed by parsing output from executing `go mod graph`.
2625
Full dependency graph generation is supported if Go v1.11+ is present on the build agent.
2726
If no Go v1.11+ is present, fallback detection strategy is performed.
2827

28+
Go detection is performed by parsing output from executing [go list -m -json all](1). To generate the graph, the command [go mod graph](2) is executed, this only adds edges between the components that were already registered by `go list`.
29+
2930
As we validate this opt-in behavior, we will eventually graduate it to the default detection strategy.
3031

3132
## Known limitations
@@ -35,11 +36,14 @@ Dev dependency tagging is not supported.
3536
Go detection will fallback if no Go v1.11+ is present.
3637

3738
Due to the nature of `go.sum` containing references for all dependencies, including historical, no-longer-needed dependencies; the fallback strategy can result in over detection.
38-
Executing `go mod tidy` before detection via fallback is encouraged.
39+
Executing [go mod tidy](https://go.dev/ref/mod#go-mod-tidy) before detection via fallback is encouraged.
40+
41+
Some legacy dependencies may report stale transitive dependencies in their manifests, in this case you can remove them safely from your binaries by using [exclude directive](https://go.dev/doc/modules/gomod-ref#exclude).
3942

4043
## Environment Variables
4144

42-
If the environment variable `EnableGoCliScan` is set, to any value, the Go detector uses [`go mod graph`][1] to discover Go dependencies.
45+
If the environment variable `EnableGoCliScan` is set, to any value, the Go detector uses [`go list -m -json all`][1] to discover Go dependencies.
4346
If the environment variable is not present, we fall back to parsing `go.mod` and `go.sum` ourselves.
4447

45-
[1]: https://go.dev/ref/mod#go-mod-graph
48+
[1]: https://go.dev/ref/mod#go-list-m
49+
[2]: https://go.dev/ref/mod#go-mod-graph

docs/feature-overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
| Conda (Python) (Beta) | <ul><li>environment.yml</li><li>environment.yaml</li></ul> | <ul><li>Conda v4.10.2+</li></ul> |||
77
| Linux (Debian, Alpine, Rhel, Centos, Fedora, Ubuntu)| <ul><li>(via [syft](https://github.com/anchore/syft))</li></ul> | - | - | - | - |
88
| Gradle | <ul><li>*.lockfile</li></ul> | <ul><li>Gradle 7 or prior using [Single File lock](https://docs.gradle.org/6.8.1/userguide/dependency_locking.html#single_lock_file_per_project)</li></ul> |||
9-
| Go | <ul><li>*go mod graph*</li></ul>Fallback</br><ul><li>go.mod</li><li>go.sum</li></ul> | <ul><li>Go 1.11+ (will fallback if not present)</li></ul> || ✔ (root idenditication only for fallback) |
9+
| Go | <ul><li>*go list -m -json all*</li><li>*go mod graph* (edge information only)</li></ul>Fallback</br><ul><li>go.mod</li><li>go.sum</li></ul> | <ul><li>Go 1.11+ (will fallback if not present)</li></ul> || ✔ (root idenditication only for fallback) |
1010
| Maven | <ul><li>pom.xml</li><li>*mvn dependency:tree -f {pom.xml}*</li></ul> | <ul><li>Maven</li><li>Maven Dependency Plugin (auto-installed with Maven)</li></ul> | ✔ (test dependency scope) ||
1111
| NPM | <ul><li>package.json</li><li>package-lock.json</li><li>npm-shrinkwrap.json</li><li>lerna.json</li></ul> | - | ✔ (dev-dependencies in package.json, dev flag in package-lock.json) ||
1212
| Yarn (v1, v2) | <ul><li>package.json</li><li>yarn.lock</li></ul> | - | ✔ (dev-dependencies in package.json) ||

src/Microsoft.ComponentDetection.Detectors/go/GoComponentDetector.cs

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.ComponentDetection.Contracts;
1010
using Microsoft.ComponentDetection.Contracts.Internal;
1111
using Microsoft.ComponentDetection.Contracts.TypedComponent;
12+
using Newtonsoft.Json;
1213

1314
namespace Microsoft.ComponentDetection.Detectors.Go
1415
{
@@ -33,7 +34,7 @@ public class GoComponentDetector : FileComponentDetector
3334

3435
public override IEnumerable<ComponentType> SupportedComponentTypes { get; } = new[] { ComponentType.Go };
3536

36-
public override int Version => 4;
37+
public override int Version => 5;
3738

3839
private HashSet<string> projectRoots = new HashSet<string>();
3940

@@ -104,22 +105,31 @@ private async Task<bool> UseGoCliToScan(string location, ISingleFileComponentRec
104105
var projectRootDirectory = Directory.GetParent(location);
105106
record.ProjectRoot = projectRootDirectory.FullName;
106107

107-
var isGoAvailable = await CommandLineInvocationService.CanCommandBeLocated("go", null, workingDirectory: projectRootDirectory, new List<string> { "version" }.ToArray());
108+
var isGoAvailable = await CommandLineInvocationService.CanCommandBeLocated("go", null, workingDirectory: projectRootDirectory, new[] { "version" });
108109
record.IsGoAvailable = isGoAvailable;
109110

110111
if (!isGoAvailable)
111112
{
112113
return false;
113114
}
114115

116+
var goDependenciesProcess = await CommandLineInvocationService.ExecuteCommand("go", null, workingDirectory: projectRootDirectory, new[] { "list", "-m", "-json", "all" });
117+
if (goDependenciesProcess.ExitCode != 0)
118+
{
119+
Logger.LogError($"Go CLI could not get dependency build list at location: {location}. Fallback go.sum/go.mod parsing will be used.");
120+
return false;
121+
}
122+
123+
RecordBuildDependencies(goDependenciesProcess.StdOut, singleFileComponentRecorder);
124+
115125
var generateGraphProcess = await CommandLineInvocationService.ExecuteCommand("go", null, workingDirectory: projectRootDirectory, new List<string> { "mod", "graph" }.ToArray());
116126
if (generateGraphProcess.ExitCode == 0)
117127
{
118128
PopulateDependencyGraph(generateGraphProcess.StdOut, singleFileComponentRecorder);
119129
record.WasGraphSuccessful = true;
120130
}
121131

122-
return record.WasGraphSuccessful;
132+
return true;
123133
}
124134

125135
private void ParseGoModFile(
@@ -200,7 +210,10 @@ private bool TryToCreateGoComponentFromSumLine(string line, out GoComponent goCo
200210
return false;
201211
}
202212

203-
private void PopulateDependencyGraph(string goGraphOutput, ISingleFileComponentRecorder singleFileComponentRecorder)
213+
/// <summary>
214+
/// This command only adds edges between parent and child components, it does not add nor remove any entries from the existing build list.
215+
/// </summary>
216+
private void PopulateDependencyGraph(string goGraphOutput, ISingleFileComponentRecorder componentRecorder)
204217
{
205218
// Yes, go always returns \n even on Windows
206219
var graphRelationships = goGraphOutput.Split('\n');
@@ -210,35 +223,29 @@ private void PopulateDependencyGraph(string goGraphOutput, ISingleFileComponentR
210223
var components = relationship.Split(' ');
211224
if (components.Length != 2)
212225
{
213-
Logger.LogWarning("Unexpected output from go mod graph:");
226+
Logger.LogWarning("Unexpected relationship output from go mod graph:");
214227
Logger.LogWarning(relationship);
215228
continue;
216229
}
217230

218231
GoComponent parentComponent;
219232
GoComponent childComponent;
220233

221-
var parentPart = components[0];
222-
var childPart = components[1];
223-
224-
var isParentParsed = TryCreateGoComponentFromRelationshipPart(parentPart, out parentComponent);
225-
var isChildParsed = TryCreateGoComponentFromRelationshipPart(childPart, out childComponent);
234+
var isParentParsed = TryCreateGoComponentFromRelationshipPart(components[0], out parentComponent);
235+
var isChildParsed = TryCreateGoComponentFromRelationshipPart(components[1], out childComponent);
226236

227-
// If the parent component doesn't have a version, it means it's one of the 'main' modules
228-
// The imports of the main modules are explicitly referenced
229-
if (!isParentParsed && isChildParsed)
237+
if (!isParentParsed)
230238
{
231-
singleFileComponentRecorder.RegisterUsage(new DetectedComponent(childComponent), isExplicitReferencedDependency: true);
239+
// These are explicit dependencies, we already have those recorded
240+
continue;
232241
}
233-
else if (isParentParsed && isChildParsed)
242+
243+
if (isChildParsed)
234244
{
235-
// Go can have a cyclic dependency between modules, which could cause child components to be listed first than parents. Reproducible with Go 1.16
236-
if (singleFileComponentRecorder.GetComponent(parentComponent.Id) == null)
245+
if (IsModuleInBuildList(componentRecorder, parentComponent) && IsModuleInBuildList(componentRecorder, childComponent))
237246
{
238-
singleFileComponentRecorder.RegisterUsage(new DetectedComponent(parentComponent));
247+
componentRecorder.RegisterUsage(new DetectedComponent(childComponent), parentComponentId: parentComponent.Id);
239248
}
240-
241-
singleFileComponentRecorder.RegisterUsage(new DetectedComponent(childComponent), parentComponentId: parentComponent.Id);
242249
}
243250
else
244251
{
@@ -247,6 +254,46 @@ private void PopulateDependencyGraph(string goGraphOutput, ISingleFileComponentR
247254
}
248255
}
249256

257+
private bool IsModuleInBuildList(ISingleFileComponentRecorder singleFileComponentRecorder, GoComponent component)
258+
{
259+
return singleFileComponentRecorder.GetComponent(component.Id) != null;
260+
}
261+
262+
private void RecordBuildDependencies(string goListOutput, ISingleFileComponentRecorder singleFileComponentRecorder)
263+
{
264+
var goBuildModules = new List<GoBuildModule>();
265+
var reader = new JsonTextReader(new StringReader(goListOutput));
266+
reader.SupportMultipleContent = true;
267+
268+
while (reader.Read())
269+
{
270+
var serializer = new JsonSerializer();
271+
var buildModule = serializer.Deserialize<GoBuildModule>(reader);
272+
273+
goBuildModules.Add(buildModule);
274+
}
275+
276+
foreach (var dependency in goBuildModules)
277+
{
278+
if (dependency.Main)
279+
{
280+
// main is the entry point module (superfluous as we already have the file location)
281+
continue;
282+
}
283+
284+
var goComponent = new GoComponent(dependency.Path, dependency.Version);
285+
286+
if (dependency.Indirect)
287+
{
288+
singleFileComponentRecorder.RegisterUsage(new DetectedComponent(goComponent));
289+
}
290+
else
291+
{
292+
singleFileComponentRecorder.RegisterUsage(new DetectedComponent(goComponent), isExplicitReferencedDependency: true);
293+
}
294+
}
295+
}
296+
250297
private bool TryCreateGoComponentFromRelationshipPart(string relationship, out GoComponent goComponent)
251298
{
252299
var componentParts = relationship.Split('@');
@@ -264,5 +311,16 @@ private bool IsGoCliManuallyEnabled()
264311
{
265312
return EnvVarService.DoesEnvironmentVariableExist("EnableGoCliScan");
266313
}
314+
315+
private class GoBuildModule
316+
{
317+
public string Path { get; set; }
318+
319+
public bool Main { get; set; }
320+
321+
public string Version { get; set; }
322+
323+
public bool Indirect { get; set; }
324+
}
267325
}
268326
}

test/Microsoft.ComponentDetection.Detectors.Tests/GoComponentDetectorTests.cs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,47 @@ public async Task TestGoDetector_GoGraphCommandThrows()
311311
[TestMethod]
312312
public async Task TestGoDetector_GoGraphHappyPath()
313313
{
314-
var goGraph = "example.com/mainModule some-package@v1.2.3\nsome-package@v1.2.3 other@v1.0.0\nsome-package@v1.2.3 test@v2.0.0\ntest@v2.0.0 a@v1.5.0";
314+
var buildDependencies = @"{
315+
""Path"": ""some-package"",
316+
""Version"": ""v1.2.3"",
317+
""Time"": ""2021-12-06T23:04:27Z"",
318+
""Indirect"": true,
319+
""GoMod"": ""C:\\test\\go.mod"",
320+
""GoVersion"": ""1.11""
321+
}" + "\n" + @"{
322+
""Path"": ""test"",
323+
""Version"": ""v2.0.0"",
324+
""Time"": ""2021-12-06T23:04:27Z"",
325+
""Indirect"": true,
326+
""GoMod"": ""C:\\test\\go.mod"",
327+
""GoVersion"": ""1.11""
328+
}" + "\n" + @"{
329+
""Path"": ""other"",
330+
""Version"": ""v1.2.0"",
331+
""Time"": ""2021-12-06T23:04:27Z"",
332+
""Indirect"": true,
333+
""GoMod"": ""C:\\test\\go.mod"",
334+
""GoVersion"": ""1.11""
335+
}" + "\n" + @"{
336+
""Path"": ""a"",
337+
""Version"": ""v1.5.0"",
338+
""Time"": ""2020-05-19T17:02:07Z"",
339+
""Indirect"": true,
340+
""GoMod"": ""C:\\test\\go.mod"",
341+
""GoVersion"": ""1.11""
342+
}";
343+
var goGraph = "example.com/mainModule some-package@v1.2.3\nsome-package@v1.2.3 other@v1.0.0\nsome-package@v1.2.3 other@v1.2.0\ntest@v2.0.0 a@v1.5.0";
315344

316345
commandLineMock.Setup(x => x.CanCommandBeLocated("go", null, It.IsAny<DirectoryInfo>(), It.IsAny<string[]>()))
317346
.ReturnsAsync(true);
318347

348+
commandLineMock.Setup(x => x.ExecuteCommand("go", null, It.IsAny<DirectoryInfo>(), new[] { "list", "-m", "-json", "all" }))
349+
.ReturnsAsync(new CommandLineExecutionResult
350+
{
351+
ExitCode = 0,
352+
StdOut = buildDependencies,
353+
});
354+
319355
commandLineMock.Setup(x => x.ExecuteCommand("go", null, It.IsAny<DirectoryInfo>(), new[] { "mod", "graph" }))
320356
.ReturnsAsync(new CommandLineExecutionResult
321357
{
@@ -333,17 +369,51 @@ public async Task TestGoDetector_GoGraphHappyPath()
333369

334370
var detectedComponents = componentRecorder.GetDetectedComponents();
335371
Assert.AreEqual(4, detectedComponents.Count());
372+
detectedComponents.Where(component => component.Component.Id == "other v1.0.0 - Go").Should().HaveCount(0);
373+
detectedComponents.Where(component => component.Component.Id == "other v1.2.0 - Go").Should().HaveCount(1);
374+
detectedComponents.Where(component => component.Component.Id == "some-package v1.2.3 - Go").Should().HaveCount(1);
375+
detectedComponents.Where(component => component.Component.Id == "test v2.0.0 - Go").Should().HaveCount(1);
376+
detectedComponents.Where(component => component.Component.Id == "a v1.5.0 - Go").Should().HaveCount(1);
336377
}
337378

338379
[TestMethod]
339380
public async Task TestGoDetector_GoGraphCyclicDependencies()
340381
{
382+
var buildDependencies = @"{
383+
""Path"": ""github.com/prometheus/common"",
384+
""Version"": ""v0.32.1"",
385+
""Time"": ""2021-12-06T23:04:27Z"",
386+
""Indirect"": true,
387+
""GoMod"": ""C:\\test\\go.mod"",
388+
""GoVersion"": ""1.11""
389+
}" + "\n" + @"{
390+
""Path"": ""github.com/prometheus/client_golang"",
391+
""Version"": ""v1.11.0"",
392+
""Time"": ""2021-12-06T23:04:27Z"",
393+
""Indirect"": true,
394+
""GoMod"": ""C:\\test\\go.mod"",
395+
""GoVersion"": ""1.11""
396+
}" + "\n" + @"{
397+
""Path"": ""github.com/prometheus/client_golang"",
398+
""Version"": ""v1.12.1"",
399+
""Time"": ""2021-12-06T23:04:27Z"",
400+
""Indirect"": true,
401+
""GoMod"": ""C:\\test\\go.mod"",
402+
""GoVersion"": ""1.11""
403+
}";
341404
var goGraph = @"
342405
github.com/prometheus/common@v0.32.1 github.com/prometheus/client_golang@v1.11.0
343406
github.com/prometheus/client_golang@v1.12.1 github.com/prometheus/common@v0.32.1";
344407
commandLineMock.Setup(x => x.CanCommandBeLocated("go", null, It.IsAny<DirectoryInfo>(), It.IsAny<string[]>()))
345408
.ReturnsAsync(true);
346409

410+
commandLineMock.Setup(x => x.ExecuteCommand("go", null, It.IsAny<DirectoryInfo>(), new[] { "list", "-m", "-json", "all" }))
411+
.ReturnsAsync(new CommandLineExecutionResult
412+
{
413+
ExitCode = 0,
414+
StdOut = buildDependencies,
415+
});
416+
347417
commandLineMock.Setup(x => x.ExecuteCommand("go", null, It.IsAny<DirectoryInfo>(), new[] { "mod", "graph" }))
348418
.ReturnsAsync(new CommandLineExecutionResult
349419
{

0 commit comments

Comments
 (0)