322 lines
11 KiB
C#
322 lines
11 KiB
C#
using System.Collections.Immutable;
|
|
using System.Formats.Tar;
|
|
using System.IO.Compression;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
|
using StellaOps.Cli.Commands.Scan;
|
|
using StellaOps.Cli.Services;
|
|
using StellaOps.Cli.Services.Models;
|
|
using StellaOps.Scanner.Analyzers.Native;
|
|
using StellaOps.Scanner.Contracts;
|
|
using StellaOps.TestKit;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Cli.Tests.Commands;
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
public sealed class BinaryDiffServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task ComputeDiffAsync_InvalidReference_ThrowsBinaryDiffException()
|
|
{
|
|
var service = CreateService(new TestOciRegistryClient(), new TestElfSectionHashExtractor(), TimeProvider.System);
|
|
|
|
var request = new BinaryDiffRequest
|
|
{
|
|
BaseImageRef = "",
|
|
TargetImageRef = "registry.example.com/app:2",
|
|
Mode = BinaryDiffMode.Elf
|
|
};
|
|
|
|
var exception = await Assert.ThrowsAsync<BinaryDiffException>(() =>
|
|
service.ComputeDiffAsync(request, null, CancellationToken.None));
|
|
|
|
Assert.Equal(BinaryDiffErrorCode.InvalidReference, exception.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeDiffAsync_ExcludesUnchanged_WhenIncludeUnchangedFalse()
|
|
{
|
|
var baseRef = "registry.example.com/app:1";
|
|
var targetRef = "registry.example.com/app:2";
|
|
var baseLayer = CreateLayer(
|
|
("usr/bin/alpha", CreateElfBytes('a')),
|
|
("usr/bin/beta", CreateElfBytes('a')));
|
|
var targetLayer = CreateLayer(
|
|
("usr/bin/alpha", CreateElfBytes('a')),
|
|
("usr/bin/beta", CreateElfBytes('b')),
|
|
("usr/bin/gamma", CreateElfBytes('b')));
|
|
|
|
var registry = new TestOciRegistryClient();
|
|
registry.AddImage(baseRef, "sha256:base", CreateManifest("sha256:layer-base", baseLayer.Length));
|
|
registry.AddImage(targetRef, "sha256:target", CreateManifest("sha256:layer-target", targetLayer.Length));
|
|
registry.AddBlob("sha256:layer-base", baseLayer);
|
|
registry.AddBlob("sha256:layer-target", targetLayer);
|
|
|
|
var service = CreateService(registry, new TestElfSectionHashExtractor(), TimeProvider.System);
|
|
|
|
var result = await service.ComputeDiffAsync(
|
|
new BinaryDiffRequest
|
|
{
|
|
BaseImageRef = baseRef,
|
|
TargetImageRef = targetRef,
|
|
Mode = BinaryDiffMode.Elf,
|
|
IncludeUnchanged = false,
|
|
Platform = new BinaryDiffPlatform { Os = "linux", Architecture = "amd64" }
|
|
},
|
|
null,
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(2, result.Findings.Length);
|
|
Assert.All(result.Findings, finding => Assert.NotEqual(ChangeType.Unchanged, finding.ChangeType));
|
|
|
|
var paths = result.Findings.Select(finding => finding.Path).ToArray();
|
|
var sortedPaths = paths.OrderBy(path => path, StringComparer.Ordinal).ToArray();
|
|
Assert.Equal(sortedPaths, paths);
|
|
|
|
Assert.Equal(3, result.Summary.TotalBinaries);
|
|
Assert.Equal(2, result.Summary.Modified);
|
|
Assert.Equal(1, result.Summary.Unchanged);
|
|
Assert.Equal(1, result.Summary.Added);
|
|
Assert.Equal(0, result.Summary.Removed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeDiffAsync_UsesTimeProviderForMetadata()
|
|
{
|
|
var baseRef = "registry.example.com/app:1";
|
|
var targetRef = "registry.example.com/app:2";
|
|
var baseLayer = CreateLayer(("usr/bin/app", CreateElfBytes('a')));
|
|
var targetLayer = CreateLayer(("usr/bin/app", CreateElfBytes('b')));
|
|
|
|
var registry = new TestOciRegistryClient();
|
|
registry.AddImage(baseRef, "sha256:base", CreateManifest("sha256:layer-base", baseLayer.Length));
|
|
registry.AddImage(targetRef, "sha256:target", CreateManifest("sha256:layer-target", targetLayer.Length));
|
|
registry.AddBlob("sha256:layer-base", baseLayer);
|
|
registry.AddBlob("sha256:layer-target", targetLayer);
|
|
|
|
var fixedTime = new DateTimeOffset(2026, 1, 13, 1, 0, 0, TimeSpan.Zero);
|
|
var service = CreateService(registry, new TestElfSectionHashExtractor(), new FixedTimeProvider(fixedTime));
|
|
|
|
var result = await service.ComputeDiffAsync(
|
|
new BinaryDiffRequest
|
|
{
|
|
BaseImageRef = baseRef,
|
|
TargetImageRef = targetRef,
|
|
Mode = BinaryDiffMode.Elf
|
|
},
|
|
null,
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(fixedTime, result.Metadata.AnalysisTimestamp);
|
|
}
|
|
|
|
private static BinaryDiffService CreateService(
|
|
IOciRegistryClient registryClient,
|
|
IElfSectionHashExtractor extractor,
|
|
TimeProvider timeProvider)
|
|
{
|
|
var options = Options.Create(new BinaryDiffOptions { ToolVersion = "test" });
|
|
return new BinaryDiffService(
|
|
registryClient,
|
|
extractor,
|
|
options,
|
|
timeProvider,
|
|
NullLogger<BinaryDiffService>.Instance);
|
|
}
|
|
|
|
private static OciManifest CreateManifest(string layerDigest, long size)
|
|
{
|
|
return new OciManifest
|
|
{
|
|
Layers = new List<OciDescriptor>
|
|
{
|
|
new OciDescriptor
|
|
{
|
|
Digest = layerDigest,
|
|
Size = size,
|
|
MediaType = "application/vnd.oci.image.layer.v1.tar+gzip"
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
private static byte[] CreateLayer(params (string Path, byte[] Content)[] entries)
|
|
{
|
|
using var output = new MemoryStream();
|
|
using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
|
|
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax))
|
|
{
|
|
foreach (var entry in entries)
|
|
{
|
|
var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.Path)
|
|
{
|
|
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
|
|
ModificationTime = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero),
|
|
DataStream = new MemoryStream(entry.Content, writable: false)
|
|
};
|
|
tarWriter.WriteEntry(tarEntry);
|
|
}
|
|
}
|
|
|
|
return output.ToArray();
|
|
}
|
|
|
|
private static byte[] CreateElfBytes(char marker)
|
|
{
|
|
return
|
|
[
|
|
0x7F,
|
|
(byte)'E',
|
|
(byte)'L',
|
|
(byte)'F',
|
|
(byte)marker
|
|
];
|
|
}
|
|
|
|
private static ElfSectionHashSet CreateHashSet(string path, char marker)
|
|
{
|
|
var hash = new string(marker, 64);
|
|
var section = new ElfSectionHash
|
|
{
|
|
Name = ".text",
|
|
Offset = 0,
|
|
Size = 16,
|
|
Sha256 = hash,
|
|
Blake3 = null,
|
|
SectionType = ElfSectionType.ProgBits,
|
|
Flags = ElfSectionFlags.Alloc
|
|
};
|
|
|
|
return new ElfSectionHashSet
|
|
{
|
|
FilePath = path,
|
|
FileHash = hash,
|
|
BuildId = "build-" + marker,
|
|
Sections = ImmutableArray.Create(section),
|
|
ExtractedAt = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero),
|
|
ExtractorVersion = "test"
|
|
};
|
|
}
|
|
|
|
private sealed class TestOciRegistryClient : IOciRegistryClient
|
|
{
|
|
private readonly Dictionary<string, string> _digestsByReference = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, OciManifest> _manifestsByDigest = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, byte[]> _blobsByDigest = new(StringComparer.Ordinal);
|
|
|
|
public void AddImage(string reference, string digest, OciManifest manifest)
|
|
{
|
|
_digestsByReference[reference] = digest;
|
|
_manifestsByDigest[digest] = manifest;
|
|
}
|
|
|
|
public void AddBlob(string digest, byte[] blob)
|
|
{
|
|
_blobsByDigest[digest] = blob;
|
|
}
|
|
|
|
public Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_digestsByReference.TryGetValue(reference.Original, out var digest))
|
|
{
|
|
return Task.FromResult(digest);
|
|
}
|
|
|
|
throw new InvalidOperationException($"Digest not configured for {reference.Original}");
|
|
}
|
|
|
|
public Task<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException("ResolveTagAsync is not used by these tests.");
|
|
}
|
|
|
|
public Task<OciReferrersResponse> ListReferrersAsync(
|
|
OciImageReference reference,
|
|
string digest,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException("ListReferrersAsync is not used by these tests.");
|
|
}
|
|
|
|
public Task<IReadOnlyList<OciReferrerDescriptor>> GetReferrersAsync(
|
|
string registry,
|
|
string repository,
|
|
string digest,
|
|
string? artifactType = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException("GetReferrersAsync is not used by these tests.");
|
|
}
|
|
|
|
public Task<OciManifest> GetManifestAsync(
|
|
OciImageReference reference,
|
|
string digest,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_manifestsByDigest.TryGetValue(digest, out var manifest))
|
|
{
|
|
return Task.FromResult(manifest);
|
|
}
|
|
|
|
throw new InvalidOperationException($"Manifest not configured for {digest}");
|
|
}
|
|
|
|
public Task<byte[]> GetBlobAsync(
|
|
OciImageReference reference,
|
|
string digest,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_blobsByDigest.TryGetValue(digest, out var blob))
|
|
{
|
|
return Task.FromResult(blob);
|
|
}
|
|
|
|
throw new InvalidOperationException($"Blob not configured for {digest}");
|
|
}
|
|
}
|
|
|
|
private sealed class TestElfSectionHashExtractor : IElfSectionHashExtractor
|
|
{
|
|
public Task<ElfSectionHashSet?> ExtractAsync(string elfPath, CancellationToken cancellationToken = default)
|
|
{
|
|
var bytes = File.ReadAllBytes(elfPath);
|
|
return ExtractFromBytesAsync(bytes, elfPath, cancellationToken);
|
|
}
|
|
|
|
public Task<ElfSectionHashSet?> ExtractFromBytesAsync(
|
|
ReadOnlyMemory<byte> elfBytes,
|
|
string virtualPath,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (elfBytes.Length < 5)
|
|
{
|
|
return Task.FromResult<ElfSectionHashSet?>(null);
|
|
}
|
|
|
|
var marker = (char)elfBytes.Span[4];
|
|
var normalized = marker switch
|
|
{
|
|
'a' => 'a',
|
|
'b' => 'b',
|
|
_ => 'c'
|
|
};
|
|
|
|
return Task.FromResult<ElfSectionHashSet?>(CreateHashSet(virtualPath, normalized));
|
|
}
|
|
}
|
|
|
|
private sealed class FixedTimeProvider : TimeProvider
|
|
{
|
|
private readonly DateTimeOffset _fixedTime;
|
|
|
|
public FixedTimeProvider(DateTimeOffset fixedTime)
|
|
{
|
|
_fixedTime = fixedTime;
|
|
}
|
|
|
|
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
|
}
|
|
}
|