Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffCommandGroup.cs
2026-02-01 21:37:40 +02:00

357 lines
13 KiB
C#

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<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);
}
}