357 lines
13 KiB
C#
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);
|
|
}
|
|
}
|