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);
+ }
+ }
}