From 919c71561489d9ec05a37d22b3fcdf7aaf37897b Mon Sep 17 00:00:00 2001 From: Jason Paulos Date: Fri, 13 Mar 2026 14:46:00 -0400 Subject: [PATCH] Revert "Add OCI image support to Linux scanner (#1708)" This reverts commit 619040be16fa40854a19a8c3615d9a8dfc20ea0c. --- docs/detectors/linux.md | 22 - .../DockerService.cs | 31 +- .../IDockerService.cs | 19 +- .../linux/Contracts/SourceClassExtensions.cs | 26 - .../linux/Contracts/SyftSourceLayer.cs | 18 - .../linux/Contracts/SyftSourceMetadata.cs | 46 -- .../linux/ILinuxScanner.cs | 31 - .../linux/ImageReference.cs | 98 --- .../linux/LinuxContainerDetector.cs | 414 +++------- .../linux/LinuxScanner.cs | 260 ++---- .../ImageReferenceTests.cs | 138 ---- .../LinuxContainerDetectorTests.cs | 753 ------------------ .../LinuxScannerTests.cs | 235 ------ 13 files changed, 183 insertions(+), 1908 deletions(-) delete mode 100644 src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SourceClassExtensions.cs delete mode 100644 src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceLayer.cs delete mode 100644 src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceMetadata.cs delete mode 100644 src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs delete mode 100644 test/Microsoft.ComponentDetection.Detectors.Tests/ImageReferenceTests.cs diff --git a/docs/detectors/linux.md b/docs/detectors/linux.md index 839363300..87789f8a9 100644 --- a/docs/detectors/linux.md +++ b/docs/detectors/linux.md @@ -11,27 +11,6 @@ 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`). @@ -49,4 +28,3 @@ 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 a3d7a96eb..cb0ce2274 100644 --- a/src/Microsoft.ComponentDetection.Common/DockerService.cs +++ b/src/Microsoft.ComponentDetection.Common/DockerService.cs @@ -183,11 +183,6 @@ 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 { @@ -195,7 +190,7 @@ public async Task InspectImageAsync(string image, Cancellation Command = JsonSerializer.Serialize(command), }; await this.TryPullImageAsync(image, cancellationToken); - var container = await CreateContainerAsync(image, command, additionalBinds, cancellationToken); + var container = await CreateContainerAsync(image, command, cancellationToken); record.Container = JsonSerializer.Serialize(container); var stream = await AttachContainerAsync(container.ID, cancellationToken); await StartContainerAsync(container.ID, cancellationToken); @@ -209,20 +204,8 @@ 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, @@ -238,7 +221,11 @@ private static async Task CreateContainerAsync( [ "no-new-privileges", ], - Binds = binds, + Binds = + [ + $"{Path.GetTempPath()}:/tmp", + "/var/run/docker.sock:/var/run/docker.sock", + ], }, }; return await Client.Containers.CreateContainerAsync(parameters, cancellationToken); @@ -275,10 +262,4 @@ 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 462aff989..4f9a35313 100644 --- a/src/Microsoft.ComponentDetection.Contracts/IDockerService.cs +++ b/src/Microsoft.ComponentDetection.Contracts/IDockerService.cs @@ -52,26 +52,9 @@ public interface IDockerService /// /// Creates and runs a container with the given image and command. /// - /// The image to run. + /// The image to inspect. /// 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 deleted file mode 100644 index 366aef450..000000000 --- a/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SourceClassExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 2f575bbab..000000000 --- a/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceLayer.cs +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 069c2adee..000000000 --- a/src/Microsoft.ComponentDetection.Detectors/linux/Contracts/SyftSourceMetadata.cs +++ /dev/null @@ -1,46 +0,0 @@ -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 3064a6013..aa3cf6b3e 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs @@ -5,7 +5,6 @@ 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. @@ -14,7 +13,6 @@ 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. @@ -31,33 +29,4 @@ 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 deleted file mode 100644 index 5ba97f9bd..000000000 --- a/src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs +++ /dev/null @@ -1,98 +0,0 @@ -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 0c762d71c..3a097db36 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs @@ -3,7 +3,6 @@ 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; @@ -31,12 +30,6 @@ 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; @@ -72,12 +65,14 @@ public async Task ExecuteDetectorAsync( CancellationToken cancellationToken = default ) { - var allImages = request +#pragma warning disable CA1308 + var imagesToProcess = request .ImagesToScan?.Where(image => !string.IsNullOrWhiteSpace(image)) - .Select(ImageReference.Parse) + .Select(image => image.ToLowerInvariant()) .ToList(); +#pragma warning restore CA1308 - if (allImages == null || allImages.Count == 0) + if (imagesToProcess == null || imagesToProcess.Count == 0) { this.logger.LogInformation("No instructions received to scan container images."); return EmptySuccessfulScan(); @@ -102,7 +97,7 @@ public async Task ExecuteDetectorAsync( try { results = await this.ProcessImagesAsync( - allImages, + imagesToProcess, request.ComponentRecorder, scannerScope, timeoutCts.Token @@ -209,347 +204,118 @@ private static void RecordImageDetectionFailure(Exception exception, string imag } private async Task> ProcessImagesAsync( - IEnumerable imageReferences, + IEnumerable imagesToProcess, IComponentRecorder componentRecorder, LinuxScannerScope scannerScope, CancellationToken cancellationToken = default ) { - // 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 processedImages = new ConcurrentDictionary(); - 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 + var inspectTasks = imagesToProcess.Select(async image => { - switch (imageRef.Kind) + try { - 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: + // Check image exists locally. Try pulling if not + if ( + !( + await this.dockerService.ImageExistsLocallyAsync(image, cancellationToken) + || await this.dockerService.TryPullImageAsync(image, cancellationToken) + ) + ) + { throw new InvalidUserInputException( - $"Unsupported image reference kind '{imageRef.Kind}' for image '{imageRef.OriginalInput}'." + $"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." ); - } - } - 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}"); - return this.RecordComponents(containerDetails, layers, componentRecorder); - } - catch (Exception e) - { - this.logger.LogWarning(e, "Scanning of image {ImageId} failed", containerDetails.ImageId); - RecordImageDetectionFailure(e, containerDetails.ImageId); + var imageDetails = + await this.dockerService.InspectImageAsync(image, cancellationToken) + ?? throw new MissingContainerDetailException(image); - var singleFileComponentRecorder = - componentRecorder.CreateSingleFileComponentRecorder(containerDetails.ImageId); - singleFileComponentRecorder.RegisterPackageParseFailure(imageId); - } + processedImages.TryAdd(imageDetails.ImageId, imageDetails); + } + catch (Exception e) + { + this.logger.LogWarning(e, "Processing of image {ContainerImage} failed", image); + RecordImageDetectionFailure(e, image); - return EmptyImageScanningResult(); - } + var singleFileComponentRecorder = + componentRecorder.CreateSingleFileComponentRecorder(image); + singleFileComponentRecorder.RegisterPackageParseFailure(image); + } + }); - /// - /// 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}'." - ); - } + await Task.WhenAll(inspectTasks); - try + var scanTasks = processedImages.Select(async kvp => { - 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 { - 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 + var internalContainerDetails = kvp.Value; + var image = kvp.Key; + var baseImageLayerCount = await this.GetBaseImageLayerCountAsync( + internalContainerDetails, + image, + cancellationToken ); - } - if (sourceMetadata?.Layers == null || sourceMetadata.Layers.Length == 0) - { - this.logger.LogWarning( - "No layer information found in Syft output for local image at {LocalImagePath}", - 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, + } ); - } - // 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 enabledComponentTypes = this.GetEnabledComponentTypes(); + var layers = await this.linuxScanner.ScanLinuxAsync( + kvp.Value.ImageId, + internalContainerDetails.Layers, + baseImageLayerCount, + enabledComponentTypes, + scannerScope, + cancellationToken + ); - // Update layers with base image attribution - containerDetails.Layers = containerDetails.Layers.Select( - layer => new DockerLayer + 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 { - 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 - ); - - return this.RecordComponents(containerDetails, layers, componentRecorder); - } - catch (Exception e) - { - this.logger.LogWarning( - e, - "Processing of local image at {LocalImagePath} failed", - localImagePath - ); - RecordImageDetectionFailure(e, localImagePath); - - var singleFileComponentRecorder = - componentRecorder.CreateSingleFileComponentRecorder(localImagePath); - singleFileComponentRecorder.RegisterPackageParseFailure(localImagePath); - } + 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); - return EmptyImageScanningResult(); - } + var singleFileComponentRecorder = + componentRecorder.CreateSingleFileComponentRecorder(kvp.Value.ImageId); + singleFileComponentRecorder.RegisterPackageParseFailure(kvp.Key); + } - /// - /// 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 EmptyImageScanningResult(); + }); - return new ImageScanningResult - { - ContainerDetails = containerDetails, - Components = components, - }; + return await Task.WhenAll(scanTasks); } private async Task GetBaseImageLayerCountAsync( diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs index 6482958b9..c895904e3 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs @@ -92,176 +92,7 @@ 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; @@ -276,6 +107,8 @@ private async Task RunSyftAsync( ), }; + using var syftTelemetryRecord = new LinuxScannerSyftTelemetryRecord(); + try { acquired = await ContainerSemaphore.WaitAsync(SemaphoreTimeout, cancellationToken); @@ -283,14 +116,13 @@ private async Task RunSyftAsync( { try { - var command = new List { syftSource } + var command = new List { imageHash } .Concat(CmdParameters) .Concat(scopeParameters) .ToList(); (stdout, stderr) = await this.dockerService.CreateAndRunContainerAsync( ScannerImage, command, - additionalBinds, cancellationToken ); } @@ -305,8 +137,8 @@ private async Task RunSyftAsync( { record.SemaphoreFailure = true; this.logger.LogWarning( - "Failed to enter the container semaphore for image {SyftSource}", - syftSource + "Failed to enter the container semaphore for image {ImageHash}", + imageHash ); } } @@ -328,7 +160,87 @@ private async Task RunSyftAsync( ); } - return stdout; + 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 []; + } } private (TypedComponent? Component, IEnumerable LayerIds) CreateComponentWithLayers( diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/ImageReferenceTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/ImageReferenceTests.cs deleted file mode 100644 index d851677e5..000000000 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/ImageReferenceTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -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 8e68e88bf..b21b8a114 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs @@ -12,7 +12,6 @@ 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; @@ -62,8 +61,6 @@ public LinuxContainerDetectorTests() Layers = [], } ); - this.mockDockerService.Setup(service => service.GetEmptyContainerDetails()) - .Returns(() => new ContainerDetails { Id = 100 }); this.mockLogger = new Mock(); this.mockLinuxContainerDetectorLogger = new Mock>(); @@ -377,754 +374,4 @@ 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 5178eec0b..ddc76a28b 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs @@ -10,7 +10,6 @@ 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; @@ -266,7 +265,6 @@ public async Task TestLinuxScannerAsync(string syftOutput) service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny() ) ) @@ -317,7 +315,6 @@ public async Task TestLinuxScanner_ReturnsNullAuthorAndLicense_Async(string syft service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny() ) ) @@ -370,7 +367,6 @@ string syftOutput service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny() ) ) @@ -423,7 +419,6 @@ string syftOutput service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny() ) ) @@ -519,7 +514,6 @@ public async Task TestLinuxScanner_SupportsMultipleComponentTypes_Async() service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny() ) ) @@ -625,7 +619,6 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyLinux_Asy service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny() ) ) @@ -712,7 +705,6 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyNpmAndPip service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny() ) ) @@ -756,7 +748,6 @@ string expectedFlag service.CreateAndRunContainerAsync( It.IsAny(), It.IsAny>(), - It.IsAny>(), It.IsAny() ) ) @@ -778,7 +769,6 @@ await this.linuxScanner.ScanLinuxAsync( It.Is>(cmd => cmd.Contains("--scope") && cmd.Contains(expectedFlag) ), - It.IsAny>(), It.IsAny() ), Times.Once @@ -802,229 +792,4 @@ 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"); - } }