|
| 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 | +} |
0 commit comments