Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/detectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@

| Detector | Status |
| ------------------------------ | ---------- |
| DockerComposeComponentDetector | DefaultOff |
| DockerComposeComponentDetector | Experimental |

- [Dockerfile](dockerfile.md)

| Detector | Status |
| --------------------------- | ---------- |
| DockerfileComponentDetector | DefaultOff |
| DockerfileComponentDetector | Experimental |

- [DotNet](dotnet.md)

Expand All @@ -52,7 +52,7 @@

| Detector | Status |
| ---------------------- | ---------- |
| HelmComponentDetector | DefaultOff |
| HelmComponentDetector | Experimental |

- [Ivy](ivy.md)

Expand Down
4 changes: 2 additions & 2 deletions docs/detectors/dockercompose.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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.
The `DockerComposeComponentDetector` is an **Experimental** detector. It runs automatically during scans, but its output is not included in the final scan results. To include its output, pass `--DetectorArgs DockerCompose=Enable` (the key is the detector Id `DockerCompose`, not the class name).

## Detection strategy

Expand Down Expand Up @@ -42,7 +42,7 @@ Images containing unresolved variables (e.g., `${TAG}` or `${REGISTRY:-docker.io

## Known limitations

- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs DockerCompose=EnableIfDefaultOff`
- **Experimental Status**: This detector runs automatically but its output is not included in scan results by default. To opt in, pass `--DetectorArgs DockerCompose=Enable`
- **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
12 changes: 9 additions & 3 deletions docs/detectors/dockerfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Dockerfile detection depends on the following to successfully run:

- One or more Dockerfile files matching the patterns: `dockerfile`, `dockerfile.*`, or `*.dockerfile`

The `DockerfileComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter.
The `DockerfileComponentDetector` is an **Experimental** detector. It runs automatically during scans, but its output is not included in the final scan results. To include its output, pass `--DetectorArgs DockerReference=Enable` (the key is the detector Id `DockerReference`, not the class name).

## Detection strategy

Expand All @@ -24,9 +24,15 @@ The detector extracts image references from `COPY --from=<image>` instructions t
### Variable Resolution
The detector attempts to resolve Dockerfile variables using the `ResolveVariables()` method from the parser library. Images with unresolved variables (containing `$`, `{`, or `}` characters) are skipped to avoid reporting incomplete or incorrect references.

### Tag and Digest Support
The detector supports the full Docker reference grammar via `DockerReferenceUtility.ParseFamiliarName()`. Image references are parsed and reported with their tag, digest, or both:
- Tagged references (e.g., `FROM nginx:1.21`) populate the `Tag` field
- Canonical references with a SHA256 digest (e.g., `FROM nginx@sha256:abc...`) populate the `Digest` field
- Dual references with both a tag and a digest (e.g., `FROM nginx:1.21@sha256:abc...`) populate both fields

## Known limitations

- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs DockerReference=EnableIfDefaultOff`
- **Experimental Status**: This detector runs automatically but its output is not included in scan results by default. To opt in, pass `--DetectorArgs DockerReference=Enable`
- **Variable Resolution**: Image references containing unresolved Dockerfile `ARG` or `ENV` variables are not reported, which may lead to under-reporting in Dockerfiles that heavily use build-time variables
- **No Version Pinning Validation**: The detector does not warn about unpinned image versions (e.g., `latest` tags), which are generally discouraged in production Dockerfiles
- **No Digest Support**: While Docker supports content-addressable image references using SHA256 digests (e.g., `ubuntu@sha256:abc...`), the parsing and reporting of these references depends on the underlying `DockerReferenceUtility.ParseFamiliarName()` implementation
- **Untagged Images Skipped**: Image references with neither a tag nor a digest (e.g. `FROM nginx`) are skipped because they cannot be uniquely identified
4 changes: 2 additions & 2 deletions docs/detectors/helm.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Helm detection depends on the following to successfully run:
- A chart metadata file named `Chart.yaml` or `Chart.yml` must exist in the same directory for file discovery/co-location checks; only values files are parsed for image references
- Lowercase `chart.yaml` and `chart.yml` do not satisfy this requirement; the detector requires an uppercase `Chart.*` file name.

The `HelmComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter.
The `HelmComponentDetector` is an **Experimental** detector. It runs automatically during scans, but its output is not included in the final scan results. To include its output, pass `--DetectorArgs Helm=Enable` (the key is the detector Id `Helm`, not the class name).

## Detection strategy

Expand Down Expand Up @@ -45,7 +45,7 @@ Images containing unresolved variables (e.g., `{{ .Values.tag }}`) are skipped t

## Known limitations

- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs Helm=EnableIfDefaultOff`
- **Experimental Status**: This detector runs automatically but its output is not included in scan results by default. To opt in, pass `--DetectorArgs Helm=Enable`
- **Values Files Only**: Only files with `values` in the name are parsed for image references. Chart.yaml files are matched but not processed
- **Same-Directory Co-location**: Values files are only processed when a `Chart.yaml` (or `Chart.yml`) exists in the **same directory**. Values files in subdirectories of a chart root (e.g., `mychart/subdir/values.yaml`) will not be detected, even if a `Chart.yaml` exists in the parent directory
- **Variable Resolution**: Image references containing unresolved Helm template expressions are not reported
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Microsoft.ComponentDetection.Detectors.DockerCompose;
using Microsoft.Extensions.Logging;
using YamlDotNet.RepresentationModel;

public class DockerComposeComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
public class DockerComposeComponentDetector : FileComponentDetector, IExperimentalDetector
{
public DockerComposeComponentDetector(
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Microsoft.ComponentDetection.Detectors.Dockerfile;
using Microsoft.Extensions.Logging;
using Valleysoft.DockerfileModel;

public class DockerfileComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
public class DockerfileComponentDetector : FileComponentDetector, IExperimentalDetector
{
private readonly ICommandLineInvocationService commandLineInvocationService;
private readonly IEnvironmentVariableService envVarService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Microsoft.ComponentDetection.Detectors.Helm;
using Microsoft.Extensions.Logging;
using YamlDotNet.RepresentationModel;

public class HelmComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
public class HelmComponentDetector : FileComponentDetector, IExperimentalDetector
{
public HelmComponentDetector(
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
#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.Dockerfile;
using Microsoft.ComponentDetection.TestsUtilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

[TestClass]
[TestCategory("Governance/All")]
[TestCategory("Governance/ComponentDetection")]
public class DockerfileComponentDetectorTests : BaseDetectorTest<DockerfileComponentDetector>
{
public DockerfileComponentDetectorTests() =>
this.DetectorTestUtility
.AddServiceMock(new Mock<ICommandLineInvocationService>())
.AddServiceMock(new Mock<IEnvironmentVariableService>());

[TestMethod]
public async Task TestDockerfile_SingleFromInstructionAsync()
{
var dockerfile = @"
FROM nginx:1.21
";

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/nginx");
dockerRef.Tag.Should().Be("1.21");
dockerRef.Digest.Should().BeNull();
}

[TestMethod]
public async Task TestDockerfile_FromWithRegistryAsync()
{
var dockerfile = @"
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /app
";

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!.Domain.Should().Be("mcr.microsoft.com");
dockerRef.Repository.Should().Be("dotnet/sdk");
dockerRef.Tag.Should().Be("8.0");
dockerRef.Digest.Should().BeNull();
}

[TestMethod]
public async Task TestDockerfile_FromWithDigestAsync()
{
var dockerfile = @"
FROM nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1
";

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!.Digest.Should().Be("sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
dockerRef.Tag.Should().BeNull();
}

[TestMethod]
public async Task TestDockerfile_MultiStageBuildAsync()
{
var dockerfile = @"
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o out

FROM mcr.microsoft.com/dotnet/runtime-deps:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/out ./
ENTRYPOINT [""/app/MyApp""]
";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("Dockerfile", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
var components = componentRecorder.GetDetectedComponents();
components.Should().HaveCount(2);

var repos = components
.Select(c => 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_CopyFromStageNameDoesNotCreateExtraComponentAsync()
{
// COPY --from=<stage> references a previous build stage and should not yield a separate image component.
var dockerfile = @"
FROM nginx:1.21 AS build
FROM alpine:3.18 AS runtime
COPY --from=build /etc/nginx/nginx.conf /etc/nginx/nginx.conf
";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("Dockerfile", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
var components = componentRecorder.GetDetectedComponents().ToList();

// Two FROM instructions => two images. The COPY --from=build should resolve back to nginx:1.21,
// which is already registered, so no new component is added.
components.Should().HaveCount(2);
var repos = components
.Select(c => (c.Component as DockerReferenceComponent)!.Repository)
.ToList();
repos.Should().Contain("library/nginx");
repos.Should().Contain("library/alpine");
}

[TestMethod]
public async Task TestDockerfile_CopyFromExternalImageAsync()
{
// COPY --from=<image> references an image directly and should produce a component.
var dockerfile = @"
FROM alpine:3.18
COPY --from=busybox:1.36 /bin/busybox /usr/local/bin/busybox
";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("Dockerfile", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
var components = componentRecorder.GetDetectedComponents().ToList();
components.Should().HaveCount(2);

var repos = components
.Select(c => (c.Component as DockerReferenceComponent)!.Repository)
.ToList();
repos.Should().Contain("library/alpine");
repos.Should().Contain("library/busybox");
}

[TestMethod]
public async Task TestDockerfile_LowercaseFilenameAsync()
{
var dockerfile = @"FROM redis:7-alpine";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("dockerfile", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
componentRecorder.GetDetectedComponents().Should().ContainSingle();
}

[TestMethod]
public async Task TestDockerfile_ExtensionFilenameAsync()
{
var dockerfile = @"FROM redis:7-alpine";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("app.dockerfile", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
componentRecorder.GetDetectedComponents().Should().ContainSingle();
}

[TestMethod]
public async Task TestDockerfile_PrefixedFilenameAsync()
{
var dockerfile = @"FROM redis:7-alpine";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("Dockerfile.prod", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
componentRecorder.GetDetectedComponents().Should().ContainSingle();
}

[TestMethod]
public async Task TestDockerfile_NoFromInstructionsAsync()
{
var dockerfile = @"
# This Dockerfile has no FROM instructions
ARG BUILD_VERSION=1.0
";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("Dockerfile", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
componentRecorder.GetDetectedComponents().Should().BeEmpty();
}

[TestMethod]
public async Task TestDockerfile_MalformedContentAsync()
{
// Garbage content should not crash the detector.
var dockerfile = "this is not a dockerfile at all { ] : >";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("Dockerfile", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
componentRecorder.GetDetectedComponents().Should().BeEmpty();
}

[TestMethod]
public async Task TestDockerfile_FromWithUnresolvedArgVariableIsSkippedAsync()
{
// References containing unresolved variable placeholders (e.g. ${BASE_TAG}) cannot be parsed
// into a concrete image identity and are skipped by DockerReferenceUtility.
var dockerfile = @"
ARG BASE_TAG=1.21
FROM nginx:${BASE_TAG}
";

var (scanResult, componentRecorder) = await this.DetectorTestUtility
.WithFile("Dockerfile", dockerfile)
.ExecuteDetectorAsync();

scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
componentRecorder.GetDetectedComponents().Should().BeEmpty();
}
}
Loading