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