audit, advisories and doctors/setup work
This commit is contained in:
355
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffCommandGroup.cs
Normal file
355
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
60
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffDsseOutput.cs
Normal file
60
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffDsseOutput.cs
Normal 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('\\', '-');
|
||||
}
|
||||
}
|
||||
29
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffErrors.cs
Normal file
29
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffErrors.cs
Normal 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
|
||||
};
|
||||
}
|
||||
57
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffKeyLoader.cs
Normal file
57
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffKeyLoader.cs
Normal 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}.")
|
||||
};
|
||||
}
|
||||
}
|
||||
62
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffModels.cs
Normal file
62
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffModels.cs
Normal 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; }
|
||||
}
|
||||
247
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffRegistryAuth.cs
Normal file
247
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffRegistryAuth.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
292
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffRenderer.cs
Normal file
292
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffRenderer.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
862
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffService.cs
Normal file
862
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffService.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user