From d1f68189091685b45c1ab449320ce67e3f44dab6 Mon Sep 17 00:00:00 2001 From: Jason Paulos Date: Fri, 19 Dec 2025 14:55:45 -0500 Subject: [PATCH 1/3] Add image scope scanning option to the Linux detector --- .../linux/ILinuxScanner.cs | 2 + .../linux/LinuxContainerDetector.cs | 27 ++++++++++++ .../linux/LinuxScanner.cs | 18 +++++++- .../linux/LinuxScannerScope.cs | 17 +++++++ .../LinuxContainerDetectorTests.cs | 44 +++++++++++++++++++ .../LinuxScannerTests.cs | 21 ++++++--- 6 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 src/Microsoft.ComponentDetection.Detectors/linux/LinuxScannerScope.cs diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs b/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs index adbf67a9c..078211863 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs @@ -19,6 +19,7 @@ public interface ILinuxScanner /// The collection of Docker layers that make up the container image. /// The number of layers that belong to the base image, used to distinguish base image layers from application layers. /// The set of component types to include in the scan results. Only components matching these types will be returned. + /// The scope for scanning the image. See for values. /// A token to monitor for cancellation requests. The default value is . /// A task that represents the asynchronous operation. The task result contains a collection of representing the components found in the image and their associated layers. public Task> ScanLinuxAsync( @@ -26,6 +27,7 @@ public Task> ScanLinuxAsync( IEnumerable containerLayers, int baseImageLayerCount, ISet enabledComponentTypes, + LinuxScannerScope scope, CancellationToken cancellationToken = default ); } diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs index fdf19a4bf..81ca7ac57 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs @@ -28,6 +28,8 @@ ILogger logger { private const string TimeoutConfigKey = "Linux.ScanningTimeoutSec"; private const int DefaultTimeoutMinutes = 10; + private const string ScanScopeConfigKey = "Linux.ImageScanScope"; + private const LinuxScannerScope DefaultScanScope = LinuxScannerScope.AllLayers; private readonly ILinuxScanner linuxScanner = linuxScanner; private readonly IDockerService dockerService = dockerService; @@ -77,6 +79,8 @@ public async Task ExecuteDetectorAsync( return EmptySuccessfulScan(); } + var scannerScope = GetScanScope(request.DetectorArgs); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(GetTimeout(request.DetectorArgs)); @@ -96,6 +100,7 @@ public async Task ExecuteDetectorAsync( results = await this.ProcessImagesAsync( imagesToProcess, request.ComponentRecorder, + scannerScope, timeoutCts.Token ); } @@ -137,6 +142,26 @@ private static TimeSpan GetTimeout(IDictionary detectorArgs) : defaultTimeout; } + /// + /// Extracts and returns the scan scope from detector arguments. + /// + /// The arguments provided by the user. + /// The to use for scanning. Defaults to if not specified. + private static LinuxScannerScope GetScanScope(IDictionary detectorArgs) + { + if (detectorArgs == null || !detectorArgs.TryGetValue(ScanScopeConfigKey, out var scopeValue)) + { + return DefaultScanScope; + } + + return scopeValue?.ToUpperInvariant() switch + { + "ALL-LAYERS" => LinuxScannerScope.AllLayers, + "SQUASHED" => LinuxScannerScope.Squashed, + _ => DefaultScanScope, + }; + } + private static IndividualDetectorScanResult EmptySuccessfulScan() => new() { ResultCode = ProcessingResultCode.Success }; @@ -179,6 +204,7 @@ private static void RecordImageDetectionFailure(Exception exception, string imag private async Task> ProcessImagesAsync( IEnumerable imagesToProcess, IComponentRecorder componentRecorder, + LinuxScannerScope scannerScope, CancellationToken cancellationToken = default ) { @@ -249,6 +275,7 @@ await this.dockerService.InspectImageAsync(image, cancellationToken) internalContainerDetails.Layers, baseImageLayerCount, enabledComponentTypes, + scannerScope, cancellationToken ); diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs index 38817916c..3ca5c426c 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs @@ -27,12 +27,14 @@ public class LinuxScanner : ILinuxScanner private static readonly IList CmdParameters = [ "--quiet", - "--scope", - "all-layers", "--output", "json", ]; + private static readonly IList ScopeAllLayersParameter = ["--scope", "all-layers"]; + + private static readonly IList ScopeSquashedParameter = ["--scope", "squashed"]; + private static readonly SemaphoreSlim ContainerSemaphore = new SemaphoreSlim(2); private static readonly int SemaphoreTimeout = Convert.ToInt32( @@ -96,6 +98,7 @@ public async Task> ScanLinuxAsync( IEnumerable containerLayers, int baseImageLayerCount, ISet enabledComponentTypes, + LinuxScannerScope scope, CancellationToken cancellationToken = default ) { @@ -120,6 +123,17 @@ public async Task> ScanLinuxAsync( { var command = new List { imageHash } .Concat(CmdParameters) + .Concat( + scope switch + { + LinuxScannerScope.AllLayers => ScopeAllLayersParameter, + LinuxScannerScope.Squashed => ScopeSquashedParameter, + _ => throw new ArgumentOutOfRangeException( + nameof(scope), + $"Unsupported scope value: {scope}" + ), + } + ) .ToList(); (stdout, stderr) = await this.dockerService.CreateAndRunContainerAsync( ScannerImage, diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScannerScope.cs b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScannerScope.cs new file mode 100644 index 000000000..164bd71ee --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScannerScope.cs @@ -0,0 +1,17 @@ +namespace Microsoft.ComponentDetection.Detectors.Linux; + +/// +/// Defines the scope for scanning Linux container images. +/// +public enum LinuxScannerScope +{ + /// + /// Scan files from all layers of the image. + /// + AllLayers, + + /// + /// Scan only the files accessible from the final layer of the image. + /// + Squashed, +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs index 07aa3235d..472afa29c 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs @@ -73,6 +73,7 @@ public LinuxContainerDetectorTests() It.IsAny>(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny() ) ) @@ -277,6 +278,7 @@ public async Task TestLinuxContainerDetector_SameImagePassedMultipleTimesAsync() It.IsAny>(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny() ), Times.Once @@ -307,6 +309,48 @@ public async Task TestLinuxContainerDetector_TimeoutParameterSpecifiedAsync() await action.Should().NotThrowAsync(); } + [TestMethod] + [DataRow("all-layers", LinuxScannerScope.AllLayers)] + [DataRow("squashed", LinuxScannerScope.Squashed)] + [DataRow("ALL-LAYERS", LinuxScannerScope.AllLayers)] + [DataRow("SQUASHED", LinuxScannerScope.Squashed)] + [DataRow(null, LinuxScannerScope.AllLayers)] // Test default behavior + [DataRow("", LinuxScannerScope.AllLayers)] // Test empty string default + [DataRow("invalid-value", LinuxScannerScope.AllLayers)] // Test invalid input defaults to AllLayers + public async Task TestLinuxContainerDetector_ImageScanScopeParameterSpecifiedAsync(string scopeValue, LinuxScannerScope expectedScope) + { + var detectorArgs = new Dictionary { { "Linux.ImageScanScope", scopeValue } }; + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + detectorArgs, + [NodeLatestImage], + new ComponentRecorder() + ); + + var linuxContainerDetector = new LinuxContainerDetector( + this.mockSyftLinuxScanner.Object, + this.mockDockerService.Object, + this.mockLinuxContainerDetectorLogger.Object + ); + + await linuxContainerDetector.ExecuteDetectorAsync(scanRequest); + + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.ScanLinuxAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + expectedScope, + It.IsAny() + ), + Times.Once + ); + } + [TestMethod] public async Task TestLinuxContainerDetector_HandlesScratchBaseAsync() { diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs index eaa2c2bb4..4cbdd52ba 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs @@ -288,7 +288,8 @@ await this.linuxScanner.ScanLinuxAsync( }, ], 0, - enabledTypes + enabledTypes, + LinuxScannerScope.AllLayers ) ) .First() @@ -335,7 +336,8 @@ await this.linuxScanner.ScanLinuxAsync( }, ], 0, - enabledTypes + enabledTypes, + LinuxScannerScope.AllLayers ) ) .First() @@ -384,7 +386,8 @@ await this.linuxScanner.ScanLinuxAsync( }, ], 0, - enabledTypes + enabledTypes, + LinuxScannerScope.AllLayers ) ) .First() @@ -433,7 +436,8 @@ await this.linuxScanner.ScanLinuxAsync( }, ], 0, - enabledTypes + enabledTypes, + LinuxScannerScope.AllLayers ) ) .First() @@ -522,7 +526,8 @@ public async Task TestLinuxScanner_SupportsMultipleComponentTypes_Async() new DockerLayer { LayerIndex = 1, DiffId = "sha256:layer2" }, ], 0, - enabledTypes + enabledTypes, + LinuxScannerScope.AllLayers ); var allComponents = layers.SelectMany(l => l.Components).ToList(); @@ -622,7 +627,8 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyLinux_Asy new DockerLayer { LayerIndex = 1, DiffId = "sha256:layer2" }, ], 0, - enabledTypes + enabledTypes, + LinuxScannerScope.AllLayers ); var allComponents = layers.SelectMany(l => l.Components).ToList(); @@ -707,7 +713,8 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyNpmAndPip new DockerLayer { LayerIndex = 1, DiffId = "sha256:layer2" }, ], 0, - enabledTypes + enabledTypes, + LinuxScannerScope.AllLayers ); var allComponents = layers.SelectMany(l => l.Components).ToList(); From d83b9f3d76ad92f6e0a8e6053e69ce6ad3ef6845 Mon Sep 17 00:00:00 2001 From: Jason Paulos Date: Fri, 19 Dec 2025 15:27:36 -0500 Subject: [PATCH 2/3] Add more tests --- .../linux/LinuxScanner.cs | 22 +++---- .../LinuxScannerTests.cs | 58 +++++++++++++++++++ 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs index 3ca5c426c..f9bdd864a 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs @@ -112,6 +112,16 @@ public async Task> ScanLinuxAsync( var stdout = string.Empty; var stderr = string.Empty; + var scopeParameters = scope switch + { + LinuxScannerScope.AllLayers => ScopeAllLayersParameter, + LinuxScannerScope.Squashed => ScopeSquashedParameter, + _ => throw new ArgumentOutOfRangeException( + nameof(scope), + $"Unsupported scope value: {scope}" + ), + }; + using var syftTelemetryRecord = new LinuxScannerSyftTelemetryRecord(); try @@ -123,17 +133,7 @@ public async Task> ScanLinuxAsync( { var command = new List { imageHash } .Concat(CmdParameters) - .Concat( - scope switch - { - LinuxScannerScope.AllLayers => ScopeAllLayersParameter, - LinuxScannerScope.Squashed => ScopeSquashedParameter, - _ => throw new ArgumentOutOfRangeException( - nameof(scope), - $"Unsupported scope value: {scope}" - ), - } - ) + .Concat(scopeParameters) .ToList(); (stdout, stderr) = await this.dockerService.CreateAndRunContainerAsync( ScannerImage, diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs index 4cbdd52ba..6a1e98515 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs @@ -1,6 +1,7 @@ #nullable disable namespace Microsoft.ComponentDetection.Detectors.Tests; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -729,4 +730,61 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyNpmAndPip var pipComponent = allComponents.OfType().Single(); pipComponent.Name.Should().Be("requests"); } + + [TestMethod] + [DataRow(LinuxScannerScope.AllLayers, "all-layers")] + [DataRow(LinuxScannerScope.Squashed, "squashed")] + public async Task TestLinuxScanner_ScopeParameter_IncludesCorrectFlagAsync( + LinuxScannerScope scope, + string expectedFlag + ) + { + this.mockDockerService.Setup(service => + service.CreateAndRunContainerAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync((SyftOutputNoAuthorOrLicense, string.Empty)); + + var enabledTypes = new HashSet { ComponentType.Linux }; + await this.linuxScanner.ScanLinuxAsync( + "fake_hash", + [new DockerLayer { LayerIndex = 0, DiffId = "sha256:layer1" }], + 0, + enabledTypes, + scope + ); + + this.mockDockerService.Verify( + service => + service.CreateAndRunContainerAsync( + It.IsAny(), + It.Is>(cmd => + cmd.Contains("--scope") && cmd.Contains(expectedFlag) + ), + It.IsAny() + ), + Times.Once + ); + } + + [TestMethod] + public async Task TestLinuxScanner_InvalidScopeParameter_ThrowsArgumentOutOfRangeExceptionAsync() + { + var enabledTypes = new HashSet { ComponentType.Linux }; + var invalidScope = (LinuxScannerScope)999; // Invalid enum value + + Func action = async () => + await this.linuxScanner.ScanLinuxAsync( + "fake_hash", + [new DockerLayer { LayerIndex = 0, DiffId = "sha256:layer1" }], + 0, + enabledTypes, + invalidScope + ); + + await action.Should().ThrowAsync(); + } } From 36db1f85806e9a4e47a5188779947719ac8ec54a Mon Sep 17 00:00:00 2001 From: Jason Paulos Date: Fri, 19 Dec 2025 15:41:23 -0500 Subject: [PATCH 3/3] Add docs --- docs/detectors/linux.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/detectors/linux.md b/docs/detectors/linux.md index d6acc11e4..87789f8a9 100644 --- a/docs/detectors/linux.md +++ b/docs/detectors/linux.md @@ -11,6 +11,20 @@ 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. +### Scanner Scope + +By default, this detector invokes Syft with the `all-layers` scanning scope (i.e. the Syft argument `--scope all-layers`). + +Syft has another scope, `squashed`, which can be used to scan only files accessible from the final layer of an image. + +The detector argument `Linux.ImageScanScope` can be used to configure this option as `squashed` or `all-layers` when invoking Component Detection. + +For example: + +```sh +--DetectorArgs Linux.ImageScanScope=squashed +``` + ## Known limitations - Windows container scanning is not supported