diff --git a/docs/detectors/README.md b/docs/detectors/README.md
index 5d96ec22f..ba789f298 100644
--- a/docs/detectors/README.md
+++ b/docs/detectors/README.md
@@ -48,6 +48,12 @@
| ----------------------- | ------ |
| GradleComponentDetector | Stable |
+- [Helm](helm.md)
+
+| Detector | Status |
+| ---------------------- | ---------- |
+| HelmComponentDetector | DefaultOff |
+
- [Ivy](ivy.md)
| Detector | Status |
diff --git a/docs/detectors/helm.md b/docs/detectors/helm.md
new file mode 100644
index 000000000..9866be7a2
--- /dev/null
+++ b/docs/detectors/helm.md
@@ -0,0 +1,52 @@
+# Helm Detection
+
+## Requirements
+
+Helm detection depends on the following to successfully run:
+
+- One or more Helm values files matching the patterns: `*values*.yaml`, `*values*.yml`
+- 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.
+
+## Detection strategy
+
+The Helm detector parses Helm values YAML files to extract Docker image references. It recursively walks the YAML tree looking for `image` keys.
+
+### Direct Image Strings
+
+The detector recognizes image references specified as simple strings:
+
+```yaml
+image: nginx:1.21
+```
+
+### Structured Image Objects
+
+The detector also supports the common Helm chart pattern of structured image definitions:
+
+```yaml
+image:
+ registry: ghcr.io
+ repository: org/myimage
+ tag: v1.0
+```
+
+The `registry` and `tag` fields are optional. When present, the detector reconstructs the full image reference. The `digest` field is also supported.
+
+### Recursive Search
+
+The detector recursively traverses all nested mappings and sequences in the values file, detecting image references at any depth in the YAML structure.
+
+### Variable Resolution
+
+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.
+
+## Known limitations
+
+- **DefaultOff Status**: This detector must be explicitly enabled using `--DetectorArgs Helm=EnableIfDefaultOff`
+- **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
+- **No Dependency Graph**: All detected images are registered as independent components without parent-child relationships
\ No newline at end of file
diff --git a/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs b/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
index 6b0b91cd4..45886d8a1 100644
--- a/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
+++ b/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
@@ -50,4 +50,7 @@ public enum DetectorClass
/// Indicates a detector applies to Docker Compose image references.
DockerCompose,
+
+ /// Indicates a detector applies to Helm chart image references.
+ Helm,
}
diff --git a/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs
new file mode 100644
index 000000000..be9f0f01c
--- /dev/null
+++ b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs
@@ -0,0 +1,226 @@
+namespace Microsoft.ComponentDetection.Detectors.Helm;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reactive.Linq;
+using System.Reactive.Threading.Tasks;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.ComponentDetection.Common;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.Internal;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.Extensions.Logging;
+using YamlDotNet.RepresentationModel;
+
+public class HelmComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
+{
+ public HelmComponentDetector(
+ IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
+ IObservableDirectoryWalkerFactory walkerFactory,
+ ILogger logger)
+ {
+ this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
+ this.Scanner = walkerFactory;
+ this.Logger = logger;
+ }
+
+ public override string Id => "Helm";
+
+ public override IList SearchPatterns { get; } =
+ [
+ "Chart.yaml", "Chart.yml",
+ "*values*.yaml", "*values*.yml",
+ ];
+
+ public override IEnumerable SupportedComponentTypes => [ComponentType.DockerReference];
+
+ public override int Version => 1;
+
+ public override IEnumerable Categories => [nameof(DetectorClass.Helm)];
+
+ ///
+ /// Pre-filters scan work to only values files co-located with a Chart.yaml/Chart.yml.
+ /// Materializes all matched files, identifies Helm chart directories, then filters.
+ ///
+ /// An observable of only the values-file requests in Helm chart directories.
+ protected override async Task> OnPrepareDetectionAsync(
+ IObservable processRequests,
+ IDictionary detectorArgs,
+ CancellationToken cancellationToken = default)
+ {
+ var allRequests = await processRequests.ToList().ToTask(cancellationToken);
+
+ var chartDirectories = new HashSet(
+ allRequests
+ .Where(r => IsChartFile(Path.GetFileName(r.ComponentStream.Location)))
+ .Select(r => Path.GetDirectoryName(r.ComponentStream.Location) ?? string.Empty),
+ StringComparer.OrdinalIgnoreCase);
+
+ return allRequests
+ .Where(r => IsValuesFile(Path.GetFileName(r.ComponentStream.Location))
+ && chartDirectories.Contains(Path.GetDirectoryName(r.ComponentStream.Location) ?? string.Empty))
+ .ToObservable();
+ }
+
+ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default)
+ {
+ var file = processRequest.ComponentStream;
+
+ // OnPrepareDetectionAsync has already filtered to values files co-located
+ // with a Helm chart file (Chart.yaml or Chart.yml), so no further
+ // filename/directory checks are needed.
+ try
+ {
+ this.Logger.LogInformation("Discovered Helm values file: {Location}", file.Location);
+
+ string contents;
+ using (var reader = new StreamReader(file.Stream))
+ {
+ contents = await reader.ReadToEndAsync(cancellationToken);
+ }
+
+ var yaml = new YamlStream();
+ yaml.Load(new StringReader(contents));
+
+ if (yaml.Documents.Count == 0)
+ {
+ return;
+ }
+
+ this.ExtractImageReferencesFromValues(yaml, processRequest.SingleFileComponentRecorder);
+ }
+ catch (Exception e)
+ {
+ this.Logger.LogError(e, "Failed to parse Helm file: {Location}", file.Location);
+ }
+ }
+
+ ///
+ /// Checks if the given file name matches Helm chart file patterns (Chart.yaml or Chart.yml).
+ ///
+ /// The file name to check.
+ /// True if the file name matches Helm chart file patterns; otherwise, false.
+ /// The C in Chart.yaml is case-sensitive .
+ private static bool IsChartFile(string fileName) =>
+ fileName.Equals("Chart.yaml", StringComparison.Ordinal) ||
+ fileName.Equals("Chart.yml", StringComparison.Ordinal);
+
+ private static bool IsValuesFile(string fileName) =>
+ fileName.Contains("values", StringComparison.OrdinalIgnoreCase) &&
+ (fileName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
+ fileName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase));
+
+ private void ExtractImageReferencesFromValues(YamlStream yaml, ISingleFileComponentRecorder recorder)
+ {
+ foreach (var document in yaml.Documents)
+ {
+ if (document.RootNode is YamlMappingNode rootMapping)
+ {
+ this.WalkYamlForImages(rootMapping, recorder);
+ }
+ }
+ }
+
+ ///
+ /// Walks the YAML tree looking for image references. Handles two common patterns:
+ /// 1. Direct image string: `image: nginx:1.21`
+ /// 2. Structured image object: `image: { repository: nginx, tag: "1.21" }`.
+ ///
+ private void WalkYamlForImages(YamlMappingNode mapping, ISingleFileComponentRecorder recorder)
+ {
+ foreach (var entry in mapping.Children)
+ {
+ var key = (entry.Key as YamlScalarNode)?.Value;
+
+ if (string.Equals(key, "image", StringComparison.OrdinalIgnoreCase))
+ {
+ switch (entry.Value)
+ {
+ // image: nginx:1.21
+ case YamlScalarNode scalarValue when !string.IsNullOrWhiteSpace(scalarValue.Value):
+ DockerReferenceUtility.TryRegisterImageReference(scalarValue.Value, recorder, this.Logger);
+ break;
+
+ // image:
+ // repository: nginx
+ // tag: "1.21"
+ case YamlMappingNode imageMapping:
+ this.TryRegisterStructuredImageReference(imageMapping, recorder);
+ break;
+
+ default:
+ break;
+ }
+ }
+ else if (entry.Value is YamlMappingNode childMapping)
+ {
+ this.WalkYamlForImages(childMapping, recorder);
+ }
+ else if (entry.Value is YamlSequenceNode sequenceNode)
+ {
+ foreach (var item in sequenceNode)
+ {
+ if (item is YamlMappingNode sequenceMapping)
+ {
+ this.WalkYamlForImages(sequenceMapping, recorder);
+ }
+ }
+ }
+ }
+ }
+
+ private void TryRegisterStructuredImageReference(YamlMappingNode imageMapping, ISingleFileComponentRecorder recorder)
+ {
+ string? repository = null;
+ string? tag = null;
+ string? digest = null;
+ string? registry = null;
+
+ foreach (var child in imageMapping.Children)
+ {
+ var childKey = (child.Key as YamlScalarNode)?.Value;
+ var childValue = (child.Value as YamlScalarNode)?.Value;
+
+ if (string.Equals(childKey, "REPOSITORY", StringComparison.OrdinalIgnoreCase))
+ {
+ repository = childValue;
+ }
+ else if (string.Equals(childKey, "TAG", StringComparison.OrdinalIgnoreCase))
+ {
+ tag = childValue;
+ }
+ else if (string.Equals(childKey, "DIGEST", StringComparison.OrdinalIgnoreCase))
+ {
+ digest = childValue;
+ }
+ else if (string.Equals(childKey, "REGISTRY", StringComparison.OrdinalIgnoreCase))
+ {
+ registry = childValue;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(repository))
+ {
+ return;
+ }
+
+ var imageRef = !string.IsNullOrWhiteSpace(registry)
+ ? $"{registry}/{repository}"
+ : repository;
+
+ if (!string.IsNullOrWhiteSpace(tag))
+ {
+ imageRef = $"{imageRef}:{tag}";
+ }
+
+ if (!string.IsNullOrWhiteSpace(digest))
+ {
+ imageRef = $"{imageRef}@{digest}";
+ }
+
+ DockerReferenceUtility.TryRegisterImageReference(imageRef, recorder, this.Logger);
+ }
+}
diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
index 322e41fc6..ff212c92f 100644
--- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs
@@ -10,6 +10,7 @@ namespace Microsoft.ComponentDetection.Orchestrator.Extensions;
using Microsoft.ComponentDetection.Detectors.DotNet;
using Microsoft.ComponentDetection.Detectors.Go;
using Microsoft.ComponentDetection.Detectors.Gradle;
+using Microsoft.ComponentDetection.Detectors.Helm;
using Microsoft.ComponentDetection.Detectors.Ivy;
using Microsoft.ComponentDetection.Detectors.Linux;
using Microsoft.ComponentDetection.Detectors.Linux.Factories;
@@ -100,6 +101,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
// Gradle
services.AddSingleton();
+ // Helm
+ services.AddSingleton();
+
// Ivy
services.AddSingleton();
diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs
new file mode 100644
index 000000000..80abab5eb
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs
@@ -0,0 +1,501 @@
+namespace Microsoft.ComponentDetection.Detectors.Tests;
+
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using AwesomeAssertions;
+using Microsoft.ComponentDetection.Contracts;
+using Microsoft.ComponentDetection.Contracts.TypedComponent;
+using Microsoft.ComponentDetection.Detectors.Helm;
+using Microsoft.ComponentDetection.TestsUtilities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[TestClass]
+[TestCategory("Governance/All")]
+[TestCategory("Governance/ComponentDetection")]
+public class HelmComponentDetectorTests : BaseDetectorTest
+{
+ private const string MinimalChartYaml = @"
+apiVersion: v2
+name: my-chart
+version: 0.1.0
+";
+
+ [TestMethod]
+ public async Task TestHelm_DirectImageStringAsync()
+ {
+ var valuesYaml = @"
+replicaCount: 1
+image: nginx:1.21
+service:
+ type: ClusterIP
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .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");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_StructuredImageReferenceAsync()
+ {
+ var valuesYaml = @"
+replicaCount: 1
+image:
+ repository: myregistry.io/myapp
+ tag: ""2.0.0""
+service:
+ type: ClusterIP
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .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("myapp");
+ dockerRef.Domain.Should().Be("myregistry.io");
+ dockerRef.Tag.Should().Be("2.0.0");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_StructuredImageWithRegistryAsync()
+ {
+ var valuesYaml = @"
+image:
+ registry: ghcr.io
+ repository: org/myimage
+ tag: ""v1.0""
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .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("ghcr.io");
+ dockerRef.Repository.Should().Be("org/myimage");
+ dockerRef.Tag.Should().Be("v1.0");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_NestedImageReferencesAsync()
+ {
+ var valuesYaml = @"
+app:
+ frontend:
+ image: nginx:1.21
+ backend:
+ image:
+ repository: node
+ tag: ""18-alpine""
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().HaveCount(2);
+ }
+
+ [TestMethod]
+ public async Task TestHelm_EmptyValuesYamlAsync()
+ {
+ var valuesYaml = @"
+replicaCount: 1
+service:
+ type: ClusterIP
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ChartYamlIgnoredAsync()
+ {
+ var chartYaml = @"
+apiVersion: v2
+name: my-chart
+version: 0.1.0
+dependencies:
+ - name: postgresql
+ version: ""11.0.0""
+ repository: https://charts.bitnami.com/bitnami
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", chartYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ValuesFileObservedBeforeChartYamlAsync()
+ {
+ // Verify that values files are processed even when they are enumerated
+ // before the co-located Chart.yaml (non-deterministic file order).
+ var valuesYaml = @"
+image: nginx:1.21
+";
+
+ // values.yaml is registered first (before Chart.yaml) to simulate the
+ // problematic enumeration order the two-pass OnPrepareDetectionAsync fixes.
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("values.yaml", valuesYaml)
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().ContainSingle();
+
+ var dockerRef = componentRecorder.GetDetectedComponents().First().Component as DockerReferenceComponent;
+ dockerRef.Should().NotBeNull();
+ dockerRef.Repository.Should().Be("library/nginx");
+ dockerRef.Tag.Should().Be("1.21");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ValuesWithoutChartYamlSkippedAsync()
+ {
+ var valuesYaml = @"
+image: nginx:1.21
+";
+
+ // No Chart.yaml provided — the values file should be skipped entirely.
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("values.yaml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ValuesInDifferentDirectoryFromChartSkippedAsync()
+ {
+ var valuesYaml = @"
+image: nginx:1.21
+";
+
+ // Chart.yaml exists but in a different directory than values.yaml.
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml, fileLocation: Path.Combine(Path.GetTempPath(), "chartA", "Chart.yaml"))
+ .WithFile("values.yaml", valuesYaml, fileLocation: Path.Combine(Path.GetTempPath(), "chartB", "values.yaml"))
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ImageWithDigestAsync()
+ {
+ var valuesYaml = @"
+image:
+ repository: nginx
+ digest: ""sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1""
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .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");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_StructuredImageWithTagAndDigestAsync()
+ {
+ var valuesYaml = @"
+image:
+ repository: nginx
+ tag: ""1.21""
+ digest: ""sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1""
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .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");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_DirectImageStringWithDigestAsync()
+ {
+ var valuesYaml = @"
+image: nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .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");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_DirectImageStringWithTagAndDigestAsync()
+ {
+ var valuesYaml = @"
+image: nginx:1.21@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .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.Tag.Should().Be("1.21");
+ dockerRef.Digest.Should().Be("sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ImagesInSequenceAsync()
+ {
+ var valuesYaml = @"
+sidecars:
+ - name: sidecar1
+ image: busybox:1.35
+ - name: sidecar2
+ image: alpine:3.18
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().HaveCount(2);
+ }
+
+ [TestMethod]
+ public async Task TestHelm_UnresolvedVariableSkippedAsync()
+ {
+ var valuesYaml = @"
+image: ${REGISTRY}/app:${TAG}
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ValuesYmlExtensionAsync()
+ {
+ var valuesYaml = @"
+image: nginx:1.21
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().ContainSingle();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ValuesOverrideFileAsync()
+ {
+ var valuesYaml = @"
+image: redis:7-alpine
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.production.yaml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().ContainSingle();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_CustomValuesFilenameAsync()
+ {
+ var valuesYaml = @"
+image: postgres:15
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("myapp-values-dev.yml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().ContainSingle();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_LowercaseChartYamlAsync()
+ {
+ var chartYaml = @"
+apiVersion: v2
+name: my-chart
+version: 0.1.0
+";
+
+ // Helm's Chart.yml file has to be named with an uppercase 'C'. Verify that files not matching this pattern are ignored.
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("chart.yaml", chartYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ChartYmlExtensionAsync()
+ {
+ var chartYaml = @"
+apiVersion: v2
+name: my-chart
+version: 0.1.0
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yml", chartYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ChartYmlWithValuesFileProcessedAsync()
+ {
+ var valuesYaml = @"
+image: nginx:1.21
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .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");
+ }
+
+ [TestMethod]
+ public async Task TestHelm_MultipleValuesFilesAsync()
+ {
+ var valuesYaml = @"
+image: nginx:1.21
+";
+
+ var valuesOverrideYaml = @"
+image: redis:7-alpine
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .WithFile("values.production.yaml", valuesOverrideYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ var components = componentRecorder.GetDetectedComponents();
+ components.Should().HaveCount(2);
+ }
+
+ [TestMethod]
+ public async Task TestHelm_ImageArrayNotSupportedAsync()
+ {
+ // YAML arrays under the "image" key (e.g. image: [nginx:1.21, redis:7]) are
+ // intentionally not supported. The Helm chart convention is one image per
+ // "image" key — either as a scalar string or a structured mapping with
+ // repository/tag/digest fields. Charts that need multiple images use separate
+ // named keys (e.g. frontendImage, sidecar.image) or sequence items that each
+ // contain their own "image" key. A bare array value is not a valid pattern in
+ // the Helm ecosystem, so we skip it.
+ var valuesYaml = @"
+image:
+ - nginx:1.21
+ - redis:7
+";
+
+ var (scanResult, componentRecorder) = await this.DetectorTestUtility
+ .WithFile("Chart.yaml", MinimalChartYaml)
+ .WithFile("values.yaml", valuesYaml)
+ .ExecuteDetectorAsync();
+
+ scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
+ componentRecorder.GetDetectedComponents().Should().BeEmpty();
+ }
+}
diff --git a/test/Microsoft.ComponentDetection.VerificationTests/resources/helm/Chart.yml b/test/Microsoft.ComponentDetection.VerificationTests/resources/helm/Chart.yml
new file mode 100644
index 000000000..9519f6609
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.VerificationTests/resources/helm/Chart.yml
@@ -0,0 +1,5 @@
+apiVersion: v2
+name: my-app
+description: A sample Helm chart
+version: 0.1.0
+appVersion: "1.0.0"
\ No newline at end of file
diff --git a/test/Microsoft.ComponentDetection.VerificationTests/resources/helm/values.yaml b/test/Microsoft.ComponentDetection.VerificationTests/resources/helm/values.yaml
new file mode 100644
index 000000000..0083b73d7
--- /dev/null
+++ b/test/Microsoft.ComponentDetection.VerificationTests/resources/helm/values.yaml
@@ -0,0 +1,36 @@
+replicaCount: 3
+
+image:
+ repository: myregistry.io/myapp
+ tag: "2.0.0"
+
+sidecar:
+ image: nginx:1.21
+
+backend:
+ image:
+ registry: ghcr.io
+ repository: org/backend
+ tag: "v1.0"
+
+monitoring:
+ image:
+ repository: prom/prometheus
+ tag: "v2.45.0"
+
+digestOnly:
+ image: nginx@sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31
+
+tagAndDigest:
+ image: redis:7-alpine@sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31
+
+structuredDigest:
+ image:
+ repository: busybox
+ digest: "sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31"
+
+structuredTagAndDigest:
+ image:
+ repository: alpine
+ tag: "3.18"
+ digest: "sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31"
\ No newline at end of file