Skip to content

Commit 9582bca

Browse files
authored
Add the Docker Compose Detector (#1785)
* Add the DockerComposeComponentDetector and tests * Address copilot PR comments. And add new tests for DockerReferenceUtility * Add logging to some of the utils in DockerReferenceUtility for parse failures
1 parent b16ab9e commit 9582bca

11 files changed

Lines changed: 695 additions & 63 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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ namespace Microsoft.ComponentDetection.Common;
2929
using System;
3030
using System.Diagnostics.CodeAnalysis;
3131
using Microsoft.ComponentDetection.Contracts;
32+
using Microsoft.Extensions.Logging;
3233

3334
public static class DockerReferenceUtility
3435
{
@@ -38,6 +39,67 @@ public static class DockerReferenceUtility
3839
private const string LEGACYDEFAULTDOMAIN = "index.docker.io";
3940
private const string OFFICIALREPOSITORYNAME = "library";
4041

42+
/// <summary>
43+
/// Returns true if the reference contains unresolved variable placeholders (e.g., ${VAR}, {{ .Values.tag }}).
44+
/// Such references should be skipped before calling <see cref="ParseFamiliarName"/> or <see cref="ParseQualifiedName"/>.
45+
/// </summary>
46+
/// <param name="reference">The image reference string to check.</param>
47+
/// <returns><c>true</c> if the reference contains variable placeholder characters; otherwise <c>false</c>.</returns>
48+
public static bool HasUnresolvedVariables(string reference) =>
49+
reference.IndexOfAny(['$', '{', '}']) >= 0;
50+
51+
/// <summary>
52+
/// Attempts to parse an image reference string into a <see cref="DockerReference"/>.
53+
/// Returns <c>null</c> if the reference contains unresolved variables or cannot be parsed.
54+
/// </summary>
55+
/// <param name="imageReference">The image reference string to parse.</param>
56+
/// <param name="logger">Optional logger for recording parse failures.</param>
57+
/// <returns>A <see cref="DockerReference"/> if parsing succeeds; otherwise <c>null</c>.</returns>
58+
public static DockerReference? TryParseImageReference(string imageReference, ILogger? logger = null)
59+
{
60+
if (HasUnresolvedVariables(imageReference))
61+
{
62+
return null;
63+
}
64+
65+
try
66+
{
67+
return ParseFamiliarName(imageReference);
68+
}
69+
catch (DockerReferenceException ex)
70+
{
71+
logger?.LogWarning(ex, "Failed to parse image reference '{ImageReference}'.", imageReference);
72+
return null;
73+
}
74+
}
75+
76+
/// <summary>
77+
/// Parses an image reference and registers it with the recorder if valid.
78+
/// Skips references with unresolved variables or that cannot be parsed,
79+
/// logging a warning for parse failures so that remaining entries continue to be processed.
80+
/// </summary>
81+
/// <param name="imageReference">The image reference string to parse.</param>
82+
/// <param name="recorder">The component recorder to register the image with.</param>
83+
/// <param name="logger">Optional logger for recording parse failures.</param>
84+
public static void TryRegisterImageReference(string imageReference, ISingleFileComponentRecorder recorder, ILogger? logger = null)
85+
{
86+
var dockerRef = TryParseImageReference(imageReference, logger);
87+
TryRegisterImageReference(dockerRef, recorder);
88+
}
89+
90+
/// <summary>
91+
/// Registers a pre-parsed <see cref="DockerReference"/> with the recorder if non-null.
92+
/// </summary>
93+
/// <param name="dockerReference">The parsed docker reference, or <c>null</c> to skip.</param>
94+
/// <param name="recorder">The component recorder to register the image with.</param>
95+
public static void TryRegisterImageReference(DockerReference? dockerReference, ISingleFileComponentRecorder recorder)
96+
{
97+
if (dockerReference != null)
98+
{
99+
recorder.RegisterUsage(new DetectedComponent(dockerReference.ToTypedDockerReferenceComponent()));
100+
}
101+
}
102+
41103
public static DockerReference ParseQualifiedName(string qualifiedName)
42104
{
43105
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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
namespace Microsoft.ComponentDetection.Detectors.DockerCompose;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Microsoft.ComponentDetection.Common;
9+
using Microsoft.ComponentDetection.Contracts;
10+
using Microsoft.ComponentDetection.Contracts.Internal;
11+
using Microsoft.ComponentDetection.Contracts.TypedComponent;
12+
using Microsoft.Extensions.Logging;
13+
using YamlDotNet.RepresentationModel;
14+
15+
public class DockerComposeComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
16+
{
17+
public DockerComposeComponentDetector(
18+
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
19+
IObservableDirectoryWalkerFactory walkerFactory,
20+
ILogger<DockerComposeComponentDetector> logger)
21+
{
22+
this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
23+
this.Scanner = walkerFactory;
24+
this.Logger = logger;
25+
}
26+
27+
public override string Id => "DockerCompose";
28+
29+
public override IList<string> SearchPatterns { get; } =
30+
[
31+
"docker-compose.yml", "docker-compose.yaml",
32+
"docker-compose.*.yml", "docker-compose.*.yaml",
33+
"compose.yml", "compose.yaml",
34+
"compose.*.yml", "compose.*.yaml",
35+
];
36+
37+
public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.DockerReference];
38+
39+
public override int Version => 1;
40+
41+
public override IEnumerable<string> Categories => [nameof(DetectorClass.DockerCompose)];
42+
43+
protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary<string, string> detectorArgs, CancellationToken cancellationToken = default)
44+
{
45+
var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder;
46+
var file = processRequest.ComponentStream;
47+
48+
try
49+
{
50+
this.Logger.LogInformation("Discovered Docker Compose file: {Location}", file.Location);
51+
52+
string contents;
53+
using (var reader = new StreamReader(file.Stream))
54+
{
55+
contents = await reader.ReadToEndAsync(cancellationToken);
56+
}
57+
58+
var yaml = new YamlStream();
59+
yaml.Load(new StringReader(contents));
60+
61+
if (yaml.Documents.Count == 0)
62+
{
63+
return;
64+
}
65+
66+
foreach (var document in yaml.Documents)
67+
{
68+
if (document.RootNode is YamlMappingNode rootMapping)
69+
{
70+
this.ExtractImageReferences(rootMapping, singleFileComponentRecorder);
71+
}
72+
}
73+
}
74+
catch (Exception e)
75+
{
76+
this.Logger.LogError(e, "Failed to parse Docker Compose file: {Location}", file.Location);
77+
}
78+
}
79+
80+
private static YamlMappingNode? GetMappingChild(YamlMappingNode parent, string key)
81+
{
82+
foreach (var entry in parent.Children)
83+
{
84+
if (entry.Key is YamlScalarNode scalarKey && string.Equals(scalarKey.Value, key, StringComparison.OrdinalIgnoreCase))
85+
{
86+
return entry.Value as YamlMappingNode;
87+
}
88+
}
89+
90+
return null;
91+
}
92+
93+
private void ExtractImageReferences(YamlMappingNode rootMapping, ISingleFileComponentRecorder recorder)
94+
{
95+
var services = GetMappingChild(rootMapping, "services");
96+
if (services == null)
97+
{
98+
return;
99+
}
100+
101+
foreach (var serviceEntry in services.Children)
102+
{
103+
if (serviceEntry.Value is not YamlMappingNode serviceMapping)
104+
{
105+
continue;
106+
}
107+
108+
// Extract direct image: references
109+
foreach (var entry in serviceMapping.Children)
110+
{
111+
var key = (entry.Key as YamlScalarNode)?.Value;
112+
if (string.Equals(key, "image", StringComparison.OrdinalIgnoreCase))
113+
{
114+
var imageRef = (entry.Value as YamlScalarNode)?.Value;
115+
if (!string.IsNullOrWhiteSpace(imageRef))
116+
{
117+
DockerReferenceUtility.TryRegisterImageReference(imageRef, recorder, this.Logger);
118+
}
119+
}
120+
}
121+
}
122+
}
123+
}

0 commit comments

Comments
 (0)