diff --git a/docs/detectors/linux.md b/docs/detectors/linux.md index 87789f8a9..839363300 100644 --- a/docs/detectors/linux.md +++ b/docs/detectors/linux.md @@ -11,6 +11,27 @@ Linux detection depends on the following: Linux package detection is performed by running [Syft](https://github.com/anchore/syft) and parsing the output. The output contains the package name, version, and the layer of the container in which it was found. +### Supported Input Types + +The Linux detector runs on container images passed under the `--DockerImagesToScan` flag. + +Supported image reference formats are: + +#### Name and Tag/Digest + +Images in the local Docker daemon or a remote registry can be referenced by name and tag or digest. For example, `ubuntu:16.04`. Remote images will be pulled if they are not present locally. + +#### Digest Only + +Images already present in the local Docker daemon can be referenced by just a digest. For example, `sha256:56bab49eef2ef07505f6a1b0d5bd3a601dfc3c76ad4460f24c91d6fa298369ab`. + +#### OCI Images + +Images present on the filesystem as either an [OCI layout directory](https://specs.opencontainers.org/image-spec/image-layout/) or an OCI image archive (tarball) can be referenced by file path. + +- For OCI image layout directories, use the prefix `oci-dir:` followed by the path to the directory, e.g. `oci-dir:/path/to/image` +- For OCI image archives (tarballs), use the prefix `oci-archive:` followed by the path to the archive file, e.g. `oci-archive:/path/to/image.tar` + ### Scanner Scope By default, this detector invokes Syft with the `all-layers` scanning scope (i.e. the Syft argument `--scope all-layers`). @@ -28,3 +49,4 @@ For example: ## Known limitations - Windows container scanning is not supported +- Multiplatform images are not supported diff --git a/src/Microsoft.ComponentDetection.Common/DockerService.cs b/src/Microsoft.ComponentDetection.Common/DockerService.cs index cb0ce2274..a3d7a96eb 100644 --- a/src/Microsoft.ComponentDetection.Common/DockerService.cs +++ b/src/Microsoft.ComponentDetection.Common/DockerService.cs @@ -183,6 +183,11 @@ public async Task InspectImageAsync(string image, Cancellation } public async Task<(string Stdout, string Stderr)> CreateAndRunContainerAsync(string image, IList command, CancellationToken cancellationToken = default) + { + return await this.CreateAndRunContainerAsync(image, command, additionalBinds: null, cancellationToken); + } + + public async Task<(string Stdout, string Stderr)> CreateAndRunContainerAsync(string image, IList command, IList additionalBinds, CancellationToken cancellationToken = default) { using var record = new DockerServiceTelemetryRecord { @@ -190,7 +195,7 @@ public async Task InspectImageAsync(string image, Cancellation Command = JsonSerializer.Serialize(command), }; await this.TryPullImageAsync(image, cancellationToken); - var container = await CreateContainerAsync(image, command, cancellationToken); + var container = await CreateContainerAsync(image, command, additionalBinds, cancellationToken); record.Container = JsonSerializer.Serialize(container); var stream = await AttachContainerAsync(container.ID, cancellationToken); await StartContainerAsync(container.ID, cancellationToken); @@ -204,8 +209,20 @@ public async Task InspectImageAsync(string image, Cancellation private static async Task CreateContainerAsync( string image, IList command, + IList additionalBinds, CancellationToken cancellationToken = default) { + var binds = new List + { + $"{Path.GetTempPath()}:/tmp", + "/var/run/docker.sock:/var/run/docker.sock", + }; + + if (additionalBinds != null) + { + binds.AddRange(additionalBinds); + } + var parameters = new CreateContainerParameters { Image = image, @@ -221,11 +238,7 @@ private static async Task CreateContainerAsync( [ "no-new-privileges", ], - Binds = - [ - $"{Path.GetTempPath()}:/tmp", - "/var/run/docker.sock:/var/run/docker.sock", - ], + Binds = binds, }, }; return await Client.Containers.CreateContainerAsync(parameters, cancellationToken); @@ -262,4 +275,10 @@ private static int GetContainerId() { return Interlocked.Increment(ref incrementingContainerId); } + + /// + public ContainerDetails GetEmptyContainerDetails() + { + return new ContainerDetails { Id = GetContainerId() }; + } } diff --git a/src/Microsoft.ComponentDetection.Contracts/IDockerService.cs b/src/Microsoft.ComponentDetection.Contracts/IDockerService.cs index 4f9a35313..462aff989 100644 --- a/src/Microsoft.ComponentDetection.Contracts/IDockerService.cs +++ b/src/Microsoft.ComponentDetection.Contracts/IDockerService.cs @@ -52,9 +52,26 @@ public interface IDockerService /// /// Creates and runs a container with the given image and command. /// - /// The image to inspect. + /// The image to run. /// The command to run in the container. /// The cancellation token. /// A tuple of stdout and stderr from the container. Task<(string Stdout, string Stderr)> CreateAndRunContainerAsync(string image, IList command, CancellationToken cancellationToken = default); + + /// + /// Creates and runs a container with the given image, command, and additional volume binds. + /// + /// The image to run. + /// The command to run in the container. + /// Additional volume bind mounts to add to the container (e.g., "/host/path:/container/path:ro"). + /// The cancellation token. + /// A tuple of stdout and stderr from the container. + Task<(string Stdout, string Stderr)> CreateAndRunContainerAsync(string image, IList command, IList additionalBinds, CancellationToken cancellationToken = default); + + /// + /// Creates an empty with a unique ID assigned. + /// Used for image types where details are not obtained from Docker inspect (e.g., OCI layout images). + /// + /// A with only the populated. + ContainerDetails GetEmptyContainerDetails(); } diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SourceClassExtensions.cs b/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SourceClassExtensions.cs new file mode 100644 index 000000000..366aef450 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SourceClassExtensions.cs @@ -0,0 +1,26 @@ +namespace Microsoft.ComponentDetection.Detectors.Linux.Contracts; + +using System.Text.Json; + +/// +/// Extends the auto-generated with a method to +/// deserialize its untyped into a +/// strongly-typed . +/// +public partial class SourceClass +{ + /// + /// Deserializes the property into a . + /// Returns null if is null or not a . + /// + /// A deserialized instance, or null. + internal SyftSourceMetadata? GetSyftSourceMetadata() + { + if (this.Metadata is JsonElement element) + { + return JsonSerializer.Deserialize(element.GetRawText()); + } + + return null; + } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceLayer.cs b/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceLayer.cs new file mode 100644 index 000000000..2f575bbab --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceLayer.cs @@ -0,0 +1,18 @@ +namespace Microsoft.ComponentDetection.Detectors.Linux.Contracts; + +using System.Text.Json.Serialization; + +/// +/// Represents a single layer in the image source metadata from Syft output. +/// +internal class SyftSourceLayer +{ + [JsonPropertyName("mediaType")] + public string? MediaType { get; set; } + + [JsonPropertyName("digest")] + public string? Digest { get; set; } + + [JsonPropertyName("size")] + public long? Size { get; set; } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceMetadata.cs b/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceMetadata.cs new file mode 100644 index 000000000..069c2adee --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceMetadata.cs @@ -0,0 +1,46 @@ +namespace Microsoft.ComponentDetection.Detectors.Linux.Contracts; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +/// +/// Represents the metadata from a Syft scan source of type "image". +/// Contains image details such as layers, labels, tags, and image ID. +/// Deserialized from the source.metadata field in Syft JSON output, +/// which is typed as object in the auto-generated . +/// +internal class SyftSourceMetadata +{ + [JsonPropertyName("userInput")] + public string? UserInput { get; set; } + + [JsonPropertyName("imageID")] + public string? ImageId { get; set; } + + [JsonPropertyName("manifestDigest")] + public string? ManifestDigest { get; set; } + + [JsonPropertyName("mediaType")] + public string? MediaType { get; set; } + + [JsonPropertyName("tags")] + public string[]? Tags { get; set; } + + [JsonPropertyName("imageSize")] + public long? ImageSize { get; set; } + + [JsonPropertyName("layers")] + public SyftSourceLayer[]? Layers { get; set; } + + [JsonPropertyName("repoDigests")] + public string[]? RepoDigests { get; set; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; set; } + + [JsonPropertyName("os")] + public string? Os { get; set; } + + [JsonPropertyName("labels")] + public Dictionary? Labels { get; set; } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs b/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs index aa3cf6b3e..3064a6013 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs @@ -5,6 +5,7 @@ namespace Microsoft.ComponentDetection.Detectors.Linux; using System.Threading.Tasks; using Microsoft.ComponentDetection.Contracts.BcdeModels; using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Linux.Contracts; /// /// Interface for scanning Linux container layers to identify components. @@ -13,6 +14,7 @@ public interface ILinuxScanner { /// /// Scans a Linux container image for components and maps them to their respective layers. + /// Runs Syft and processes the output in a single step. /// /// The hash identifier of the container image to scan. /// The collection of Docker layers that make up the container image. @@ -29,4 +31,33 @@ public Task> ScanLinuxAsync( LinuxScannerScope scope, CancellationToken cancellationToken = default ); + + /// + /// Runs the Syft scanner and returns the raw parsed output without processing components. + /// Use this when the caller needs access to the full Syft output (e.g., to extract source metadata for OCI images). + /// + /// The source argument passed to Syft (e.g., an image hash or "oci-dir:/oci-image"). + /// Additional volume bind mounts for the Syft container (e.g., for mounting OCI directories). + /// The scope for scanning the image. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. The task result contains the parsed . + public Task GetSyftOutputAsync( + string syftSource, + IList additionalBinds, + LinuxScannerScope scope, + CancellationToken cancellationToken = default + ); + + /// + /// Processes parsed Syft output into layer-mapped components. + /// + /// The parsed Syft output. + /// The layers to map components to. + /// The set of component types to include in the results. + /// A collection of representing the components found and their associated layers. + public IEnumerable ProcessSyftOutput( + SyftOutput syftOutput, + IEnumerable containerLayers, + ISet enabledComponentTypes + ); } diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs b/src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs new file mode 100644 index 000000000..5ba97f9bd --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs @@ -0,0 +1,98 @@ +namespace Microsoft.ComponentDetection.Detectors.Linux; + +using System; + +/// +/// Specifies the type of image reference. +/// +internal enum ImageReferenceKind +{ + /// + /// A Docker image reference (e.g., "node:latest", "sha256:abc123"). + /// + DockerImage, + + /// + /// An OCI Image Layout directory on disk (e.g., "oci-dir:/path/to/image"). + /// + OciLayout, + + /// + /// An OCI archive (tarball) file on disk (e.g., "oci-archive:/path/to/image.tar"). + /// + OciArchive, +} + +/// +/// Represents a parsed image reference from the scan input, with its type and cleaned reference string. +/// +internal class ImageReference +{ + private const string OciDirPrefix = "oci-dir:"; + private const string OciArchivePrefix = "oci-archive:"; + + /// + /// Gets the original input string as provided by the user. + /// + public required string OriginalInput { get; init; } + + /// + /// Gets the cleaned reference string with any scheme prefix removed. + /// For Docker images, this is lowercased. For OCI paths, case is preserved. + /// + public required string Reference { get; init; } + + /// + /// Gets the kind of image reference. + /// + public required ImageReferenceKind Kind { get; init; } + + /// + /// Parses an input image string into an . + /// + /// The raw image input string. + /// A parsed . + public static ImageReference Parse(string input) + { + if (input.StartsWith(OciDirPrefix, StringComparison.OrdinalIgnoreCase)) + { + var path = input[OciDirPrefix.Length..]; + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException($"Input with '{OciDirPrefix}' prefix must include a path.", nameof(input)); + } + + return new ImageReference + { + OriginalInput = input, + Reference = path, + Kind = ImageReferenceKind.OciLayout, + }; + } + + if (input.StartsWith(OciArchivePrefix, StringComparison.OrdinalIgnoreCase)) + { + var path = input[OciArchivePrefix.Length..]; + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException($"Input with '{OciArchivePrefix}' prefix must include a path.", nameof(input)); + } + + return new ImageReference + { + OriginalInput = input, + Reference = path, + Kind = ImageReferenceKind.OciArchive, + }; + } + +#pragma warning disable CA1308 + return new ImageReference + { + OriginalInput = input, + Reference = input.ToLowerInvariant(), + Kind = ImageReferenceKind.DockerImage, + }; +#pragma warning restore CA1308 + } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs index 3a097db36..0c762d71c 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs @@ -3,6 +3,7 @@ namespace Microsoft.ComponentDetection.Detectors.Linux; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading; @@ -30,6 +31,12 @@ ILogger logger private const string ScanScopeConfigKey = "Linux.ImageScanScope"; private const LinuxScannerScope DefaultScanScope = LinuxScannerScope.AllLayers; + private const string LocalImageMountPoint = "/image"; + + // Base image annotations from ADO dockerTask + private const string BaseImageRefAnnotation = "image.base.ref.name"; + private const string BaseImageDigestAnnotation = "image.base.digest"; + private readonly ILinuxScanner linuxScanner = linuxScanner; private readonly IDockerService dockerService = dockerService; private readonly ILogger logger = logger; @@ -65,14 +72,12 @@ public async Task ExecuteDetectorAsync( CancellationToken cancellationToken = default ) { -#pragma warning disable CA1308 - var imagesToProcess = request + var allImages = request .ImagesToScan?.Where(image => !string.IsNullOrWhiteSpace(image)) - .Select(image => image.ToLowerInvariant()) + .Select(ImageReference.Parse) .ToList(); -#pragma warning restore CA1308 - if (imagesToProcess == null || imagesToProcess.Count == 0) + if (allImages == null || allImages.Count == 0) { this.logger.LogInformation("No instructions received to scan container images."); return EmptySuccessfulScan(); @@ -97,7 +102,7 @@ public async Task ExecuteDetectorAsync( try { results = await this.ProcessImagesAsync( - imagesToProcess, + allImages, request.ComponentRecorder, scannerScope, timeoutCts.Token @@ -204,118 +209,347 @@ private static void RecordImageDetectionFailure(Exception exception, string imag } private async Task> ProcessImagesAsync( - IEnumerable imagesToProcess, + IEnumerable imageReferences, IComponentRecorder componentRecorder, LinuxScannerScope scannerScope, CancellationToken cancellationToken = default ) { - var processedImages = new ConcurrentDictionary(); + // Phase 1: Resolve images. + + // Docker images will resolve to ContainerDetails via inspect. Deduplicate by ImageId since multiple refs can resolve to the same image. + var processedDockerImages = new ConcurrentDictionary(); + + // Local images will be validated for existence and tracked by their file path. + var localImages = new ConcurrentDictionary(); + + var resolveTasks = imageReferences.Select(imageRef => + this.ResolveImageAsync(imageRef, processedDockerImages, localImages, componentRecorder, cancellationToken)); + + await Task.WhenAll(resolveTasks); + + // Phase 2: Scan and record components for all resolved images concurrently. + var scanTasks = new List>(); + + scanTasks.AddRange(processedDockerImages.Select(kvp => + this.ScanDockerImageAsync(kvp.Key, kvp.Value, scannerScope, componentRecorder, cancellationToken))); - var inspectTasks = imagesToProcess.Select(async image => + scanTasks.AddRange(localImages + .Select(kvp => + this.ScanLocalImageAsync(kvp.Key, kvp.Value, scannerScope, componentRecorder, cancellationToken))); + + return await Task.WhenAll(scanTasks); + } + + /// + /// Resolves an image by doing one of the following: + /// * For Docker images, resolve the reference by pulling (if needed) and inspecting it. + /// Adds the result to the processedImages dictionary for deduplication. + /// * For local images, verify the path exists and adds the reference to a concurrent + /// set for tracking which images to scan in phase 2. + /// + private async Task ResolveImageAsync( + ImageReference imageRef, + ConcurrentDictionary resolvedDockerImages, + ConcurrentDictionary localImages, + IComponentRecorder componentRecorder, + CancellationToken cancellationToken) + { + try { - try + switch (imageRef.Kind) { - // Check image exists locally. Try pulling if not - if ( - !( - await this.dockerService.ImageExistsLocallyAsync(image, cancellationToken) - || await this.dockerService.TryPullImageAsync(image, cancellationToken) - ) - ) - { + case ImageReferenceKind.DockerImage: + await this.ResolveDockerImageAsync(imageRef.Reference, resolvedDockerImages, cancellationToken); + break; + case ImageReferenceKind.OciLayout: + case ImageReferenceKind.OciArchive: + var fullPath = this.ValidateLocalImagePath(imageRef); + localImages.TryAdd(fullPath, imageRef.Kind); + break; + default: throw new InvalidUserInputException( - $"Container image {image} could not be found locally and could not be pulled. Verify the image is either available locally or can be pulled from a registry." + $"Unsupported image reference kind '{imageRef.Kind}' for image '{imageRef.OriginalInput}'." ); + } + } + catch (Exception e) + { + this.logger.LogWarning(e, "Processing of image {ContainerImage} (kind {ImageType}) failed", imageRef.OriginalInput, imageRef.Kind); + RecordImageDetectionFailure(e, imageRef.OriginalInput); + + var singleFileComponentRecorder = + componentRecorder.CreateSingleFileComponentRecorder(imageRef.OriginalInput); + singleFileComponentRecorder.RegisterPackageParseFailure(imageRef.OriginalInput); + } + } + + private async Task ResolveDockerImageAsync( + string image, + ConcurrentDictionary resolvedDockerImages, + CancellationToken cancellationToken) + { + if ( + !( + await this.dockerService.ImageExistsLocallyAsync(image, cancellationToken) + || await this.dockerService.TryPullImageAsync(image, cancellationToken) + ) + ) + { + throw new InvalidUserInputException( + $"Container image {image} could not be found locally and could not be pulled. Verify the image is either available locally or can be pulled from a registry." + ); + } + + var imageDetails = + await this.dockerService.InspectImageAsync(image, cancellationToken) + ?? throw new MissingContainerDetailException(image); + + resolvedDockerImages.TryAdd(imageDetails.ImageId, imageDetails); + } + + /// + /// Validates that a local image path exists on disk. Throws a FileNotFoundException if it does not. + /// For OCI layouts, checks for a directory. For OCI archives, checks for a file. + /// Returns the full path to the local image if validation succeeds. + /// + private string ValidateLocalImagePath(ImageReference imageRef) + { + var path = Path.GetFullPath(imageRef.Reference); + var exists = imageRef.Kind == ImageReferenceKind.OciLayout + ? Directory.Exists(path) + : System.IO.File.Exists(path); + + if (!exists) + { + throw new FileNotFoundException( + $"Local image at path {imageRef.Reference} does not exist.", + imageRef.Reference + ); + } + + return path; + } + + /// + /// Scans a Docker image (already inspected) and records its components. + /// + private async Task ScanDockerImageAsync( + string imageId, + ContainerDetails containerDetails, + LinuxScannerScope scannerScope, + IComponentRecorder componentRecorder, + CancellationToken cancellationToken) + { + try + { + var baseImageLayerCount = await this.GetBaseImageLayerCountAsync( + containerDetails, + imageId, + cancellationToken + ); + + // Update layers with base image attribution + containerDetails.Layers = containerDetails.Layers.Select( + layer => new DockerLayer + { + DiffId = layer.DiffId, + LayerIndex = layer.LayerIndex, + IsBaseImage = layer.LayerIndex < baseImageLayerCount, } + ).ToList(); + + var enabledComponentTypes = this.GetEnabledComponentTypes(); + var layers = await this.linuxScanner.ScanLinuxAsync( + containerDetails.ImageId, + containerDetails.Layers, + baseImageLayerCount, + enabledComponentTypes, + scannerScope, + cancellationToken + ) ?? throw new InvalidOperationException($"Failed to scan image layers for image {containerDetails.ImageId}"); - var imageDetails = - await this.dockerService.InspectImageAsync(image, cancellationToken) - ?? throw new MissingContainerDetailException(image); + return this.RecordComponents(containerDetails, layers, componentRecorder); + } + catch (Exception e) + { + this.logger.LogWarning(e, "Scanning of image {ImageId} failed", containerDetails.ImageId); + RecordImageDetectionFailure(e, containerDetails.ImageId); - processedImages.TryAdd(imageDetails.ImageId, imageDetails); - } - catch (Exception e) - { - this.logger.LogWarning(e, "Processing of image {ContainerImage} failed", image); - RecordImageDetectionFailure(e, image); + var singleFileComponentRecorder = + componentRecorder.CreateSingleFileComponentRecorder(containerDetails.ImageId); + singleFileComponentRecorder.RegisterPackageParseFailure(imageId); + } - var singleFileComponentRecorder = - componentRecorder.CreateSingleFileComponentRecorder(image); - singleFileComponentRecorder.RegisterPackageParseFailure(image); - } - }); + return EmptyImageScanningResult(); + } - await Task.WhenAll(inspectTasks); + /// + /// Scans a local image (OCI layout directory or archive file) by invoking Syft with a volume + /// mount, extracting metadata from the Syft output to build ContainerDetails, and processing + /// detected components. + /// + private async Task ScanLocalImageAsync( + string localImagePath, + ImageReferenceKind imageRefKind, + LinuxScannerScope scannerScope, + IComponentRecorder componentRecorder, + CancellationToken cancellationToken) + { + string hostPathToBind; + string syftContainerPath; + switch (imageRefKind) + { + case ImageReferenceKind.OciLayout: + hostPathToBind = localImagePath; + syftContainerPath = $"oci-dir:{LocalImageMountPoint}"; + break; + case ImageReferenceKind.OciArchive: + hostPathToBind = Path.GetDirectoryName(localImagePath) + ?? throw new InvalidOperationException($"Could not determine parent directory for OCI archive path '{localImagePath}'."); + syftContainerPath = $"oci-archive:{LocalImageMountPoint}/{Path.GetFileName(localImagePath)}"; + break; + case ImageReferenceKind.DockerImage: + default: + throw new InvalidUserInputException( + $"Unsupported image reference kind '{imageRefKind}' for local image at path '{localImagePath}'." + ); + } - var scanTasks = processedImages.Select(async kvp => + try { + var additionalBinds = new List + { + // Bind the local image path into the Syft container as read-only + $"{hostPathToBind}:{LocalImageMountPoint}:ro", + }; + + var syftOutput = await this.linuxScanner.GetSyftOutputAsync( + syftContainerPath, + additionalBinds, + scannerScope, + cancellationToken + ); + + SyftSourceMetadata? sourceMetadata = null; try { - var internalContainerDetails = kvp.Value; - var image = kvp.Key; - var baseImageLayerCount = await this.GetBaseImageLayerCountAsync( - internalContainerDetails, - image, - cancellationToken + sourceMetadata = syftOutput.Source?.GetSyftSourceMetadata(); + } + catch (Exception e) + { + this.logger.LogWarning( + e, + "Failed to deserialize Syft source metadata for local image at {LocalImagePath}. Proceeding without metadata", + localImagePath ); + } - // Update the layer information to specify if a layer was found in the specified baseImage - internalContainerDetails.Layers = internalContainerDetails.Layers.Select( - layer => new DockerLayer - { - DiffId = layer.DiffId, - LayerIndex = layer.LayerIndex, - IsBaseImage = layer.LayerIndex < baseImageLayerCount, - } + if (sourceMetadata?.Layers == null || sourceMetadata.Layers.Length == 0) + { + this.logger.LogWarning( + "No layer information found in Syft output for local image at {LocalImagePath}", + localImagePath ); + } - var enabledComponentTypes = this.GetEnabledComponentTypes(); - var layers = await this.linuxScanner.ScanLinuxAsync( - kvp.Value.ImageId, - internalContainerDetails.Layers, - baseImageLayerCount, - enabledComponentTypes, - scannerScope, - cancellationToken - ); + // Build ContainerDetails from Syft source metadata + var containerDetails = this.dockerService.GetEmptyContainerDetails(); + containerDetails.ImageId = !string.IsNullOrWhiteSpace(sourceMetadata?.ImageId) + ? sourceMetadata.ImageId + : localImagePath; + containerDetails.Digests = sourceMetadata?.RepoDigests ?? []; + containerDetails.Tags = sourceMetadata?.Tags ?? []; + containerDetails.Layers = sourceMetadata?.Layers? + .Select((layer, index) => new DockerLayer + { + DiffId = layer.Digest ?? string.Empty, + LayerIndex = index, + }) + .ToList() ?? []; + + // Extract base image annotations from the Syft source metadata labels + var baseImageRef = string.Empty; + var baseImageDigest = string.Empty; + sourceMetadata?.Labels?.TryGetValue(BaseImageRefAnnotation, out baseImageRef); + sourceMetadata?.Labels?.TryGetValue(BaseImageDigestAnnotation, out baseImageDigest); + containerDetails.BaseImageRef = baseImageRef; + containerDetails.BaseImageDigest = baseImageDigest; + + // Determine base image layer count using existing logic + var baseImageLayerCount = await this.GetBaseImageLayerCountAsync( + containerDetails, + localImagePath, + cancellationToken + ); - var components = layers.SelectMany(layer => - layer.Components.Select(component => new DetectedComponent( - component, - null, - internalContainerDetails.Id, - layer.DockerLayer.LayerIndex - )) - ); - internalContainerDetails.Layers = layers.Select(layer => layer.DockerLayer); - var singleFileComponentRecorder = - componentRecorder.CreateSingleFileComponentRecorder(kvp.Value.ImageId); - components - .ToList() - .ForEach(detectedComponent => - singleFileComponentRecorder.RegisterUsage(detectedComponent, true) - ); - return new ImageScanningResult + // Update layers with base image attribution + containerDetails.Layers = containerDetails.Layers.Select( + layer => new DockerLayer { - ContainerDetails = kvp.Value, - Components = components, - }; - } - catch (Exception e) - { - this.logger.LogWarning(e, "Scanning of image {ImageId} failed", kvp.Value.ImageId); - RecordImageDetectionFailure(e, kvp.Value.ImageId); + DiffId = layer.DiffId, + LayerIndex = layer.LayerIndex, + IsBaseImage = layer.LayerIndex < baseImageLayerCount, + } + ).ToList(); + + // Process components from the same Syft output + var enabledComponentTypes = this.GetEnabledComponentTypes(); + var layers = this.linuxScanner.ProcessSyftOutput( + syftOutput, + containerDetails.Layers, + enabledComponentTypes + ); - var singleFileComponentRecorder = - componentRecorder.CreateSingleFileComponentRecorder(kvp.Value.ImageId); - singleFileComponentRecorder.RegisterPackageParseFailure(kvp.Key); - } + return this.RecordComponents(containerDetails, layers, componentRecorder); + } + catch (Exception e) + { + this.logger.LogWarning( + e, + "Processing of local image at {LocalImagePath} failed", + localImagePath + ); + RecordImageDetectionFailure(e, localImagePath); - return EmptyImageScanningResult(); - }); + var singleFileComponentRecorder = + componentRecorder.CreateSingleFileComponentRecorder(localImagePath); + singleFileComponentRecorder.RegisterPackageParseFailure(localImagePath); + } - return await Task.WhenAll(scanTasks); + return EmptyImageScanningResult(); + } + + /// + /// Records detected components from layer-mapped scan results into the component recorder. + /// + private ImageScanningResult RecordComponents( + ContainerDetails containerDetails, + IEnumerable layers, + IComponentRecorder componentRecorder) + { + var materializedLayers = layers.ToList(); + var components = materializedLayers.SelectMany(layer => + layer.Components.Select(component => new DetectedComponent( + component, + null, + containerDetails.Id, + layer.DockerLayer.LayerIndex + )) + ).ToList(); + containerDetails.Layers = materializedLayers.Select(layer => layer.DockerLayer); + + var singleFileComponentRecorder = + componentRecorder.CreateSingleFileComponentRecorder(containerDetails.ImageId); + components.ForEach(detectedComponent => + singleFileComponentRecorder.RegisterUsage(detectedComponent, true) + ); + + return new ImageScanningResult + { + ContainerDetails = containerDetails, + Components = components, + }; } private async Task GetBaseImageLayerCountAsync( diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs index c895904e3..6482958b9 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs @@ -92,7 +92,176 @@ public async Task> ScanLinuxAsync( ImageToScan = imageHash, ScannerVersion = ScannerImage, }; + using var syftTelemetryRecord = new LinuxScannerSyftTelemetryRecord(); + var stdout = await this.RunSyftAsync(imageHash, scope, additionalBinds: [], record, syftTelemetryRecord, cancellationToken); + + try + { + var syftOutput = SyftOutput.FromJson(stdout); + return this.ProcessSyftOutputWithTelemetry(syftOutput, containerLayers, enabledComponentTypes, syftTelemetryRecord); + } + catch (Exception e) + { + record.FailedDeserializingScannerOutput = e.ToString(); + this.logger.LogError(e, "Failed to deserialize Syft output for image {ImageHash}", imageHash); + return []; + } + } + + /// + public async Task GetSyftOutputAsync( + string syftSource, + IList additionalBinds, + LinuxScannerScope scope, + CancellationToken cancellationToken = default + ) + { + using var record = new LinuxScannerTelemetryRecord + { + ImageToScan = syftSource, + ScannerVersion = ScannerImage, + }; + using var syftTelemetryRecord = new LinuxScannerSyftTelemetryRecord(); + var stdout = await this.RunSyftAsync(syftSource, scope, additionalBinds, record, syftTelemetryRecord, cancellationToken); + try + { + return SyftOutput.FromJson(stdout); + } + catch (Exception e) + { + record.FailedDeserializingScannerOutput = e.ToString(); + this.logger.LogError(e, "Failed to deserialize Syft output for source {SyftSource}", syftSource); + throw; + } + } + + /// + public IEnumerable ProcessSyftOutput( + SyftOutput syftOutput, + IEnumerable containerLayers, + ISet enabledComponentTypes) + { + using var syftTelemetryRecord = new LinuxScannerSyftTelemetryRecord(); + return this.ProcessSyftOutputWithTelemetry(syftOutput, containerLayers, enabledComponentTypes, syftTelemetryRecord); + } + + private IEnumerable ProcessSyftOutputWithTelemetry( + SyftOutput syftOutput, + IEnumerable containerLayers, + ISet enabledComponentTypes, + LinuxScannerSyftTelemetryRecord syftTelemetryRecord) + { + // Apply artifact filters (e.g., Mariner 2.0 workaround) + var validArtifacts = syftOutput.Artifacts.AsEnumerable(); + foreach (var filter in this.artifactFilters) + { + validArtifacts = filter.Filter(validArtifacts, syftOutput.Distro); + } + + // Build a set of enabled factories based on requested component types + var enabledFactories = new HashSet(); + foreach (var componentType in enabledComponentTypes) + { + if ( + this.componentTypeToFactoryLookup.TryGetValue(componentType, out var factory) + && factory != null + ) + { + enabledFactories.Add(factory); + } + } + + // Create components using only enabled factories + var componentsWithLayers = validArtifacts + .DistinctBy(artifact => (artifact.Name, artifact.Version, artifact.Type)) + .Select(artifact => + this.CreateComponentWithLayers(artifact, syftOutput.Distro, enabledFactories) + ) + .Where(result => result.Component != null) + .Select(result => (Component: result.Component!, result.LayerIds)) + .ToList(); + + // Track unsupported artifact types for telemetry + var unsupportedTypes = validArtifacts + .Where(a => !this.artifactTypeToFactoryLookup.ContainsKey(a.Type)) + .Select(a => a.Type) + .Distinct() + .ToList(); + + if (unsupportedTypes.Count > 0) + { + this.logger.LogDebug( + "Encountered unsupported artifact types: {UnsupportedTypes}", + string.Join(", ", unsupportedTypes) + ); + } + + // Track detected components in telemetry + syftTelemetryRecord.Components = JsonSerializer.Serialize( + componentsWithLayers.Select(c => c.Component.Id) + ); + + // Build a layer dictionary from the provided container layers and map components. + var knownLayers = containerLayers.ToList(); + + if (knownLayers.Count > 0) + { + var layerDictionary = knownLayers + .DistinctBy(layer => layer.DiffId) + .ToDictionary(layer => layer.DiffId, _ => new List()); + + foreach (var (component, layers) in componentsWithLayers) + { + foreach (var layer in layers) + { + if (layerDictionary.TryGetValue(layer, out var componentList)) + { + componentList.Add(component); + } + } + } + + return layerDictionary.Select(kvp => new LayerMappedLinuxComponents + { + Components = kvp.Value, + DockerLayer = knownLayers.First(layer => layer.DiffId == kvp.Key), + }); + } + + // No container layers provided — return all components under a single + // entry with no layer information rather than silently dropping them. + var allComponents = componentsWithLayers.Select(c => c.Component).ToList(); + if (allComponents.Count == 0) + { + return []; + } + return + [ + new LayerMappedLinuxComponents + { + Components = allComponents, + DockerLayer = new DockerLayer() + { + DiffId = string.Empty, + LayerIndex = 0, + IsBaseImage = false, + }, + }, + ]; + } + + /// + /// Runs the Syft scanner container and returns the stdout output. + /// + private async Task RunSyftAsync( + string syftSource, + LinuxScannerScope scope, + IList additionalBinds, + LinuxScannerTelemetryRecord record, + LinuxScannerSyftTelemetryRecord syftTelemetryRecord, + CancellationToken cancellationToken) + { var acquired = false; var stdout = string.Empty; var stderr = string.Empty; @@ -107,8 +276,6 @@ public async Task> ScanLinuxAsync( ), }; - using var syftTelemetryRecord = new LinuxScannerSyftTelemetryRecord(); - try { acquired = await ContainerSemaphore.WaitAsync(SemaphoreTimeout, cancellationToken); @@ -116,13 +283,14 @@ public async Task> ScanLinuxAsync( { try { - var command = new List { imageHash } + var command = new List { syftSource } .Concat(CmdParameters) .Concat(scopeParameters) .ToList(); (stdout, stderr) = await this.dockerService.CreateAndRunContainerAsync( ScannerImage, command, + additionalBinds, cancellationToken ); } @@ -137,8 +305,8 @@ public async Task> ScanLinuxAsync( { record.SemaphoreFailure = true; this.logger.LogWarning( - "Failed to enter the container semaphore for image {ImageHash}", - imageHash + "Failed to enter the container semaphore for image {SyftSource}", + syftSource ); } } @@ -160,87 +328,7 @@ public async Task> ScanLinuxAsync( ); } - var layerDictionary = containerLayers - .DistinctBy(layer => layer.DiffId) - .ToDictionary(layer => layer.DiffId, _ => new List()); - - try - { - var syftOutput = SyftOutput.FromJson(stdout); - - // Apply artifact filters (e.g., Mariner 2.0 workaround) - var validArtifacts = syftOutput.Artifacts.AsEnumerable(); - foreach (var filter in this.artifactFilters) - { - validArtifacts = filter.Filter(validArtifacts, syftOutput.Distro); - } - - // Build a set of enabled factories based on requested component types - var enabledFactories = new HashSet(); - foreach (var componentType in enabledComponentTypes) - { - if ( - this.componentTypeToFactoryLookup.TryGetValue(componentType, out var factory) - && factory != null - ) - { - enabledFactories.Add(factory); - } - } - - // Create components using only enabled factories - var componentsWithLayers = validArtifacts - .DistinctBy(artifact => (artifact.Name, artifact.Version, artifact.Type)) - .Select(artifact => - this.CreateComponentWithLayers(artifact, syftOutput.Distro, enabledFactories) - ) - .Where(result => result.Component != null) - .Select(result => (Component: result.Component!, result.LayerIds)) - .ToList(); - - // Track unsupported artifact types for telemetry - var unsupportedTypes = validArtifacts - .Where(a => !this.artifactTypeToFactoryLookup.ContainsKey(a.Type)) - .Select(a => a.Type) - .Distinct() - .ToList(); - - if (unsupportedTypes.Count > 0) - { - this.logger.LogDebug( - "Encountered unsupported artifact types: {UnsupportedTypes}", - string.Join(", ", unsupportedTypes) - ); - } - - // Map components to layers - foreach (var (component, layers) in componentsWithLayers) - { - layers.ToList().ForEach(layer => layerDictionary[layer].Add(component)); - } - - var layerMappedLinuxComponents = layerDictionary.Select(kvp => - { - (var layerId, var components) = kvp; - return new LayerMappedLinuxComponents - { - Components = components, - DockerLayer = containerLayers.First(layer => layer.DiffId == layerId), - }; - }); - - // Track detected components in telemetry - syftTelemetryRecord.Components = JsonSerializer.Serialize( - componentsWithLayers.Select(c => c.Component.Id) - ); - - return layerMappedLinuxComponents; - } - catch (Exception e) - { - record.FailedDeserializingScannerOutput = e.ToString(); - return []; - } + return stdout; } private (TypedComponent? Component, IEnumerable LayerIds) CreateComponentWithLayers( diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/ImageReferenceTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/ImageReferenceTests.cs new file mode 100644 index 000000000..d851677e5 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/ImageReferenceTests.cs @@ -0,0 +1,138 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System; +using AwesomeAssertions; +using Microsoft.ComponentDetection.Detectors.Linux; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class ImageReferenceTests +{ + [TestMethod] + public void Parse_DockerImage_ReturnsDockerImageKind() + { + var result = ImageReference.Parse("node:latest"); + + result.Kind.Should().Be(ImageReferenceKind.DockerImage); + result.OriginalInput.Should().Be("node:latest"); + result.Reference.Should().Be("node:latest"); + } + + [TestMethod] + public void Parse_DockerImage_LowercasesReference() + { + var result = ImageReference.Parse("MyImage:Latest"); + + result.Kind.Should().Be(ImageReferenceKind.DockerImage); + result.OriginalInput.Should().Be("MyImage:Latest"); + result.Reference.Should().Be("myimage:latest"); + } + + [TestMethod] + public void Parse_DockerImageSha_ReturnsDockerImageKind() + { + var result = ImageReference.Parse("sha256:abc123def456"); + + result.Kind.Should().Be(ImageReferenceKind.DockerImage); + result.OriginalInput.Should().Be("sha256:abc123def456"); + result.Reference.Should().Be("sha256:abc123def456"); + } + + [TestMethod] + public void Parse_OciDir_ReturnsOciLayoutKind() + { + var result = ImageReference.Parse("oci-dir:/path/to/image"); + + result.Kind.Should().Be(ImageReferenceKind.OciLayout); + result.OriginalInput.Should().Be("oci-dir:/path/to/image"); + result.Reference.Should().Be("/path/to/image"); + } + + [TestMethod] + public void Parse_OciDir_PreservesPathCase() + { + var result = ImageReference.Parse("oci-dir:/Path/To/Image"); + + result.Kind.Should().Be(ImageReferenceKind.OciLayout); + result.OriginalInput.Should().Be("oci-dir:/Path/To/Image"); + result.Reference.Should().Be("/Path/To/Image"); + } + + [TestMethod] + public void Parse_OciDirCaseInsensitivePrefix_ReturnsOciLayoutKind() + { + var result = ImageReference.Parse("OCI-DIR:/path/to/image"); + + result.Kind.Should().Be(ImageReferenceKind.OciLayout); + result.OriginalInput.Should().Be("OCI-DIR:/path/to/image"); + result.Reference.Should().Be("/path/to/image"); + } + + [TestMethod] + public void Parse_OciDir_ErrorsOnEmptyPath() + { + var act = () => ImageReference.Parse("oci-dir:"); + act.Should().Throw() + .WithMessage("Input with 'oci-dir:' prefix must include a path.*") + .WithParameterName("input"); + } + + [TestMethod] + public void Parse_OciDir_ErrorsOnWhitespaceOnlyPath() + { + var act = () => ImageReference.Parse("oci-dir: "); + act.Should().Throw() + .WithMessage("Input with 'oci-dir:' prefix must include a path.*") + .WithParameterName("input"); + } + + [TestMethod] + public void Parse_OciArchive_ReturnsOciArchiveKind() + { + var result = ImageReference.Parse("oci-archive:/path/to/image.tar"); + + result.Kind.Should().Be(ImageReferenceKind.OciArchive); + result.OriginalInput.Should().Be("oci-archive:/path/to/image.tar"); + result.Reference.Should().Be("/path/to/image.tar"); + } + + [TestMethod] + public void Parse_OciArchive_PreservesPathCase() + { + var result = ImageReference.Parse("oci-archive:/Path/To/Image.tar"); + + result.Kind.Should().Be(ImageReferenceKind.OciArchive); + result.OriginalInput.Should().Be("oci-archive:/Path/To/Image.tar"); + result.Reference.Should().Be("/Path/To/Image.tar"); + } + + [TestMethod] + public void Parse_OciArchiveCaseInsensitivePrefix_ReturnsOciArchiveKind() + { + var result = ImageReference.Parse("OCI-ARCHIVE:/path/to/image.tar"); + + result.Kind.Should().Be(ImageReferenceKind.OciArchive); + result.OriginalInput.Should().Be("OCI-ARCHIVE:/path/to/image.tar"); + result.Reference.Should().Be("/path/to/image.tar"); + } + + [TestMethod] + public void Parse_OciArchive_ErrorsOnEmptyPath() + { + var act = () => ImageReference.Parse("oci-archive:"); + act.Should().Throw() + .WithMessage("Input with 'oci-archive:' prefix must include a path.*") + .WithParameterName("input"); + } + + [TestMethod] + public void Parse_OciArchive_ErrorsOnWhitespaceOnlyPath() + { + var act = () => ImageReference.Parse("oci-archive: "); + act.Should().Throw() + .WithMessage("Input with 'oci-archive:' prefix must include a path.*") + .WithParameterName("input"); + } +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs index b21b8a114..8e68e88bf 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs @@ -12,6 +12,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using Microsoft.ComponentDetection.Contracts.BcdeModels; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.Linux; +using Microsoft.ComponentDetection.Detectors.Linux.Contracts; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -61,6 +62,8 @@ public LinuxContainerDetectorTests() Layers = [], } ); + this.mockDockerService.Setup(service => service.GetEmptyContainerDetails()) + .Returns(() => new ContainerDetails { Id = 100 }); this.mockLogger = new Mock(); this.mockLinuxContainerDetectorLogger = new Mock>(); @@ -374,4 +377,754 @@ public async Task TestLinuxContainerDetector_HandlesScratchBaseAsync() ); await this.TestLinuxContainerDetectorAsync(); } + + [TestMethod] + public async Task TestLinuxContainerDetector_OciLayoutImage_DetectsComponentsAsync() + { + var componentRecorder = new ComponentRecorder(); + + // Create a temp directory to act as the OCI layout path + var ociDir = Path.Combine(Path.GetTempPath(), "test-oci-layout-" + Guid.NewGuid().ToString("N")).TrimEnd(Path.DirectorySeparatorChar); + Directory.CreateDirectory(ociDir); + + try + { + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + null, + [$"oci-dir:{ociDir}"], + componentRecorder + ); + + // Build a SyftOutput with source metadata containing layers, labels, tags + var syftOutputJson = """ + { + "distro": { "id": "azurelinux", "versionID": "3.0" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc", + "metadata": { + "userInput": "/oci-image", + "imageID": "sha256:ociimage123", + "tags": ["myregistry.io/myimage:latest"], + "repoDigests": [], + "layers": [ + { "digest": "sha256:layer1", "size": 40000 }, + { "digest": "sha256:layer2", "size": 50000 } + ], + "labels": { + "image.base.ref.name": "mcr.microsoft.com/azurelinux/base/core:3.0", + "image.base.digest": "sha256:basedigest" + } + } + } + } + """; + var syftOutput = SyftOutput.FromJson(syftOutputJson); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.GetSyftOutputAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(syftOutput); + + var layerMappedComponents = new[] + { + new LayerMappedLinuxComponents + { + DockerLayer = new DockerLayer { DiffId = "sha256:layer1", LayerIndex = 0 }, + Components = [new LinuxComponent("azurelinux", "3.0", "bash", "5.2.15")], + }, + }; + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.IsAny>(), + It.IsAny>() + ) + ) + .Returns(layerMappedComponents); + + var linuxContainerDetector = new LinuxContainerDetector( + this.mockSyftLinuxScanner.Object, + this.mockDockerService.Object, + this.mockLinuxContainerDetectorLogger.Object + ); + + var scanResult = await linuxContainerDetector.ExecuteDetectorAsync(scanRequest); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + scanResult.ContainerDetails.Should().ContainSingle(); + + var containerDetails = scanResult.ContainerDetails.First(); + containerDetails.ImageId.Should().Be("sha256:ociimage123"); + containerDetails.BaseImageRef.Should().Be("mcr.microsoft.com/azurelinux/base/core:3.0"); + containerDetails.BaseImageDigest.Should().Be("sha256:basedigest"); + containerDetails.Tags.Should().ContainSingle().Which.Should().Be("myregistry.io/myimage:latest"); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().ContainSingle(); + var detectedComponent = detectedComponents.First(); + detectedComponent.Component.Id.Should().Contain("bash"); + detectedComponent.ContainerLayerIds.Keys.Should().ContainSingle(); + var containerId = detectedComponent.ContainerLayerIds.Keys.First(); + detectedComponent.ContainerLayerIds[containerId].Should().BeEquivalentTo([0]); // Layer index from SyftOutput + + // Verify GetSyftOutputAsync was called (not ScanLinuxAsync) + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.GetSyftOutputAsync( + It.Is(s => s.StartsWith("oci-dir:")), + It.Is>(binds => + binds.Count == 1 && binds[0].Contains(ociDir)), + It.IsAny(), + It.IsAny() + ), + Times.Once + ); + + // Verify Docker inspect was NOT called for OCI images + this.mockDockerService.Verify( + service => + service.InspectImageAsync(ociDir, It.IsAny()), + Times.Never + ); + + // Verify ProcessSyftOutput was called with the correct layers + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.Is>(layers => + layers.Count() == 2 && + layers.First().DiffId == "sha256:layer1" && + layers.Last().DiffId == "sha256:layer2" + ), + It.IsAny>() + ), + Times.Once + ); + } + finally + { + Directory.Delete(ociDir, true); + } + } + + [TestMethod] + public async Task TestLinuxContainerDetector_OciLayoutImage_DoesNotLowercasePathAsync() + { + var componentRecorder = new ComponentRecorder(); + + // Create a temp directory with mixed case + var ociDir = Path.Combine(Path.GetTempPath(), "TestOciLayout-" + Guid.NewGuid().ToString("N")).TrimEnd(Path.DirectorySeparatorChar); + Directory.CreateDirectory(ociDir); + + try + { + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + null, + [$"oci-dir:{ociDir}"], + componentRecorder + ); + + var syftOutputJson = """ + { + "distro": { "id": "test", "versionID": "1.0" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc", + "metadata": { + "userInput": "/oci-image", + "imageID": "sha256:img", + "layers": [], + "labels": {} + } + } + } + """; + var syftOutput = SyftOutput.FromJson(syftOutputJson); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.GetSyftOutputAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(syftOutput); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.IsAny>(), + It.IsAny>() + ) + ) + .Returns([]); + + var linuxContainerDetector = new LinuxContainerDetector( + this.mockSyftLinuxScanner.Object, + this.mockDockerService.Object, + this.mockLinuxContainerDetectorLogger.Object + ); + + await linuxContainerDetector.ExecuteDetectorAsync(scanRequest); + + // Verify the bind mount path was passed as-is (not lowercased) + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.GetSyftOutputAsync( + It.Is(s => s.StartsWith("oci-dir:")), + It.Is>(binds => + binds.Count == 1 && binds[0].Contains(ociDir)), + It.IsAny(), + It.IsAny() + ), + Times.Once + ); + } + finally + { + Directory.Delete(ociDir, true); + } + } + + [TestMethod] + public async Task TestLinuxContainerDetector_OciLayoutImage_NormalizesPathAsync() + { + var componentRecorder = new ComponentRecorder(); + + // Create a temp directory with mixed case + var ociDir = Path.Combine(Path.GetTempPath(), "test-oci-layout-" + Guid.NewGuid().ToString("N")).TrimEnd(Path.DirectorySeparatorChar); + Directory.CreateDirectory(ociDir); + + var ociDirWithExtraComponents = Path.Combine(Path.GetDirectoryName(ociDir)!, ".", "random", "..", Path.GetFileName(ociDir)); + + try + { + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + null, + [$"oci-dir:{ociDirWithExtraComponents}"], + componentRecorder + ); + + var syftOutputJson = """ + { + "distro": { "id": "test", "versionID": "1.0" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc", + "metadata": { + "userInput": "/oci-image", + "imageID": "sha256:img", + "layers": [], + "labels": {} + } + } + } + """; + var syftOutput = SyftOutput.FromJson(syftOutputJson); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.GetSyftOutputAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(syftOutput); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.IsAny>(), + It.IsAny>() + ) + ) + .Returns([]); + + var linuxContainerDetector = new LinuxContainerDetector( + this.mockSyftLinuxScanner.Object, + this.mockDockerService.Object, + this.mockLinuxContainerDetectorLogger.Object + ); + + await linuxContainerDetector.ExecuteDetectorAsync(scanRequest); + + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.GetSyftOutputAsync( + It.Is(s => s.StartsWith("oci-dir:")), + It.Is>(binds => + binds.Count == 1 && binds[0].Contains(ociDir) && !binds[0].Contains(ociDirWithExtraComponents)), + It.IsAny(), + It.IsAny() + ), + Times.Once + ); + } + finally + { + Directory.Delete(ociDir, true); + } + } + + [TestMethod] + public async Task TestLinuxContainerDetector_MixedDockerAndOciImages_BothProcessedAsync() + { + var componentRecorder = new ComponentRecorder(); + + var ociDir = Path.Combine(Path.GetTempPath(), "test-oci-mixed-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(ociDir); + + try + { + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + null, + [NodeLatestImage, $"oci-dir:{ociDir}"], + componentRecorder + ); + + var syftOutputJson = """ + { + "distro": { "id": "azurelinux", "versionID": "3.0" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc", + "metadata": { + "userInput": "/oci-image", + "imageID": "sha256:ociimg", + "tags": [], + "repoDigests": [], + "layers": [ + { "digest": "sha256:ocilayer1", "size": 10000 } + ], + "labels": {} + } + } + } + """; + var syftOutput = SyftOutput.FromJson(syftOutputJson); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.GetSyftOutputAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(syftOutput); + + var ociLayerMappedComponents = new[] + { + new LayerMappedLinuxComponents + { + DockerLayer = new DockerLayer { DiffId = "sha256:ocilayer1", LayerIndex = 0 }, + Components = [new LinuxComponent("azurelinux", "3.0", "curl", "8.0")], + }, + }; + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.IsAny>(), + It.IsAny>() + ) + ) + .Returns(ociLayerMappedComponents); + + var linuxContainerDetector = new LinuxContainerDetector( + this.mockSyftLinuxScanner.Object, + this.mockDockerService.Object, + this.mockLinuxContainerDetectorLogger.Object + ); + + var scanResult = await linuxContainerDetector.ExecuteDetectorAsync(scanRequest); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Both Docker and OCI images should have results + scanResult.ContainerDetails.Should().HaveCount(2); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().HaveCount(2); + } + finally + { + Directory.Delete(ociDir, true); + } + } + + [TestMethod] + public async Task TestLinuxContainerDetector_OciLayoutImage_NoMetadata_DetectsComponentsAsync() + { + // Ensure that if Syft output for an OCI image is missing metadata, we can still detect components and associate them with the correct container and layers. + var componentRecorder = new ComponentRecorder(); + + var ociDir = Path.Combine(Path.GetTempPath(), "test-oci-no-meta-" + Guid.NewGuid().ToString("N")).TrimEnd(Path.DirectorySeparatorChar); + Directory.CreateDirectory(ociDir); + + try + { + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + null, + [$"oci-dir:{ociDir}"], + componentRecorder + ); + + // Syft output with no source metadata at all + var syftOutputJson = """ + { + "distro": { "id": "azurelinux", "versionID": "3.0" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc" + } + } + """; + var syftOutput = SyftOutput.FromJson(syftOutputJson); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.GetSyftOutputAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(syftOutput); + + var layerMappedComponents = new[] + { + new LayerMappedLinuxComponents + { + DockerLayer = new DockerLayer { DiffId = "unknown", LayerIndex = 0 }, + Components = [new LinuxComponent("azurelinux", "3.0", "curl", "8.0.0")], + }, + }; + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.IsAny>(), + It.IsAny>() + ) + ) + .Returns(layerMappedComponents); + + var linuxContainerDetector = new LinuxContainerDetector( + this.mockSyftLinuxScanner.Object, + this.mockDockerService.Object, + this.mockLinuxContainerDetectorLogger.Object + ); + + var scanResult = await linuxContainerDetector.ExecuteDetectorAsync(scanRequest); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + scanResult.ContainerDetails.Should().ContainSingle(); + + var containerDetails = scanResult.ContainerDetails.First(); + + // When metadata is missing, ImageId falls back to the OCI path + containerDetails.ImageId.Should().Be(Path.GetFullPath(ociDir)); + containerDetails.Tags.Should().BeEmpty(); + containerDetails.BaseImageRef.Should().BeEmpty(); + containerDetails.BaseImageDigest.Should().BeEmpty(); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().ContainSingle(); + var detectedComponent = detectedComponents.First(); + detectedComponent.Component.Id.Should().Contain("curl"); + detectedComponent.ContainerLayerIds.Keys.Should().ContainSingle(); + var containerId = detectedComponent.ContainerLayerIds.Keys.First(); + detectedComponent.ContainerLayerIds[containerId].Should().BeEquivalentTo([0]); // Layer index from SyftOutput + + // Verify ProcessSyftOutput was called with empty layers + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.Is>(layers => !layers.Any()), + It.IsAny>() + ), + Times.Once + ); + } + finally + { + Directory.Delete(ociDir, true); + } + } + + [TestMethod] + public async Task TestLinuxContainerDetector_OciLayoutImage_IncompatibleMetadata_DetectsComponentsAsync() + { + // Ensure that if Syft output contains metadata with an incompatible schema, + // scanning still works as if no metadata were provided. + var componentRecorder = new ComponentRecorder(); + + var ociDir = Path.Combine(Path.GetTempPath(), "test-oci-bad-meta-" + Guid.NewGuid().ToString("N")).TrimEnd(Path.DirectorySeparatorChar); + Directory.CreateDirectory(ociDir); + + try + { + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + null, + [$"oci-dir:{ociDir}"], + componentRecorder + ); + + // Syft output with incompatible metadata (layers is a string, not an array) + var syftOutputJson = """ + { + "distro": { "id": "azurelinux", "versionID": "3.0" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc", + "metadata": { + "imageID": 12345, + "layers": "not-an-array", + "tags": "also-not-an-array" + } + } + } + """; + var syftOutput = SyftOutput.FromJson(syftOutputJson); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.GetSyftOutputAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(syftOutput); + + var layerMappedComponents = new[] + { + new LayerMappedLinuxComponents + { + DockerLayer = new DockerLayer { DiffId = "unknown", LayerIndex = 0 }, + Components = [new LinuxComponent("azurelinux", "3.0", "zlib", "1.2.13")], + }, + }; + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.IsAny>(), + It.IsAny>() + ) + ) + .Returns(layerMappedComponents); + + var linuxContainerDetector = new LinuxContainerDetector( + this.mockSyftLinuxScanner.Object, + this.mockDockerService.Object, + this.mockLinuxContainerDetectorLogger.Object + ); + + var scanResult = await linuxContainerDetector.ExecuteDetectorAsync(scanRequest); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + scanResult.ContainerDetails.Should().ContainSingle(); + + var containerDetails = scanResult.ContainerDetails.First(); + + // Incompatible metadata is treated like missing metadata — ImageId falls back to path + containerDetails.ImageId.Should().Be(Path.GetFullPath(ociDir)); + containerDetails.Tags.Should().BeEmpty(); + containerDetails.BaseImageRef.Should().BeEmpty(); + containerDetails.BaseImageDigest.Should().BeEmpty(); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().ContainSingle(); + var detectedComponent = detectedComponents.First(); + detectedComponent.Component.Id.Should().Contain("zlib"); + detectedComponent.ContainerLayerIds.Keys.Should().ContainSingle(); + var containerId = detectedComponent.ContainerLayerIds.Keys.First(); + detectedComponent.ContainerLayerIds[containerId].Should().BeEquivalentTo([0]); + } + finally + { + Directory.Delete(ociDir, true); + } + } + + [TestMethod] + public async Task TestLinuxContainerDetector_OciArchiveImage_DetectsComponentsAsync() + { + var componentRecorder = new ComponentRecorder(); + + // Create a temp file to act as the OCI archive + var ociArchiveDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var ociArchiveName = "test-oci-archive-" + Guid.NewGuid().ToString("N") + ".tar"; + var ociArchive = Path.Combine(ociArchiveDir, ociArchiveName); + await System.IO.File.WriteAllBytesAsync(ociArchive, []); + + try + { + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + null, + [$"oci-archive:{ociArchive}"], + componentRecorder + ); + + var syftOutputJson = """ + { + "distro": { "id": "azurelinux", "versionID": "3.0" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc", + "metadata": { + "userInput": "/oci-image", + "imageID": "sha256:archiveimg", + "tags": ["myregistry.io/archived:v1"], + "repoDigests": [], + "layers": [ + { "digest": "sha256:archivelayer1", "size": 30000 }, + { "digest": "sha256:archivelayer2", "size": 40000 } + ], + "labels": {} + } + } + } + """; + var syftOutput = SyftOutput.FromJson(syftOutputJson); + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.GetSyftOutputAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(syftOutput); + + var layerMappedComponents = new[] + { + new LayerMappedLinuxComponents + { + DockerLayer = new DockerLayer { DiffId = "sha256:archivelayer2", LayerIndex = 1 }, + Components = [new LinuxComponent("azurelinux", "3.0", "openssl", "3.1.0")], + }, + }; + + this.mockSyftLinuxScanner.Setup(scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.IsAny>(), + It.IsAny>() + ) + ) + .Returns(layerMappedComponents); + + var linuxContainerDetector = new LinuxContainerDetector( + this.mockSyftLinuxScanner.Object, + this.mockDockerService.Object, + this.mockLinuxContainerDetectorLogger.Object + ); + + var scanResult = await linuxContainerDetector.ExecuteDetectorAsync(scanRequest); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + scanResult.ContainerDetails.Should().ContainSingle(); + + var containerDetails = scanResult.ContainerDetails.First(); + containerDetails.ImageId.Should().Be("sha256:archiveimg"); + containerDetails.Tags.Should().ContainSingle().Which.Should().Be("myregistry.io/archived:v1"); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().ContainSingle(); + var detectedComponent = detectedComponents.First(); + detectedComponent.Component.Id.Should().Contain("openssl"); + detectedComponent.ContainerLayerIds.Keys.Should().ContainSingle(); + var containerId = detectedComponent.ContainerLayerIds.Keys.First(); + detectedComponent.ContainerLayerIds[containerId].Should().BeEquivalentTo([1]); // Layer index from SyftOutput + + // Verify GetSyftOutputAsync was called with oci-archive: prefix + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.GetSyftOutputAsync( + It.Is(s => s.StartsWith("oci-archive:") && s.Contains(ociArchiveName)), + It.Is>(binds => + binds.Count == 1 && binds[0].Contains(ociArchiveDir)), + It.IsAny(), + It.IsAny() + ), + Times.Once + ); + + // Verify ProcessSyftOutput was called with the correct layers + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.ProcessSyftOutput( + It.IsAny(), + It.Is>(layers => + layers.Count() == 2 && + layers.First().DiffId == "sha256:archivelayer1" && + layers.Last().DiffId == "sha256:archivelayer2" + ), + It.IsAny>() + ), + Times.Once + ); + } + finally + { + System.IO.File.Delete(ociArchive); + } + } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs index ddc76a28b..5178eec0b 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs @@ -10,6 +10,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using Microsoft.ComponentDetection.Contracts.BcdeModels; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.Linux; +using Microsoft.ComponentDetection.Detectors.Linux.Contracts; using Microsoft.ComponentDetection.Detectors.Linux.Factories; using Microsoft.ComponentDetection.Detectors.Linux.Filters; using Microsoft.Extensions.Logging; @@ -265,6 +266,7 @@ public async Task TestLinuxScannerAsync(string syftOutput) service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny() ) ) @@ -315,6 +317,7 @@ public async Task TestLinuxScanner_ReturnsNullAuthorAndLicense_Async(string syft service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny() ) ) @@ -367,6 +370,7 @@ string syftOutput service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny() ) ) @@ -419,6 +423,7 @@ string syftOutput service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny() ) ) @@ -514,6 +519,7 @@ public async Task TestLinuxScanner_SupportsMultipleComponentTypes_Async() service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny() ) ) @@ -619,6 +625,7 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyLinux_Asy service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny() ) ) @@ -705,6 +712,7 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyNpmAndPip service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny() ) ) @@ -748,6 +756,7 @@ string expectedFlag service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny() ) ) @@ -769,6 +778,7 @@ await this.linuxScanner.ScanLinuxAsync( It.Is>(cmd => cmd.Contains("--scope") && cmd.Contains(expectedFlag) ), + It.IsAny>(), It.IsAny() ), Times.Once @@ -792,4 +802,229 @@ await this.linuxScanner.ScanLinuxAsync( await action.Should().ThrowAsync(); } + + [TestMethod] + public async Task TestLinuxScanner_ScanLinuxSyftOutputAsync_ReturnsParsedSyftOutputAsync() + { + const string syftOutputWithSource = """ + { + "distro": { + "id": "azurelinux", + "versionID": "3.0" + }, + "artifacts": [ + { + "name": "bash", + "version": "5.2.15-3.azl3", + "type": "rpm", + "locations": [ + { + "path": "/var/lib/rpm/Packages", + "layerID": "sha256:aaa111" + } + ], + "metadata": {}, + "licenses": [ + { "value": "GPL-3.0-or-later" } + ] + } + ], + "source": { + "id": "sha256:abc123", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc123", + "metadata": { + "userInput": "/oci-image", + "imageID": "sha256:image123", + "manifestDigest": "sha256:abc123", + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "tags": ["myregistry.io/myimage:latest"], + "imageSize": 100000, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": "sha256:aaa111", + "size": 50000 + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": "sha256:bbb222", + "size": 50000 + } + ], + "repoDigests": [], + "architecture": "amd64", + "os": "linux", + "labels": { + "image.base.ref.name": "mcr.microsoft.com/azurelinux/base/core:3.0", + "image.base.digest": "sha256:basedigest123" + } + } + } + } + """; + + this.mockDockerService.Setup(service => + service.CreateAndRunContainerAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync((syftOutputWithSource, string.Empty)); + + var additionalBinds = new List { "/some/oci/path:/oci-image:ro" }; + var syftOutput = await this.linuxScanner.GetSyftOutputAsync( + "oci-dir:/oci-image", + additionalBinds, + LinuxScannerScope.AllLayers + ); + + syftOutput.Should().NotBeNull(); + syftOutput.Artifacts.Should().ContainSingle(); + syftOutput.Artifacts[0].Name.Should().Be("bash"); + + // Verify source metadata can be extracted + var sourceMetadata = syftOutput.Source?.GetSyftSourceMetadata(); + sourceMetadata.Should().NotBeNull(); + sourceMetadata.ImageId.Should().Be("sha256:image123"); + sourceMetadata.Tags.Should().ContainSingle().Which.Should().Be("myregistry.io/myimage:latest"); + sourceMetadata.Layers.Should().HaveCount(2); + sourceMetadata.Labels.Should().ContainKey("image.base.ref.name"); + + // Verify ProcessSyftOutput works with the returned output + var containerLayers = sourceMetadata.Layers + .Select((layer, index) => new DockerLayer { DiffId = layer.Digest, LayerIndex = index }) + .ToList(); + var enabledTypes = new HashSet { ComponentType.Linux }; + var layerMappedComponents = this.linuxScanner.ProcessSyftOutput( + syftOutput, containerLayers, enabledTypes); + + layerMappedComponents.Should().HaveCount(2); + var layerWithComponents = layerMappedComponents + .First(l => l.DockerLayer.DiffId == "sha256:aaa111"); + layerWithComponents.Components.Should().ContainSingle(); + layerWithComponents.Components.First().Should().BeOfType(); + var bashComponent = layerWithComponents.Components.First() as LinuxComponent; + bashComponent.Should().NotBeNull(); + bashComponent.Name.Should().Be("bash"); + bashComponent.Version.Should().Be("5.2.15-3.azl3"); + bashComponent.Distribution.Should().Be("azurelinux"); + } + + [TestMethod] + public async Task TestLinuxScanner_ScanLinuxSyftOutputAsync_PassesAdditionalBindsAndCommandAsync() + { + const string syftOutput = """ + { + "distro": { "id": "test", "versionID": "1.0" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc", + "metadata": { + "userInput": "/oci-image", + "imageID": "sha256:img", + "layers": [], + "labels": {} + } + } + } + """; + + this.mockDockerService.Setup(service => + service.CreateAndRunContainerAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync((syftOutput, string.Empty)); + + var additionalBinds = new List { "/host/path/to/oci:/oci-image:ro" }; + await this.linuxScanner.GetSyftOutputAsync( + "oci-dir:/oci-image", + additionalBinds, + LinuxScannerScope.AllLayers + ); + + // Verify the Syft command uses oci-dir: scheme and passes binds + this.mockDockerService.Verify( + service => + service.CreateAndRunContainerAsync( + It.IsAny(), + It.Is>(cmd => cmd[0] == "oci-dir:/oci-image"), + It.Is>(binds => + binds.Count == 1 && binds[0] == "/host/path/to/oci:/oci-image:ro" + ), + It.IsAny() + ), + Times.Once + ); + } + + [TestMethod] + public void TestLinuxScanner_ProcessSyftOutput_ReturnsComponentsWithoutLayerInfoWhenNoContainerLayers() + { + var syftOutputJson = """ + { + "distro": { "id": "azurelinux", "versionID": "3.0" }, + "artifacts": [ + { + "name": "bash", + "version": "5.2.15", + "type": "rpm", + "locations": [ + { + "path": "/var/lib/rpm/rpmdb.sqlite", + "layerID": "sha256:layer1" + } + ] + }, + { + "name": "openssl", + "version": "3.1.0", + "type": "rpm", + "locations": [ + { + "path": "/var/lib/rpm/rpmdb.sqlite", + "layerID": "sha256:layer2" + } + ] + } + ], + "source": { + "id": "sha256:abc", + "name": "/oci-image", + "type": "image", + "version": "sha256:abc" + } + } + """; + var syftOutput = SyftOutput.FromJson(syftOutputJson); + var enabledTypes = new HashSet { ComponentType.Linux }; + + // Pass empty container layers — components should still be returned + var result = this.linuxScanner.ProcessSyftOutput( + syftOutput, [], enabledTypes).ToList(); + + // All components should be grouped under a single entry with no layer info + result.Should().ContainSingle(); + + var entry = result.First(); + entry.DockerLayer.Should().NotBeNull(); + entry.DockerLayer.DiffId.Should().Be(string.Empty); + entry.DockerLayer.LayerIndex.Should().Be(0); + entry.DockerLayer.IsBaseImage.Should().BeFalse(); + + entry.Components.Should().HaveCount(2); + entry.Components.Should().AllBeOfType(); + entry.Components.Select(c => (c as LinuxComponent)!.Name) + .Should().Contain("bash").And.Contain("openssl"); + } }