Skip to content

Commit cbfa1bc

Browse files
committed
Add the HelmComponentDetector and associated tests.
1 parent 9582bca commit cbfa1bc

8 files changed

Lines changed: 807 additions & 0 deletions

File tree

docs/detectors/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
| ----------------------- | ------ |
4949
| GradleComponentDetector | Stable |
5050

51+
- [Helm](helm.md)
52+
53+
| Detector | Status |
54+
| ---------------------- | ---------- |
55+
| HelmComponentDetector | DefaultOff |
56+
5157
- [Ivy](ivy.md)
5258

5359
| Detector | Status |

docs/detectors/helm.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Helm Detection
2+
3+
## Requirements
4+
5+
Helm detection depends on the following to successfully run:
6+
7+
- One or more Helm values files matching the patterns: `*values*.yaml`, `*values*.yml`
8+
- Chart metadata files (`Chart.yaml`, `Chart.yml`) are matched for file discovery but only values files are parsed for image references
9+
- `chart.yaml` and `chart.yml` aren't supported by Helm, the `c` has to be uppercased.
10+
11+
The `HelmComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter.
12+
13+
## Detection strategy
14+
15+
The Helm detector parses Helm values YAML files to extract Docker image references. It recursively walks the YAML tree looking for `image` keys.
16+
17+
### Direct Image Strings
18+
19+
The detector recognizes image references specified as simple strings:
20+
21+
```yaml
22+
image: nginx:1.21
23+
```
24+
25+
### Structured Image Objects
26+
27+
The detector also supports the common Helm chart pattern of structured image definitions:
28+
29+
```yaml
30+
image:
31+
registry: ghcr.io
32+
repository: org/myimage
33+
tag: v1.0
34+
```
35+
36+
The `registry` and `tag` fields are optional. When present, the detector reconstructs the full image reference. The `digest` field is also supported.
37+
38+
### Recursive Search
39+
40+
The detector recursively traverses all nested mappings and sequences in the values file, detecting image references at any depth in the YAML structure.
41+
42+
### Variable Resolution
43+
44+
Images containing unresolved variables (e.g., `{{ .Values.tag }}`) are skipped to avoid reporting incomplete or incorrect references. The detector checks for `$`, `{`, or `}` characters in image references.
45+
46+
## Known limitations
47+
48+
- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs Helm=EnableIfDefaultOff`
49+
- **Values Files Only**: Only files with `values` in the name are parsed for image references. Chart.yaml files are matched but not processed
50+
- **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
51+
- **Variable Resolution**: Image references containing unresolved Helm template expressions are not reported
52+
- **No Dependency Graph**: All detected images are registered as independent components without parent-child relationships

src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,7 @@ public enum DetectorClass
5050

5151
/// <summary>Indicates a detector applies to Docker Compose image references.</summary>
5252
DockerCompose,
53+
54+
/// <summary>Indicates a detector applies to Helm chart image references.</summary>
55+
Helm,
5356
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Helm;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Linq;
7+
using System.Reactive.Linq;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.ComponentDetection.Common;
11+
using Microsoft.ComponentDetection.Contracts;
12+
using Microsoft.ComponentDetection.Contracts.Internal;
13+
using Microsoft.ComponentDetection.Contracts.TypedComponent;
14+
using Microsoft.Extensions.Logging;
15+
using YamlDotNet.RepresentationModel;
16+
17+
public class HelmComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
18+
{
19+
public HelmComponentDetector(
20+
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
21+
IObservableDirectoryWalkerFactory walkerFactory,
22+
ILogger<HelmComponentDetector> logger)
23+
{
24+
this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
25+
this.Scanner = walkerFactory;
26+
this.Logger = logger;
27+
}
28+
29+
public override string Id => "Helm";
30+
31+
public override IList<string> SearchPatterns { get; } =
32+
[
33+
"Chart.yaml", "Chart.yml",
34+
"*values*.yaml", "*values*.yml",
35+
];
36+
37+
public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.DockerReference];
38+
39+
public override int Version => 1;
40+
41+
public override IEnumerable<string> Categories => [nameof(DetectorClass.Helm)];
42+
43+
/// <summary>
44+
/// Pre-filters scan work to only values files co-located with a Chart.yaml/Chart.yml.
45+
/// Materializes all matched files, identifies Helm chart directories, then filters.
46+
/// </summary>
47+
/// <returns>An observable of only the values-file requests in Helm chart directories.</returns>
48+
protected override async Task<IObservable<ProcessRequest>> OnPrepareDetectionAsync(
49+
IObservable<ProcessRequest> processRequests,
50+
IDictionary<string, string> detectorArgs,
51+
CancellationToken cancellationToken = default)
52+
{
53+
var allRequests = await processRequests.ToList();
54+
55+
var chartDirectories = new HashSet<string>(
56+
allRequests
57+
.Where(r => IsChartFile(Path.GetFileName(r.ComponentStream.Location)))
58+
.Select(r => Path.GetDirectoryName(r.ComponentStream.Location) ?? string.Empty),
59+
StringComparer.OrdinalIgnoreCase);
60+
61+
return allRequests
62+
.Where(r => IsValuesFile(Path.GetFileName(r.ComponentStream.Location))
63+
&& chartDirectories.Contains(Path.GetDirectoryName(r.ComponentStream.Location) ?? string.Empty))
64+
.ToObservable();
65+
}
66+
67+
protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary<string, string> detectorArgs, CancellationToken cancellationToken = default)
68+
{
69+
var file = processRequest.ComponentStream;
70+
71+
// OnPrepareDetectionAsync has already filtered to values files co-located
72+
// with a Chart.yaml — no further filename/directory checks are needed.
73+
try
74+
{
75+
this.Logger.LogInformation("Discovered Helm values file: {Location}", file.Location);
76+
77+
string contents;
78+
using (var reader = new StreamReader(file.Stream))
79+
{
80+
contents = await reader.ReadToEndAsync(cancellationToken);
81+
}
82+
83+
var yaml = new YamlStream();
84+
yaml.Load(new StringReader(contents));
85+
86+
if (yaml.Documents.Count == 0)
87+
{
88+
return;
89+
}
90+
91+
this.ExtractImageReferencesFromValues(yaml, processRequest.SingleFileComponentRecorder);
92+
}
93+
catch (Exception e)
94+
{
95+
this.Logger.LogError(e, "Failed to parse Helm file: {Location}", file.Location);
96+
}
97+
}
98+
99+
/// <summary>
100+
/// Checks if the given file name matches Helm chart file patterns (Chart.yaml or Chart.yml).
101+
/// </summary>
102+
/// <param name="fileName">The file name to check.</param>
103+
/// <returns>True if the file name matches Helm chart file patterns; otherwise, false.</returns>
104+
/// <remarks> The <c>C</c> in <c>Chart.yaml</c> is case-sensitive <see href="https://helm.sh/docs/chart_best_practices/conventions/#usage-of-the-words-helm-and-chart"/>.</remarks>
105+
private static bool IsChartFile(string fileName) =>
106+
fileName.Equals("Chart.yaml", StringComparison.Ordinal) ||
107+
fileName.Equals("Chart.yml", StringComparison.Ordinal);
108+
109+
private static bool IsValuesFile(string fileName) =>
110+
fileName.Contains("values", StringComparison.OrdinalIgnoreCase) &&
111+
(fileName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
112+
fileName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase));
113+
114+
private void ExtractImageReferencesFromValues(YamlStream yaml, ISingleFileComponentRecorder recorder)
115+
{
116+
foreach (var document in yaml.Documents)
117+
{
118+
if (document.RootNode is YamlMappingNode rootMapping)
119+
{
120+
this.WalkYamlForImages(rootMapping, recorder);
121+
}
122+
}
123+
}
124+
125+
/// <summary>
126+
/// Walks the YAML tree looking for image references. Handles two common patterns:
127+
/// 1. Direct image string: `image: nginx:1.21`
128+
/// 2. Structured image object: `image: { repository: nginx, tag: "1.21" }`.
129+
/// </summary>
130+
private void WalkYamlForImages(YamlMappingNode mapping, ISingleFileComponentRecorder recorder)
131+
{
132+
foreach (var entry in mapping.Children)
133+
{
134+
var key = (entry.Key as YamlScalarNode)?.Value;
135+
136+
if (string.Equals(key, "image", StringComparison.OrdinalIgnoreCase))
137+
{
138+
switch (entry.Value)
139+
{
140+
// image: nginx:1.21
141+
case YamlScalarNode scalarValue when !string.IsNullOrWhiteSpace(scalarValue.Value):
142+
DockerReferenceUtility.TryRegisterImageReference(scalarValue.Value, recorder);
143+
break;
144+
145+
// image:
146+
// repository: nginx
147+
// tag: "1.21"
148+
case YamlMappingNode imageMapping:
149+
this.TryRegisterStructuredImageReference(imageMapping, recorder);
150+
break;
151+
152+
default:
153+
break;
154+
}
155+
}
156+
else if (entry.Value is YamlMappingNode childMapping)
157+
{
158+
this.WalkYamlForImages(childMapping, recorder);
159+
}
160+
else if (entry.Value is YamlSequenceNode sequenceNode)
161+
{
162+
foreach (var item in sequenceNode)
163+
{
164+
if (item is YamlMappingNode sequenceMapping)
165+
{
166+
this.WalkYamlForImages(sequenceMapping, recorder);
167+
}
168+
}
169+
}
170+
}
171+
}
172+
173+
private void TryRegisterStructuredImageReference(YamlMappingNode imageMapping, ISingleFileComponentRecorder recorder)
174+
{
175+
string? repository = null;
176+
string? tag = null;
177+
string? digest = null;
178+
string? registry = null;
179+
180+
foreach (var child in imageMapping.Children)
181+
{
182+
var childKey = (child.Key as YamlScalarNode)?.Value;
183+
var childValue = (child.Value as YamlScalarNode)?.Value;
184+
185+
switch (childKey?.ToUpperInvariant())
186+
{
187+
case "REPOSITORY":
188+
repository = childValue;
189+
break;
190+
case "TAG":
191+
tag = childValue;
192+
break;
193+
case "DIGEST":
194+
digest = childValue;
195+
break;
196+
case "REGISTRY":
197+
registry = childValue;
198+
break;
199+
default:
200+
break;
201+
}
202+
}
203+
204+
if (string.IsNullOrWhiteSpace(repository))
205+
{
206+
return;
207+
}
208+
209+
var imageRef = !string.IsNullOrWhiteSpace(registry)
210+
? $"{registry}/{repository}"
211+
: repository;
212+
213+
if (!string.IsNullOrWhiteSpace(tag))
214+
{
215+
imageRef = $"{imageRef}:{tag}";
216+
}
217+
218+
if (!string.IsNullOrWhiteSpace(digest))
219+
{
220+
imageRef = $"{imageRef}@{digest}";
221+
}
222+
223+
DockerReferenceUtility.TryRegisterImageReference(imageRef, recorder);
224+
}
225+
}

src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ namespace Microsoft.ComponentDetection.Orchestrator.Extensions;
1010
using Microsoft.ComponentDetection.Detectors.DotNet;
1111
using Microsoft.ComponentDetection.Detectors.Go;
1212
using Microsoft.ComponentDetection.Detectors.Gradle;
13+
using Microsoft.ComponentDetection.Detectors.Helm;
1314
using Microsoft.ComponentDetection.Detectors.Ivy;
1415
using Microsoft.ComponentDetection.Detectors.Linux;
1516
using Microsoft.ComponentDetection.Detectors.Linux.Factories;
@@ -100,6 +101,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
100101
// Gradle
101102
services.AddSingleton<IComponentDetector, GradleComponentDetector>();
102103

104+
// Helm
105+
services.AddSingleton<IComponentDetector, HelmComponentDetector>();
106+
103107
// Ivy
104108
services.AddSingleton<IComponentDetector, IvyDetector>();
105109

0 commit comments

Comments
 (0)