From cbfa1bccbb7a2be5bdd5ae6d0ec0f8021e68b33d Mon Sep 17 00:00:00 2001 From: Julian Pinzer Date: Mon, 20 Apr 2026 18:36:00 +0000 Subject: [PATCH 1/4] Add the HelmComponentDetector and associated tests. --- docs/detectors/README.md | 6 + docs/detectors/helm.md | 52 ++ .../DetectorClass.cs | 3 + .../helm/HelmComponentDetector.cs | 225 +++++++++ .../Extensions/ServiceCollectionExtensions.cs | 4 + .../HelmComponentDetectorTests.cs | 476 ++++++++++++++++++ .../resources/helm/Chart.yml | 5 + .../resources/helm/values.yaml | 36 ++ 8 files changed, 807 insertions(+) create mode 100644 docs/detectors/helm.md create mode 100644 src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs create mode 100644 test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs create mode 100644 test/Microsoft.ComponentDetection.VerificationTests/resources/helm/Chart.yml create mode 100644 test/Microsoft.ComponentDetection.VerificationTests/resources/helm/values.yaml 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..0d881ee7b --- /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` +- Chart metadata files (`Chart.yaml`, `Chart.yml`) are matched for file discovery but only values files are parsed for image references + - `chart.yaml` and `chart.yml` aren't supported by Helm, the `c` has to be uppercased. + +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..e5fdbdb38 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs @@ -0,0 +1,225 @@ +namespace Microsoft.ComponentDetection.Detectors.Helm; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +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(); + + 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 Chart.yaml — 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); + 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; + + switch (childKey?.ToUpperInvariant()) + { + case "REPOSITORY": + repository = childValue; + break; + case "TAG": + tag = childValue; + break; + case "DIGEST": + digest = childValue; + break; + case "REGISTRY": + registry = childValue; + break; + default: + break; + } + } + + 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); + } +} 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..d18805ce0 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs @@ -0,0 +1,476 @@ +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); + } +} 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 From 26dfe9e1d65b8917b0ab327a7f83aacdd6a404a2 Mon Sep 17 00:00:00 2001 From: Julian Pinzer Date: Mon, 20 Apr 2026 19:05:46 +0000 Subject: [PATCH 2/4] Address copilot PR comments Co-authored-by: Copilot --- .../helm/HelmComponentDetector.cs | 32 +++++++++---------- .../HelmComponentDetectorTests.cs | 25 +++++++++++++++ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs index e5fdbdb38..e221109c3 100644 --- a/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs @@ -5,6 +5,7 @@ namespace Microsoft.ComponentDetection.Detectors.Helm; 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; @@ -50,7 +51,7 @@ protected override async Task> OnPrepareDetectionAsy IDictionary detectorArgs, CancellationToken cancellationToken = default) { - var allRequests = await processRequests.ToList(); + var allRequests = await processRequests.ToList().ToTask(cancellationToken); var chartDirectories = new HashSet( allRequests @@ -182,22 +183,21 @@ private void TryRegisterStructuredImageReference(YamlMappingNode imageMapping, I var childKey = (child.Key as YamlScalarNode)?.Value; var childValue = (child.Value as YamlScalarNode)?.Value; - switch (childKey?.ToUpperInvariant()) + if (string.Equals(childKey, "REPOSITORY", StringComparison.OrdinalIgnoreCase)) { - case "REPOSITORY": - repository = childValue; - break; - case "TAG": - tag = childValue; - break; - case "DIGEST": - digest = childValue; - break; - case "REGISTRY": - registry = childValue; - break; - default: - break; + 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; } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs index d18805ce0..80abab5eb 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs @@ -473,4 +473,29 @@ public async Task TestHelm_MultipleValuesFilesAsync() 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(); + } } From 67ae9abbfecafd5dd69d5bfca067dbbb9e8468ac Mon Sep 17 00:00:00 2001 From: Julian Pinzer Date: Mon, 20 Apr 2026 19:14:44 +0000 Subject: [PATCH 3/4] Address more copilot comments. Co-authored-by: Copilot --- .../helm/HelmComponentDetector.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs index e221109c3..be9f0f01c 100644 --- a/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs @@ -70,7 +70,8 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID var file = processRequest.ComponentStream; // OnPrepareDetectionAsync has already filtered to values files co-located - // with a Chart.yaml — no further filename/directory checks are needed. + // 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); @@ -140,7 +141,7 @@ private void WalkYamlForImages(YamlMappingNode mapping, ISingleFileComponentReco { // image: nginx:1.21 case YamlScalarNode scalarValue when !string.IsNullOrWhiteSpace(scalarValue.Value): - DockerReferenceUtility.TryRegisterImageReference(scalarValue.Value, recorder); + DockerReferenceUtility.TryRegisterImageReference(scalarValue.Value, recorder, this.Logger); break; // image: @@ -220,6 +221,6 @@ private void TryRegisterStructuredImageReference(YamlMappingNode imageMapping, I imageRef = $"{imageRef}@{digest}"; } - DockerReferenceUtility.TryRegisterImageReference(imageRef, recorder); + DockerReferenceUtility.TryRegisterImageReference(imageRef, recorder, this.Logger); } } From 64692bd1b296025e7b80f5f5fd00154af4cb232b Mon Sep 17 00:00:00 2001 From: Julian Date: Mon, 20 Apr 2026 15:25:54 -0400 Subject: [PATCH 4/4] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/detectors/helm.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/detectors/helm.md b/docs/detectors/helm.md index 0d881ee7b..9866be7a2 100644 --- a/docs/detectors/helm.md +++ b/docs/detectors/helm.md @@ -5,8 +5,8 @@ Helm detection depends on the following to successfully run: - One or more Helm values files matching the patterns: `*values*.yaml`, `*values*.yml` -- Chart metadata files (`Chart.yaml`, `Chart.yml`) are matched for file discovery but only values files are parsed for image references - - `chart.yaml` and `chart.yml` aren't supported by Helm, the `c` has to be uppercased. +- 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.