diff --git a/.gitignore b/.gitignore index 533090632..1a86942c2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ launchSettings.json # Dev nupkgs dev-source +.nuget/ diff --git a/docs/detectors/README.md b/docs/detectors/README.md index 3309e9eed..d2f23eb87 100644 --- a/docs/detectors/README.md +++ b/docs/detectors/README.md @@ -18,6 +18,12 @@ | -------------------------- | ---------- | | CondaLockComponentDetector | DefaultOff | +- [Docker Compose](dockercompose.md) + +| Detector | Status | +| ------------------------------- | ---------- | +| DockerComposeComponentDetector | DefaultOff | + - [Dockerfile](dockerfile.md) | Detector | Status | @@ -42,12 +48,24 @@ | ----------------------- | ------ | | GradleComponentDetector | Stable | +- [Helm](helm.md) + +| Detector | Status | +| ---------------------- | ---------- | +| HelmComponentDetector | DefaultOff | + - [Ivy](ivy.md) | Detector | Status | | ----------- | ------------ | | IvyDetector | Experimental | +- [Kubernetes](kubernetes.md) + +| Detector | Status | +| ------------------------------ | ---------- | +| KubernetesComponentDetector | DefaultOff | + - [Linux](linux.md) | Detector | Status | diff --git a/docs/detectors/dockercompose.md b/docs/detectors/dockercompose.md new file mode 100644 index 000000000..7e523b44a --- /dev/null +++ b/docs/detectors/dockercompose.md @@ -0,0 +1,48 @@ +# Docker Compose Detection + +## Requirements + +Docker Compose detection depends on the following to successfully run: + +- One or more Docker Compose files matching the patterns: `docker-compose.yml`, `docker-compose.yaml`, `docker-compose.*.yml`, `docker-compose.*.yaml`, `compose.yml`, `compose.yaml`, `compose.*.yml`, `compose.*.yaml` + +The `DockerComposeComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter. + +## Detection strategy + +The Docker Compose detector parses YAML compose files to extract Docker image references from service definitions. + +### Service Image Detection + +The detector looks for the `services` section and extracts the `image` field from each service: + +```yaml +services: + web: + image: nginx:1.21 + db: + image: postgres:14 +``` + +Services that only define a `build` directive without an `image` field are skipped, as they do not reference external Docker images. + +### Full Registry References + +The detector supports full registry image references: + +```yaml +services: + app: + image: ghcr.io/myorg/myapp:v2.0 +``` + +### Variable Resolution + +Images containing unresolved variables (e.g., `${TAG}` or `{{ .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 DockerCompose=EnableIfDefaultOff` +- **Variable Resolution**: Image references containing unresolved environment variables or template expressions are not reported, which may lead to under-reporting in compose files that heavily use variable substitution +- **Build-Only Services**: Services that only specify a `build` directive without an `image` field are not reported +- **No Dependency Graph**: All detected images are registered as independent components without parent-child relationships diff --git a/docs/detectors/helm.md b/docs/detectors/helm.md new file mode 100644 index 000000000..5f30ffa67 --- /dev/null +++ b/docs/detectors/helm.md @@ -0,0 +1,50 @@ +# 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`, `chart.yaml`, `chart.yml`) are matched for file discovery but only values files are parsed for image references + +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 +- **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 diff --git a/docs/detectors/kubernetes.md b/docs/detectors/kubernetes.md new file mode 100644 index 000000000..568342eed --- /dev/null +++ b/docs/detectors/kubernetes.md @@ -0,0 +1,58 @@ +# Kubernetes Detection + +## Requirements + +Kubernetes detection depends on the following to successfully run: + +- One or more Kubernetes manifest files matching the patterns: `*.yaml`, `*.yml` +- Manifests must contain both `apiVersion` and `kind` fields to be recognized as Kubernetes resources + +The `KubernetesComponentDetector` is a **DefaultOff** detector and must be explicitly enabled via the `--DetectorArgs` parameter. + +## Detection strategy + +The Kubernetes detector parses Kubernetes manifest YAML files to extract Docker image references from container specifications. + +### Supported Resource Kinds + +The detector recognizes the following Kubernetes resource kinds: + +- `Pod` +- `Deployment` +- `StatefulSet` +- `DaemonSet` +- `ReplicaSet` +- `Job` +- `CronJob` +- `ReplicationController` + +Files with an unrecognized `kind` or missing `apiVersion`/`kind` fields are skipped. + +### Container Image Detection + +The detector extracts image references from all container types within pod specifications: + +- **containers**: Main application containers +- **initContainers**: Initialization containers that run before app containers +- **ephemeralContainers**: Ephemeral debugging containers + +### Pod Spec Locations + +The detector handles different pod spec locations depending on the resource kind: + +- **Pod**: `spec.containers` +- **Deployment, StatefulSet, DaemonSet, ReplicaSet, ReplicationController**: `spec.template.spec.containers` +- **Job**: `spec.template.spec.containers` +- **CronJob**: `spec.jobTemplate.spec.template.spec.containers` + +### Variable Resolution + +Images containing unresolved variables (e.g., `${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 Kubernetes=EnableIfDefaultOff` +- **Broad File Matching**: The `*.yaml` and `*.yml` search patterns match all YAML files, so the detector relies on content-based filtering (`apiVersion` and `kind` fields) to identify Kubernetes manifests +- **Variable Resolution**: Image references containing unresolved template variables are not reported +- **Limited Resource Kinds**: Only the eight resource kinds listed above are supported. Custom resources (CRDs) or other workload types are not detected +- **No Dependency Graph**: All detected images are registered as independent components without parent-child relationships diff --git a/src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs b/src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs index 022f8b612..bfd206e12 100644 --- a/src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs +++ b/src/Microsoft.ComponentDetection.Common/DockerReference/DockerReferenceUtility.cs @@ -38,6 +38,15 @@ public static class DockerReferenceUtility private const string LEGACYDEFAULTDOMAIN = "index.docker.io"; private const string OFFICIALREPOSITORYNAME = "library"; + /// + /// Returns true if the reference contains unresolved variable placeholders (e.g., ${VAR}, {{ .Values.tag }}). + /// Such references should be skipped before calling or . + /// + /// The image reference string to check. + /// true if the reference contains variable placeholder characters; otherwise false. + public static bool HasUnresolvedVariables(string reference) => + reference.IndexOfAny(['$', '{', '}']) >= 0; + public static DockerReference ParseQualifiedName(string qualifiedName) { var regexp = DockerRegex.ReferenceRegexp; diff --git a/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs b/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs index becfa4f9f..859be21c3 100644 --- a/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs +++ b/src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs @@ -47,4 +47,13 @@ public enum DetectorClass /// Indicates a detector applies to Swift packages. Swift, + + /// Indicates a detector applies to Docker Compose image references. + DockerCompose, + + /// Indicates a detector applies to Helm chart image references. + Helm, + + /// Indicates a detector applies to Kubernetes manifest image references. + Kubernetes, } diff --git a/src/Microsoft.ComponentDetection.Detectors/dockercompose/DockerComposeComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dockercompose/DockerComposeComponentDetector.cs new file mode 100644 index 000000000..2309056c4 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/dockercompose/DockerComposeComponentDetector.cs @@ -0,0 +1,145 @@ +#nullable disable +namespace Microsoft.ComponentDetection.Detectors.DockerCompose; + +using System; +using System.Collections.Generic; +using System.IO; +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 DockerComposeComponentDetector : FileComponentDetector, IDefaultOffComponentDetector +{ + public DockerComposeComponentDetector( + IComponentStreamEnumerableFactory componentStreamEnumerableFactory, + IObservableDirectoryWalkerFactory walkerFactory, + ILogger logger) + { + this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; + this.Scanner = walkerFactory; + this.Logger = logger; + } + + public override string Id => "DockerCompose"; + + public override IList SearchPatterns { get; } = + [ + "docker-compose.yml", "docker-compose.yaml", + "docker-compose.*.yml", "docker-compose.*.yaml", + "compose.yml", "compose.yaml", + "compose.*.yml", "compose.*.yaml", + ]; + + public override IEnumerable SupportedComponentTypes => [ComponentType.DockerReference]; + + public override int Version => 1; + + public override IEnumerable Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.DockerCompose)]; + + protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) + { + var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; + var file = processRequest.ComponentStream; + + try + { + this.Logger.LogInformation("Discovered Docker Compose 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; + } + + foreach (var document in yaml.Documents) + { + if (document.RootNode is YamlMappingNode rootMapping) + { + this.ExtractImageReferences(rootMapping, singleFileComponentRecorder, file.Location); + } + } + } + catch (Exception e) + { + this.Logger.LogError(e, "Failed to parse Docker Compose file: {Location}", file.Location); + } + } + + private static YamlMappingNode GetMappingChild(YamlMappingNode parent, string key) + { + foreach (var entry in parent.Children) + { + if (entry.Key is YamlScalarNode scalarKey && string.Equals(scalarKey.Value, key, StringComparison.OrdinalIgnoreCase)) + { + return entry.Value as YamlMappingNode; + } + } + + return null; + } + + private void ExtractImageReferences(YamlMappingNode rootMapping, ISingleFileComponentRecorder recorder, string fileLocation) + { + var services = GetMappingChild(rootMapping, "services"); + if (services == null) + { + return; + } + + foreach (var serviceEntry in services.Children) + { + if (serviceEntry.Value is not YamlMappingNode serviceMapping) + { + continue; + } + + // Extract direct image: references + foreach (var entry in serviceMapping.Children) + { + var key = (entry.Key as YamlScalarNode)?.Value; + if (string.Equals(key, "image", StringComparison.OrdinalIgnoreCase)) + { + var imageRef = (entry.Value as YamlScalarNode)?.Value; + if (!string.IsNullOrWhiteSpace(imageRef)) + { + this.TryRegisterImageReference(imageRef, recorder, fileLocation); + } + } + } + } + } + + private void TryRegisterImageReference(string imageReference, ISingleFileComponentRecorder recorder, string fileLocation) + { + if (DockerReferenceUtility.HasUnresolvedVariables(imageReference)) + { + return; + } + + try + { + var dockerRef = DockerReferenceUtility.ParseFamiliarName(imageReference); + if (dockerRef != null) + { + recorder.RegisterUsage(new DetectedComponent(dockerRef.ToTypedDockerReferenceComponent())); + } + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Failed to parse image reference '{ImageReference}' in {Location}", imageReference, fileLocation); + } + } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs index d1d19f3a9..1892c92de 100644 --- a/src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/dockerfile/DockerfileComponentDetector.cs @@ -5,7 +5,6 @@ namespace Microsoft.ComponentDetection.Detectors.Dockerfile; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.ComponentDetection.Common; @@ -143,7 +142,7 @@ private DockerReference ParseFromInstruction(DockerfileConstruct construct, char if (!string.IsNullOrEmpty(stageNameReference)) { - if (this.HasUnresolvedVariables(stageNameReference)) + if (DockerReferenceUtility.HasUnresolvedVariables(stageNameReference)) { return null; } @@ -151,7 +150,7 @@ private DockerReference ParseFromInstruction(DockerfileConstruct construct, char return DockerReferenceUtility.ParseFamiliarName(stageNameReference); } - if (this.HasUnresolvedVariables(reference)) + if (DockerReferenceUtility.HasUnresolvedVariables(reference)) { return null; } @@ -172,7 +171,7 @@ private DockerReference ParseCopyInstruction(DockerfileConstruct construct, char stageNameMap.TryGetValue(reference, out var stageNameReference); if (!string.IsNullOrEmpty(stageNameReference)) { - if (this.HasUnresolvedVariables(stageNameReference)) + if (DockerReferenceUtility.HasUnresolvedVariables(stageNameReference)) { return null; } @@ -182,16 +181,11 @@ private DockerReference ParseCopyInstruction(DockerfileConstruct construct, char } } - if (this.HasUnresolvedVariables(reference)) + if (DockerReferenceUtility.HasUnresolvedVariables(reference)) { return null; } return DockerReferenceUtility.ParseFamiliarName(reference); } - - private bool HasUnresolvedVariables(string reference) - { - return new Regex("[${}]").IsMatch(reference); - } } diff --git a/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs new file mode 100644 index 000000000..b2d3e6ea1 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/helm/HelmComponentDetector.cs @@ -0,0 +1,254 @@ +#nullable disable +namespace Microsoft.ComponentDetection.Detectors.Helm; + +using System; +using System.Collections.Concurrent; +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 +{ + private readonly ConcurrentDictionary helmChartDirectories = new(StringComparer.OrdinalIgnoreCase); + + 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", + "chart.yaml", "chart.yml", + "*values*.yaml", "*values*.yml", + ]; + + public override IEnumerable SupportedComponentTypes => [ComponentType.DockerReference]; + + public override int Version => 1; + + public override IEnumerable Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.Helm)]; + + public override async Task ExecuteDetectorAsync(ScanRequest request, CancellationToken cancellationToken = default) + { + this.helmChartDirectories.Clear(); + return await base.ExecuteDetectorAsync(request, cancellationToken); + } + + protected override async Task> OnPrepareDetectionAsync( + IObservable processRequests, + IDictionary detectorArgs, + CancellationToken cancellationToken = default) + { + // Materialize all matching files first so that chart directories are fully + // known before any values file is decided on, regardless of enumeration order. + var allRequests = await processRequests.ToList(); + + // Pass 1: record every directory that contains a Chart.yaml / Chart.yml. + foreach (var request in allRequests) + { + if (IsChartFile(Path.GetFileName(request.ComponentStream.Location))) + { + this.helmChartDirectories.TryAdd( + Path.GetDirectoryName(request.ComponentStream.Location), true); + } + } + + // Pass 2: emit only the values files that sit in a known chart directory. + return allRequests + .Where(r => + IsValuesFile(Path.GetFileName(r.ComponentStream.Location)) && + this.helmChartDirectories.ContainsKey(Path.GetDirectoryName(r.ComponentStream.Location))) + .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, file.Location); + } + catch (Exception e) + { + this.Logger.LogError(e, "Failed to parse Helm file: {Location}", file.Location); + } + } + + private static bool IsChartFile(string fileName) => + fileName.Equals("Chart.yaml", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("Chart.yml", StringComparison.OrdinalIgnoreCase); + + 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, string fileLocation) + { + foreach (var document in yaml.Documents) + { + if (document.RootNode is YamlMappingNode rootMapping) + { + this.WalkYamlForImages(rootMapping, recorder, fileLocation); + } + } + } + + /// + /// 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, string fileLocation) + { + 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): + this.TryRegisterImageReference(scalarValue.Value, recorder, fileLocation); + break; + + // image: + // repository: nginx + // tag: "1.21" + case YamlMappingNode imageMapping: + this.TryRegisterStructuredImageReference(imageMapping, recorder, fileLocation); + break; + + default: + break; + } + } + else if (entry.Value is YamlMappingNode childMapping) + { + this.WalkYamlForImages(childMapping, recorder, fileLocation); + } + else if (entry.Value is YamlSequenceNode sequenceNode) + { + foreach (var item in sequenceNode) + { + if (item is YamlMappingNode sequenceMapping) + { + this.WalkYamlForImages(sequenceMapping, recorder, fileLocation); + } + } + } + } + } + + private void TryRegisterStructuredImageReference(YamlMappingNode imageMapping, ISingleFileComponentRecorder recorder, string fileLocation) + { + 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}"; + } + + this.TryRegisterImageReference(imageRef, recorder, fileLocation); + } + + private void TryRegisterImageReference(string imageReference, ISingleFileComponentRecorder recorder, string fileLocation) + { + if (DockerReferenceUtility.HasUnresolvedVariables(imageReference)) + { + return; + } + + try + { + var dockerRef = DockerReferenceUtility.ParseFamiliarName(imageReference); + if (dockerRef != null) + { + recorder.RegisterUsage(new DetectedComponent(dockerRef.ToTypedDockerReferenceComponent())); + } + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Failed to parse image reference '{ImageReference}' in {Location}", imageReference, fileLocation); + } + } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/kubernetes/KubernetesComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/kubernetes/KubernetesComponentDetector.cs new file mode 100644 index 000000000..8b3d6191f --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/kubernetes/KubernetesComponentDetector.cs @@ -0,0 +1,248 @@ +#nullable disable +namespace Microsoft.ComponentDetection.Detectors.Kubernetes; + +using System; +using System.Collections.Generic; +using System.IO; +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.Core; +using YamlDotNet.RepresentationModel; + +public class KubernetesComponentDetector : FileComponentDetector, IDefaultOffComponentDetector +{ + private static readonly HashSet KubernetesKinds = new(StringComparer.OrdinalIgnoreCase) + { + "Pod", + "Deployment", + "StatefulSet", + "DaemonSet", + "ReplicaSet", + "Job", + "CronJob", + "ReplicationController", + }; + + public KubernetesComponentDetector( + IComponentStreamEnumerableFactory componentStreamEnumerableFactory, + IObservableDirectoryWalkerFactory walkerFactory, + ILogger logger) + { + this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; + this.Scanner = walkerFactory; + this.Logger = logger; + } + + public override string Id => "Kubernetes"; + + public override IList SearchPatterns { get; } = ["*.yaml", "*.yml"]; + + public override IEnumerable SupportedComponentTypes => [ComponentType.DockerReference]; + + public override int Version => 1; + + public override IEnumerable Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.Kubernetes)]; + + protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) + { + var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; + var file = processRequest.ComponentStream; + + try + { + string contents; + using (var reader = new StreamReader(file.Stream)) + { + contents = await reader.ReadToEndAsync(cancellationToken); + } + + // Skip files that aren't Kubernetes manifests. + if (!contents.Contains("apiVersion", StringComparison.Ordinal) || + !contents.Contains("kind", StringComparison.Ordinal)) + { + return; + } + + var yaml = new YamlStream(); + yaml.Load(new StringReader(contents)); + + foreach (var document in yaml.Documents) + { + if (document.RootNode is not YamlMappingNode rootMapping) + { + continue; + } + + if (!this.IsKubernetesManifest(rootMapping)) + { + continue; + } + + this.Logger.LogInformation("Discovered Kubernetes manifest: {Location}", file.Location); + this.ExtractImageReferences(rootMapping, singleFileComponentRecorder, file.Location); + } + } + catch (YamlException e) + { + this.Logger.LogWarning(e, "Failed to parse YAML file: {Location}", file.Location); + } + catch (Exception e) + { + this.Logger.LogError(e, "Unexpected error processing file: {Location}", file.Location); + } + } + + private static YamlMappingNode GetMappingChild(YamlMappingNode parent, string key) + { + foreach (var entry in parent.Children) + { + if (entry.Key is YamlScalarNode scalarKey && string.Equals(scalarKey.Value, key, StringComparison.OrdinalIgnoreCase)) + { + return entry.Value as YamlMappingNode; + } + } + + return null; + } + + private static YamlSequenceNode GetSequenceChild(YamlMappingNode parent, string key) + { + foreach (var entry in parent.Children) + { + if (entry.Key is YamlScalarNode scalarKey && string.Equals(scalarKey.Value, key, StringComparison.OrdinalIgnoreCase)) + { + return entry.Value as YamlSequenceNode; + } + } + + return null; + } + + private bool IsKubernetesManifest(YamlMappingNode rootMapping) + { + string apiVersion = null; + string kind = null; + + foreach (var entry in rootMapping.Children) + { + var key = (entry.Key as YamlScalarNode)?.Value; + if (string.Equals(key, "apiVersion", StringComparison.OrdinalIgnoreCase)) + { + apiVersion = (entry.Value as YamlScalarNode)?.Value; + } + else if (string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase)) + { + kind = (entry.Value as YamlScalarNode)?.Value; + } + } + + return !string.IsNullOrEmpty(apiVersion) && !string.IsNullOrEmpty(kind) && KubernetesKinds.Contains(kind); + } + + private void ExtractImageReferences(YamlMappingNode rootMapping, ISingleFileComponentRecorder recorder, string fileLocation) + { + // For Pod, the spec is at the top level + // For Deployment/StatefulSet/etc, the pod spec is at spec.template.spec + var spec = GetMappingChild(rootMapping, "spec"); + if (spec == null) + { + return; + } + + // Direct pod spec (kind: Pod) + this.ExtractContainerImages(spec, recorder, fileLocation); + + // Templated pod spec (kind: Deployment, StatefulSet, etc.) + var template = GetMappingChild(spec, "template"); + if (template != null) + { + var templateSpec = GetMappingChild(template, "spec"); + if (templateSpec != null) + { + this.ExtractContainerImages(templateSpec, recorder, fileLocation); + } + } + + // CronJob has spec.jobTemplate.spec.template.spec + var jobTemplate = GetMappingChild(spec, "jobTemplate"); + if (jobTemplate != null) + { + var jobSpec = GetMappingChild(jobTemplate, "spec"); + if (jobSpec != null) + { + var jobPodTemplate = GetMappingChild(jobSpec, "template"); + if (jobPodTemplate != null) + { + var jobPodSpec = GetMappingChild(jobPodTemplate, "spec"); + if (jobPodSpec != null) + { + this.ExtractContainerImages(jobPodSpec, recorder, fileLocation); + } + } + } + } + } + + private void ExtractContainerImages(YamlMappingNode podSpec, ISingleFileComponentRecorder recorder, string fileLocation) + { + this.ExtractImagesFromContainerList(podSpec, "containers", recorder, fileLocation); + this.ExtractImagesFromContainerList(podSpec, "initContainers", recorder, fileLocation); + this.ExtractImagesFromContainerList(podSpec, "ephemeralContainers", recorder, fileLocation); + } + + private void ExtractImagesFromContainerList(YamlMappingNode podSpec, string containerKey, ISingleFileComponentRecorder recorder, string fileLocation) + { + var containers = GetSequenceChild(podSpec, containerKey); + if (containers == null) + { + return; + } + + foreach (var container in containers) + { + if (container is not YamlMappingNode containerMapping) + { + continue; + } + + foreach (var entry in containerMapping.Children) + { + var key = (entry.Key as YamlScalarNode)?.Value; + if (string.Equals(key, "image", StringComparison.OrdinalIgnoreCase)) + { + var imageRef = (entry.Value as YamlScalarNode)?.Value; + if (!string.IsNullOrWhiteSpace(imageRef)) + { + this.TryRegisterImageReference(imageRef, recorder, fileLocation); + } + } + } + } + } + + private void TryRegisterImageReference(string imageReference, ISingleFileComponentRecorder recorder, string fileLocation) + { + if (DockerReferenceUtility.HasUnresolvedVariables(imageReference)) + { + return; + } + + try + { + var dockerRef = DockerReferenceUtility.ParseFamiliarName(imageReference); + if (dockerRef != null) + { + recorder.RegisterUsage(new DetectedComponent(dockerRef.ToTypedDockerReferenceComponent())); + } + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Failed to parse image reference '{ImageReference}' in {Location}", imageReference, fileLocation); + } + } +} diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index 9a9a2b2c3..2f11c522e 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -5,11 +5,14 @@ namespace Microsoft.ComponentDetection.Orchestrator.Extensions; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Detectors.CocoaPods; using Microsoft.ComponentDetection.Detectors.Conan; +using Microsoft.ComponentDetection.Detectors.DockerCompose; using Microsoft.ComponentDetection.Detectors.Dockerfile; 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.Kubernetes; using Microsoft.ComponentDetection.Detectors.Linux; using Microsoft.ComponentDetection.Detectors.Linux.Factories; using Microsoft.ComponentDetection.Detectors.Linux.Filters; @@ -87,6 +90,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s // Dockerfile services.AddSingleton(); + // Docker Compose + services.AddSingleton(); + // DotNet services.AddSingleton(); @@ -97,6 +103,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s // Gradle services.AddSingleton(); + // Helm + services.AddSingleton(); + // Ivy services.AddSingleton(); @@ -171,6 +180,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s // uv services.AddSingleton(); + // Kubernetes + services.AddSingleton(); + return services; } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DockerComposeComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DockerComposeComponentDetectorTests.cs new file mode 100644 index 000000000..37b1f725a --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DockerComposeComponentDetectorTests.cs @@ -0,0 +1,276 @@ +#nullable disable +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.DockerCompose; +using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class DockerComposeComponentDetectorTests : BaseDetectorTest +{ + [TestMethod] + public async Task TestCompose_SingleServiceImageAsync() + { + var composeYaml = @" +version: '3' +services: + web: + image: nginx:1.21 + ports: + - ""80:80"" +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.yml", composeYaml) + .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 TestCompose_MultipleServicesAsync() + { + var composeYaml = @" +version: '3' +services: + web: + image: nginx:1.21 + db: + image: postgres:15 + cache: + image: redis:7-alpine +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.yml", composeYaml) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var components = componentRecorder.GetDetectedComponents(); + components.Should().HaveCount(3); + } + + [TestMethod] + public async Task TestCompose_FullRegistryImageAsync() + { + var composeYaml = @" +services: + app: + image: ghcr.io/myorg/myapp:v2.0 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("compose.yaml", composeYaml) + .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("myorg/myapp"); + dockerRef.Tag.Should().Be("v2.0"); + } + + [TestMethod] + public async Task TestCompose_BuildOnlyServiceIgnoredAsync() + { + var composeYaml = @" +version: '3' +services: + app: + build: ./app + ports: + - ""3000:3000"" +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.yml", composeYaml) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().BeEmpty(); + } + + [TestMethod] + public async Task TestCompose_MixedBuildAndImageAsync() + { + var composeYaml = @" +version: '3' +services: + app: + build: ./app + db: + image: postgres:15 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.yml", composeYaml) + .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/postgres"); + dockerRef.Tag.Should().Be("15"); + } + + [TestMethod] + public async Task TestCompose_NoServicesKeyAsync() + { + var composeYaml = @" +version: '3' +networks: + frontend: +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.yml", composeYaml) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().BeEmpty(); + } + + [TestMethod] + public async Task TestCompose_ImageWithDigestAsync() + { + var composeYaml = @" +services: + app: + image: nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.yaml", composeYaml) + .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 TestCompose_ImageWithTagAndDigestAsync() + { + var composeYaml = @" +services: + app: + image: nginx:1.21@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.yaml", composeYaml) + .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 TestCompose_OverrideFileAsync() + { + var composeYaml = @" +services: + web: + image: myregistry.io/web:latest +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.override.yml", composeYaml) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var components = componentRecorder.GetDetectedComponents(); + components.Should().ContainSingle(); + } + + [TestMethod] + public async Task TestCompose_UnresolvedVariableSkippedAsync() + { + var composeYaml = @" +services: + app: + image: ${REGISTRY}/app:${TAG} + db: + image: postgres:15 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("docker-compose.yml", composeYaml) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var components = componentRecorder.GetDetectedComponents(); + + // Only the literal image reference (postgres:15) should be registered; + // the variable-interpolated image (${REGISTRY}/app:${TAG}) should be silently skipped. + components.Should().ContainSingle(); + var dockerRef = components.First().Component as DockerReferenceComponent; + dockerRef.Should().NotBeNull(); + dockerRef.Repository.Should().Be("library/postgres"); + } + + [TestMethod] + public async Task TestCompose_ComposeOverrideFileAsync() + { + var composeYaml = @" +services: + web: + image: nginx:1.21 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("compose.override.yml", composeYaml) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().ContainSingle(); + } + + [TestMethod] + public async Task TestCompose_ComposeOverrideYamlAsync() + { + var composeYaml = @" +services: + db: + image: postgres:15 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("compose.prod.yaml", composeYaml) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().ContainSingle(); + } +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/DockerfileComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/DockerfileComponentDetectorTests.cs new file mode 100644 index 000000000..42d864671 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/DockerfileComponentDetectorTests.cs @@ -0,0 +1,224 @@ +#nullable disable +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Dockerfile; +using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class DockerfileComponentDetectorTests : BaseDetectorTest +{ + private readonly Mock mockCommandLineInvocationService = new(); + private readonly Mock mockEnvironmentVariableService = new(); + + [TestInitialize] + public void TestInitialize() + { + this.DetectorTestUtility + .AddServiceMock(this.mockCommandLineInvocationService) + .AddServiceMock(this.mockEnvironmentVariableService); + } + + [TestMethod] + public async Task TestDockerfile_SimpleFromInstructionAsync() + { + var dockerfile = "FROM nginx:1.21\n"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .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 TestDockerfile_MultiStageFromAsync() + { + var dockerfile = @"FROM node:18-alpine AS build +RUN npm ci +FROM nginx:1.21 +COPY --from=build /app/dist /usr/share/nginx/html +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var components = componentRecorder.GetDetectedComponents(); + components.Should().HaveCount(2); + } + + [TestMethod] + public async Task TestDockerfile_FullRegistryAsync() + { + var dockerfile = "FROM gcr.io/my-project/my-app:latest\n"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .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("gcr.io"); + dockerRef.Repository.Should().Be("my-project/my-app"); + dockerRef.Tag.Should().Be("latest"); + } + + [TestMethod] + public async Task TestDockerfile_WithDigestAsync() + { + var dockerfile = "FROM nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1\n"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .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 TestDockerfile_WithTagAndDigestAsync() + { + var dockerfile = "FROM nginx:1.21@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1\n"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .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 TestDockerfile_CopyFromExternalImageAsync() + { + var dockerfile = @"FROM nginx:1.21 +COPY --from=busybox:1.35 /bin/busybox /bin/busybox +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var components = componentRecorder.GetDetectedComponents(); + components.Should().HaveCount(2); + } + + [TestMethod] + public async Task TestDockerfile_CopyFromNamedStageNotDuplicatedAsync() + { + var dockerfile = @"FROM node:18-alpine AS builder +RUN echo hello +FROM nginx:1.21 +COPY --from=builder /app /app +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + // COPY --from=builder should resolve to the existing stage, not register a new component + var components = componentRecorder.GetDetectedComponents(); + components.Should().HaveCount(2); + } + + [TestMethod] + public async Task TestDockerfile_UnresolvedVariableSkippedAsync() + { + var dockerfile = @"ARG BASE_IMAGE=nginx +FROM ${BASE_IMAGE}:latest +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Unresolved variables are skipped + componentRecorder.GetDetectedComponents().Should().BeEmpty(); + } + + [TestMethod] + public async Task TestDockerfile_ScratchBaseImageAsync() + { + var dockerfile = "FROM scratch\nCOPY myapp /\n"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + // scratch is a valid Docker base but it resolves to docker.io/library/scratch + var components = componentRecorder.GetDetectedComponents(); + components.Should().ContainSingle(); + } + + [TestMethod] + public async Task TestDockerfile_EmptyFileAsync() + { + var dockerfile = "# just a comment\n"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("dockerfile", dockerfile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().BeEmpty(); + } + + [TestMethod] + public async Task TestDockerfile_NamedDockerfilePatternAsync() + { + var dockerfile = "FROM alpine:3.18\n"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("app.dockerfile", dockerfile) + .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/alpine"); + dockerRef.Tag.Should().Be("3.18"); + } +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs new file mode 100644 index 000000000..5afd80193 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/HelmComponentDetectorTests.cs @@ -0,0 +1,414 @@ +#nullable disable +namespace Microsoft.ComponentDetection.Detectors.Tests; + +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_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 +"; + + 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(); + } +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/KubernetesComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/KubernetesComponentDetectorTests.cs new file mode 100644 index 000000000..be3f68f03 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/KubernetesComponentDetectorTests.cs @@ -0,0 +1,362 @@ +#nullable disable +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Kubernetes; +using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class KubernetesComponentDetectorTests : BaseDetectorTest +{ + [TestMethod] + public async Task TestK8s_PodWithContainerImageAsync() + { + var manifest = @" +apiVersion: v1 +kind: Pod +metadata: + name: my-pod +spec: + containers: + - name: web + image: nginx:1.21 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("pod.yaml", manifest) + .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 TestK8s_DeploymentWithMultipleContainersAsync() + { + var manifest = @" +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + replicas: 3 + selector: + matchLabels: + app: my-app + template: + metadata: + labels: + app: my-app + spec: + containers: + - name: app + image: myregistry.io/myapp:v2.0 + - name: sidecar + image: busybox:1.35 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("deployment.yaml", manifest) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var components = componentRecorder.GetDetectedComponents(); + components.Should().HaveCount(2); + } + + [TestMethod] + public async Task TestK8s_InitContainersAsync() + { + var manifest = @" +apiVersion: v1 +kind: Pod +metadata: + name: my-pod +spec: + initContainers: + - name: init + image: busybox:1.35 + containers: + - name: app + image: nginx:1.21 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("pod.yaml", manifest) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var components = componentRecorder.GetDetectedComponents(); + components.Should().HaveCount(2); + } + + [TestMethod] + public async Task TestK8s_CronJobAsync() + { + var manifest = @" +apiVersion: batch/v1 +kind: CronJob +metadata: + name: my-cronjob +spec: + schedule: ""*/5 * * * *"" + jobTemplate: + spec: + template: + spec: + containers: + - name: job + image: python:3.11-slim + restartPolicy: OnFailure +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("cronjob.yaml", manifest) + .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/python"); + dockerRef.Tag.Should().Be("3.11-slim"); + } + + [TestMethod] + public async Task TestK8s_NonKubernetesYamlIgnoredAsync() + { + var manifest = @" +name: my-config +settings: + debug: true + image: nginx:latest +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("config.yaml", manifest) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().BeEmpty(); + } + + [TestMethod] + public async Task TestK8s_ServiceIgnoredAsync() + { + var manifest = @" +apiVersion: v1 +kind: Service +metadata: + name: my-service +spec: + selector: + app: my-app + ports: + - port: 80 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("service.yaml", manifest) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().BeEmpty(); + } + + [TestMethod] + public async Task TestK8s_StatefulSetAsync() + { + var manifest = @" +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: my-db +spec: + serviceName: my-db + replicas: 3 + selector: + matchLabels: + app: my-db + template: + metadata: + labels: + app: my-db + spec: + containers: + - name: db + image: postgres:15 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("statefulset.yaml", manifest) + .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/postgres"); + dockerRef.Tag.Should().Be("15"); + } + + [TestMethod] + public async Task TestK8s_DaemonSetAsync() + { + var manifest = @" +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: fluentd +spec: + selector: + matchLabels: + name: fluentd + template: + metadata: + labels: + name: fluentd + spec: + containers: + - name: fluentd + image: fluentd:v1.16 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("daemonset.yaml", manifest) + .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/fluentd"); + dockerRef.Tag.Should().Be("v1.16"); + } + + [TestMethod] + public async Task TestK8s_ImageWithFullRegistryAsync() + { + var manifest = @" +apiVersion: v1 +kind: Pod +metadata: + name: my-pod +spec: + containers: + - name: app + image: gcr.io/my-project/my-app:latest +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("pod.yaml", manifest) + .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("gcr.io"); + dockerRef.Repository.Should().Be("my-project/my-app"); + dockerRef.Tag.Should().Be("latest"); + } + + [TestMethod] + public async Task TestK8s_EmptyContainersAsync() + { + var manifest = @" +apiVersion: v1 +kind: Pod +metadata: + name: my-pod +spec: + containers: [] +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("pod.yaml", manifest) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().BeEmpty(); + } + + [TestMethod] + public async Task TestK8s_ImageWithDigestOnlyAsync() + { + var manifest = @" +apiVersion: v1 +kind: Pod +metadata: + name: my-pod +spec: + containers: + - name: app + image: nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("pod.yaml", manifest) + .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 TestK8s_UnresolvedVariablesSkippedAsync() + { + var manifest = @" +apiVersion: v1 +kind: Pod +metadata: + name: my-pod +spec: + containers: + - name: app + image: ${REGISTRY}/app:${TAG} + - name: sidecar + image: nginx:1.21 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("pod.yaml", manifest) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var components = componentRecorder.GetDetectedComponents(); + + // Only the literal image reference (nginx:1.21) should be registered; + // the variable-interpolated image (${REGISTRY}/app:${TAG}) should be silently skipped. + 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"); + } +}