using Microsoft.Extensions.DependencyInjection; using StellaOps.Attestor.StandardPredicates.BinaryDiff; using StellaOps.Cli.Extensions; using System.Collections.Immutable; using System.CommandLine; using System.Globalization; using System.Net.Http; namespace StellaOps.Cli.Commands.Scan; internal static class BinaryDiffCommandGroup { internal static Command BuildDiffCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var baseOption = new Option("--base", new[] { "-b" }) { Description = "Base image reference (tag or @digest)", Required = true }; var targetOption = new Option("--target", new[] { "-t" }) { Description = "Target image reference (tag or @digest)", Required = true }; var modeOption = new Option("--mode", new[] { "-m" }) { Description = "Analysis mode: elf, pe, auto (default: auto)" }.SetDefaultValue("auto").FromAmong("elf", "pe", "auto"); var emitDsseOption = new Option("--emit-dsse", new[] { "-d" }) { Description = "Directory for DSSE attestation output" }; var signingKeyOption = new Option("--signing-key") { Description = "Path to ECDSA private key (PEM) for DSSE signing" }; var formatOption = new Option("--format", new[] { "-f" }) { Description = "Output format: table, json, summary (default: table)" }.SetDefaultValue("table").FromAmong("table", "json", "summary"); var platformOption = new Option("--platform", new[] { "-p" }) { Description = "Platform filter (e.g., linux/amd64)" }; var includeUnchangedOption = new Option("--include-unchanged") { Description = "Include unchanged binaries in output" }; var sectionsOption = new Option("--sections") { Description = "Sections to analyze (comma-separated or repeatable)" }; sectionsOption.AllowMultipleArgumentsPerToken = true; var registryAuthOption = new Option("--registry-auth") { Description = "Path to Docker config for registry authentication" }; var timeoutOption = new Option("--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(); 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? progress = showProgress ? new Progress(ReportProgress) : null; var diffService = services.GetRequiredService(); var renderer = services.GetRequiredService(); var signer = services.GetRequiredService(); 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 NormalizeSections(string[] sections) { var set = new HashSet(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); } }