diff --git a/docs/detectors/README.md b/docs/detectors/README.md
index f15c1a72a..5d96ec22f 100644
--- a/docs/detectors/README.md
+++ b/docs/detectors/README.md
@@ -18,6 +18,12 @@
| -------------------------- | ---------- |
| CondaLockComponentDetector | DefaultOff |
+- [Docker Compose](dockercompose.md)
+
+| Detector | Status |
+| ------------------------------ | ---------- |
+| DockerComposeComponentDetector | DefaultOff |
+
- [Dockerfile](dockerfile.md)
| Detector | Status |
diff --git a/docs/detectors/dockercompose.md b/docs/detectors/dockercompose.md
new file mode 100644
index 000000000..f05e026ad
--- /dev/null
+++ b/docs/detectors/dockercompose.md
@@ -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
\ No newline at end of file
diff --git a/src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs b/src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs
index 022f8b612..fe3122647 100644
--- a/src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs
+++ b/src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs
@@ -29,6 +29,7 @@ namespace Microsoft.ComponentDetection.Common;
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.ComponentDetection.Contracts;
+using Microsoft.Extensions.Logging;
public static class DockerReferenceUtility
{
@@ -38,6 +39,67 @@ public static class DockerReferenceUtility
private const string LEGACYDEFAULTDOMAIN = "index.docker.io";
private const string OFFICIALREPOSITORYNAME = "library";
+ ///
+ /// Returns true if the reference contains unresolved variable placeholders (e.g., ${VAR}, {{ .Values.tag }}).
+ /// Such references should be skipped before calling or .
+ ///
+ /// The image reference string to check.
+ /// true if the reference contains variable placeholder characters; otherwise false.
+ public static bool HasUnresolvedVariables(string reference) =>
+ reference.IndexOfAny(['$', '{', '}']) >= 0;
+
+ ///
+ /// Attempts to parse an image reference string into a .
+ /// Returns null if the reference contains unresolved variables or cannot be parsed.
+ ///
+ /// The image reference string to parse.
+ /// Optional logger for recording parse failures.
+ /// A if parsing succeeds; otherwise null.
+ public static DockerReference? TryParseImageReference(string imageReference, ILogger? logger = null)
+ {
+ if (HasUnresolvedVariables(imageReference))
+ {
+ return null;
+ }
+
+ try
+ {
+ return ParseFamiliarName(imageReference);
+ }
+ catch (DockerReferenceException ex)
+ {
+ logger?.LogWarning(ex, "Failed to parse image reference '{ImageReference}'.", imageReference);
+ return null;
+ }
+ }
+
+ ///
+ /// Parses an image reference and registers it with the recorder if valid.
+ /// Skips references with unresolved variables or that cannot be parsed,
+ /// logging a warning for parse failures so that remaining entries continue to be processed.
+ ///
+ /// The image reference string to parse.
+ /// The component recorder to register the image with.
+ /// Optional logger for recording parse failures.
+ public static void TryRegisterImageReference(string imageReference, ISingleFileComponentRecorder recorder, ILogger? logger = null)
+ {
+ var dockerRef = TryParseImageReference(imageReference, logger);
+ TryRegisterImageReference(dockerRef, recorder);
+ }
+
+ ///
+ /// Registers a pre-parsed with the recorder if non-null.
+ ///
+ /// The parsed docker reference, or null to skip.
+ /// The component recorder to register the image with.
+ public static void TryRegisterImageReference(DockerReference? dockerReference, ISingleFileComponentRecorder recorder)
+ {
+ if (dockerReference != null)
+ {
+ recorder.RegisterUsage(new DetectedComponent(dockerReference.ToTypedDockerReferenceComponent()));
+ }
+ }
+
public static DockerReference ParseQualifiedName(string qualifiedName)
{
var regexp = DockerRegex.ReferenceRegexp;
diff --git a/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs b/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
index becfa4f9f..6b0b91cd4 100644
--- a/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
+++ b/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
@@ -47,4 +47,7 @@ public enum DetectorClass
/// Indicates a detector applies to Swift packages.
Swift,
+
+ /// Indicates a detector applies to Docker Compose image references.
+ DockerCompose,
}
diff --git a/src/Microsoft.ComponentDetection.Detectors/dockercompose/DockerComposeComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dockercompose/DockerComposeComponentDetector.cs
new file mode 100644
index 000000000..a582728a1
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/dockercompose/DockerComposeComponentDetector.cs
@@ -0,0 +1,123 @@
+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;
+using Microsoft.Extensions.Logging;
+using YamlDotNet.RepresentationModel;
+
+public class DockerComposeComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
+{
+ public DockerComposeComponentDetector(
+ IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
+ IObservableDirectoryWalkerFactory walkerFactory,
+ ILogger logger)
+ {
+ this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
+ this.Scanner = walkerFactory;
+ this.Logger = logger;
+ }
+
+ public override string Id => "DockerCompose";
+
+ public override IList 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 SupportedComponentTypes => [ComponentType.DockerReference];
+
+ public override int Version => 1;
+
+ public override IEnumerable Categories => [nameof(DetectorClass.DockerCompose)];
+
+ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary 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, this.Logger);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs
index d1d19f3a9..e314fb82d 100644
--- a/src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs
+++ b/src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs
@@ -1,11 +1,8 @@
-#nullable disable
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;
@@ -36,7 +33,7 @@ public DockerfileComponentDetector(
public override string Id { get; } = "DockerReference";
- public override IEnumerable Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.DockerReference)];
+ public override IEnumerable Categories => [nameof(DetectorClass.DockerReference)];
public override IList SearchPatterns { get; } = ["dockerfile", "dockerfile.*", "*.dockerfile"];
@@ -75,38 +72,22 @@ private Task ParseDockerFileAsync(string fileContents, string fileLocation, ISin
foreach (var instruction in instructions)
{
var imageReference = this.ProcessDockerfileConstruct(instruction, dockerfileModel.EscapeChar, stageNameMap);
- if (imageReference != null)
- {
- singleFileComponentRecorder.RegisterUsage(new DetectedComponent(imageReference.ToTypedDockerReferenceComponent()));
- }
+ DockerReferenceUtility.TryRegisterImageReference(imageReference, singleFileComponentRecorder);
}
return Task.CompletedTask;
}
- private DockerReference ProcessDockerfileConstruct(DockerfileConstruct construct, char escapeChar, Dictionary stageNameMap)
+ private DockerReference? ProcessDockerfileConstruct(DockerfileConstruct construct, char escapeChar, Dictionary stageNameMap)
{
try
{
- var instructionKeyword = construct.Type;
- DockerReference baseImage = null;
- if (instructionKeyword == ConstructType.Instruction)
+ return construct switch
{
- var constructType = construct.GetType().Name;
- switch (constructType)
- {
- case "FromInstruction":
- baseImage = this.ParseFromInstruction(construct, escapeChar, stageNameMap);
- break;
- case "CopyInstruction":
- baseImage = this.ParseCopyInstruction(construct, escapeChar, stageNameMap);
- break;
- default:
- break;
- }
- }
-
- return baseImage;
+ FromInstruction => this.ParseFromInstruction(construct, escapeChar, stageNameMap),
+ CopyInstruction => this.ParseCopyInstruction(construct, escapeChar, stageNameMap),
+ _ => null,
+ };
}
catch (Exception e)
{
@@ -115,10 +96,9 @@ private DockerReference ProcessDockerfileConstruct(DockerfileConstruct construct
}
}
- private DockerReference ParseFromInstruction(DockerfileConstruct construct, char escapeChar, Dictionary stageNameMap)
+ private DockerReference? ParseFromInstruction(DockerfileConstruct construct, char escapeChar, Dictionary 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))
@@ -143,25 +123,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, this.Logger);
}
- return DockerReferenceUtility.ParseFamiliarName(reference);
+ return DockerReferenceUtility.TryParseImageReference(reference, this.Logger);
}
- private DockerReference ParseCopyInstruction(DockerfileConstruct construct, char escapeChar, Dictionary stageNameMap)
+ private DockerReference? ParseCopyInstruction(DockerfileConstruct construct, char escapeChar, Dictionary 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))
@@ -172,26 +142,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, this.Logger);
}
- if (this.HasUnresolvedVariables(reference))
- {
- return null;
- }
-
- return DockerReferenceUtility.ParseFamiliarName(reference);
- }
-
- private bool HasUnresolvedVariables(string reference)
- {
- return new Regex("[${}]").IsMatch(reference);
+ return DockerReferenceUtility.TryParseImageReference(reference, this.Logger);
}
}
diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
index f0e3233cf..322e41fc6 100644
--- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
@@ -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;
@@ -83,6 +84,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
// Conda
services.AddSingleton();
+ // Docker Compose
+ services.AddSingleton();
+
// Dockerfile
services.AddSingleton();
diff --git a/test/Microsoft.ComponentDetection.Common.Tests/DockerReferenceUtilityTests.cs b/test/Microsoft.ComponentDetection.Common.Tests/DockerReferenceUtilityTests.cs
index bc4f4f1d4..d50c65be0 100644
--- a/test/Microsoft.ComponentDetection.Common.Tests/DockerReferenceUtilityTests.cs
+++ b/test/Microsoft.ComponentDetection.Common.Tests/DockerReferenceUtilityTests.cs
@@ -4,7 +4,10 @@ namespace Microsoft.ComponentDetection.Common.Tests;
using System;
using AwesomeAssertions;
using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.BcdeModels;
+using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
[TestClass]
[TestCategory("Governance/All")]
@@ -268,4 +271,116 @@ public void ParseAll_ParsesFamiliarNames()
result.Should().NotBeNull();
result.Should().BeAssignableTo();
}
+
+ [TestMethod]
+ public void HasUnresolvedVariables_ReturnsTrueForDollarSign()
+ {
+ DockerReferenceUtility.HasUnresolvedVariables("${MY_IMAGE}:latest").Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void HasUnresolvedVariables_ReturnsTrueForBraces()
+ {
+ DockerReferenceUtility.HasUnresolvedVariables("{{ .Values.image }}").Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void HasUnresolvedVariables_ReturnsFalseForPlainReference()
+ {
+ DockerReferenceUtility.HasUnresolvedVariables("docker.io/library/nginx:latest").Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void TryParseImageReference_ReturnsNullForUnresolvedVariables()
+ {
+ DockerReferenceUtility.TryParseImageReference("${IMAGE}:latest").Should().BeNull();
+ }
+
+ [TestMethod]
+ public void TryParseImageReference_ReturnsNullForInvalidReference()
+ {
+ DockerReferenceUtility.TryParseImageReference("docker.io/library/Nginx").Should().BeNull();
+ }
+
+ [TestMethod]
+ public void TryParseImageReference_ReturnsParsedReferenceForValidInput()
+ {
+ var result = DockerReferenceUtility.TryParseImageReference("nginx:latest");
+
+ result.Should().NotBeNull();
+ result.Should().BeAssignableTo();
+ }
+
+ [TestMethod]
+ public void TryParseImageReference_ReturnsParsedReferenceForDigest()
+ {
+ var digest = $"sha256:{new string('a', 64)}";
+ var result = DockerReferenceUtility.TryParseImageReference($"nginx@{digest}");
+
+ result.Should().NotBeNull();
+ result.Should().BeAssignableTo();
+ ((CanonicalReference)result).Digest.Should().Be(digest);
+ }
+
+ [TestMethod]
+ public void TryParseImageReference_ReturnsParsedReferenceForTagAndDigest()
+ {
+ var digest = $"sha256:{new string('a', 64)}";
+ var result = DockerReferenceUtility.TryParseImageReference($"nginx:latest@{digest}");
+
+ result.Should().NotBeNull();
+ result.Should().BeAssignableTo();
+ var dualRef = (DualReference)result;
+ dualRef.Tag.Should().Be("latest");
+ dualRef.Digest.Should().Be(digest);
+ }
+
+ [TestMethod]
+ public void TryRegisterImageReference_RegistersValidReference()
+ {
+ var recorder = new Mock();
+
+ DockerReferenceUtility.TryRegisterImageReference("nginx:latest", recorder.Object);
+
+ recorder.Verify(r => r.RegisterUsage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [TestMethod]
+ public void TryRegisterImageReference_SkipsUnresolvedVariables()
+ {
+ var recorder = new Mock();
+
+ DockerReferenceUtility.TryRegisterImageReference("${IMAGE}", recorder.Object);
+
+ recorder.Verify(r => r.RegisterUsage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public void TryRegisterImageReference_SkipsInvalidReference()
+ {
+ var recorder = new Mock();
+
+ DockerReferenceUtility.TryRegisterImageReference("docker.io/library/Nginx", recorder.Object);
+
+ recorder.Verify(r => r.RegisterUsage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public void TryRegisterImageReference_LogsWarningForInvalidReference()
+ {
+ var recorder = new Mock();
+ var logger = new Mock();
+
+ DockerReferenceUtility.TryRegisterImageReference("docker.io/library/Nginx", recorder.Object, logger.Object);
+
+ recorder.Verify(r => r.RegisterUsage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
}
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DockerComposeComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DockerComposeComponentDetectorTests.cs
new file mode 100644
index 000000000..64fcbd56a
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DockerComposeComponentDetectorTests.cs
@@ -0,0 +1,276 @@
+#nullable enable
+namespace Microsoft.ComponentDetection.Detectors.Tests;
+
+using System.Linq;
+using System.Threading.Tasks;
+using AwesomeAssertions;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.ComponentDetection.Detectors.DockerCompose;
+using Microsoft.ComponentDetection.TestsUtilities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[TestClass]
+[TestCategory("Governance/All")]
+[TestCategory("Governance/ComponentDetection")]
+public class DockerComposeComponentDetectorTests : BaseDetectorTest
+{
+ [TestMethod]
+ public async Task TestCompose_SingleServiceImageAsync()
+ {
+ var composeYaml = @"
+version: '3'
+services:
+ web:
+ image: nginx:1.21
+ ports:
+ - ""80:80""
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.yml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().ContainSingle();
+
+ var dockerRef = components.First().Component as DockerReferenceComponent;
+ dockerRef.Should().NotBeNull();
+ dockerRef.Repository.Should().Be("library/nginx");
+ dockerRef.Tag.Should().Be("1.21");
+ }
+
+ [TestMethod]
+ public async Task TestCompose_MultipleServicesAsync()
+ {
+ var composeYaml = @"
+version: '3'
+services:
+ web:
+ image: nginx:1.21
+ db:
+ image: postgres:15
+ cache:
+ image: redis:7-alpine
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.yml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().HaveCount(3);
+ }
+
+ [TestMethod]
+ public async Task TestCompose_FullRegistryImageAsync()
+ {
+ var composeYaml = @"
+services:
+ app:
+ image: ghcr.io/myorg/myapp:v2.0
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("compose.yaml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().ContainSingle();
+
+ var dockerRef = components.First().Component as DockerReferenceComponent;
+ dockerRef.Should().NotBeNull();
+ dockerRef.Domain.Should().Be("ghcr.io");
+ dockerRef.Repository.Should().Be("myorg/myapp");
+ dockerRef.Tag.Should().Be("v2.0");
+ }
+
+ [TestMethod]
+ public async Task TestCompose_BuildOnlyServiceIgnoredAsync()
+ {
+ var composeYaml = @"
+version: '3'
+services:
+ app:
+ build: ./app
+ ports:
+ - ""3000:3000""
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.yml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestCompose_MixedBuildAndImageAsync()
+ {
+ var composeYaml = @"
+version: '3'
+services:
+ app:
+ build: ./app
+ db:
+ image: postgres:15
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.yml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().ContainSingle();
+
+ var dockerRef = components.First().Component as DockerReferenceComponent;
+ dockerRef.Should().NotBeNull();
+ dockerRef.Repository.Should().Be("library/postgres");
+ dockerRef.Tag.Should().Be("15");
+ }
+
+ [TestMethod]
+ public async Task TestCompose_NoServicesKeyAsync()
+ {
+ var composeYaml = @"
+version: '3'
+networks:
+ frontend:
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.yml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestCompose_ImageWithDigestAsync()
+ {
+ var composeYaml = @"
+services:
+ app:
+ image: nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.yaml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().ContainSingle();
+
+ var dockerRef = components.First().Component as DockerReferenceComponent;
+ dockerRef.Should().NotBeNull();
+ dockerRef.Digest.Should().Be("sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
+ }
+
+ [TestMethod]
+ public async Task TestCompose_ImageWithTagAndDigestAsync()
+ {
+ var composeYaml = @"
+services:
+ app:
+ image: nginx:1.21@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.yaml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().ContainSingle();
+
+ var dockerRef = components.First().Component as DockerReferenceComponent;
+ dockerRef.Should().NotBeNull();
+ dockerRef.Tag.Should().Be("1.21");
+ dockerRef.Digest.Should().Be("sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
+ }
+
+ [TestMethod]
+ public async Task TestCompose_OverrideFileAsync()
+ {
+ var composeYaml = @"
+services:
+ web:
+ image: myregistry.io/web:latest
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.override.yml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().ContainSingle();
+ }
+
+ [TestMethod]
+ public async Task TestCompose_UnresolvedVariableSkippedAsync()
+ {
+ var composeYaml = @"
+services:
+ app:
+ image: ${REGISTRY}/app:${TAG}
+ db:
+ image: postgres:15
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("docker-compose.yml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+
+ // Only the literal image reference (postgres:15) should be registered;
+ // the variable-interpolated image (${REGISTRY}/app:${TAG}) should be silently skipped.
+ components.Should().ContainSingle();
+ var dockerRef = components.First().Component as DockerReferenceComponent;
+ dockerRef.Should().NotBeNull();
+ dockerRef.Repository.Should().Be("library/postgres");
+ }
+
+ [TestMethod]
+ public async Task TestCompose_ComposeOverrideFileAsync()
+ {
+ var composeYaml = @"
+services:
+ web:
+ image: nginx:1.21
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("compose.override.yml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().ContainSingle();
+ }
+
+ [TestMethod]
+ public async Task TestCompose_ComposeOverrideYamlAsync()
+ {
+ var composeYaml = @"
+services:
+ db:
+ image: postgres:15
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("compose.prod.yaml", composeYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().ContainSingle();
+ }
+}
diff --git a/test/Microsoft.ComponentDetection.VerificationTests/resources/dockercompose/docker-compose.override.yml b/test/Microsoft.ComponentDetection.VerificationTests/resources/dockercompose/docker-compose.override.yml
new file mode 100644
index 000000000..6075b6789
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.VerificationTests/resources/dockercompose/docker-compose.override.yml
@@ -0,0 +1,5 @@
+version: '3.8'
+services:
+ debug:
+ image: busybox:1.35
+ command: ['sh', '-c', 'sleep 3600']
\ No newline at end of file
diff --git a/test/Microsoft.ComponentDetection.VerificationTests/resources/dockercompose/docker-compose.yml b/test/Microsoft.ComponentDetection.VerificationTests/resources/dockercompose/docker-compose.yml
new file mode 100644
index 000000000..c5ce18f94
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.VerificationTests/resources/dockercompose/docker-compose.yml
@@ -0,0 +1,37 @@
+version: '3.8'
+services:
+ web:
+ image: nginx:1.21
+ ports:
+ - "8080:80"
+ depends_on:
+ - api
+ - db
+
+ api:
+ image: ghcr.io/myorg/myapp:v2.0
+ environment:
+ - DATABASE_URL=postgres://db:5432/app
+ depends_on:
+ - db
+
+ db:
+ image: postgres:15
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ environment:
+ - POSTGRES_PASSWORD=secret
+
+ cache:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+
+ digest-only:
+ image: nginx@sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31
+
+ tag-and-digest:
+ image: redis:7-alpine@sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31
+
+volumes:
+ db-data:
\ No newline at end of file