Skip to content

Commit 0059cf2

Browse files
jpinzCopilot
andcommitted
Add the DockerComposeComponentDetector and tests
Co-authored-by: Copilot <copilot@github.com>
1 parent 810548e commit 0059cf2

9 files changed

Lines changed: 550 additions & 0 deletions

File tree

docs/detectors/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
| -------------------------- | ---------- |
1919
| CondaLockComponentDetector | DefaultOff |
2020

21+
- [Docker Compose](dockercompose.md)
22+
23+
| Detector | Status |
24+
| ------------------------------- | ---------- |
25+
| DockerComposeComponentDetector | DefaultOff |
26+
2127
- [Dockerfile](dockerfile.md)
2228

2329
| Detector | Status |

docs/detectors/dockercompose.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Docker Compose Detection
2+
3+
## Requirements
4+
5+
Docker Compose detection depends on the following to successfully run:
6+
7+
- One or more Docker Compose files matching the patterns: `docker-compose.yml`, `docker-compose.yaml`, `docker-compose.*.yml`, `docker-compose.*.yaml`, `compose.yml`, `compose.yaml`, `compose.*.yml`, `compose.*.yaml`
8+
9+
The `DockerComposeComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter.
10+
11+
## Detection strategy
12+
13+
The Docker Compose detector parses YAML compose files to extract Docker image references from service definitions.
14+
15+
### Service Image Detection
16+
17+
The detector looks for the `services` section and extracts the `image` field from each service:
18+
19+
```yaml
20+
services:
21+
web:
22+
image: nginx:1.21
23+
db:
24+
image: postgres:14
25+
```
26+
27+
Services that only define a `build` directive without an `image` field are skipped, as they do not reference external Docker images.
28+
29+
### Full Registry References
30+
31+
The detector supports full registry image references:
32+
33+
```yaml
34+
services:
35+
app:
36+
image: ghcr.io/myorg/myapp:v2.0
37+
```
38+
39+
### Variable Resolution
40+
41+
Images containing unresolved variables (e.g., `${TAG}` or `${REGISTRY:-docker.io}`) are skipped to avoid reporting incomplete or incorrect references. The detector checks for `$`, `{`, or `}` characters in image references.
42+
43+
## Known limitations
44+
45+
- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs DockerCompose=EnableIfDefaultOff`
46+
- **Variable Resolution**: Image references containing unresolved environment variables or template expressions are not reported, which may lead to under-reporting in compose files that heavily use variable substitution
47+
- **Build-Only Services**: Services that only specify a `build` directive without an `image` field are not reported
48+
- **No Dependency Graph**: All detected images are registered as independent components without parent-child relationships

src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,53 @@ public static class DockerReferenceUtility
3838
private const string LEGACYDEFAULTDOMAIN = "index.docker.io";
3939
private const string OFFICIALREPOSITORYNAME = "library";
4040

41+
/// <summary>
42+
/// Returns true if the reference contains unresolved variable placeholders (e.g., ${VAR}, {{ .Values.tag }}).
43+
/// Such references should be skipped before calling <see cref="ParseFamiliarName"/> or <see cref="ParseQualifiedName"/>.
44+
/// </summary>
45+
/// <param name="reference">The image reference string to check.</param>
46+
/// <returns><c>true</c> if the reference contains variable placeholder characters; otherwise <c>false</c>.</returns>
47+
public static bool HasUnresolvedVariables(string reference) =>
48+
reference.IndexOfAny(['$', '{', '}']) >= 0;
49+
50+
/// <summary>
51+
/// Attempts to parse an image reference string into a <see cref="DockerReference"/>.
52+
/// Returns <c>null</c> if the reference contains unresolved variables or cannot be parsed.
53+
/// </summary>
54+
/// <param name="imageReference">The image reference string to parse.</param>
55+
/// <returns>A <see cref="DockerReference"/> if parsing succeeds; otherwise <c>null</c>.</returns>
56+
public static DockerReference? TryParseImageReference(string imageReference)
57+
{
58+
if (HasUnresolvedVariables(imageReference))
59+
{
60+
return null;
61+
}
62+
63+
try
64+
{
65+
return ParseFamiliarName(imageReference);
66+
}
67+
catch
68+
{
69+
return null;
70+
}
71+
}
72+
73+
/// <summary>
74+
/// Parses an image reference and registers it with the recorder if valid.
75+
/// Silently skips references with unresolved variables or that cannot be parsed.
76+
/// </summary>
77+
/// <param name="imageReference">The image reference string to parse.</param>
78+
/// <param name="recorder">The component recorder to register the image with.</param>
79+
public static void TryRegisterImageReference(string imageReference, ISingleFileComponentRecorder recorder)
80+
{
81+
var dockerRef = TryParseImageReference(imageReference);
82+
if (dockerRef != null)
83+
{
84+
recorder.RegisterUsage(new DetectedComponent(dockerRef.ToTypedDockerReferenceComponent()));
85+
}
86+
}
87+
4188
public static DockerReference ParseQualifiedName(string qualifiedName)
4289
{
4390
var regexp = DockerRegex.ReferenceRegexp;

src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,7 @@ public enum DetectorClass
4747

4848
/// <summary> Indicates a detector applies to Swift packages.</summary>
4949
Swift,
50+
51+
/// <summary>Indicates a detector applies to Docker Compose image references.</summary>
52+
DockerCompose,
5053
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#nullable enable
2+
namespace Microsoft.ComponentDetection.Detectors.DockerCompose;
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.ComponentDetection.Common;
10+
using Microsoft.ComponentDetection.Contracts;
11+
using Microsoft.ComponentDetection.Contracts.Internal;
12+
using Microsoft.ComponentDetection.Contracts.TypedComponent;
13+
using Microsoft.Extensions.Logging;
14+
using YamlDotNet.RepresentationModel;
15+
16+
public class DockerComposeComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
17+
{
18+
public DockerComposeComponentDetector(
19+
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
20+
IObservableDirectoryWalkerFactory walkerFactory,
21+
ILogger<DockerComposeComponentDetector> logger)
22+
{
23+
this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
24+
this.Scanner = walkerFactory;
25+
this.Logger = logger;
26+
}
27+
28+
public override string Id => "DockerCompose";
29+
30+
public override IList<string> SearchPatterns { get; } =
31+
[
32+
"docker-compose.yml", "docker-compose.yaml",
33+
"docker-compose.*.yml", "docker-compose.*.yaml",
34+
"compose.yml", "compose.yaml",
35+
"compose.*.yml", "compose.*.yaml",
36+
];
37+
38+
public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.DockerReference];
39+
40+
public override int Version => 1;
41+
42+
public override IEnumerable<string> Categories => [nameof(DetectorClass.DockerCompose)];
43+
44+
protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary<string, string> detectorArgs, CancellationToken cancellationToken = default)
45+
{
46+
var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder;
47+
var file = processRequest.ComponentStream;
48+
49+
try
50+
{
51+
this.Logger.LogInformation("Discovered Docker Compose file: {Location}", file.Location);
52+
53+
string contents;
54+
using (var reader = new StreamReader(file.Stream))
55+
{
56+
contents = await reader.ReadToEndAsync(cancellationToken);
57+
}
58+
59+
var yaml = new YamlStream();
60+
yaml.Load(new StringReader(contents));
61+
62+
if (yaml.Documents.Count == 0)
63+
{
64+
return;
65+
}
66+
67+
foreach (var document in yaml.Documents)
68+
{
69+
if (document.RootNode is YamlMappingNode rootMapping)
70+
{
71+
this.ExtractImageReferences(rootMapping, singleFileComponentRecorder);
72+
}
73+
}
74+
}
75+
catch (Exception e)
76+
{
77+
this.Logger.LogError(e, "Failed to parse Docker Compose file: {Location}", file.Location);
78+
}
79+
}
80+
81+
private static YamlMappingNode? GetMappingChild(YamlMappingNode parent, string key)
82+
{
83+
foreach (var entry in parent.Children)
84+
{
85+
if (entry.Key is YamlScalarNode scalarKey && string.Equals(scalarKey.Value, key, StringComparison.OrdinalIgnoreCase))
86+
{
87+
return entry.Value as YamlMappingNode;
88+
}
89+
}
90+
91+
return null;
92+
}
93+
94+
private void ExtractImageReferences(YamlMappingNode rootMapping, ISingleFileComponentRecorder recorder)
95+
{
96+
var services = GetMappingChild(rootMapping, "services");
97+
if (services == null)
98+
{
99+
return;
100+
}
101+
102+
foreach (var serviceEntry in services.Children)
103+
{
104+
if (serviceEntry.Value is not YamlMappingNode serviceMapping)
105+
{
106+
continue;
107+
}
108+
109+
// Extract direct image: references
110+
foreach (var entry in serviceMapping.Children)
111+
{
112+
var key = (entry.Key as YamlScalarNode)?.Value;
113+
if (string.Equals(key, "image", StringComparison.OrdinalIgnoreCase))
114+
{
115+
var imageRef = (entry.Value as YamlScalarNode)?.Value;
116+
if (!string.IsNullOrWhiteSpace(imageRef))
117+
{
118+
DockerReferenceUtility.TryRegisterImageReference(imageRef, recorder);
119+
}
120+
}
121+
}
122+
}
123+
}
124+
}

src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ namespace Microsoft.ComponentDetection.Orchestrator.Extensions;
55
using Microsoft.ComponentDetection.Contracts;
66
using Microsoft.ComponentDetection.Detectors.CocoaPods;
77
using Microsoft.ComponentDetection.Detectors.Conan;
8+
using Microsoft.ComponentDetection.Detectors.DockerCompose;
89
using Microsoft.ComponentDetection.Detectors.Dockerfile;
910
using Microsoft.ComponentDetection.Detectors.DotNet;
1011
using Microsoft.ComponentDetection.Detectors.Go;
@@ -84,6 +85,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
8485
// Conda
8586
services.AddSingleton<IComponentDetector, CondaLockComponentDetector>();
8687

88+
// Docker Compose
89+
services.AddSingleton<IComponentDetector, DockerComposeComponentDetector>();
90+
8791
// Dockerfile
8892
services.AddSingleton<IComponentDetector, DockerfileComponentDetector>();
8993

0 commit comments

Comments
 (0)