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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user