Skip to content

Commit 74a7efc

Browse files
authored
Add image scope scanning option to the Linux detector
1 parent e2c9188 commit 74a7efc

File tree

7 files changed

+192
-9
lines changed

7 files changed

+192
-9
lines changed

docs/detectors/linux.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ Linux detection depends on the following:
1111
Linux package detection is performed by running [Syft](https://github.com/anchore/syft) and parsing the output.
1212
The output contains the package name, version, and the layer of the container in which it was found.
1313

14+
### Scanner Scope
15+
16+
By default, this detector invokes Syft with the `all-layers` scanning scope (i.e. the Syft argument `--scope all-layers`).
17+
18+
Syft has another scope, `squashed`, which can be used to scan only files accessible from the final layer of an image.
19+
20+
The detector argument `Linux.ImageScanScope` can be used to configure this option as `squashed` or `all-layers` when invoking Component Detection.
21+
22+
For example:
23+
24+
```sh
25+
--DetectorArgs Linux.ImageScanScope=squashed
26+
```
27+
1428
## Known limitations
1529

1630
- Windows container scanning is not supported

src/Microsoft.ComponentDetection.Detectors/linux/ILinuxScanner.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ public interface ILinuxScanner
1919
/// <param name="containerLayers">The collection of Docker layers that make up the container image.</param>
2020
/// <param name="baseImageLayerCount">The number of layers that belong to the base image, used to distinguish base image layers from application layers.</param>
2121
/// <param name="enabledComponentTypes">The set of component types to include in the scan results. Only components matching these types will be returned.</param>
22+
/// <param name="scope">The scope for scanning the image. See <see cref="LinuxScannerScope"/> for values.</param>
2223
/// <param name="cancellationToken">A token to monitor for cancellation requests. The default value is <see cref="CancellationToken.None"/>.</param>
2324
/// <returns>A task that represents the asynchronous operation. The task result contains a collection of <see cref="LayerMappedLinuxComponents"/> representing the components found in the image and their associated layers.</returns>
2425
public Task<IEnumerable<LayerMappedLinuxComponents>> ScanLinuxAsync(
2526
string imageHash,
2627
IEnumerable<DockerLayer> containerLayers,
2728
int baseImageLayerCount,
2829
ISet<ComponentType> enabledComponentTypes,
30+
LinuxScannerScope scope,
2931
CancellationToken cancellationToken = default
3032
);
3133
}

src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ ILogger<LinuxContainerDetector> logger
2828
{
2929
private const string TimeoutConfigKey = "Linux.ScanningTimeoutSec";
3030
private const int DefaultTimeoutMinutes = 10;
31+
private const string ScanScopeConfigKey = "Linux.ImageScanScope";
32+
private const LinuxScannerScope DefaultScanScope = LinuxScannerScope.AllLayers;
3133

3234
private readonly ILinuxScanner linuxScanner = linuxScanner;
3335
private readonly IDockerService dockerService = dockerService;
@@ -77,6 +79,8 @@ public async Task<IndividualDetectorScanResult> ExecuteDetectorAsync(
7779
return EmptySuccessfulScan();
7880
}
7981

82+
var scannerScope = GetScanScope(request.DetectorArgs);
83+
8084
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
8185
timeoutCts.CancelAfter(GetTimeout(request.DetectorArgs));
8286

@@ -96,6 +100,7 @@ public async Task<IndividualDetectorScanResult> ExecuteDetectorAsync(
96100
results = await this.ProcessImagesAsync(
97101
imagesToProcess,
98102
request.ComponentRecorder,
103+
scannerScope,
99104
timeoutCts.Token
100105
);
101106
}
@@ -137,6 +142,26 @@ private static TimeSpan GetTimeout(IDictionary<string, string> detectorArgs)
137142
: defaultTimeout;
138143
}
139144

145+
/// <summary>
146+
/// Extracts and returns the scan scope from detector arguments.
147+
/// </summary>
148+
/// <param name="detectorArgs">The arguments provided by the user.</param>
149+
/// <returns>The <see cref="LinuxScannerScope"/> to use for scanning. Defaults to <see cref="DefaultScanScope"/> if not specified.</returns>
150+
private static LinuxScannerScope GetScanScope(IDictionary<string, string> detectorArgs)
151+
{
152+
if (detectorArgs == null || !detectorArgs.TryGetValue(ScanScopeConfigKey, out var scopeValue))
153+
{
154+
return DefaultScanScope;
155+
}
156+
157+
return scopeValue?.ToUpperInvariant() switch
158+
{
159+
"ALL-LAYERS" => LinuxScannerScope.AllLayers,
160+
"SQUASHED" => LinuxScannerScope.Squashed,
161+
_ => DefaultScanScope,
162+
};
163+
}
164+
140165
private static IndividualDetectorScanResult EmptySuccessfulScan() =>
141166
new() { ResultCode = ProcessingResultCode.Success };
142167

@@ -179,6 +204,7 @@ private static void RecordImageDetectionFailure(Exception exception, string imag
179204
private async Task<IEnumerable<ImageScanningResult>> ProcessImagesAsync(
180205
IEnumerable<string> imagesToProcess,
181206
IComponentRecorder componentRecorder,
207+
LinuxScannerScope scannerScope,
182208
CancellationToken cancellationToken = default
183209
)
184210
{
@@ -249,6 +275,7 @@ await this.dockerService.InspectImageAsync(image, cancellationToken)
249275
internalContainerDetails.Layers,
250276
baseImageLayerCount,
251277
enabledComponentTypes,
278+
scannerScope,
252279
cancellationToken
253280
);
254281

src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ public class LinuxScanner : ILinuxScanner
2727
private static readonly IList<string> CmdParameters =
2828
[
2929
"--quiet",
30-
"--scope",
31-
"all-layers",
3230
"--output",
3331
"json",
3432
];
3533

34+
private static readonly IList<string> ScopeAllLayersParameter = ["--scope", "all-layers"];
35+
36+
private static readonly IList<string> ScopeSquashedParameter = ["--scope", "squashed"];
37+
3638
private static readonly SemaphoreSlim ContainerSemaphore = new SemaphoreSlim(2);
3739

3840
private static readonly int SemaphoreTimeout = Convert.ToInt32(
@@ -100,6 +102,7 @@ public async Task<IEnumerable<LayerMappedLinuxComponents>> ScanLinuxAsync(
100102
IEnumerable<DockerLayer> containerLayers,
101103
int baseImageLayerCount,
102104
ISet<ComponentType> enabledComponentTypes,
105+
LinuxScannerScope scope,
103106
CancellationToken cancellationToken = default
104107
)
105108
{
@@ -113,6 +116,16 @@ public async Task<IEnumerable<LayerMappedLinuxComponents>> ScanLinuxAsync(
113116
var stdout = string.Empty;
114117
var stderr = string.Empty;
115118

119+
var scopeParameters = scope switch
120+
{
121+
LinuxScannerScope.AllLayers => ScopeAllLayersParameter,
122+
LinuxScannerScope.Squashed => ScopeSquashedParameter,
123+
_ => throw new ArgumentOutOfRangeException(
124+
nameof(scope),
125+
$"Unsupported scope value: {scope}"
126+
),
127+
};
128+
116129
using var syftTelemetryRecord = new LinuxScannerSyftTelemetryRecord();
117130

118131
try
@@ -124,6 +137,7 @@ public async Task<IEnumerable<LayerMappedLinuxComponents>> ScanLinuxAsync(
124137
{
125138
var command = new List<string> { imageHash }
126139
.Concat(CmdParameters)
140+
.Concat(scopeParameters)
127141
.ToList();
128142
(stdout, stderr) = await this.dockerService.CreateAndRunContainerAsync(
129143
ScannerImage,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Linux;
2+
3+
/// <summary>
4+
/// Defines the scope for scanning Linux container images.
5+
/// </summary>
6+
public enum LinuxScannerScope
7+
{
8+
/// <summary>
9+
/// Scan files from all layers of the image.
10+
/// </summary>
11+
AllLayers,
12+
13+
/// <summary>
14+
/// Scan only the files accessible from the final layer of the image.
15+
/// </summary>
16+
Squashed,
17+
}

test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public LinuxContainerDetectorTests()
7373
It.IsAny<IEnumerable<DockerLayer>>(),
7474
It.IsAny<int>(),
7575
It.IsAny<ISet<ComponentType>>(),
76+
It.IsAny<LinuxScannerScope>(),
7677
It.IsAny<CancellationToken>()
7778
)
7879
)
@@ -277,6 +278,7 @@ public async Task TestLinuxContainerDetector_SameImagePassedMultipleTimesAsync()
277278
It.IsAny<IEnumerable<DockerLayer>>(),
278279
It.IsAny<int>(),
279280
It.IsAny<ISet<ComponentType>>(),
281+
It.IsAny<LinuxScannerScope>(),
280282
It.IsAny<CancellationToken>()
281283
),
282284
Times.Once
@@ -307,6 +309,48 @@ public async Task TestLinuxContainerDetector_TimeoutParameterSpecifiedAsync()
307309
await action.Should().NotThrowAsync<OperationCanceledException>();
308310
}
309311

312+
[TestMethod]
313+
[DataRow("all-layers", LinuxScannerScope.AllLayers)]
314+
[DataRow("squashed", LinuxScannerScope.Squashed)]
315+
[DataRow("ALL-LAYERS", LinuxScannerScope.AllLayers)]
316+
[DataRow("SQUASHED", LinuxScannerScope.Squashed)]
317+
[DataRow(null, LinuxScannerScope.AllLayers)] // Test default behavior
318+
[DataRow("", LinuxScannerScope.AllLayers)] // Test empty string default
319+
[DataRow("invalid-value", LinuxScannerScope.AllLayers)] // Test invalid input defaults to AllLayers
320+
public async Task TestLinuxContainerDetector_ImageScanScopeParameterSpecifiedAsync(string scopeValue, LinuxScannerScope expectedScope)
321+
{
322+
var detectorArgs = new Dictionary<string, string> { { "Linux.ImageScanScope", scopeValue } };
323+
var scanRequest = new ScanRequest(
324+
new DirectoryInfo(Path.GetTempPath()),
325+
(_, __) => false,
326+
this.mockLogger.Object,
327+
detectorArgs,
328+
[NodeLatestImage],
329+
new ComponentRecorder()
330+
);
331+
332+
var linuxContainerDetector = new LinuxContainerDetector(
333+
this.mockSyftLinuxScanner.Object,
334+
this.mockDockerService.Object,
335+
this.mockLinuxContainerDetectorLogger.Object
336+
);
337+
338+
await linuxContainerDetector.ExecuteDetectorAsync(scanRequest);
339+
340+
this.mockSyftLinuxScanner.Verify(
341+
scanner =>
342+
scanner.ScanLinuxAsync(
343+
It.IsAny<string>(),
344+
It.IsAny<IEnumerable<DockerLayer>>(),
345+
It.IsAny<int>(),
346+
It.IsAny<ISet<ComponentType>>(),
347+
expectedScope,
348+
It.IsAny<CancellationToken>()
349+
),
350+
Times.Once
351+
);
352+
}
353+
310354
[TestMethod]
311355
public async Task TestLinuxContainerDetector_HandlesScratchBaseAsync()
312356
{

test/Microsoft.ComponentDetection.Detectors.Tests/LinuxScannerTests.cs

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#nullable disable
22
namespace Microsoft.ComponentDetection.Detectors.Tests;
33

4+
using System;
45
using System.Collections.Generic;
56
using System.Linq;
67
using System.Threading;
@@ -288,7 +289,8 @@ await this.linuxScanner.ScanLinuxAsync(
288289
},
289290
],
290291
0,
291-
enabledTypes
292+
enabledTypes,
293+
LinuxScannerScope.AllLayers
292294
)
293295
)
294296
.First()
@@ -335,7 +337,8 @@ await this.linuxScanner.ScanLinuxAsync(
335337
},
336338
],
337339
0,
338-
enabledTypes
340+
enabledTypes,
341+
LinuxScannerScope.AllLayers
339342
)
340343
)
341344
.First()
@@ -384,7 +387,8 @@ await this.linuxScanner.ScanLinuxAsync(
384387
},
385388
],
386389
0,
387-
enabledTypes
390+
enabledTypes,
391+
LinuxScannerScope.AllLayers
388392
)
389393
)
390394
.First()
@@ -433,7 +437,8 @@ await this.linuxScanner.ScanLinuxAsync(
433437
},
434438
],
435439
0,
436-
enabledTypes
440+
enabledTypes,
441+
LinuxScannerScope.AllLayers
437442
)
438443
)
439444
.First()
@@ -522,7 +527,8 @@ public async Task TestLinuxScanner_SupportsMultipleComponentTypes_Async()
522527
new DockerLayer { LayerIndex = 1, DiffId = "sha256:layer2" },
523528
],
524529
0,
525-
enabledTypes
530+
enabledTypes,
531+
LinuxScannerScope.AllLayers
526532
);
527533

528534
var allComponents = layers.SelectMany(l => l.Components).ToList();
@@ -622,7 +628,8 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyLinux_Asy
622628
new DockerLayer { LayerIndex = 1, DiffId = "sha256:layer2" },
623629
],
624630
0,
625-
enabledTypes
631+
enabledTypes,
632+
LinuxScannerScope.AllLayers
626633
);
627634

628635
var allComponents = layers.SelectMany(l => l.Components).ToList();
@@ -707,7 +714,8 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyNpmAndPip
707714
new DockerLayer { LayerIndex = 1, DiffId = "sha256:layer2" },
708715
],
709716
0,
710-
enabledTypes
717+
enabledTypes,
718+
LinuxScannerScope.AllLayers
711719
);
712720

713721
var allComponents = layers.SelectMany(l => l.Components).ToList();
@@ -722,4 +730,61 @@ public async Task TestLinuxScanner_FiltersComponentsByEnabledTypes_OnlyNpmAndPip
722730
var pipComponent = allComponents.OfType<PipComponent>().Single();
723731
pipComponent.Name.Should().Be("requests");
724732
}
733+
734+
[TestMethod]
735+
[DataRow(LinuxScannerScope.AllLayers, "all-layers")]
736+
[DataRow(LinuxScannerScope.Squashed, "squashed")]
737+
public async Task TestLinuxScanner_ScopeParameter_IncludesCorrectFlagAsync(
738+
LinuxScannerScope scope,
739+
string expectedFlag
740+
)
741+
{
742+
this.mockDockerService.Setup(service =>
743+
service.CreateAndRunContainerAsync(
744+
It.IsAny<string>(),
745+
It.IsAny<List<string>>(),
746+
It.IsAny<CancellationToken>()
747+
)
748+
)
749+
.ReturnsAsync((SyftOutputNoAuthorOrLicense, string.Empty));
750+
751+
var enabledTypes = new HashSet<ComponentType> { ComponentType.Linux };
752+
await this.linuxScanner.ScanLinuxAsync(
753+
"fake_hash",
754+
[new DockerLayer { LayerIndex = 0, DiffId = "sha256:layer1" }],
755+
0,
756+
enabledTypes,
757+
scope
758+
);
759+
760+
this.mockDockerService.Verify(
761+
service =>
762+
service.CreateAndRunContainerAsync(
763+
It.IsAny<string>(),
764+
It.Is<List<string>>(cmd =>
765+
cmd.Contains("--scope") && cmd.Contains(expectedFlag)
766+
),
767+
It.IsAny<CancellationToken>()
768+
),
769+
Times.Once
770+
);
771+
}
772+
773+
[TestMethod]
774+
public async Task TestLinuxScanner_InvalidScopeParameter_ThrowsArgumentOutOfRangeExceptionAsync()
775+
{
776+
var enabledTypes = new HashSet<ComponentType> { ComponentType.Linux };
777+
var invalidScope = (LinuxScannerScope)999; // Invalid enum value
778+
779+
Func<Task> action = async () =>
780+
await this.linuxScanner.ScanLinuxAsync(
781+
"fake_hash",
782+
[new DockerLayer { LayerIndex = 0, DiffId = "sha256:layer1" }],
783+
0,
784+
enabledTypes,
785+
invalidScope
786+
);
787+
788+
await action.Should().ThrowAsync<ArgumentOutOfRangeException>();
789+
}
725790
}

0 commit comments

Comments
 (0)