diff --git a/Directory.Packages.props b/Directory.Packages.props index d1cf38c0c..21e67f6a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,7 +48,7 @@ - + diff --git a/docs/detectors/dockerfile.md b/docs/detectors/dockerfile.md index 2da4fa3a4..febfad138 100644 --- a/docs/detectors/dockerfile.md +++ b/docs/detectors/dockerfile.md @@ -12,6 +12,8 @@ The `DockerfileComponentDetector` is an **Experimental** detector. It runs autom The Dockerfile detector parses Dockerfile syntax to extract Docker image references from `FROM` and `COPY --from` instructions. It uses the [Valleysoft.DockerfileModel](https://github.com/mthalman/DockerfileModel) library to parse Dockerfile syntax. +BuildKit [here-documents](https://docs.docker.com/reference/dockerfile/#here-documents) (for example `RUN < SupportedComponentTypes { get; } = [ComponentType.DockerReference]; - public override int Version => 1; + public override int Version => 2; protected override IList SkippedFolders => ["node_modules"]; diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DockerfileComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DockerfileComponentDetectorTests.cs index 90d685172..0baa8430f 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/DockerfileComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DockerfileComponentDetectorTests.cs @@ -305,4 +305,128 @@ public async Task TestDockerfile_FromWithUnresolvedArgVariableIsSkippedAsync() scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); componentRecorder.GetDetectedComponents().Should().BeEmpty(); } + + [TestMethod] + public async Task TestDockerfile_RunHeredocAsync() + { + // BuildKit heredoc syntax: https://docs.docker.com/reference/dockerfile/#here-documents + var dockerfile = @" +FROM ubuntu:noble + +RUN <>(); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("Dockerfile", dockerfile) + .AddServiceMock(loggerMock) + .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/ubuntu"); + dockerRef.Tag.Should().Be("noble"); + + loggerMock.Verify( + logger => logger.Log( + It.Is(level => level >= LogLevel.Warning), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + [TestMethod] + public async Task TestDockerfile_RunHeredocWithInterpreterAndSyntaxDirectiveAsync() + { + var dockerfile = @"# syntax=docker/dockerfile:1.4 +FROM ubuntu:noble-20260113 + +RUN < c.Component as DockerReferenceComponent) + .Where(c => c != null) + .Select(c => c!.Repository) + .ToList(); + repos.Should().Contain("dotnet/sdk"); + repos.Should().Contain("dotnet/runtime-deps"); + } + + [TestMethod] + public async Task TestDockerfile_HeredocBodyIsNotParsedAsInstructionsAsync() + { + // A heredoc body is opaque content, so a FROM line inside it is not a base image. + var dockerfile = @" +FROM alpine:3.20 +RUN < /tmp/Dockerfile.inner +FROM inner/image:1.0 +EOF +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("Dockerfile", dockerfile) + .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/alpine"); + dockerRef.Tag.Should().Be("3.20"); + } }