Skip to content

Commit 62bca0d

Browse files
authored
Merge branch 'main' into user/aamaini/dependency-graph-identity-reconciliation
2 parents 9eb6bc3 + 4eb4071 commit 62bca0d

8 files changed

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

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)