audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,355 @@
using System.Collections.Immutable;
using System.CommandLine;
using System.Globalization;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using StellaOps.Cli.Extensions;
namespace StellaOps.Cli.Commands.Scan;
internal static class BinaryDiffCommandGroup
{
internal static Command BuildDiffCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var baseOption = new Option<string>("--base", new[] { "-b" })
{
Description = "Base image reference (tag or @digest)",
Required = true
};
var targetOption = new Option<string>("--target", new[] { "-t" })
{
Description = "Target image reference (tag or @digest)",
Required = true
};
var modeOption = new Option<string>("--mode", new[] { "-m" })
{
Description = "Analysis mode: elf, pe, auto (default: auto)"
}.SetDefaultValue("auto").FromAmong("elf", "pe", "auto");
var emitDsseOption = new Option<string?>("--emit-dsse", new[] { "-d" })
{
Description = "Directory for DSSE attestation output"
};
var signingKeyOption = new Option<string?>("--signing-key")
{
Description = "Path to ECDSA private key (PEM) for DSSE signing"
};
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: table, json, summary (default: table)"
}.SetDefaultValue("table").FromAmong("table", "json", "summary");
var platformOption = new Option<string?>("--platform", new[] { "-p" })
{
Description = "Platform filter (e.g., linux/amd64)"
};
var includeUnchangedOption = new Option<bool>("--include-unchanged")
{
Description = "Include unchanged binaries in output"
};
var sectionsOption = new Option<string[]>("--sections")
{
Description = "Sections to analyze (comma-separated or repeatable)"
};
sectionsOption.AllowMultipleArgumentsPerToken = true;
var registryAuthOption = new Option<string?>("--registry-auth")
{
Description = "Path to Docker config for registry authentication"
};
var timeoutOption = new Option<int>("--timeout")
{
Description = "Timeout in seconds for operations (default: 300)"
}.SetDefaultValue(300);
var command = new Command("diff", GetCommandDescription())
{
baseOption,
targetOption,
modeOption,
emitDsseOption,
signingKeyOption,
formatOption,
platformOption,
includeUnchangedOption,
sectionsOption,
registryAuthOption,
timeoutOption,
verboseOption
};
command.SetAction(async (parseResult, ct) =>
{
var baseRef = parseResult.GetValue(baseOption) ?? string.Empty;
var targetRef = parseResult.GetValue(targetOption) ?? string.Empty;
var modeValue = parseResult.GetValue(modeOption) ?? "auto";
var emitDsse = parseResult.GetValue(emitDsseOption);
var signingKeyPath = parseResult.GetValue(signingKeyOption);
var formatValue = parseResult.GetValue(formatOption) ?? "table";
var platformValue = parseResult.GetValue(platformOption);
var includeUnchanged = parseResult.GetValue(includeUnchangedOption);
var sectionsValue = parseResult.GetValue(sectionsOption) ?? Array.Empty<string>();
var registryAuthPath = parseResult.GetValue(registryAuthOption);
var timeoutSeconds = parseResult.GetValue(timeoutOption);
var verbose = parseResult.GetValue(verboseOption);
if (!TryParseMode(modeValue, out var mode, out var modeError))
{
Console.Error.WriteLine($"Error: {modeError}");
return 1;
}
if (!TryParseFormat(formatValue, out var format, out var formatError))
{
Console.Error.WriteLine($"Error: {formatError}");
return 1;
}
if (!TryParsePlatform(platformValue, out var platform, out var platformError))
{
Console.Error.WriteLine($"Error: {platformError}");
return 1;
}
var sections = NormalizeSections(sectionsValue);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
if (timeoutSeconds > 0)
{
linkedCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
}
var showProgress = format != BinaryDiffOutputFormat.Json || verbose;
IProgress<BinaryDiffProgress>? progress = showProgress
? new Progress<BinaryDiffProgress>(ReportProgress)
: null;
var diffService = services.GetRequiredService<IBinaryDiffService>();
var renderer = services.GetRequiredService<IBinaryDiffRenderer>();
var signer = services.GetRequiredService<IBinaryDiffDsseSigner>();
try
{
var result = await diffService.ComputeDiffAsync(
new BinaryDiffRequest
{
BaseImageRef = baseRef,
TargetImageRef = targetRef,
Mode = mode,
Platform = platform,
Sections = sections,
IncludeUnchanged = includeUnchanged,
RegistryAuthPath = registryAuthPath
},
progress,
linkedCts.Token).ConfigureAwait(false);
if (result.Summary.TotalBinaries == 0)
{
Console.Error.WriteLine("Warning: No ELF binaries found in images.");
}
BinaryDiffDsseOutputResult? dsseOutput = null;
if (!string.IsNullOrWhiteSpace(emitDsse))
{
if (result.Predicate is null)
{
Console.Error.WriteLine("Error: DSSE output requested but predicate is missing.");
return 1;
}
var signingKey = BinaryDiffKeyLoader.LoadSigningKey(signingKeyPath ?? string.Empty);
var dsse = await signer.SignAsync(result.Predicate, signingKey, linkedCts.Token).ConfigureAwait(false);
dsseOutput = await BinaryDiffDsseOutputWriter.WriteAsync(
emitDsse,
result.Platform,
dsse,
linkedCts.Token).ConfigureAwait(false);
}
await renderer.RenderAsync(result, format, Console.Out, linkedCts.Token).ConfigureAwait(false);
if (format == BinaryDiffOutputFormat.Summary && dsseOutput is not null)
{
Console.Out.WriteLine($"DSSE Attestation: {dsseOutput.EnvelopePath}");
}
return 0;
}
catch (BinaryDiffException ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return ex.ExitCode;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Console.Error.WriteLine($"Error: Operation timed out after {timeoutSeconds.ToString(CultureInfo.InvariantCulture)}s");
return 124;
}
catch (HttpRequestException ex)
{
Console.Error.WriteLine($"Error: Network error: {ex.Message}");
return 5;
}
catch (InvalidOperationException ex) when (IsAuthFailure(ex))
{
Console.Error.WriteLine($"Error: Registry authentication failed: {ex.Message}");
return 2;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
});
return command;
}
private static string GetCommandDescription()
{
return "Compare binaries between two images using section hashes.\n\nExamples:\n" +
" stella scan diff --base image1 --target image2\n" +
" stella scan diff --base docker://repo/app:1.0.0 --target docker://repo/app:1.0.1 --mode=elf\n" +
" stella scan diff --base image1 --target image2 --emit-dsse=./attestations --signing-key=signing-key.pem\n" +
" stella scan diff --base image1 --target image2 --format=json > diff.json\n" +
" stella scan diff --base image1 --target image2 --platform=linux/amd64";
}
private static void ReportProgress(BinaryDiffProgress progress)
{
if (progress.Total > 0)
{
Console.Error.WriteLine($"[{progress.Phase}] {progress.CurrentItem} ({progress.Current}/{progress.Total})");
return;
}
Console.Error.WriteLine($"[{progress.Phase}] {progress.CurrentItem} ({progress.Current})");
}
private static bool TryParseMode(string value, out BinaryDiffMode mode, out string error)
{
error = string.Empty;
mode = BinaryDiffMode.Auto;
if (string.IsNullOrWhiteSpace(value))
{
error = "Mode is required.";
return false;
}
switch (value.Trim().ToLowerInvariant())
{
case "elf":
mode = BinaryDiffMode.Elf;
return true;
case "pe":
mode = BinaryDiffMode.Pe;
return true;
case "auto":
mode = BinaryDiffMode.Auto;
return true;
default:
error = $"Unsupported mode '{value}'.";
return false;
}
}
private static bool TryParseFormat(string value, out BinaryDiffOutputFormat format, out string error)
{
error = string.Empty;
format = BinaryDiffOutputFormat.Table;
if (string.IsNullOrWhiteSpace(value))
{
error = "Format is required.";
return false;
}
switch (value.Trim().ToLowerInvariant())
{
case "table":
format = BinaryDiffOutputFormat.Table;
return true;
case "json":
format = BinaryDiffOutputFormat.Json;
return true;
case "summary":
format = BinaryDiffOutputFormat.Summary;
return true;
default:
error = $"Unsupported format '{value}'.";
return false;
}
}
private static bool TryParsePlatform(string? value, out BinaryDiffPlatform? platform, out string error)
{
error = string.Empty;
platform = null;
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
var parts = value.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || parts.Length > 3)
{
error = "Platform must be in the form os/arch or os/arch/variant.";
return false;
}
platform = new BinaryDiffPlatform
{
Os = parts[0],
Architecture = parts[1],
Variant = parts.Length == 3 ? parts[2] : null
};
return true;
}
private static ImmutableArray<string> NormalizeSections(string[] sections)
{
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in sections)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var parts = entry.Split(',', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var trimmed = part.Trim();
if (!string.IsNullOrWhiteSpace(trimmed))
{
set.Add(trimmed);
}
}
}
return set
.OrderBy(section => section, StringComparer.Ordinal)
.ToImmutableArray();
}
private static bool IsAuthFailure(InvalidOperationException ex)
{
return ex.Message.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("Forbidden", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,60 @@
using System.Text;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
namespace StellaOps.Cli.Commands.Scan;
internal sealed record BinaryDiffDsseOutputResult
{
public required string EnvelopePath { get; init; }
public required string PayloadPath { get; init; }
}
internal static class BinaryDiffDsseOutputWriter
{
public static async Task<BinaryDiffDsseOutputResult> WriteAsync(
string outputDirectory,
BinaryDiffPlatform platform,
BinaryDiffDsseResult result,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(outputDirectory))
{
throw new ArgumentException("Output directory is required.", nameof(outputDirectory));
}
Directory.CreateDirectory(outputDirectory);
var platformSuffix = FormatPlatformForFile(platform);
var baseName = $"{platformSuffix}-binarydiff";
var envelopePath = Path.Combine(outputDirectory, $"{baseName}.dsse.json");
var payloadPath = Path.Combine(outputDirectory, $"{baseName}.payload.json");
await File.WriteAllTextAsync(envelopePath, result.EnvelopeJson, cancellationToken).ConfigureAwait(false);
var payloadJson = Encoding.UTF8.GetString(result.Payload);
await File.WriteAllTextAsync(payloadPath, payloadJson, cancellationToken).ConfigureAwait(false);
return new BinaryDiffDsseOutputResult
{
EnvelopePath = envelopePath,
PayloadPath = payloadPath
};
}
private static string FormatPlatformForFile(BinaryDiffPlatform platform)
{
var parts = new List<string>
{
platform.Os,
platform.Architecture
};
if (!string.IsNullOrWhiteSpace(platform.Variant))
{
parts.Add(platform.Variant);
}
return string.Join("-", parts)
.ToLowerInvariant()
.Replace('/', '-')
.Replace('\\', '-');
}
}

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Cli.Commands.Scan;
internal enum BinaryDiffErrorCode
{
InvalidReference,
AuthFailed,
PlatformNotFound,
UnsupportedMode,
RegistryAuthInvalid,
SigningKeyInvalid
}
internal sealed class BinaryDiffException : Exception
{
public BinaryDiffException(BinaryDiffErrorCode code, string message, Exception? innerException = null)
: base(message, innerException)
{
Code = code;
}
public BinaryDiffErrorCode Code { get; }
public int ExitCode => Code switch
{
BinaryDiffErrorCode.AuthFailed => 2,
BinaryDiffErrorCode.PlatformNotFound => 3,
_ => 1
};
}

View File

@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using StellaOps.Attestor.Envelope;
using StellaOps.Cryptography;
namespace StellaOps.Cli.Commands.Scan;
internal static class BinaryDiffKeyLoader
{
public static EnvelopeKey LoadSigningKey(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new BinaryDiffException(
BinaryDiffErrorCode.SigningKeyInvalid,
"Signing key path is required for DSSE output.");
}
if (!File.Exists(path))
{
throw new BinaryDiffException(
BinaryDiffErrorCode.SigningKeyInvalid,
$"Signing key file not found: {path}");
}
var pem = File.ReadAllText(path);
using var ecdsa = ECDsa.Create();
try
{
ecdsa.ImportFromPem(pem);
}
catch (CryptographicException ex)
{
throw new BinaryDiffException(
BinaryDiffErrorCode.SigningKeyInvalid,
"Failed to load ECDSA private key from PEM.",
ex);
}
var keySize = ecdsa.KeySize;
var parameters = ecdsa.ExportParameters(true);
var algorithm = ResolveEcdsaAlgorithm(keySize);
return EnvelopeKey.CreateEcdsaSigner(algorithm, parameters);
}
private static string ResolveEcdsaAlgorithm(int keySize)
{
return keySize switch
{
256 => SignatureAlgorithms.Es256,
384 => SignatureAlgorithms.Es384,
521 => SignatureAlgorithms.Es512,
_ => throw new BinaryDiffException(
BinaryDiffErrorCode.SigningKeyInvalid,
$"Unsupported ECDSA key size: {keySize}.")
};
}
}

View File

@@ -0,0 +1,62 @@
using System.Collections.Immutable;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Commands.Scan;
internal enum BinaryDiffMode
{
Auto,
Elf,
Pe
}
internal enum BinaryDiffOutputFormat
{
Table,
Json,
Summary
}
internal sealed record BinaryDiffRequest
{
public required string BaseImageRef { get; init; }
public required string TargetImageRef { get; init; }
public required BinaryDiffMode Mode { get; init; }
public BinaryDiffPlatform? Platform { get; init; }
public ImmutableArray<string> Sections { get; init; } = ImmutableArray<string>.Empty;
public bool IncludeUnchanged { get; init; }
public string? RegistryAuthPath { get; init; }
}
internal sealed record BinaryDiffResult
{
public required BinaryDiffImageReference Base { get; init; }
public required BinaryDiffImageReference Target { get; init; }
public required BinaryDiffPlatform Platform { get; init; }
public required BinaryDiffMode Mode { get; init; }
public required ImmutableArray<BinaryDiffFinding> Findings { get; init; }
public required BinaryDiffSummary Summary { get; init; }
public required BinaryDiffMetadata Metadata { get; init; }
public BinaryDiffPredicate? Predicate { get; init; }
public OciImageReference? BaseReference { get; init; }
public OciImageReference? TargetReference { get; init; }
}
internal sealed record BinaryDiffSummary
{
public required int TotalBinaries { get; init; }
public required int Modified { get; init; }
public required int Added { get; init; }
public required int Removed { get; init; }
public required int Unchanged { get; init; }
public required ImmutableDictionary<string, int> Verdicts { get; init; }
}
internal sealed record BinaryDiffProgress
{
public required string Phase { get; init; }
public required string CurrentItem { get; init; }
public required int Current { get; init; }
public required int Total { get; init; }
}

View File

@@ -0,0 +1,247 @@
using System.Text;
using System.Text.Json;
namespace StellaOps.Cli.Commands.Scan;
internal sealed record RegistryAuthCredentials(string Username, string Password);
internal sealed class RegistryAuthConfig
{
public RegistryAuthConfig(Dictionary<string, RegistryAuthCredentials> auths)
{
Auths = auths;
}
public Dictionary<string, RegistryAuthCredentials> Auths { get; }
}
internal sealed class RegistryAuthScope : IDisposable
{
private readonly string? _previousUser;
private readonly string? _previousPassword;
private bool _disposed;
private RegistryAuthScope(string? previousUser, string? previousPassword)
{
_previousUser = previousUser;
_previousPassword = previousPassword;
}
public static RegistryAuthScope? Apply(RegistryAuthConfig? config, string registry)
{
if (config is null)
{
return null;
}
if (!TryResolveCredentials(config, registry, out var credentials))
{
return null;
}
var previousUser = Environment.GetEnvironmentVariable("STELLAOPS_REGISTRY_USERNAME");
var previousPassword = Environment.GetEnvironmentVariable("STELLAOPS_REGISTRY_PASSWORD");
Environment.SetEnvironmentVariable("STELLAOPS_REGISTRY_USERNAME", credentials!.Username);
Environment.SetEnvironmentVariable("STELLAOPS_REGISTRY_PASSWORD", credentials.Password);
return new RegistryAuthScope(previousUser, previousPassword);
}
public void Dispose()
{
if (_disposed)
{
return;
}
Environment.SetEnvironmentVariable("STELLAOPS_REGISTRY_USERNAME", _previousUser);
Environment.SetEnvironmentVariable("STELLAOPS_REGISTRY_PASSWORD", _previousPassword);
_disposed = true;
}
private static bool TryResolveCredentials(
RegistryAuthConfig config,
string registry,
out RegistryAuthCredentials? credentials)
{
credentials = null;
var normalized = NormalizeRegistryKey(registry);
if (config.Auths.TryGetValue(normalized, out var resolved))
{
credentials = resolved;
return true;
}
return false;
}
private static string NormalizeRegistryKey(string registry)
{
if (string.IsNullOrWhiteSpace(registry))
{
return string.Empty;
}
var trimmed = registry.Trim();
if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
{
trimmed = uri.Authority;
}
}
trimmed = trimmed.TrimEnd('/');
if (string.Equals(trimmed, "index.docker.io", StringComparison.OrdinalIgnoreCase) ||
string.Equals(trimmed, "registry-1.docker.io", StringComparison.OrdinalIgnoreCase))
{
return "docker.io";
}
return trimmed;
}
}
internal static class RegistryAuthConfigLoader
{
public static RegistryAuthConfig? Load(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
if (!File.Exists(path))
{
throw new BinaryDiffException(
BinaryDiffErrorCode.RegistryAuthInvalid,
$"Registry auth file not found: {path}");
}
var json = File.ReadAllText(path);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("auths", out var authsElement) ||
authsElement.ValueKind != JsonValueKind.Object)
{
throw new BinaryDiffException(
BinaryDiffErrorCode.RegistryAuthInvalid,
"Registry auth file does not contain an auths section.");
}
var auths = new Dictionary<string, RegistryAuthCredentials>(StringComparer.OrdinalIgnoreCase);
foreach (var authEntry in authsElement.EnumerateObject())
{
var key = authEntry.Name;
if (authEntry.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
if (!TryParseAuthEntry(authEntry.Value, out var credentials))
{
continue;
}
var normalized = NormalizeRegistryKey(key);
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
auths[normalized] = credentials!;
}
return new RegistryAuthConfig(auths);
}
private static bool TryParseAuthEntry(JsonElement authEntry, out RegistryAuthCredentials? credentials)
{
credentials = null;
if (authEntry.TryGetProperty("auth", out var authValue) &&
authValue.ValueKind == JsonValueKind.String)
{
var decoded = TryDecodeBasicAuth(authValue.GetString());
if (decoded is not null)
{
credentials = decoded;
return true;
}
}
if (authEntry.TryGetProperty("username", out var userValue) &&
authEntry.TryGetProperty("password", out var passwordValue) &&
userValue.ValueKind == JsonValueKind.String &&
passwordValue.ValueKind == JsonValueKind.String)
{
var username = userValue.GetString();
var password = passwordValue.GetString();
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{
credentials = new RegistryAuthCredentials(username!, password!);
return true;
}
}
return false;
}
private static RegistryAuthCredentials? TryDecodeBasicAuth(string? encoded)
{
if (string.IsNullOrWhiteSpace(encoded))
{
return null;
}
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
var parts = decoded.Split(':', 2);
if (parts.Length != 2)
{
return null;
}
if (string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
{
return null;
}
return new RegistryAuthCredentials(parts[0], parts[1]);
}
catch (FormatException)
{
return null;
}
}
private static string NormalizeRegistryKey(string registry)
{
if (string.IsNullOrWhiteSpace(registry))
{
return string.Empty;
}
var trimmed = registry.Trim();
if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
{
trimmed = uri.Authority;
}
}
trimmed = trimmed.TrimEnd('/');
if (string.Equals(trimmed, "index.docker.io", StringComparison.OrdinalIgnoreCase) ||
string.Equals(trimmed, "registry-1.docker.io", StringComparison.OrdinalIgnoreCase))
{
return "docker.io";
}
return trimmed;
}
}

View File

@@ -0,0 +1,292 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.StandardPredicates;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
namespace StellaOps.Cli.Commands.Scan;
internal interface IBinaryDiffRenderer
{
Task RenderAsync(
BinaryDiffResult result,
BinaryDiffOutputFormat format,
TextWriter writer,
CancellationToken cancellationToken = default);
}
internal sealed class BinaryDiffRenderer : IBinaryDiffRenderer
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public Task RenderAsync(
BinaryDiffResult result,
BinaryDiffOutputFormat format,
TextWriter writer,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(writer);
return format switch
{
BinaryDiffOutputFormat.Json => RenderJsonAsync(result, writer, cancellationToken),
BinaryDiffOutputFormat.Summary => RenderSummaryAsync(result, writer, cancellationToken),
_ => RenderTableAsync(result, writer, cancellationToken)
};
}
private static Task RenderTableAsync(BinaryDiffResult result, TextWriter writer, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
writer.WriteLine($"Binary Diff: {FormatReference(result.Base)} -> {FormatReference(result.Target)}");
writer.WriteLine($"Platform: {FormatPlatform(result.Platform)}");
writer.WriteLine($"Analysis Mode: {FormatMode(result.Mode)}");
writer.WriteLine();
var rows = result.Findings
.OrderBy(finding => finding.Path, StringComparer.Ordinal)
.Select(finding => new TableRow(
finding.Path,
finding.ChangeType.ToString().ToLowerInvariant(),
FormatVerdict(finding.Verdict),
FormatConfidence(finding.Confidence),
FormatSections(finding.SectionDeltas)))
.ToList();
if (rows.Count == 0)
{
writer.WriteLine("No ELF binaries found.");
writer.WriteLine();
WriteSummary(writer, result.Summary);
return Task.CompletedTask;
}
var widths = ComputeWidths(rows);
WriteHeader(writer, widths);
foreach (var row in rows)
{
writer.WriteLine($"{row.Path.PadRight(widths.Path)} {row.Change.PadRight(widths.Change)} {row.Verdict.PadRight(widths.Verdict)} {row.Confidence.PadRight(widths.Confidence)} {row.Sections}");
}
writer.WriteLine();
WriteSummary(writer, result.Summary);
return Task.CompletedTask;
}
private static Task RenderSummaryAsync(BinaryDiffResult result, TextWriter writer, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
writer.WriteLine("Binary Diff Summary");
writer.WriteLine("-------------------");
writer.WriteLine($"Base: {FormatReference(result.Base)}");
writer.WriteLine($"Target: {FormatReference(result.Target)}");
writer.WriteLine($"Platform: {FormatPlatform(result.Platform)}");
writer.WriteLine();
writer.WriteLine($"Binaries: {result.Summary.TotalBinaries} total, {result.Summary.Modified} modified, {result.Summary.Unchanged} unchanged");
writer.WriteLine($"Added: {result.Summary.Added}, Removed: {result.Summary.Removed}");
if (result.Summary.Verdicts.Count > 0)
{
var verdicts = string.Join(", ", result.Summary.Verdicts
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
writer.WriteLine($"Verdicts: {verdicts}");
}
return Task.CompletedTask;
}
private static Task RenderJsonAsync(BinaryDiffResult result, TextWriter writer, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var report = new BinaryDiffReport
{
SchemaVersion = "1.0.0",
Base = new BinaryDiffReportImage
{
Reference = result.Base.Reference,
Digest = result.Base.Digest
},
Target = new BinaryDiffReportImage
{
Reference = result.Target.Reference,
Digest = result.Target.Digest
},
Platform = result.Platform,
AnalysisMode = result.Mode.ToString().ToLowerInvariant(),
Timestamp = result.Metadata.AnalysisTimestamp,
Findings = result.Findings,
Summary = new BinaryDiffReportSummary
{
TotalBinaries = result.Summary.TotalBinaries,
Modified = result.Summary.Modified,
Unchanged = result.Summary.Unchanged,
Added = result.Summary.Added,
Removed = result.Summary.Removed,
Verdicts = result.Summary.Verdicts
}
};
var json = JsonSerializer.Serialize(report, JsonOptions);
var canonical = JsonCanonicalizer.Canonicalize(json);
writer.WriteLine(canonical);
return Task.CompletedTask;
}
private static void WriteHeader(TextWriter writer, TableWidths widths)
{
writer.WriteLine($"{Pad("PATH", widths.Path)} {Pad("CHANGE", widths.Change)} {Pad("VERDICT", widths.Verdict)} {Pad("CONFIDENCE", widths.Confidence)} SECTIONS CHANGED");
writer.WriteLine($"{new string('-', widths.Path)} {new string('-', widths.Change)} {new string('-', widths.Verdict)} {new string('-', widths.Confidence)} {new string('-', 16)}");
}
private static void WriteSummary(TextWriter writer, BinaryDiffSummary summary)
{
writer.WriteLine($"Summary: {summary.TotalBinaries} binaries analyzed, {summary.Modified} modified, {summary.Unchanged} unchanged");
writer.WriteLine($" Added: {summary.Added}, Removed: {summary.Removed}");
if (summary.Verdicts.Count > 0)
{
var verdicts = string.Join(", ", summary.Verdicts
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
writer.WriteLine($" Verdicts: {verdicts}");
}
}
private static string FormatReference(BinaryDiffImageReference reference)
{
return reference.Reference ?? reference.Digest;
}
private static string FormatPlatform(BinaryDiffPlatform platform)
{
if (string.IsNullOrWhiteSpace(platform.Variant))
{
return $"{platform.Os}/{platform.Architecture}";
}
return $"{platform.Os}/{platform.Architecture}/{platform.Variant}";
}
private static string FormatMode(BinaryDiffMode mode)
{
return mode switch
{
BinaryDiffMode.Elf => "ELF section hashes",
BinaryDiffMode.Pe => "PE section hashes",
_ => "ELF section hashes"
};
}
private static string FormatVerdict(StellaOps.Attestor.StandardPredicates.BinaryDiff.Verdict? verdict)
{
return verdict is null ? "-" : verdict.Value.ToString().ToLowerInvariant();
}
private static string FormatConfidence(double? confidence)
{
return confidence.HasValue
? confidence.Value.ToString("0.00", CultureInfo.InvariantCulture)
: "-";
}
private static string FormatSections(ImmutableArray<SectionDelta> deltas)
{
if (deltas.IsDefaultOrEmpty)
{
return "-";
}
var sections = deltas
.Where(delta => delta.Status != SectionStatus.Identical)
.Select(delta => delta.Section)
.Distinct(StringComparer.Ordinal)
.OrderBy(section => section, StringComparer.Ordinal)
.ToArray();
return sections.Length == 0 ? "-" : string.Join(", ", sections);
}
private static TableWidths ComputeWidths(IEnumerable<TableRow> rows)
{
var pathWidth = Math.Max(4, rows.Max(row => row.Path.Length));
var changeWidth = Math.Max(6, rows.Max(row => row.Change.Length));
var verdictWidth = Math.Max(7, rows.Max(row => row.Verdict.Length));
var confidenceWidth = Math.Max(10, rows.Max(row => row.Confidence.Length));
return new TableWidths(pathWidth, changeWidth, verdictWidth, confidenceWidth);
}
private static string Pad(string value, int width) => value.PadRight(width);
private sealed record TableRow(string Path, string Change, string Verdict, string Confidence, string Sections);
private sealed record TableWidths(int Path, int Change, int Verdict, int Confidence);
private sealed record BinaryDiffReport
{
[JsonPropertyName("schemaVersion")]
public required string SchemaVersion { get; init; }
[JsonPropertyName("base")]
public required BinaryDiffReportImage Base { get; init; }
[JsonPropertyName("target")]
public required BinaryDiffReportImage Target { get; init; }
[JsonPropertyName("platform")]
public required BinaryDiffPlatform Platform { get; init; }
[JsonPropertyName("analysisMode")]
public required string AnalysisMode { get; init; }
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("findings")]
public required ImmutableArray<BinaryDiffFinding> Findings { get; init; }
[JsonPropertyName("summary")]
public required BinaryDiffReportSummary Summary { get; init; }
}
private sealed record BinaryDiffReportImage
{
[JsonPropertyName("reference")]
public string? Reference { get; init; }
[JsonPropertyName("digest")]
public required string Digest { get; init; }
}
private sealed record BinaryDiffReportSummary
{
[JsonPropertyName("totalBinaries")]
public required int TotalBinaries { get; init; }
[JsonPropertyName("modified")]
public required int Modified { get; init; }
[JsonPropertyName("unchanged")]
public required int Unchanged { get; init; }
[JsonPropertyName("added")]
public required int Added { get; init; }
[JsonPropertyName("removed")]
public required int Removed { get; init; }
[JsonPropertyName("verdicts")]
public required ImmutableDictionary<string, int> Verdicts { get; init; }
}
}

View File

@@ -0,0 +1,862 @@
using System.Collections.Immutable;
using System.Formats.Tar;
using System.IO.Compression;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using BinaryDiffVerdict = StellaOps.Attestor.StandardPredicates.BinaryDiff.Verdict;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Scanner.Analyzers.Native;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Cli.Commands.Scan;
internal interface IBinaryDiffService
{
Task<BinaryDiffResult> ComputeDiffAsync(
BinaryDiffRequest request,
IProgress<BinaryDiffProgress>? progress = null,
CancellationToken cancellationToken = default);
}
internal sealed class BinaryDiffService : IBinaryDiffService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IOciRegistryClient _registryClient;
private readonly IElfSectionHashExtractor _elfExtractor;
private readonly IOptions<BinaryDiffOptions> _diffOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<BinaryDiffService> _logger;
public BinaryDiffService(
IOciRegistryClient registryClient,
IElfSectionHashExtractor elfExtractor,
IOptions<BinaryDiffOptions> diffOptions,
TimeProvider timeProvider,
ILogger<BinaryDiffService> logger)
{
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
_elfExtractor = elfExtractor ?? throw new ArgumentNullException(nameof(elfExtractor));
_diffOptions = diffOptions ?? throw new ArgumentNullException(nameof(diffOptions));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<BinaryDiffResult> ComputeDiffAsync(
BinaryDiffRequest request,
IProgress<BinaryDiffProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (request.Mode == BinaryDiffMode.Pe)
{
throw new BinaryDiffException(
BinaryDiffErrorCode.UnsupportedMode,
"PE mode is not supported yet.");
}
var baseReference = ParseReference(request.BaseImageRef);
var targetReference = ParseReference(request.TargetImageRef);
var registryAuth = RegistryAuthConfigLoader.Load(request.RegistryAuthPath);
var analyzedSections = NormalizeSections(request.Sections);
ResolvedManifest baseResolved;
string baseDigest;
Dictionary<string, BinaryDiffFileEntry> baseFiles;
using (RegistryAuthScope.Apply(registryAuth, baseReference.Registry))
{
baseDigest = await _registryClient.ResolveDigestAsync(baseReference, cancellationToken).ConfigureAwait(false);
baseResolved = await ResolvePlatformManifestAsync(
baseReference,
baseDigest,
request.Platform,
progress,
cancellationToken).ConfigureAwait(false);
baseFiles = await ExtractElfFilesAsync(
baseReference,
baseResolved.Manifest,
analyzedSections,
progress,
cancellationToken).ConfigureAwait(false);
}
ResolvedManifest targetResolved;
string targetDigest;
Dictionary<string, BinaryDiffFileEntry> targetFiles;
using (RegistryAuthScope.Apply(registryAuth, targetReference.Registry))
{
targetDigest = await _registryClient.ResolveDigestAsync(targetReference, cancellationToken).ConfigureAwait(false);
targetResolved = await ResolvePlatformManifestAsync(
targetReference,
targetDigest,
request.Platform,
progress,
cancellationToken).ConfigureAwait(false);
targetFiles = await ExtractElfFilesAsync(
targetReference,
targetResolved.Manifest,
analyzedSections,
progress,
cancellationToken).ConfigureAwait(false);
}
var platform = ResolvePlatform(request.Platform, baseResolved.Platform, targetResolved.Platform);
var allFindings = ComputeFindings(baseFiles, targetFiles);
var summary = ComputeSummary(allFindings);
var outputFindings = request.IncludeUnchanged
? allFindings
: allFindings.Where(finding => finding.ChangeType != ChangeType.Unchanged).ToImmutableArray();
var predicate = BuildPredicate(
baseReference,
targetReference,
baseDigest,
targetDigest,
baseResolved.ManifestDigest,
targetResolved.ManifestDigest,
platform,
allFindings,
summary,
analyzedSections);
return new BinaryDiffResult
{
Base = new BinaryDiffImageReference
{
Reference = request.BaseImageRef,
Digest = baseDigest,
ManifestDigest = baseResolved.ManifestDigest,
Platform = platform
},
Target = new BinaryDiffImageReference
{
Reference = request.TargetImageRef,
Digest = targetDigest,
ManifestDigest = targetResolved.ManifestDigest,
Platform = platform
},
Platform = platform,
Mode = request.Mode == BinaryDiffMode.Auto ? BinaryDiffMode.Elf : request.Mode,
Findings = outputFindings,
Summary = summary,
Metadata = predicate.Metadata,
Predicate = predicate,
BaseReference = baseReference,
TargetReference = targetReference
};
}
private static OciImageReference ParseReference(string reference)
{
try
{
return OciImageReferenceParser.Parse(reference);
}
catch (ArgumentException ex)
{
throw new BinaryDiffException(BinaryDiffErrorCode.InvalidReference, ex.Message, ex);
}
}
private static ImmutableHashSet<string>? NormalizeSections(ImmutableArray<string> sections)
{
if (sections.IsDefaultOrEmpty)
{
return null;
}
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
foreach (var section in sections)
{
if (string.IsNullOrWhiteSpace(section))
{
continue;
}
builder.Add(section.Trim());
}
return builder.Count == 0 ? null : builder.ToImmutable();
}
private BinaryDiffPredicate BuildPredicate(
OciImageReference baseReference,
OciImageReference targetReference,
string baseDigest,
string targetDigest,
string? baseManifestDigest,
string? targetManifestDigest,
BinaryDiffPlatform platform,
ImmutableArray<BinaryDiffFinding> findings,
BinaryDiffSummary summary,
ImmutableHashSet<string>? analyzedSections)
{
var builder = new BinaryDiffPredicateBuilder(_diffOptions, _timeProvider);
builder.WithSubject(targetReference.Original, targetDigest, platform)
.WithInputs(
new BinaryDiffImageReference
{
Reference = baseReference.Original,
Digest = baseDigest,
ManifestDigest = baseManifestDigest,
Platform = platform
},
new BinaryDiffImageReference
{
Reference = targetReference.Original,
Digest = targetDigest,
ManifestDigest = targetManifestDigest,
Platform = platform
});
foreach (var finding in findings)
{
builder.AddFinding(finding);
}
builder.WithMetadata(metadataBuilder =>
{
metadataBuilder
.WithToolVersion(ResolveToolVersion())
.WithAnalysisTimestamp(_timeProvider.GetUtcNow())
.WithTotals(summary.TotalBinaries, summary.Modified);
if (analyzedSections is { Count: > 0 })
{
metadataBuilder.WithAnalyzedSections(analyzedSections);
}
});
return builder.Build();
}
private async Task<ResolvedManifest> ResolvePlatformManifestAsync(
OciImageReference reference,
string digest,
BinaryDiffPlatform? platformFilter,
IProgress<BinaryDiffProgress>? progress,
CancellationToken cancellationToken)
{
progress?.Report(new BinaryDiffProgress
{
Phase = "pulling",
CurrentItem = $"manifest:{digest}",
Current = 1,
Total = 1
});
var manifest = await _registryClient.GetManifestAsync(reference, digest, cancellationToken).ConfigureAwait(false);
if (manifest.Manifests is { Count: > 0 })
{
var selected = SelectManifestDescriptor(manifest.Manifests, platformFilter);
if (selected is null)
{
throw new BinaryDiffException(
BinaryDiffErrorCode.PlatformNotFound,
platformFilter is null
? "Platform is required when image index contains multiple manifests."
: $"Platform '{FormatPlatform(platformFilter)}' not found in image index.");
}
var platform = BuildPlatform(selected.Platform) ?? platformFilter ?? UnknownPlatform();
var resolved = await _registryClient.GetManifestAsync(reference, selected.Digest, cancellationToken).ConfigureAwait(false);
return new ResolvedManifest(resolved, selected.Digest, platform);
}
var inferredPlatform = platformFilter ?? await TryResolvePlatformFromConfigAsync(reference, manifest, cancellationToken).ConfigureAwait(false)
?? UnknownPlatform();
return new ResolvedManifest(manifest, digest, inferredPlatform);
}
private static OciIndexDescriptor? SelectManifestDescriptor(
IReadOnlyList<OciIndexDescriptor> manifests,
BinaryDiffPlatform? platformFilter)
{
var candidates = manifests
.Where(descriptor => descriptor.Platform is not null)
.OrderBy(descriptor => descriptor.Platform!.Os, StringComparer.OrdinalIgnoreCase)
.ThenBy(descriptor => descriptor.Platform!.Architecture, StringComparer.OrdinalIgnoreCase)
.ThenBy(descriptor => descriptor.Platform!.Variant, StringComparer.OrdinalIgnoreCase)
.ToList();
if (platformFilter is null)
{
return candidates.Count == 1 ? candidates[0] : null;
}
return candidates.FirstOrDefault(descriptor =>
IsPlatformMatch(platformFilter, descriptor.Platform));
}
private static bool IsPlatformMatch(BinaryDiffPlatform platform, OciPlatform? candidate)
{
if (candidate is null)
{
return false;
}
var osMatch = string.Equals(platform.Os, candidate.Os, StringComparison.OrdinalIgnoreCase);
var archMatch = string.Equals(platform.Architecture, candidate.Architecture, StringComparison.OrdinalIgnoreCase);
if (!osMatch || !archMatch)
{
return false;
}
if (string.IsNullOrWhiteSpace(platform.Variant))
{
return true;
}
return string.Equals(platform.Variant, candidate.Variant, StringComparison.OrdinalIgnoreCase);
}
private static BinaryDiffPlatform? BuildPlatform(OciPlatform? platform)
{
if (platform is null ||
string.IsNullOrWhiteSpace(platform.Os) ||
string.IsNullOrWhiteSpace(platform.Architecture))
{
return null;
}
return new BinaryDiffPlatform
{
Os = platform.Os!,
Architecture = platform.Architecture!,
Variant = string.IsNullOrWhiteSpace(platform.Variant) ? null : platform.Variant
};
}
private async Task<BinaryDiffPlatform?> TryResolvePlatformFromConfigAsync(
OciImageReference reference,
OciManifest manifest,
CancellationToken cancellationToken)
{
if (manifest.Config?.Digest is null)
{
return null;
}
try
{
var blob = await _registryClient.GetBlobAsync(reference, manifest.Config.Digest, cancellationToken).ConfigureAwait(false);
var config = JsonSerializer.Deserialize<OciImageConfig>(blob, JsonOptions);
if (config is null ||
string.IsNullOrWhiteSpace(config.Os) ||
string.IsNullOrWhiteSpace(config.Architecture))
{
return null;
}
return new BinaryDiffPlatform
{
Os = config.Os,
Architecture = config.Architecture,
Variant = string.IsNullOrWhiteSpace(config.Variant) ? null : config.Variant
};
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read image config for platform detection.");
return null;
}
}
private static BinaryDiffPlatform ResolvePlatform(
BinaryDiffPlatform? requested,
BinaryDiffPlatform? basePlatform,
BinaryDiffPlatform? targetPlatform)
{
if (requested is not null)
{
return requested;
}
if (basePlatform is not null && targetPlatform is not null)
{
var osMatch = string.Equals(basePlatform.Os, targetPlatform.Os, StringComparison.OrdinalIgnoreCase);
var archMatch = string.Equals(basePlatform.Architecture, targetPlatform.Architecture, StringComparison.OrdinalIgnoreCase);
if (osMatch && archMatch)
{
return basePlatform;
}
throw new BinaryDiffException(
BinaryDiffErrorCode.PlatformNotFound,
$"Base platform '{FormatPlatform(basePlatform)}' does not match target platform '{FormatPlatform(targetPlatform)}'.");
}
return basePlatform ?? targetPlatform ?? UnknownPlatform();
}
private async Task<Dictionary<string, BinaryDiffFileEntry>> ExtractElfFilesAsync(
OciImageReference reference,
OciManifest manifest,
ImmutableHashSet<string>? sections,
IProgress<BinaryDiffProgress>? progress,
CancellationToken cancellationToken)
{
var files = new Dictionary<string, BinaryDiffFileEntry>(StringComparer.Ordinal);
var layers = manifest.Layers ?? new List<OciDescriptor>();
var totalLayers = layers.Count;
for (var index = 0; index < totalLayers; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var layer = layers[index];
progress?.Report(new BinaryDiffProgress
{
Phase = "pulling",
CurrentItem = layer.Digest,
Current = index + 1,
Total = totalLayers
});
var blob = await _registryClient.GetBlobAsync(reference, layer.Digest, cancellationToken).ConfigureAwait(false);
progress?.Report(new BinaryDiffProgress
{
Phase = "extracting",
CurrentItem = layer.Digest,
Current = index + 1,
Total = totalLayers
});
await using var layerStream = OpenLayerStream(layer.MediaType, blob);
using var tarReader = new TarReader(layerStream, leaveOpen: false);
TarEntry? entry;
while ((entry = tarReader.GetNextEntry()) is not null)
{
cancellationToken.ThrowIfCancellationRequested();
if (entry.EntryType is TarEntryType.Directory or TarEntryType.SymbolicLink or TarEntryType.HardLink)
{
continue;
}
var path = NormalizeTarPath(entry.Name);
if (string.IsNullOrWhiteSpace(path))
{
continue;
}
if (TryApplyWhiteout(files, path))
{
continue;
}
if (!IsRegularFile(entry.EntryType))
{
continue;
}
if (entry.DataStream is null)
{
continue;
}
var content = await ReadEntryAsync(entry.DataStream, cancellationToken).ConfigureAwait(false);
if (!IsElf(content.Span))
{
continue;
}
var hashSet = await _elfExtractor.ExtractFromBytesAsync(content, path, cancellationToken).ConfigureAwait(false);
if (hashSet is null)
{
continue;
}
var normalized = MapSectionHashSet(hashSet, sections);
files[path] = new BinaryDiffFileEntry
{
Path = path,
Hashes = normalized,
LayerDigest = layer.Digest
};
progress?.Report(new BinaryDiffProgress
{
Phase = "analyzing",
CurrentItem = path,
Current = files.Count,
Total = 0
});
}
}
return files;
}
private static bool IsRegularFile(TarEntryType entryType)
{
return entryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile;
}
private static MemoryStream OpenLayerStream(string? mediaType, byte[] blob)
{
var input = new MemoryStream(blob, writable: false);
if (mediaType is null)
{
return input;
}
if (mediaType.Contains("gzip", StringComparison.OrdinalIgnoreCase))
{
return new MemoryStream(DecompressGzip(input));
}
return input;
}
private static byte[] DecompressGzip(Stream input)
{
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
}
private static async Task<ReadOnlyMemory<byte>> ReadEntryAsync(
Stream stream,
CancellationToken cancellationToken)
{
await using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return buffer.ToArray();
}
private static bool IsElf(ReadOnlySpan<byte> bytes)
{
return bytes.Length >= 4 &&
bytes[0] == 0x7F &&
bytes[1] == (byte)'E' &&
bytes[2] == (byte)'L' &&
bytes[3] == (byte)'F';
}
private static bool TryApplyWhiteout(Dictionary<string, BinaryDiffFileEntry> files, string path)
{
var fileName = GetFileName(path);
if (string.Equals(fileName, ".wh..wh..opq", StringComparison.Ordinal))
{
var directory = GetDirectoryName(path);
var prefix = directory.EndsWith("/", StringComparison.Ordinal)
? directory
: directory + "/";
var keysToRemove = files.Keys
.Where(key => key.StartsWith(prefix, StringComparison.Ordinal))
.ToList();
foreach (var key in keysToRemove)
{
files.Remove(key);
}
return true;
}
if (fileName.StartsWith(".wh.", StringComparison.Ordinal))
{
var directory = GetDirectoryName(path);
var target = CombinePath(directory, fileName[4..]);
files.Remove(target);
return true;
}
return false;
}
private static string NormalizeTarPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
var normalized = path.Replace('\\', '/');
while (normalized.StartsWith("./", StringComparison.Ordinal))
{
normalized = normalized[2..];
}
normalized = normalized.Trim('/');
if (string.IsNullOrWhiteSpace(normalized))
{
return string.Empty;
}
return "/" + normalized;
}
private static string GetFileName(string path)
{
var index = path.LastIndexOf("/", StringComparison.Ordinal);
return index >= 0 ? path[(index + 1)..] : path;
}
private static string GetDirectoryName(string path)
{
var index = path.LastIndexOf("/", StringComparison.Ordinal);
if (index <= 0)
{
return "/";
}
return path[..index];
}
private static string CombinePath(string directory, string fileName)
{
if (string.IsNullOrWhiteSpace(directory) || directory == "/")
{
return "/" + fileName;
}
return directory + "/" + fileName;
}
private static SectionHashSet MapSectionHashSet(ElfSectionHashSet hashSet, ImmutableHashSet<string>? filter)
{
var sections = ImmutableDictionary.CreateBuilder<string, SectionInfo>(StringComparer.Ordinal);
foreach (var section in hashSet.Sections.OrderBy(section => section.Name, StringComparer.Ordinal))
{
if (filter is not null && !filter.Contains(section.Name))
{
continue;
}
sections[section.Name] = new SectionInfo
{
Sha256 = section.Sha256,
Blake3 = section.Blake3,
Size = section.Size
};
}
return new SectionHashSet
{
BuildId = hashSet.BuildId,
FileHash = hashSet.FileHash,
Sections = sections.ToImmutable()
};
}
private static ImmutableArray<BinaryDiffFinding> ComputeFindings(
Dictionary<string, BinaryDiffFileEntry> baseFiles,
Dictionary<string, BinaryDiffFileEntry> targetFiles)
{
var paths = baseFiles.Keys
.Union(targetFiles.Keys, StringComparer.Ordinal)
.OrderBy(path => path, StringComparer.Ordinal)
.ToList();
var findings = ImmutableArray.CreateBuilder<BinaryDiffFinding>(paths.Count);
foreach (var path in paths)
{
baseFiles.TryGetValue(path, out var baseEntry);
targetFiles.TryGetValue(path, out var targetEntry);
var deltas = ComputeSectionDeltas(baseEntry?.Hashes, targetEntry?.Hashes);
var changeType = ResolveChangeType(baseEntry, targetEntry, deltas);
var verdict = changeType == ChangeType.Unchanged ? BinaryDiffVerdict.Vanilla : BinaryDiffVerdict.Unknown;
var confidence = ComputeConfidence(baseEntry?.Hashes, targetEntry?.Hashes, deltas);
findings.Add(new BinaryDiffFinding
{
Path = path,
ChangeType = changeType,
BinaryFormat = BinaryFormat.Elf,
LayerDigest = targetEntry?.LayerDigest ?? baseEntry?.LayerDigest,
BaseHashes = baseEntry?.Hashes,
TargetHashes = targetEntry?.Hashes,
SectionDeltas = deltas,
Confidence = confidence,
Verdict = verdict
});
}
return findings.ToImmutable();
}
private static ImmutableArray<SectionDelta> ComputeSectionDeltas(
SectionHashSet? baseHashes,
SectionHashSet? targetHashes)
{
if (baseHashes is null && targetHashes is null)
{
return ImmutableArray<SectionDelta>.Empty;
}
var baseSections = baseHashes?.Sections ?? ImmutableDictionary<string, SectionInfo>.Empty;
var targetSections = targetHashes?.Sections ?? ImmutableDictionary<string, SectionInfo>.Empty;
var sectionNames = baseSections.Keys
.Union(targetSections.Keys, StringComparer.Ordinal)
.OrderBy(name => name, StringComparer.Ordinal);
var deltas = new List<SectionDelta>();
foreach (var name in sectionNames)
{
var hasBase = baseSections.TryGetValue(name, out var baseInfo);
var hasTarget = targetSections.TryGetValue(name, out var targetInfo);
if (hasBase && hasTarget)
{
var identical = string.Equals(baseInfo!.Sha256, targetInfo!.Sha256, StringComparison.Ordinal);
if (!identical)
{
deltas.Add(new SectionDelta
{
Section = name,
Status = SectionStatus.Modified,
BaseSha256 = baseInfo.Sha256,
TargetSha256 = targetInfo.Sha256,
SizeDelta = targetInfo.Size - baseInfo.Size
});
}
}
else if (hasBase)
{
deltas.Add(new SectionDelta
{
Section = name,
Status = SectionStatus.Removed,
BaseSha256 = baseInfo!.Sha256,
TargetSha256 = null,
SizeDelta = -baseInfo.Size
});
}
else if (hasTarget)
{
deltas.Add(new SectionDelta
{
Section = name,
Status = SectionStatus.Added,
BaseSha256 = null,
TargetSha256 = targetInfo!.Sha256,
SizeDelta = targetInfo.Size
});
}
}
return deltas.OrderBy(delta => delta.Section, StringComparer.Ordinal).ToImmutableArray();
}
private static ChangeType ResolveChangeType(
BinaryDiffFileEntry? baseEntry,
BinaryDiffFileEntry? targetEntry,
ImmutableArray<SectionDelta> deltas)
{
if (baseEntry is null && targetEntry is not null)
{
return ChangeType.Added;
}
if (baseEntry is not null && targetEntry is null)
{
return ChangeType.Removed;
}
if (deltas.Length == 0)
{
return ChangeType.Unchanged;
}
return ChangeType.Modified;
}
private static double? ComputeConfidence(
SectionHashSet? baseHashes,
SectionHashSet? targetHashes,
ImmutableArray<SectionDelta> deltas)
{
if (baseHashes is null || targetHashes is null)
{
return null;
}
var totalSections = baseHashes.Sections.Count + targetHashes.Sections.Count;
if (totalSections == 0)
{
return null;
}
var changedCount = deltas.Length;
var ratio = 1.0 - (changedCount / (double)totalSections);
return Math.Clamp(Math.Round(ratio, 4, MidpointRounding.ToZero), 0.0, 1.0);
}
private static BinaryDiffSummary ComputeSummary(ImmutableArray<BinaryDiffFinding> findings)
{
var total = findings.Length;
var added = findings.Count(f => f.ChangeType == ChangeType.Added);
var removed = findings.Count(f => f.ChangeType == ChangeType.Removed);
var unchanged = findings.Count(f => f.ChangeType == ChangeType.Unchanged);
var modified = total - unchanged;
var verdicts = ImmutableDictionary.CreateBuilder<string, int>(StringComparer.Ordinal);
foreach (var verdict in findings.Select(finding => finding.Verdict).Where(v => v.HasValue))
{
var key = verdict!.Value.ToString().ToLowerInvariant();
verdicts[key] = verdicts.TryGetValue(key, out var count) ? count + 1 : 1;
}
return new BinaryDiffSummary
{
TotalBinaries = total,
Modified = modified,
Added = added,
Removed = removed,
Unchanged = unchanged,
Verdicts = verdicts.ToImmutable()
};
}
private static BinaryDiffPlatform UnknownPlatform()
{
return new BinaryDiffPlatform
{
Os = "unknown",
Architecture = "unknown"
};
}
private static string FormatPlatform(BinaryDiffPlatform platform)
{
if (string.IsNullOrWhiteSpace(platform.Variant))
{
return $"{platform.Os}/{platform.Architecture}";
}
return $"{platform.Os}/{platform.Architecture}/{platform.Variant}";
}
private static string ResolveToolVersion()
{
var version = typeof(BinaryDiffService).Assembly.GetName().Version?.ToString();
return string.IsNullOrWhiteSpace(version) ? "stellaops-cli" : version;
}
private sealed record ResolvedManifest(OciManifest Manifest, string ManifestDigest, BinaryDiffPlatform Platform);
private sealed record BinaryDiffFileEntry
{
public required string Path { get; init; }
public required SectionHashSet Hashes { get; init; }
public required string LayerDigest { get; init; }
}
private sealed record OciImageConfig
{
public string? Os { get; init; }
public string? Architecture { get; init; }
public string? Variant { get; init; }
}
}