Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/detectors/linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ internal enum ImageReferenceKind
/// An OCI archive (tarball) file on disk (e.g., "oci-archive:/path/to/image.tar").
/// </summary>
OciArchive,

/// <summary>
/// A Docker archive (tarball) file on disk created by "docker save" (e.g., "docker-archive:/path/to/image.tar").
/// </summary>
DockerArchive,
}

/// <summary>
Expand All @@ -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:";

/// <summary>
/// Gets the original input string as provided by the user.
Expand All @@ -38,7 +44,7 @@ internal class ImageReference

/// <summary>
/// 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.
/// </summary>
public required string Reference { get; init; }

Expand Down Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -309,16 +310,21 @@ await this.dockerService.InspectImageAsync(image, cancellationToken)
}

/// <summary>
/// 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 <see cref="FileNotFoundException"/> 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.
/// </summary>
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)
{
Expand Down Expand Up @@ -409,6 +415,11 @@ private async Task<ImageScanningResult> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(),
It.IsAny<IList<string>>(),
It.IsAny<LinuxScannerScope>(),
It.IsAny<CancellationToken>()
)
)
.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<SyftOutput>(),
It.IsAny<IEnumerable<DockerLayer>>(),
It.IsAny<ISet<ComponentType>>()
)
)
.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<string>(s => s.StartsWith("docker-archive:") && s.Contains(dockerArchiveName)),
It.Is<IList<string>>(binds =>
binds.Count == 1 && binds[0].Contains(dockerArchiveDir)),
It.IsAny<LinuxScannerScope>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
finally
{
System.IO.File.Delete(dockerArchive);
}
}
}
Loading