Skip to content

Commit 248e744

Browse files
jpinzCopilotCopilot
authored
Promote the Dockerfile, Docker Compose, and Helm Detectors to Experimental Status (#1803)
* Promote the Dockerfile, Docker Compose, and Helm Detectors to Experimental status Co-authored-by: Copilot <copilot@github.com> * docs: document opt-in mechanism for experimental detectors in dockerfile, dockercompose, and helm docs Agent-Logs-Url: https://github.com/microsoft/component-detection/sessions/c1b52ac3-7a11-4689-b324-b61c26014208 Co-authored-by: jpinz <8357054+jpinz@users.noreply.github.com> * Add DockerfileComponentDetectorTests Co-authored-by: Copilot <copilot@github.com> * Update the dockerfile.md docs Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jpinz <8357054+jpinz@users.noreply.github.com>
1 parent 4c08ecd commit 248e744

8 files changed

Lines changed: 279 additions & 13 deletions

File tree

docs/detectors/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@
2222

2323
| Detector | Status |
2424
| ------------------------------ | ---------- |
25-
| DockerComposeComponentDetector | DefaultOff |
25+
| DockerComposeComponentDetector | Experimental |
2626

2727
- [Dockerfile](dockerfile.md)
2828

2929
| Detector | Status |
3030
| --------------------------- | ---------- |
31-
| DockerfileComponentDetector | DefaultOff |
31+
| DockerfileComponentDetector | Experimental |
3232

3333
- [DotNet](dotnet.md)
3434

@@ -52,7 +52,7 @@
5252

5353
| Detector | Status |
5454
| ---------------------- | ---------- |
55-
| HelmComponentDetector | DefaultOff |
55+
| HelmComponentDetector | Experimental |
5656

5757
- [Ivy](ivy.md)
5858

docs/detectors/dockercompose.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Docker Compose detection depends on the following to successfully run:
66

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

9-
The `DockerComposeComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter.
9+
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).
1010

1111
## Detection strategy
1212

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

4343
## Known limitations
4444

45-
- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs DockerCompose=EnableIfDefaultOff`
45+
- **Experimental Status**: This detector runs automatically but its output is not included in scan results by default. To opt in, pass `--DetectorArgs DockerCompose=Enable`
4646
- **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
4747
- **Build-Only Services**: Services that only specify a `build` directive without an `image` field are not reported
4848
- **No Dependency Graph**: All detected images are registered as independent components without parent-child relationships

docs/detectors/dockerfile.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Dockerfile detection depends on the following to successfully run:
66

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

9-
The `DockerfileComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter.
9+
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).
1010

1111
## Detection strategy
1212

@@ -24,9 +24,15 @@ The detector extracts image references from `COPY --from=<image>` instructions t
2424
### Variable Resolution
2525
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.
2626

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

29-
- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs DockerReference=EnableIfDefaultOff`
35+
- **Experimental Status**: This detector runs automatically but its output is not included in scan results by default. To opt in, pass `--DetectorArgs DockerReference=Enable`
3036
- **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
3137
- **No Version Pinning Validation**: The detector does not warn about unpinned image versions (e.g., `latest` tags), which are generally discouraged in production Dockerfiles
32-
- **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
38+
- **Untagged Images Skipped**: Image references with neither a tag nor a digest (e.g. `FROM nginx`) are skipped because they cannot be uniquely identified

docs/detectors/helm.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Helm detection depends on the following to successfully run:
88
- 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
99
- Lowercase `chart.yaml` and `chart.yml` do not satisfy this requirement; the detector requires an uppercase `Chart.*` file name.
1010

11-
The `HelmComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter.
11+
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).
1212

1313
## Detection strategy
1414

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

4646
## Known limitations
4747

48-
- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs Helm=EnableIfDefaultOff`
48+
- **Experimental Status**: This detector runs automatically but its output is not included in scan results by default. To opt in, pass `--DetectorArgs Helm=Enable`
4949
- **Values Files Only**: Only files with `values` in the name are parsed for image references. Chart.yaml files are matched but not processed
5050
- **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
5151
- **Variable Resolution**: Image references containing unresolved Helm template expressions are not reported

src/Microsoft.ComponentDetection.Detectors/dockercompose/DockerComposeComponentDetector.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace Microsoft.ComponentDetection.Detectors.DockerCompose;
1212
using Microsoft.Extensions.Logging;
1313
using YamlDotNet.RepresentationModel;
1414

15-
public class DockerComposeComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
15+
public class DockerComposeComponentDetector : FileComponentDetector, IExperimentalDetector
1616
{
1717
public DockerComposeComponentDetector(
1818
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,

src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace Microsoft.ComponentDetection.Detectors.Dockerfile;
1212
using Microsoft.Extensions.Logging;
1313
using Valleysoft.DockerfileModel;
1414

15-
public class DockerfileComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
15+
public class DockerfileComponentDetector : FileComponentDetector, IExperimentalDetector
1616
{
1717
private readonly ICommandLineInvocationService commandLineInvocationService;
1818
private readonly IEnvironmentVariableService envVarService;

src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace Microsoft.ComponentDetection.Detectors.Helm;
1515
using Microsoft.Extensions.Logging;
1616
using YamlDotNet.RepresentationModel;
1717

18-
public class HelmComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
18+
public class HelmComponentDetector : FileComponentDetector, IExperimentalDetector
1919
{
2020
public HelmComponentDetector(
2121
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
#nullable enable
2+
namespace Microsoft.ComponentDetection.Detectors.Tests;
3+
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using AwesomeAssertions;
7+
using Microsoft.ComponentDetection.Contracts;
8+
using Microsoft.ComponentDetection.Contracts.TypedComponent;
9+
using Microsoft.ComponentDetection.Detectors.Dockerfile;
10+
using Microsoft.ComponentDetection.TestsUtilities;
11+
using Microsoft.VisualStudio.TestTools.UnitTesting;
12+
using Moq;
13+
14+
[TestClass]
15+
[TestCategory("Governance/All")]
16+
[TestCategory("Governance/ComponentDetection")]
17+
public class DockerfileComponentDetectorTests : BaseDetectorTest<DockerfileComponentDetector>
18+
{
19+
public DockerfileComponentDetectorTests() =>
20+
this.DetectorTestUtility
21+
.AddServiceMock(new Mock<ICommandLineInvocationService>())
22+
.AddServiceMock(new Mock<IEnvironmentVariableService>());
23+
24+
[TestMethod]
25+
public async Task TestDockerfile_SingleFromInstructionAsync()
26+
{
27+
var dockerfile = @"
28+
FROM nginx:1.21
29+
";
30+
31+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
32+
.WithFile("Dockerfile", dockerfile)
33+
.ExecuteDetectorAsync();
34+
35+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
36+
var components = componentRecorder.GetDetectedComponents();
37+
components.Should().ContainSingle();
38+
39+
var dockerRef = components.First().Component as DockerReferenceComponent;
40+
dockerRef.Should().NotBeNull();
41+
dockerRef!.Repository.Should().Be("library/nginx");
42+
dockerRef.Tag.Should().Be("1.21");
43+
dockerRef.Digest.Should().BeNull();
44+
}
45+
46+
[TestMethod]
47+
public async Task TestDockerfile_FromWithRegistryAsync()
48+
{
49+
var dockerfile = @"
50+
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
51+
WORKDIR /app
52+
";
53+
54+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
55+
.WithFile("Dockerfile", dockerfile)
56+
.ExecuteDetectorAsync();
57+
58+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
59+
var components = componentRecorder.GetDetectedComponents();
60+
components.Should().ContainSingle();
61+
62+
var dockerRef = components.First().Component as DockerReferenceComponent;
63+
dockerRef.Should().NotBeNull();
64+
dockerRef!.Domain.Should().Be("mcr.microsoft.com");
65+
dockerRef.Repository.Should().Be("dotnet/sdk");
66+
dockerRef.Tag.Should().Be("8.0");
67+
dockerRef.Digest.Should().BeNull();
68+
}
69+
70+
[TestMethod]
71+
public async Task TestDockerfile_FromWithDigestAsync()
72+
{
73+
var dockerfile = @"
74+
FROM nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1
75+
";
76+
77+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
78+
.WithFile("Dockerfile", dockerfile)
79+
.ExecuteDetectorAsync();
80+
81+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
82+
var components = componentRecorder.GetDetectedComponents();
83+
components.Should().ContainSingle();
84+
85+
var dockerRef = components.First().Component as DockerReferenceComponent;
86+
dockerRef.Should().NotBeNull();
87+
dockerRef!.Digest.Should().Be("sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
88+
dockerRef.Tag.Should().BeNull();
89+
}
90+
91+
[TestMethod]
92+
public async Task TestDockerfile_MultiStageBuildAsync()
93+
{
94+
var dockerfile = @"
95+
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
96+
WORKDIR /app
97+
COPY . .
98+
RUN dotnet publish -c Release -o out
99+
100+
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0 AS runtime
101+
WORKDIR /app
102+
COPY --from=build /app/out ./
103+
ENTRYPOINT [""/app/MyApp""]
104+
";
105+
106+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
107+
.WithFile("Dockerfile", dockerfile)
108+
.ExecuteDetectorAsync();
109+
110+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
111+
var components = componentRecorder.GetDetectedComponents();
112+
components.Should().HaveCount(2);
113+
114+
var repos = components
115+
.Select(c => c.Component as DockerReferenceComponent)
116+
.Where(c => c != null)
117+
.Select(c => c!.Repository)
118+
.ToList();
119+
repos.Should().Contain("dotnet/sdk");
120+
repos.Should().Contain("dotnet/runtime-deps");
121+
}
122+
123+
[TestMethod]
124+
public async Task TestDockerfile_CopyFromStageNameDoesNotCreateExtraComponentAsync()
125+
{
126+
// COPY --from=<stage> references a previous build stage and should not yield a separate image component.
127+
var dockerfile = @"
128+
FROM nginx:1.21 AS build
129+
FROM alpine:3.18 AS runtime
130+
COPY --from=build /etc/nginx/nginx.conf /etc/nginx/nginx.conf
131+
";
132+
133+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
134+
.WithFile("Dockerfile", dockerfile)
135+
.ExecuteDetectorAsync();
136+
137+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
138+
var components = componentRecorder.GetDetectedComponents().ToList();
139+
140+
// Two FROM instructions => two images. The COPY --from=build should resolve back to nginx:1.21,
141+
// which is already registered, so no new component is added.
142+
components.Should().HaveCount(2);
143+
var repos = components
144+
.Select(c => (c.Component as DockerReferenceComponent)!.Repository)
145+
.ToList();
146+
repos.Should().Contain("library/nginx");
147+
repos.Should().Contain("library/alpine");
148+
}
149+
150+
[TestMethod]
151+
public async Task TestDockerfile_CopyFromExternalImageAsync()
152+
{
153+
// COPY --from=<image> references an image directly and should produce a component.
154+
var dockerfile = @"
155+
FROM alpine:3.18
156+
COPY --from=busybox:1.36 /bin/busybox /usr/local/bin/busybox
157+
";
158+
159+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
160+
.WithFile("Dockerfile", dockerfile)
161+
.ExecuteDetectorAsync();
162+
163+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
164+
var components = componentRecorder.GetDetectedComponents().ToList();
165+
components.Should().HaveCount(2);
166+
167+
var repos = components
168+
.Select(c => (c.Component as DockerReferenceComponent)!.Repository)
169+
.ToList();
170+
repos.Should().Contain("library/alpine");
171+
repos.Should().Contain("library/busybox");
172+
}
173+
174+
[TestMethod]
175+
public async Task TestDockerfile_LowercaseFilenameAsync()
176+
{
177+
var dockerfile = @"FROM redis:7-alpine";
178+
179+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
180+
.WithFile("dockerfile", dockerfile)
181+
.ExecuteDetectorAsync();
182+
183+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
184+
componentRecorder.GetDetectedComponents().Should().ContainSingle();
185+
}
186+
187+
[TestMethod]
188+
public async Task TestDockerfile_ExtensionFilenameAsync()
189+
{
190+
var dockerfile = @"FROM redis:7-alpine";
191+
192+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
193+
.WithFile("app.dockerfile", dockerfile)
194+
.ExecuteDetectorAsync();
195+
196+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
197+
componentRecorder.GetDetectedComponents().Should().ContainSingle();
198+
}
199+
200+
[TestMethod]
201+
public async Task TestDockerfile_PrefixedFilenameAsync()
202+
{
203+
var dockerfile = @"FROM redis:7-alpine";
204+
205+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
206+
.WithFile("Dockerfile.prod", dockerfile)
207+
.ExecuteDetectorAsync();
208+
209+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
210+
componentRecorder.GetDetectedComponents().Should().ContainSingle();
211+
}
212+
213+
[TestMethod]
214+
public async Task TestDockerfile_NoFromInstructionsAsync()
215+
{
216+
var dockerfile = @"
217+
# This Dockerfile has no FROM instructions
218+
ARG BUILD_VERSION=1.0
219+
";
220+
221+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
222+
.WithFile("Dockerfile", dockerfile)
223+
.ExecuteDetectorAsync();
224+
225+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
226+
componentRecorder.GetDetectedComponents().Should().BeEmpty();
227+
}
228+
229+
[TestMethod]
230+
public async Task TestDockerfile_MalformedContentAsync()
231+
{
232+
// Garbage content should not crash the detector.
233+
var dockerfile = "this is not a dockerfile at all { ] : >";
234+
235+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
236+
.WithFile("Dockerfile", dockerfile)
237+
.ExecuteDetectorAsync();
238+
239+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
240+
componentRecorder.GetDetectedComponents().Should().BeEmpty();
241+
}
242+
243+
[TestMethod]
244+
public async Task TestDockerfile_FromWithUnresolvedArgVariableIsSkippedAsync()
245+
{
246+
// References containing unresolved variable placeholders (e.g. ${BASE_TAG}) cannot be parsed
247+
// into a concrete image identity and are skipped by DockerReferenceUtility.
248+
var dockerfile = @"
249+
ARG BASE_TAG=1.21
250+
FROM nginx:${BASE_TAG}
251+
";
252+
253+
var (scanResult, componentRecorder) = await this.DetectorTestUtility
254+
.WithFile("Dockerfile", dockerfile)
255+
.ExecuteDetectorAsync();
256+
257+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
258+
componentRecorder.GetDetectedComponents().Should().BeEmpty();
259+
}
260+
}

0 commit comments

Comments
 (0)