Skip to content
6 changes: 6 additions & 0 deletions docs/detectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
| -------------------------- | ---------- |
| CondaLockComponentDetector | DefaultOff |

- [Docker Compose](dockercompose.md)

| Detector | Status |
| ------------------------------- | ---------- |
| DockerComposeComponentDetector | DefaultOff |
Comment thread
jpinz marked this conversation as resolved.
Outdated

- [Dockerfile](dockerfile.md)

| Detector | Status |
Expand Down
48 changes: 48 additions & 0 deletions docs/detectors/dockercompose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Docker Compose Detection

## Requirements

Docker Compose detection depends on the following to successfully run:

- 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`

The `DockerComposeComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter.

## Detection strategy

The Docker Compose detector parses YAML compose files to extract Docker image references from service definitions.

### Service Image Detection

The detector looks for the `services` section and extracts the `image` field from each service:

```yaml
services:
web:
image: nginx:1.21
db:
image: postgres:14
```

Services that only define a `build` directive without an `image` field are skipped, as they do not reference external Docker images.

### Full Registry References

The detector supports full registry image references:

```yaml
services:
app:
image: ghcr.io/myorg/myapp:v2.0
```

### Variable Resolution

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.

## Known limitations

- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs DockerCompose=EnableIfDefaultOff`
- **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
- **Build-Only Services**: Services that only specify a `build` directive without an `image` field are not reported
- **No Dependency Graph**: All detected images are registered as independent components without parent-child relationships
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,53 @@ public static class DockerReferenceUtility
private const string LEGACYDEFAULTDOMAIN = "index.docker.io";
private const string OFFICIALREPOSITORYNAME = "library";

/// <summary>
/// Returns true if the reference contains unresolved variable placeholders (e.g., ${VAR}, {{ .Values.tag }}).
/// Such references should be skipped before calling <see cref="ParseFamiliarName"/> or <see cref="ParseQualifiedName"/>.
/// </summary>
/// <param name="reference">The image reference string to check.</param>
/// <returns><c>true</c> if the reference contains variable placeholder characters; otherwise <c>false</c>.</returns>
public static bool HasUnresolvedVariables(string reference) =>
reference.IndexOfAny(['$', '{', '}']) >= 0;

/// <summary>
/// Attempts to parse an image reference string into a <see cref="DockerReference"/>.
/// Returns <c>null</c> if the reference contains unresolved variables or cannot be parsed.
/// </summary>
/// <param name="imageReference">The image reference string to parse.</param>
/// <returns>A <see cref="DockerReference"/> if parsing succeeds; otherwise <c>null</c>.</returns>
public static DockerReference? TryParseImageReference(string imageReference)
{
if (HasUnresolvedVariables(imageReference))
{
return null;
}

try
{
return ParseFamiliarName(imageReference);
}
catch
{
return null;
Comment thread
jpinz marked this conversation as resolved.
}
}

/// <summary>
/// Parses an image reference and registers it with the recorder if valid.
/// Silently skips references with unresolved variables or that cannot be parsed.
/// </summary>
/// <param name="imageReference">The image reference string to parse.</param>
/// <param name="recorder">The component recorder to register the image with.</param>
public static void TryRegisterImageReference(string imageReference, ISingleFileComponentRecorder recorder)
{
var dockerRef = TryParseImageReference(imageReference);
if (dockerRef != null)
{
recorder.RegisterUsage(new DetectedComponent(dockerRef.ToTypedDockerReferenceComponent()));
}
Comment thread
jpinz marked this conversation as resolved.
Outdated
}
Comment thread
jpinz marked this conversation as resolved.

public static DockerReference ParseQualifiedName(string qualifiedName)
{
var regexp = DockerRegex.ReferenceRegexp;
Expand Down
3 changes: 3 additions & 0 deletions src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,7 @@ public enum DetectorClass

/// <summary> Indicates a detector applies to Swift packages.</summary>
Swift,

/// <summary>Indicates a detector applies to Docker Compose image references.</summary>
DockerCompose,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#nullable enable
Comment thread
jpinz marked this conversation as resolved.
Outdated
namespace Microsoft.ComponentDetection.Detectors.DockerCompose;

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.ComponentDetection.Common;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Contracts.Internal;
using Microsoft.ComponentDetection.Contracts.TypedComponent;
Comment thread
jpinz marked this conversation as resolved.
using Microsoft.Extensions.Logging;
using YamlDotNet.RepresentationModel;

public class DockerComposeComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
{
public DockerComposeComponentDetector(
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
IObservableDirectoryWalkerFactory walkerFactory,
ILogger<DockerComposeComponentDetector> logger)
{
this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
this.Scanner = walkerFactory;
this.Logger = logger;
}

public override string Id => "DockerCompose";

public override IList<string> SearchPatterns { get; } =
[
"docker-compose.yml", "docker-compose.yaml",
"docker-compose.*.yml", "docker-compose.*.yaml",
"compose.yml", "compose.yaml",
"compose.*.yml", "compose.*.yaml",
];

public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.DockerReference];

public override int Version => 1;

public override IEnumerable<string> Categories => [nameof(DetectorClass.DockerCompose)];

protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary<string, string> detectorArgs, CancellationToken cancellationToken = default)
{
var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder;
var file = processRequest.ComponentStream;

try
{
this.Logger.LogInformation("Discovered Docker Compose file: {Location}", file.Location);

string contents;
using (var reader = new StreamReader(file.Stream))
{
contents = await reader.ReadToEndAsync(cancellationToken);
}

var yaml = new YamlStream();
yaml.Load(new StringReader(contents));

if (yaml.Documents.Count == 0)
{
return;
}

foreach (var document in yaml.Documents)
{
if (document.RootNode is YamlMappingNode rootMapping)
{
this.ExtractImageReferences(rootMapping, singleFileComponentRecorder);
}
}
}
catch (Exception e)
{
this.Logger.LogError(e, "Failed to parse Docker Compose file: {Location}", file.Location);
}
}

private static YamlMappingNode? GetMappingChild(YamlMappingNode parent, string key)
{
foreach (var entry in parent.Children)
{
if (entry.Key is YamlScalarNode scalarKey && string.Equals(scalarKey.Value, key, StringComparison.OrdinalIgnoreCase))
{
return entry.Value as YamlMappingNode;
}
}

return null;
}

private void ExtractImageReferences(YamlMappingNode rootMapping, ISingleFileComponentRecorder recorder)
{
var services = GetMappingChild(rootMapping, "services");
if (services == null)
{
return;
}

foreach (var serviceEntry in services.Children)
{
if (serviceEntry.Value is not YamlMappingNode serviceMapping)
{
continue;
}

// Extract direct image: references
foreach (var entry in serviceMapping.Children)
{
var key = (entry.Key as YamlScalarNode)?.Value;
if (string.Equals(key, "image", StringComparison.OrdinalIgnoreCase))
{
var imageRef = (entry.Value as YamlScalarNode)?.Value;
if (!string.IsNullOrWhiteSpace(imageRef))
{
DockerReferenceUtility.TryRegisterImageReference(imageRef, recorder);
Comment thread
jpinz marked this conversation as resolved.
Outdated
}
}
}
}
}
}
Comment thread
jpinz marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
#nullable disable
#nullable enable
namespace Microsoft.ComponentDetection.Detectors.Dockerfile;

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.ComponentDetection.Common;
Expand Down Expand Up @@ -36,7 +34,7 @@ public DockerfileComponentDetector(

public override string Id { get; } = "DockerReference";

public override IEnumerable<string> Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.DockerReference)];
public override IEnumerable<string> Categories => [nameof(DetectorClass.DockerReference)];

public override IList<string> SearchPatterns { get; } = ["dockerfile", "dockerfile.*", "*.dockerfile"];

Expand Down Expand Up @@ -84,12 +82,12 @@ private Task ParseDockerFileAsync(string fileContents, string fileLocation, ISin
return Task.CompletedTask;
}

private DockerReference ProcessDockerfileConstruct(DockerfileConstruct construct, char escapeChar, Dictionary<string, string> stageNameMap)
private DockerReference? ProcessDockerfileConstruct(DockerfileConstruct construct, char escapeChar, Dictionary<string, string> stageNameMap)
{
try
{
var instructionKeyword = construct.Type;
DockerReference baseImage = null;
DockerReference? baseImage = null;
if (instructionKeyword == ConstructType.Instruction)
{
var constructType = construct.GetType().Name;
Comment thread
jpinz marked this conversation as resolved.
Outdated
Expand All @@ -115,10 +113,9 @@ private DockerReference ProcessDockerfileConstruct(DockerfileConstruct construct
}
}

private DockerReference ParseFromInstruction(DockerfileConstruct construct, char escapeChar, Dictionary<string, string> stageNameMap)
private DockerReference? ParseFromInstruction(DockerfileConstruct construct, char escapeChar, Dictionary<string, string> stageNameMap)
{
var tokens = construct.Tokens.ToArray();
var resolvedFromStatement = construct.ResolveVariables(escapeChar).TrimEnd();
var resolvedFromStatement = construct.ResolveVariables(escapeChar)?.TrimEnd();
var fromInstruction = (FromInstruction)construct;
var reference = fromInstruction.ImageName;
if (string.IsNullOrWhiteSpace(resolvedFromStatement) || string.IsNullOrEmpty(reference))
Expand All @@ -143,25 +140,15 @@ private DockerReference ParseFromInstruction(DockerfileConstruct construct, char

if (!string.IsNullOrEmpty(stageNameReference))
{
if (this.HasUnresolvedVariables(stageNameReference))
{
return null;
}

return DockerReferenceUtility.ParseFamiliarName(stageNameReference);
}

if (this.HasUnresolvedVariables(reference))
{
return null;
return DockerReferenceUtility.TryParseImageReference(stageNameReference);
}

return DockerReferenceUtility.ParseFamiliarName(reference);
return DockerReferenceUtility.TryParseImageReference(reference);
Comment thread
jpinz marked this conversation as resolved.
Outdated
}

private DockerReference ParseCopyInstruction(DockerfileConstruct construct, char escapeChar, Dictionary<string, string> stageNameMap)
private DockerReference? ParseCopyInstruction(DockerfileConstruct construct, char escapeChar, Dictionary<string, string> stageNameMap)
{
var resolvedCopyStatement = construct.ResolveVariables(escapeChar).TrimEnd();
var resolvedCopyStatement = construct.ResolveVariables(escapeChar)?.TrimEnd();
var copyInstruction = (CopyInstruction)construct;
var reference = copyInstruction.FromStageName;
if (string.IsNullOrWhiteSpace(resolvedCopyStatement) || string.IsNullOrWhiteSpace(reference))
Expand All @@ -172,26 +159,9 @@ private DockerReference ParseCopyInstruction(DockerfileConstruct construct, char
stageNameMap.TryGetValue(reference, out var stageNameReference);
if (!string.IsNullOrEmpty(stageNameReference))
{
if (this.HasUnresolvedVariables(stageNameReference))
{
return null;
}
else
{
return DockerReferenceUtility.ParseFamiliarName(stageNameReference);
}
return DockerReferenceUtility.TryParseImageReference(stageNameReference);
}

Comment thread
jpinz marked this conversation as resolved.
if (this.HasUnresolvedVariables(reference))
{
return null;
}

return DockerReferenceUtility.ParseFamiliarName(reference);
}

private bool HasUnresolvedVariables(string reference)
{
return new Regex("[${}]").IsMatch(reference);
return DockerReferenceUtility.TryParseImageReference(reference);
}
Comment thread
jpinz marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace Microsoft.ComponentDetection.Orchestrator.Extensions;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Detectors.CocoaPods;
using Microsoft.ComponentDetection.Detectors.Conan;
using Microsoft.ComponentDetection.Detectors.DockerCompose;
using Microsoft.ComponentDetection.Detectors.Dockerfile;
using Microsoft.ComponentDetection.Detectors.DotNet;
using Microsoft.ComponentDetection.Detectors.Go;
Expand Down Expand Up @@ -84,6 +85,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
// Conda
services.AddSingleton<IComponentDetector, CondaLockComponentDetector>();

// Docker Compose
services.AddSingleton<IComponentDetector, DockerComposeComponentDetector>();

// Dockerfile
services.AddSingleton<IComponentDetector, DockerfileComponentDetector>();

Expand Down
Loading
Loading