From 131b7f7503da43d3abd9f45a288ec299cb095af7 Mon Sep 17 00:00:00 2001 From: Jason Paulos Date: Fri, 13 Mar 2026 15:15:31 -0400 Subject: [PATCH] Reapply "Add Docker archive support to Linux scanner (#1711)" (#1715) This reverts commit 69a20576841aab5dbdd235c9066daeb7b5a7695e. --- docs/detectors/linux.md | 4 + .../linux/ImageReference.cs | 24 +++- .../linux/LinuxContainerDetector.cs | 21 +++- .../LinuxContainerDetectorTests.cs | 117 ++++++++++++++++++ 4 files changed, 160 insertions(+), 6 deletions(-) diff --git a/docs/detectors/linux.md b/docs/detectors/linux.md index 839363300..b4e1c613f 100644 --- a/docs/detectors/linux.md +++ b/docs/detectors/linux.md @@ -32,6 +32,10 @@ Images present on the filesystem as either an [OCI layout directory](https://spe - 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` +#### Docker Archives + +Images saved to disk via `docker save` can be referenced using the `docker-archive:` prefix followed by the path to the tarball, e.g. `docker-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`). diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs b/src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs index 5ba97f9bd..fcb8c1c34 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/ImageReference.cs @@ -21,6 +21,11 @@ internal enum ImageReferenceKind /// An OCI archive (tarball) file on disk (e.g., "oci-archive:/path/to/image.tar"). /// OciArchive, + + /// + /// A Docker archive (tarball) file on disk created by "docker save" (e.g., "docker-archive:/path/to/image.tar"). + /// + DockerArchive, } /// @@ -30,6 +35,7 @@ internal class ImageReference { private const string OciDirPrefix = "oci-dir:"; private const string OciArchivePrefix = "oci-archive:"; + private const string DockerArchivePrefix = "docker-archive:"; /// /// Gets the original input string as provided by the user. @@ -38,7 +44,7 @@ internal class ImageReference /// /// Gets the cleaned reference string with any scheme prefix removed. - /// For Docker images, this is lowercased. For OCI paths, case is preserved. + /// For Docker images, this is lowercased. For file paths, case is preserved. /// public required string Reference { get; init; } @@ -86,6 +92,22 @@ public static ImageReference Parse(string input) }; } + if (input.StartsWith(DockerArchivePrefix, StringComparison.OrdinalIgnoreCase)) + { + var path = input[DockerArchivePrefix.Length..]; + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException($"Input with '{DockerArchivePrefix}' prefix must include a path.", nameof(input)); + } + + return new ImageReference + { + OriginalInput = input, + Reference = path, + Kind = ImageReferenceKind.DockerArchive, + }; + } + #pragma warning disable CA1308 return new ImageReference { diff --git a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs index 0c762d71c..41d0747d0 100644 --- a/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs @@ -264,6 +264,7 @@ private async Task ResolveImageAsync( break; case ImageReferenceKind.OciLayout: case ImageReferenceKind.OciArchive: + case ImageReferenceKind.DockerArchive: var fullPath = this.ValidateLocalImagePath(imageRef); localImages.TryAdd(fullPath, imageRef.Kind); break; @@ -309,16 +310,21 @@ await this.dockerService.InspectImageAsync(image, cancellationToken) } /// - /// 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. + /// Validates that a local image path exists on disk. Throws a if it does not. + /// For OCI layouts, checks for a directory. For OCI archives and Docker 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); + var exists = imageRef.Kind switch + { + ImageReferenceKind.OciLayout => Directory.Exists(path), + ImageReferenceKind.OciArchive => System.IO.File.Exists(path), + ImageReferenceKind.DockerArchive => System.IO.File.Exists(path), + ImageReferenceKind.DockerImage or _ => throw new InvalidOperationException( + $"ValidateLocalImagePath does not support image kind '{imageRef.Kind}'."), + }; if (!exists) { @@ -409,6 +415,11 @@ private async Task ScanLocalImageAsync( ?? throw new InvalidOperationException($"Could not determine parent directory for OCI archive path '{localImagePath}'."); syftContainerPath = $"oci-archive:{LocalImageMountPoint}/{Path.GetFileName(localImagePath)}"; break; + case ImageReferenceKind.DockerArchive: + hostPathToBind = Path.GetDirectoryName(localImagePath) + ?? throw new InvalidOperationException($"Could not determine parent directory for Docker archive path '{localImagePath}'."); + syftContainerPath = $"docker-archive:{LocalImageMountPoint}/{Path.GetFileName(localImagePath)}"; + break; case ImageReferenceKind.DockerImage: default: throw new InvalidUserInputException( diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs index 8e68e88bf..823dbe395 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs @@ -1127,4 +1127,121 @@ public async Task TestLinuxContainerDetector_OciArchiveImage_DetectsComponentsAs System.IO.File.Delete(ociArchive); } } + + [TestMethod] + public async Task TestLinuxContainerDetector_DockerArchiveImage_DetectsComponentsAsync() + { + var componentRecorder = new ComponentRecorder(); + + // Create a temp file to act as the Docker archive + var dockerArchiveDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var dockerArchiveName = "test-docker-archive-" + Guid.NewGuid().ToString("N") + ".tar"; + var dockerArchive = Path.Combine(dockerArchiveDir, dockerArchiveName); + await System.IO.File.WriteAllBytesAsync(dockerArchive, []); + + try + { + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + (_, __) => false, + this.mockLogger.Object, + null, + [$"docker-archive:{dockerArchive}"], + componentRecorder + ); + + var syftOutputJson = """ + { + "distro": { "id": "ubuntu", "versionID": "22.04" }, + "artifacts": [], + "source": { + "id": "sha256:abc", + "name": "/local-image", + "type": "image", + "version": "sha256:abc", + "metadata": { + "userInput": "/local-image", + "imageID": "sha256:dockerarchiveimg", + "tags": ["myapp:v2"], + "repoDigests": [], + "layers": [ + { "digest": "sha256:dockerlayer1", "size": 50000 }, + { "digest": "sha256:dockerlayer2", "size": 60000 } + ], + "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:dockerlayer1", LayerIndex = 0 }, + Components = [new LinuxComponent("ubuntu", "22.04", "libc6", "2.35-0ubuntu3")], + }, + }; + + 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:dockerarchiveimg"); + containerDetails.Tags.Should().ContainSingle().Which.Should().Be("myapp:v2"); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().ContainSingle(); + var detectedComponent = detectedComponents.First(); + detectedComponent.Component.Id.Should().Contain("libc6"); + detectedComponent.ContainerLayerIds.Keys.Should().ContainSingle(); + var containerId = detectedComponent.ContainerLayerIds.Keys.First(); + detectedComponent.ContainerLayerIds[containerId].Should().BeEquivalentTo([0]); + + // Verify GetSyftOutputAsync was called with docker-archive: prefix + this.mockSyftLinuxScanner.Verify( + scanner => + scanner.GetSyftOutputAsync( + It.Is(s => s.StartsWith("docker-archive:") && s.Contains(dockerArchiveName)), + It.Is>(binds => + binds.Count == 1 && binds[0].Contains(dockerArchiveDir)), + It.IsAny(), + It.IsAny() + ), + Times.Once + ); + } + finally + { + System.IO.File.Delete(dockerArchive); + } + } }