Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -23,6 +23,7 @@ internal static class AirGapCommandGroup
|
||||
airgap.Add(BuildImportCommand(services, verboseOption, cancellationToken));
|
||||
airgap.Add(BuildDiffCommand(services, verboseOption, cancellationToken));
|
||||
airgap.Add(BuildStatusCommand(services, verboseOption, cancellationToken));
|
||||
airgap.Add(BuildJobsCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return airgap;
|
||||
}
|
||||
@@ -104,7 +105,7 @@ internal static class AirGapCommandGroup
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? $"knowledge-{DateTime.UtcNow:yyyyMMdd}.tar.gz";
|
||||
var includeAdvisories = parseResult.GetValue(includeAdvisoriesOption);
|
||||
var includeVex = parseResult.GetValue(includeVexOption);
|
||||
var includePolicies = parseResult.GetValue(includePoliciesOption);
|
||||
@@ -300,4 +301,179 @@ internal static class AirGapCommandGroup
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'airgap jobs' subcommand group for HLC job sync bundles.
|
||||
/// Sprint: SPRINT_20260105_002_003_ROUTER
|
||||
/// </summary>
|
||||
private static Command BuildJobsCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var jobs = new Command("jobs", "Manage HLC job sync bundles for offline/air-gap scenarios.");
|
||||
|
||||
jobs.Add(BuildJobsExportCommand(services, verboseOption, cancellationToken));
|
||||
jobs.Add(BuildJobsImportCommand(services, verboseOption, cancellationToken));
|
||||
jobs.Add(BuildJobsListCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
private static Command BuildJobsExportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output file path for the job sync bundle."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string>("--tenant", "-t")
|
||||
{
|
||||
Description = "Tenant ID for the export (required)."
|
||||
}.SetDefaultValue("default");
|
||||
|
||||
var nodeOption = new Option<string?>("--node")
|
||||
{
|
||||
Description = "Specific node ID to export (default: current node)."
|
||||
};
|
||||
|
||||
var signOption = new Option<bool>("--sign")
|
||||
{
|
||||
Description = "Sign the bundle with DSSE."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output result as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("export", "Export offline job logs to a sync bundle.")
|
||||
{
|
||||
outputOption,
|
||||
tenantOption,
|
||||
nodeOption,
|
||||
signOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var output = parseResult.GetValue(outputOption) ?? string.Empty;
|
||||
var tenant = parseResult.GetValue(tenantOption) ?? "default";
|
||||
var node = parseResult.GetValue(nodeOption);
|
||||
var sign = parseResult.GetValue(signOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAirGapJobsExportAsync(
|
||||
services,
|
||||
output,
|
||||
tenant,
|
||||
node,
|
||||
sign,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildJobsImportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundleArg = new Argument<string>("bundle")
|
||||
{
|
||||
Description = "Path to the job sync bundle file."
|
||||
};
|
||||
|
||||
var verifyOnlyOption = new Option<bool>("--verify-only")
|
||||
{
|
||||
Description = "Only verify the bundle without importing."
|
||||
};
|
||||
|
||||
var forceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Force import even if validation fails."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output result as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("import", "Import a job sync bundle.")
|
||||
{
|
||||
bundleArg,
|
||||
verifyOnlyOption,
|
||||
forceOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var bundle = parseResult.GetValue(bundleArg) ?? string.Empty;
|
||||
var verifyOnly = parseResult.GetValue(verifyOnlyOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAirGapJobsImportAsync(
|
||||
services,
|
||||
bundle,
|
||||
verifyOnly,
|
||||
force,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildJobsListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceOption = new Option<string?>("--source", "-s")
|
||||
{
|
||||
Description = "Source directory to scan for bundles (default: current directory)."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output result as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("list", "List available job sync bundles.")
|
||||
{
|
||||
sourceOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var source = parseResult.GetValue(sourceOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAirGapJobsListAsync(
|
||||
services,
|
||||
source,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
1218
src/Cli/StellaOps.Cli/Commands/Chain/ChainCommandGroup.cs
Normal file
1218
src/Cli/StellaOps.Cli/Commands/Chain/ChainCommandGroup.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands.Admin;
|
||||
using StellaOps.Cli.Commands.Budget;
|
||||
using StellaOps.Cli.Commands.Chain;
|
||||
using StellaOps.Cli.Commands.DeltaSig;
|
||||
using StellaOps.Cli.Commands.Proof;
|
||||
using StellaOps.Cli.Configuration;
|
||||
@@ -99,6 +100,7 @@ internal static class CommandFactory
|
||||
root.Add(ScoreReplayCommandGroup.BuildScoreCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(UnknownsCommandGroup.BuildUnknownsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(ProofCommandGroup.BuildProofCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(ChainCommandGroup.BuildChainCommand(verboseOption, cancellationToken)); // Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
root.Add(ReplayCommandGroup.BuildReplayCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(DeltaCommandGroup.BuildDeltaCommand(verboseOption, cancellationToken));
|
||||
root.Add(RiskBudgetCommandGroup.BuildBudgetCommand(services, verboseOption, cancellationToken));
|
||||
@@ -116,6 +118,12 @@ internal static class CommandFactory
|
||||
// Sprint: SPRINT_8200_0014_0002 - Federation bundle export
|
||||
root.Add(FederationCommandGroup.BuildFeedserCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260105_002_001_REPLAY - Replay proof generation
|
||||
root.Add(ProveCommandGroup.BuildProveCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle - Evidence bundle export and verify
|
||||
root.Add(EvidenceCommandGroup.BuildEvidenceCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
// Add scan graph subcommand to existing scan command
|
||||
var scanCommand = root.Children.OfType<Command>().FirstOrDefault(c => c.Name == "scan");
|
||||
if (scanCommand is not null)
|
||||
@@ -384,6 +392,20 @@ internal static class CommandFactory
|
||||
var replay = BuildScanReplayCommand(services, verboseOption, cancellationToken);
|
||||
scan.Add(replay);
|
||||
|
||||
// VEX gate commands (Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service, Tasks: T026, T027)
|
||||
var gatePolicy = VexGateScanCommandGroup.BuildVexGateCommand(services, options, verboseOption, cancellationToken);
|
||||
scan.Add(gatePolicy);
|
||||
var gateResults = VexGateScanCommandGroup.BuildGateResultsCommand(services, options, verboseOption, cancellationToken);
|
||||
scan.Add(gateResults);
|
||||
|
||||
// Per-layer SBOM commands (Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api, Tasks: T017-T019)
|
||||
var layers = LayerSbomCommandGroup.BuildLayersCommand(services, options, verboseOption, cancellationToken);
|
||||
scan.Add(layers);
|
||||
var layerSbom = LayerSbomCommandGroup.BuildLayerSbomCommand(services, options, verboseOption, cancellationToken);
|
||||
scan.Add(layerSbom);
|
||||
var recipe = LayerSbomCommandGroup.BuildRecipeCommand(services, options, verboseOption, cancellationToken);
|
||||
scan.Add(recipe);
|
||||
|
||||
scan.Add(run);
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CommandHandlers.AirGap.cs
|
||||
// Sprint: SPRINT_4300_0001_0002_one_command_audit_replay
|
||||
// Sprint: SPRINT_20260105_002_003_ROUTER (HLC Offline Merge Protocol)
|
||||
// Description: Command handlers for airgap operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
using StellaOps.AirGap.Sync;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.AirGap.Sync.Services;
|
||||
using StellaOps.AirGap.Sync.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -104,4 +110,371 @@ internal static partial class CommandHandlers
|
||||
AnsiConsole.MarkupLine("[green]Airgap mode: Enabled[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#region Job Sync Commands (SPRINT_20260105_002_003_ROUTER)
|
||||
|
||||
/// <summary>
|
||||
/// Handler for 'stella airgap jobs export' command.
|
||||
/// Exports offline job logs for air-gap transfer.
|
||||
/// </summary>
|
||||
internal static async Task<int> HandleAirGapJobsExportAsync(
|
||||
IServiceProvider services,
|
||||
string output,
|
||||
string tenantId,
|
||||
string? nodeId,
|
||||
bool sign,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitGeneralError = 1;
|
||||
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
|
||||
try
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetService<IAirGapBundleExporter>();
|
||||
if (exporter is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Air-gap sync services not configured. Register with AddAirGapSyncServices().");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Exporting job logs for tenant: {Markup.Escape(tenantId)}[/]");
|
||||
}
|
||||
|
||||
// Export bundle
|
||||
var nodeIds = !string.IsNullOrWhiteSpace(nodeId) ? new[] { nodeId } : null;
|
||||
var bundle = await exporter.ExportAsync(tenantId, nodeIds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle.JobLogs.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Warning:[/] No offline job logs found to export.");
|
||||
return ExitSuccess;
|
||||
}
|
||||
|
||||
// Export to file
|
||||
var outputPath = output;
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
outputPath = $"job-sync-{bundle.BundleId:N}.json";
|
||||
}
|
||||
|
||||
await exporter.ExportToFileAsync(bundle, outputPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Output result
|
||||
if (emitJson)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
success = true,
|
||||
bundleId = bundle.BundleId,
|
||||
tenantId = bundle.TenantId,
|
||||
outputPath,
|
||||
createdAt = bundle.CreatedAt,
|
||||
nodeCount = bundle.JobLogs.Count,
|
||||
totalEntries = bundle.JobLogs.Sum(l => l.Entries.Count),
|
||||
manifestDigest = bundle.ManifestDigest
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Exported job sync bundle:[/] {Markup.Escape(outputPath)}");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{bundle.BundleId}[/]");
|
||||
AnsiConsole.MarkupLine($" Tenant: {Markup.Escape(bundle.TenantId)}");
|
||||
AnsiConsole.MarkupLine($" Node logs: {bundle.JobLogs.Count}");
|
||||
AnsiConsole.MarkupLine($" Total entries: {bundle.JobLogs.Sum(l => l.Entries.Count)}");
|
||||
AnsiConsole.MarkupLine($" Manifest digest: {Markup.Escape(bundle.ManifestDigest)}");
|
||||
}
|
||||
|
||||
return ExitSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return ExitGeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for 'stella airgap jobs import' command.
|
||||
/// Imports job sync bundle from air-gap transfer.
|
||||
/// </summary>
|
||||
internal static async Task<int> HandleAirGapJobsImportAsync(
|
||||
IServiceProvider services,
|
||||
string bundlePath,
|
||||
bool verifyOnly,
|
||||
bool force,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitGeneralError = 1;
|
||||
const int ExitValidationFailed = 2;
|
||||
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
|
||||
try
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetService<IAirGapBundleImporter>();
|
||||
if (importer is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Air-gap sync services not configured. Register with AddAirGapSyncServices().");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle file not found: {Markup.Escape(bundlePath)}");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Importing job sync bundle: {Markup.Escape(bundlePath)}[/]");
|
||||
}
|
||||
|
||||
// Import bundle
|
||||
var bundle = await importer.ImportFromFileAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Validate bundle
|
||||
var validation = importer.Validate(bundle);
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
if (emitJson)
|
||||
{
|
||||
var errorResult = new
|
||||
{
|
||||
success = false,
|
||||
bundleId = bundle.BundleId,
|
||||
validationPassed = false,
|
||||
issues = validation.Issues
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(errorResult, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Bundle validation failed![/]");
|
||||
foreach (var issue in validation.Issues)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" - {Markup.Escape(issue)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!force)
|
||||
{
|
||||
return ExitValidationFailed;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[yellow]Warning:[/] Proceeding with import despite validation failures (--force).");
|
||||
}
|
||||
|
||||
if (verifyOnly)
|
||||
{
|
||||
if (emitJson)
|
||||
{
|
||||
var verifyResult = new
|
||||
{
|
||||
success = true,
|
||||
bundleId = bundle.BundleId,
|
||||
tenantId = bundle.TenantId,
|
||||
validationPassed = validation.IsValid,
|
||||
nodeCount = bundle.JobLogs.Count,
|
||||
totalEntries = bundle.JobLogs.Sum(l => l.Entries.Count),
|
||||
manifestDigest = bundle.ManifestDigest
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(verifyResult, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Bundle verification passed.[/]");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{bundle.BundleId}[/]");
|
||||
AnsiConsole.MarkupLine($" Tenant: {Markup.Escape(bundle.TenantId)}");
|
||||
AnsiConsole.MarkupLine($" Node logs: {bundle.JobLogs.Count}");
|
||||
AnsiConsole.MarkupLine($" Total entries: {bundle.JobLogs.Sum(l => l.Entries.Count)}");
|
||||
}
|
||||
return ExitSuccess;
|
||||
}
|
||||
|
||||
// Sync to scheduler (if service available)
|
||||
var syncService = scope.ServiceProvider.GetService<IAirGapSyncService>();
|
||||
if (syncService is not null)
|
||||
{
|
||||
var syncResult = await syncService.SyncFromBundleAsync(bundle, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
success = true,
|
||||
bundleId = syncResult.BundleId,
|
||||
totalInBundle = syncResult.TotalInBundle,
|
||||
appended = syncResult.Appended,
|
||||
duplicates = syncResult.Duplicates,
|
||||
newChainHead = syncResult.NewChainHead is not null ? Convert.ToBase64String(syncResult.NewChainHead) : null
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Job sync bundle imported successfully.[/]");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{syncResult.BundleId}[/]");
|
||||
AnsiConsole.MarkupLine($" Jobs in bundle: {syncResult.TotalInBundle}");
|
||||
AnsiConsole.MarkupLine($" Jobs appended: {syncResult.Appended}");
|
||||
AnsiConsole.MarkupLine($" Duplicates skipped: {syncResult.Duplicates}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No sync service - just report the imported bundle
|
||||
if (emitJson)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
success = true,
|
||||
bundleId = bundle.BundleId,
|
||||
tenantId = bundle.TenantId,
|
||||
nodeCount = bundle.JobLogs.Count,
|
||||
totalEntries = bundle.JobLogs.Sum(l => l.Entries.Count),
|
||||
note = "Bundle imported but sync service not available"
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Job sync bundle loaded.[/]");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{bundle.BundleId}[/]");
|
||||
AnsiConsole.MarkupLine($" Tenant: {Markup.Escape(bundle.TenantId)}");
|
||||
AnsiConsole.MarkupLine($" Node logs: {bundle.JobLogs.Count}");
|
||||
AnsiConsole.MarkupLine($" Total entries: {bundle.JobLogs.Sum(l => l.Entries.Count)}");
|
||||
AnsiConsole.MarkupLine("[yellow]Note:[/] Sync service not available. Bundle validated but not synced to scheduler.");
|
||||
}
|
||||
}
|
||||
|
||||
return ExitSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return ExitGeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for 'stella airgap jobs list' command.
|
||||
/// Lists available job sync bundles.
|
||||
/// </summary>
|
||||
internal static async Task<int> HandleAirGapJobsListAsync(
|
||||
IServiceProvider services,
|
||||
string? source,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitGeneralError = 1;
|
||||
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
|
||||
try
|
||||
{
|
||||
var transport = scope.ServiceProvider.GetService<IJobSyncTransport>();
|
||||
if (transport is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Job sync transport not configured. Register with AddFileBasedJobSyncTransport().");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
var sourcePath = source ?? ".";
|
||||
var bundles = await transport.ListAvailableBundlesAsync(sourcePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
source = sourcePath,
|
||||
bundles = bundles.Select(b => new
|
||||
{
|
||||
bundleId = b.BundleId,
|
||||
tenantId = b.TenantId,
|
||||
sourceNodeId = b.SourceNodeId,
|
||||
createdAt = b.CreatedAt,
|
||||
entryCount = b.EntryCount,
|
||||
sizeBytes = b.SizeBytes
|
||||
})
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (bundles.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]No job sync bundles found in: {Markup.Escape(sourcePath)}[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
var table = new Table { Border = TableBorder.Rounded };
|
||||
table.AddColumn("Bundle ID");
|
||||
table.AddColumn("Tenant");
|
||||
table.AddColumn("Source Node");
|
||||
table.AddColumn("Created");
|
||||
table.AddColumn("Entries");
|
||||
table.AddColumn("Size");
|
||||
|
||||
foreach (var b in bundles)
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(b.BundleId.ToString("N")[..8] + "..."),
|
||||
Markup.Escape(b.TenantId),
|
||||
Markup.Escape(b.SourceNodeId),
|
||||
b.CreatedAt.ToString("yyyy-MM-dd HH:mm"),
|
||||
b.EntryCount.ToString(),
|
||||
FormatBytesCompact(b.SizeBytes));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
}
|
||||
|
||||
return ExitSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return ExitGeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatBytesCompact(long bytes)
|
||||
{
|
||||
string[] sizes = ["B", "KB", "MB", "GB"];
|
||||
double size = bytes;
|
||||
var order = 0;
|
||||
while (size >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
size /= 1024;
|
||||
}
|
||||
return $"{size:0.#} {sizes[order]}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CommandHandlers.VerdictRationale.cs
|
||||
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
|
||||
// Task: VRR-021 - Integrate into CLI triage commands
|
||||
// Description: Command handler for verdict rationale operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static partial class CommandHandlers
|
||||
{
|
||||
private static readonly JsonSerializerOptions RationaleJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
internal static async Task<int> HandleVerdictRationaleAsync(
|
||||
IServiceProvider services,
|
||||
string findingId,
|
||||
string? tenant,
|
||||
string output,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("verdict-rationale");
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.verdict.rationale", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("verdict rationale");
|
||||
|
||||
if (!OfflineModeGuard.IsNetworkAllowed(options, "verdict rationale"))
|
||||
{
|
||||
WriteRationaleError("Offline mode enabled. Cannot fetch verdict rationale.", output, console);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
WriteRationaleError("Finding ID is required.", output, console);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rationaleClient = scope.ServiceProvider.GetRequiredService<IRationaleClient>();
|
||||
|
||||
switch (output.ToLowerInvariant())
|
||||
{
|
||||
case "json":
|
||||
var jsonResult = await rationaleClient.GetRationaleAsync(findingId, "json", tenant, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (jsonResult is null)
|
||||
{
|
||||
WriteRationaleError($"Rationale not found for finding: {findingId}", output, console);
|
||||
Environment.ExitCode = 1;
|
||||
return 1;
|
||||
}
|
||||
console.WriteLine(JsonSerializer.Serialize(jsonResult, RationaleJsonOptions));
|
||||
break;
|
||||
|
||||
case "markdown":
|
||||
var mdResult = await rationaleClient.GetRationaleMarkdownAsync(findingId, tenant, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (mdResult is null)
|
||||
{
|
||||
WriteRationaleError($"Rationale not found for finding: {findingId}", output, console);
|
||||
Environment.ExitCode = 1;
|
||||
return 1;
|
||||
}
|
||||
console.WriteLine(mdResult.Content);
|
||||
break;
|
||||
|
||||
case "text":
|
||||
case "plaintext":
|
||||
var textResult = await rationaleClient.GetRationalePlainTextAsync(findingId, tenant, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (textResult is null)
|
||||
{
|
||||
WriteRationaleError($"Rationale not found for finding: {findingId}", output, console);
|
||||
Environment.ExitCode = 1;
|
||||
return 1;
|
||||
}
|
||||
console.WriteLine(textResult.Content);
|
||||
break;
|
||||
|
||||
default: // table
|
||||
var tableResult = await rationaleClient.GetRationaleAsync(findingId, "json", tenant, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (tableResult is null)
|
||||
{
|
||||
WriteRationaleError($"Rationale not found for finding: {findingId}", output, console);
|
||||
Environment.ExitCode = 1;
|
||||
return 1;
|
||||
}
|
||||
WriteRationaleTable(tableResult, verbose, console);
|
||||
break;
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get rationale for finding {FindingId}", findingId);
|
||||
WriteRationaleError($"Failed to get rationale: {ex.Message}", output, console);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteRationaleTable(VerdictRationaleResponse rationale, bool verbose, IAnsiConsole console)
|
||||
{
|
||||
console.MarkupLine($"[bold]Finding:[/] {Markup.Escape(rationale.FindingId)}");
|
||||
console.MarkupLine($"[bold]Rationale ID:[/] {Markup.Escape(rationale.RationaleId)}");
|
||||
console.MarkupLine($"[bold]Generated:[/] {rationale.GeneratedAt:u}");
|
||||
console.WriteLine();
|
||||
|
||||
// Evidence section
|
||||
var evidencePanel = new Panel(Markup.Escape(rationale.Evidence?.Text ?? "No evidence information"))
|
||||
{
|
||||
Header = new PanelHeader("[bold green]1. Evidence[/]"),
|
||||
Border = BoxBorder.Rounded
|
||||
};
|
||||
console.Write(evidencePanel);
|
||||
console.WriteLine();
|
||||
|
||||
// Policy clause section
|
||||
var policyPanel = new Panel(Markup.Escape(rationale.PolicyClause?.Text ?? "No policy information"))
|
||||
{
|
||||
Header = new PanelHeader("[bold blue]2. Policy Clause[/]"),
|
||||
Border = BoxBorder.Rounded
|
||||
};
|
||||
console.Write(policyPanel);
|
||||
console.WriteLine();
|
||||
|
||||
// Attestations section
|
||||
var attestationsPanel = new Panel(Markup.Escape(rationale.Attestations?.Text ?? "No attestations"))
|
||||
{
|
||||
Header = new PanelHeader("[bold yellow]3. Attestations[/]"),
|
||||
Border = BoxBorder.Rounded
|
||||
};
|
||||
console.Write(attestationsPanel);
|
||||
console.WriteLine();
|
||||
|
||||
// Decision section
|
||||
var decisionText = rationale.Decision?.Text ?? "No decision information";
|
||||
var decisionColor = rationale.Decision?.Verdict?.ToLowerInvariant() switch
|
||||
{
|
||||
"affected" => "red",
|
||||
"not affected" => "green",
|
||||
"fixed (backport)" => "green",
|
||||
"resolved" => "green",
|
||||
"muted" => "dim",
|
||||
_ => "yellow"
|
||||
};
|
||||
var decisionPanel = new Panel($"[{decisionColor}]{Markup.Escape(decisionText)}[/]")
|
||||
{
|
||||
Header = new PanelHeader("[bold magenta]4. Decision[/]"),
|
||||
Border = BoxBorder.Rounded
|
||||
};
|
||||
console.Write(decisionPanel);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.WriteLine();
|
||||
console.MarkupLine("[dim]Input Digests:[/]");
|
||||
|
||||
var digestTable = new Table();
|
||||
digestTable.AddColumns("Digest Type", "Value");
|
||||
digestTable.Border = TableBorder.Simple;
|
||||
|
||||
if (rationale.InputDigests is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(rationale.InputDigests.VerdictDigest))
|
||||
{
|
||||
digestTable.AddRow("Verdict", Markup.Escape(rationale.InputDigests.VerdictDigest));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(rationale.InputDigests.PolicyDigest))
|
||||
{
|
||||
digestTable.AddRow("Policy", Markup.Escape(rationale.InputDigests.PolicyDigest));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(rationale.InputDigests.EvidenceDigest))
|
||||
{
|
||||
digestTable.AddRow("Evidence", Markup.Escape(rationale.InputDigests.EvidenceDigest));
|
||||
}
|
||||
}
|
||||
|
||||
console.Write(digestTable);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteRationaleError(string message, string output, IAnsiConsole console)
|
||||
{
|
||||
if (string.Equals(output, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var payload = new { status = "error", message };
|
||||
console.WriteLine(JsonSerializer.Serialize(payload, RationaleJsonOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
console.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,18 @@
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestation;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Replay.Core.Models;
|
||||
using StellaOps.Verdict;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
@@ -33,7 +38,8 @@ internal static partial class CommandHandlers
|
||||
var logger = loggerFactory.CreateLogger("verify-bundle");
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.verify.bundle", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("verify bundle");
|
||||
using var durationMetric = CliMetrics.MeasureCommandDuration("verify bundle");
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var emitJson = string.Equals(outputFormat, "json", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -128,14 +134,40 @@ internal static partial class CommandHandlers
|
||||
|
||||
// 5. Verify DSSE signature (if present)
|
||||
var signatureVerified = false;
|
||||
string? signatureKeyId = null;
|
||||
var dssePath = Path.Combine(workingDir, "outputs", "verdict.dsse.json");
|
||||
if (File.Exists(dssePath))
|
||||
{
|
||||
logger.LogInformation("Verifying DSSE signature...");
|
||||
signatureVerified = await VerifyDsseSignatureAsync(dssePath, workingDir, violations, logger, cancellationToken).ConfigureAwait(false);
|
||||
var (verified, keyId) = await VerifyDsseSignatureAsync(dssePath, workingDir, violations, logger, cancellationToken).ConfigureAwait(false);
|
||||
signatureVerified = verified;
|
||||
signatureKeyId = keyId;
|
||||
}
|
||||
|
||||
// 6. Output result
|
||||
// 6. Compute bundle hash for replay proof
|
||||
var bundleHash = await ComputeDirectoryHashAsync(workingDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 7. Generate ReplayProof
|
||||
var verdictMatches = replayedVerdictHash is not null
|
||||
&& manifest.ExpectedOutputs.VerdictHash is not null
|
||||
&& string.Equals(replayedVerdictHash, manifest.ExpectedOutputs.VerdictHash, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var replayProof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: bundleHash,
|
||||
policyVersion: manifest.Scan.PolicyDigest,
|
||||
verdictRoot: replayedVerdictHash ?? manifest.ExpectedOutputs.VerdictHash ?? "unknown",
|
||||
verdictMatches: verdictMatches,
|
||||
durationMs: stopwatch.ElapsedMilliseconds,
|
||||
replayedAt: DateTimeOffset.UtcNow,
|
||||
engineVersion: "1.0.0",
|
||||
artifactDigest: manifest.Scan.ImageDigest,
|
||||
signatureVerified: signatureVerified,
|
||||
signatureKeyId: signatureKeyId,
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("bundleId", manifest.BundleId)
|
||||
.Add("schemaVersion", manifest.SchemaVersion));
|
||||
|
||||
// 8. Output result
|
||||
var passed = violations.Count == 0;
|
||||
var exitCode = passed ? CliExitCodes.Success : CliExitCodes.GeneralError;
|
||||
|
||||
@@ -147,10 +179,12 @@ internal static partial class CommandHandlers
|
||||
BundleId: manifest.BundleId,
|
||||
BundlePath: workingDir,
|
||||
SchemaVersion: manifest.SchemaVersion,
|
||||
InputsValidated: violations.Count(v => v.Rule.StartsWith("input.hash")) == 0,
|
||||
InputsValidated: violations.Count(v => v.Rule.StartsWith("input.hash", StringComparison.Ordinal)) == 0,
|
||||
ReplayedVerdictHash: replayedVerdictHash,
|
||||
ExpectedVerdictHash: manifest.ExpectedOutputs.VerdictHash,
|
||||
SignatureVerified: signatureVerified,
|
||||
ReplayProofCompact: replayProof.ToCompactString(),
|
||||
ReplayProofJson: replayProof.ToCanonicalJson(),
|
||||
Violations: violations),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -276,41 +310,139 @@ internal static partial class CommandHandlers
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// STUB: VerdictBuilder integration not yet available
|
||||
// This would normally call:
|
||||
// var verdictBuilder = services.GetRequiredService<IVerdictBuilder>();
|
||||
// var verdict = await verdictBuilder.ReplayAsync(manifest);
|
||||
// return verdict.CgsHash;
|
||||
// RPL-004: Get VerdictBuilder from scope service provider
|
||||
// Note: VerdictBuilder is registered in DI via AddVerdictBuilderAirGap()
|
||||
// Since we're in a static method, we need to access it through scope.
|
||||
// For CLI commands, we create the service directly here.
|
||||
var verdictBuilder = new VerdictBuilderService(
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance.CreateLogger<VerdictBuilderService>(),
|
||||
signer: null);
|
||||
|
||||
logger.LogWarning("Verdict replay not implemented - VerdictBuilder service integration pending");
|
||||
violations.Add(new BundleViolation(
|
||||
"verdict.replay.not_implemented",
|
||||
"Verdict replay requires VerdictBuilder service (not yet integrated)"));
|
||||
try
|
||||
{
|
||||
// Build replay request from bundle manifest
|
||||
var sbomPath = Path.Combine(bundleDir, manifest.Inputs.Sbom.Path);
|
||||
var feedsPath = manifest.Inputs.Feeds is not null
|
||||
? Path.Combine(bundleDir, manifest.Inputs.Feeds.Path)
|
||||
: null;
|
||||
var vexPath = manifest.Inputs.Vex is not null
|
||||
? Path.Combine(bundleDir, manifest.Inputs.Vex.Path)
|
||||
: null;
|
||||
var policyPath = manifest.Inputs.Policy is not null
|
||||
? Path.Combine(bundleDir, manifest.Inputs.Policy.Path)
|
||||
: null;
|
||||
|
||||
return await Task.FromResult<string?>(null).ConfigureAwait(false);
|
||||
var replayRequest = new VerdictReplayRequest
|
||||
{
|
||||
SbomPath = sbomPath,
|
||||
FeedsPath = feedsPath,
|
||||
VexPath = vexPath,
|
||||
PolicyPath = policyPath,
|
||||
ImageDigest = manifest.Scan.ImageDigest,
|
||||
PolicyDigest = manifest.Scan.PolicyDigest,
|
||||
FeedSnapshotDigest = manifest.Scan.FeedSnapshotDigest
|
||||
};
|
||||
|
||||
logger.LogInformation("Replaying verdict with frozen inputs from bundle");
|
||||
var result = await verdictBuilder.ReplayFromBundleAsync(replayRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
violations.Add(new BundleViolation(
|
||||
"verdict.replay.failed",
|
||||
result.Error ?? "Verdict replay failed without error message"));
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogInformation("Verdict replay completed: Hash={Hash}, Duration={DurationMs}ms",
|
||||
result.VerdictHash, result.DurationMs);
|
||||
return result.VerdictHash;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Verdict replay threw exception");
|
||||
violations.Add(new BundleViolation(
|
||||
"verdict.replay.exception",
|
||||
$"Replay exception: {ex.Message}"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> VerifyDsseSignatureAsync(
|
||||
private static async Task<(bool IsValid, string? KeyId)> VerifyDsseSignatureAsync(
|
||||
string dssePath,
|
||||
string bundleDir,
|
||||
List<BundleViolation> violations,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// STUB: DSSE signature verification not yet available
|
||||
// This would normally call:
|
||||
// var signer = services.GetRequiredService<ISigner>();
|
||||
// var dsseEnvelope = await File.ReadAllTextAsync(dssePath);
|
||||
// var publicKey = await File.ReadAllTextAsync(Path.Combine(bundleDir, "attestation", "public-key.pem"));
|
||||
// var result = await signer.VerifyAsync(dsseEnvelope, publicKey);
|
||||
// return result.IsValid;
|
||||
// Load the DSSE envelope
|
||||
string envelopeJson;
|
||||
try
|
||||
{
|
||||
envelopeJson = await File.ReadAllTextAsync(dssePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
violations.Add(new BundleViolation(
|
||||
"signature.file.read_error",
|
||||
$"Failed to read DSSE envelope: {ex.Message}"));
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
logger.LogWarning("DSSE signature verification not implemented - Signer service integration pending");
|
||||
violations.Add(new BundleViolation(
|
||||
"signature.verify.not_implemented",
|
||||
"DSSE signature verification requires Signer service (not yet integrated)"));
|
||||
// Look for public key in standard locations
|
||||
var publicKeyPaths = new[]
|
||||
{
|
||||
Path.Combine(bundleDir, "attestation", "public-key.pem"),
|
||||
Path.Combine(bundleDir, "keys", "public-key.pem"),
|
||||
Path.Combine(bundleDir, "public-key.pem"),
|
||||
};
|
||||
|
||||
return await Task.FromResult(false).ConfigureAwait(false);
|
||||
string? publicKeyPem = null;
|
||||
foreach (var keyPath in publicKeyPaths)
|
||||
{
|
||||
if (File.Exists(keyPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
publicKeyPem = await File.ReadAllTextAsync(keyPath, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug("Loaded public key from {KeyPath}", keyPath);
|
||||
break;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to read public key from {KeyPath}", keyPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(publicKeyPem))
|
||||
{
|
||||
violations.Add(new BundleViolation(
|
||||
"signature.key.not_found",
|
||||
"No public key found for DSSE signature verification"));
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
// Use the DsseVerifier for verification
|
||||
var verifier = new DsseVerifier(
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance.CreateLogger<DsseVerifier>());
|
||||
|
||||
var result = await verifier.VerifyAsync(envelopeJson, publicKeyPem, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
foreach (var issue in result.Issues)
|
||||
{
|
||||
violations.Add(new BundleViolation($"signature.{issue}", issue));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("DSSE signature verified successfully. KeyId: {KeyId}", result.PrimaryKeyId ?? "unknown");
|
||||
}
|
||||
|
||||
return (result.IsValid, result.PrimaryKeyId);
|
||||
}
|
||||
|
||||
private static Task WriteVerifyBundleErrorAsync(
|
||||
@@ -366,7 +498,7 @@ internal static partial class CommandHandlers
|
||||
table.AddRow("Bundle ID", Markup.Escape(payload.BundleId));
|
||||
table.AddRow("Bundle Path", Markup.Escape(payload.BundlePath));
|
||||
table.AddRow("Schema Version", Markup.Escape(payload.SchemaVersion));
|
||||
table.AddRow("Inputs Validated", payload.InputsValidated ? "[green]✓[/]" : "[red]✗[/]");
|
||||
table.AddRow("Inputs Validated", payload.InputsValidated ? "[green]Yes[/]" : "[red]No[/]");
|
||||
|
||||
if (payload.ReplayedVerdictHash is not null)
|
||||
{
|
||||
@@ -378,7 +510,13 @@ internal static partial class CommandHandlers
|
||||
table.AddRow("Expected Verdict Hash", Markup.Escape(payload.ExpectedVerdictHash));
|
||||
}
|
||||
|
||||
table.AddRow("Signature Verified", payload.SignatureVerified ? "[green]✓[/]" : "[yellow]N/A[/]");
|
||||
table.AddRow("Signature Verified", payload.SignatureVerified ? "[green]Yes[/]" : "[yellow]N/A[/]");
|
||||
|
||||
if (!string.IsNullOrEmpty(payload.ReplayProofCompact))
|
||||
{
|
||||
table.AddRow("Replay Proof", Markup.Escape(payload.ReplayProofCompact));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
|
||||
if (payload.Violations.Count > 0)
|
||||
@@ -406,6 +544,8 @@ internal static partial class CommandHandlers
|
||||
string? ReplayedVerdictHash,
|
||||
string? ExpectedVerdictHash,
|
||||
bool SignatureVerified,
|
||||
string? ReplayProofCompact,
|
||||
string? ReplayProofJson,
|
||||
IReadOnlyList<BundleViolation> Violations);
|
||||
}
|
||||
|
||||
|
||||
@@ -10375,6 +10375,7 @@ internal static partial class CommandHandlers
|
||||
var required = requiredSigners.EnumerateArray()
|
||||
.Select(s => s.GetString())
|
||||
.Where(s => s != null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var actualSigners = signatures.Select(s => s.KeyId).ToHashSet();
|
||||
@@ -11730,7 +11731,6 @@ internal static partial class CommandHandlers
|
||||
}
|
||||
|
||||
// Check 3: Integrity verification (root hash)
|
||||
_ = false; // integrityOk - tracked via checks list
|
||||
if (index.TryGetProperty("integrity", out var integrity) &&
|
||||
integrity.TryGetProperty("rootHash", out var rootHashElem))
|
||||
{
|
||||
|
||||
857
src/Cli/StellaOps.Cli/Commands/EvidenceCommandGroup.cs
Normal file
857
src/Cli/StellaOps.Cli/Commands/EvidenceCommandGroup.cs
Normal file
@@ -0,0 +1,857 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceCommandGroup.cs
|
||||
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
|
||||
// Task: T025, T026, T027 - Evidence bundle export and verify CLI commands
|
||||
// Description: CLI commands for exporting and verifying evidence bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for evidence bundle operations.
|
||||
/// Implements `stella evidence export` and `stella evidence verify`.
|
||||
/// </summary>
|
||||
public static class EvidenceCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the evidence command group.
|
||||
/// </summary>
|
||||
public static Command BuildEvidenceCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var evidence = new Command("evidence", "Evidence bundle operations for audits and offline verification")
|
||||
{
|
||||
BuildExportCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildVerifyCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildStatusCommand(services, options, verboseOption, cancellationToken)
|
||||
};
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the export command.
|
||||
/// T025: stella evidence export --bundle <id> --output <path>
|
||||
/// T027: Progress indicator for large exports
|
||||
/// </summary>
|
||||
public static Command BuildExportCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundleIdArg = new Argument<string>("bundle-id")
|
||||
{
|
||||
Description = "Bundle ID to export (e.g., eb-2026-01-06-abc123)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file path (defaults to evidence-bundle-<id>.tar.gz)",
|
||||
Required = false
|
||||
};
|
||||
|
||||
var includeLayersOption = new Option<bool>("--include-layers")
|
||||
{
|
||||
Description = "Include per-layer SBOMs in the export"
|
||||
};
|
||||
|
||||
var includeRekorOption = new Option<bool>("--include-rekor-proofs")
|
||||
{
|
||||
Description = "Include Rekor transparency log proofs"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Export format: tar.gz (default), zip"
|
||||
};
|
||||
|
||||
var compressionOption = new Option<int>("--compression", new[] { "-c" })
|
||||
{
|
||||
Description = "Compression level (1-9, default: 6)"
|
||||
};
|
||||
|
||||
var export = new Command("export", "Export evidence bundle for offline audits")
|
||||
{
|
||||
bundleIdArg,
|
||||
outputOption,
|
||||
includeLayersOption,
|
||||
includeRekorOption,
|
||||
formatOption,
|
||||
compressionOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
export.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var bundleId = parseResult.GetValue(bundleIdArg) ?? string.Empty;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var includeLayers = parseResult.GetValue(includeLayersOption);
|
||||
var includeRekor = parseResult.GetValue(includeRekorOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "tar.gz";
|
||||
var compression = parseResult.GetValue(compressionOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleExportAsync(
|
||||
services, options, bundleId, output, includeLayers, includeRekor, format,
|
||||
compression > 0 ? compression : 6, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the verify command.
|
||||
/// T026: stella evidence verify <path>
|
||||
/// </summary>
|
||||
public static Command BuildVerifyCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pathArg = new Argument<string>("path")
|
||||
{
|
||||
Description = "Path to evidence bundle archive (.tar.gz)"
|
||||
};
|
||||
|
||||
var offlineOption = new Option<bool>("--offline")
|
||||
{
|
||||
Description = "Skip Rekor transparency log verification (for air-gapped environments)"
|
||||
};
|
||||
|
||||
var skipSignaturesOption = new Option<bool>("--skip-signatures")
|
||||
{
|
||||
Description = "Skip DSSE signature verification (checksums only)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
|
||||
var verify = new Command("verify", "Verify an exported evidence bundle")
|
||||
{
|
||||
pathArg,
|
||||
offlineOption,
|
||||
skipSignaturesOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
verify.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var path = parseResult.GetValue(pathArg) ?? string.Empty;
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var skipSignatures = parseResult.GetValue(skipSignaturesOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleVerifyAsync(services, options, path, offline, skipSignatures, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return verify;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the status command for checking async export progress.
|
||||
/// </summary>
|
||||
public static Command BuildStatusCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exportIdArg = new Argument<string>("export-id")
|
||||
{
|
||||
Description = "Export job ID to check status for"
|
||||
};
|
||||
|
||||
var bundleIdOption = new Option<string>("--bundle", new[] { "-b" })
|
||||
{
|
||||
Description = "Bundle ID (optional, for disambiguation)"
|
||||
};
|
||||
|
||||
var status = new Command("status", "Check status of an async export job")
|
||||
{
|
||||
exportIdArg,
|
||||
bundleIdOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
status.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var exportId = parseResult.GetValue(exportIdArg) ?? string.Empty;
|
||||
var bundleId = parseResult.GetValue(bundleIdOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleStatusAsync(services, options, exportId, bundleId, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleExportAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string bundleId,
|
||||
string? outputPath,
|
||||
bool includeLayers,
|
||||
bool includeRekor,
|
||||
string format,
|
||||
int compression,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(bundleId))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Bundle ID is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(EvidenceCommandGroup));
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var client = httpClientFactory.CreateClient("EvidenceLocker");
|
||||
|
||||
// Get backend URL
|
||||
var backendUrl = options.BackendUrl
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_EVIDENCE_URL")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL")
|
||||
?? "http://localhost:5000";
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Backend URL: {backendUrl}[/]");
|
||||
}
|
||||
|
||||
outputPath ??= $"evidence-bundle-{bundleId}.tar.gz";
|
||||
|
||||
// Start export with progress
|
||||
await AnsiConsole.Progress()
|
||||
.AutoClear(false)
|
||||
.HideCompleted(false)
|
||||
.Columns(
|
||||
new TaskDescriptionColumn(),
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn(),
|
||||
new RemainingTimeColumn(),
|
||||
new SpinnerColumn())
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
var exportTask = ctx.AddTask("[yellow]Exporting evidence bundle[/]");
|
||||
exportTask.MaxValue = 100;
|
||||
|
||||
try
|
||||
{
|
||||
// Request export
|
||||
var exportRequest = new
|
||||
{
|
||||
format,
|
||||
compressionLevel = compression,
|
||||
includeLayerSboms = includeLayers,
|
||||
includeRekorProofs = includeRekor
|
||||
};
|
||||
|
||||
var requestUrl = $"{backendUrl}/api/v1/bundles/{bundleId}/export";
|
||||
var response = await client.PostAsJsonAsync(requestUrl, exportRequest, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[red]Export failed:[/] {response.StatusCode} - {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
var exportResponse = await response.Content.ReadFromJsonAsync<ExportResponseDto>(cancellationToken);
|
||||
if (exportResponse is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Invalid response from server[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
exportTask.Description = $"[yellow]Exporting {bundleId}[/]";
|
||||
|
||||
// Poll for completion
|
||||
var statusUrl = $"{backendUrl}/api/v1/bundles/{bundleId}/export/{exportResponse.ExportId}";
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var statusResponse = await client.GetAsync(statusUrl, cancellationToken);
|
||||
|
||||
if (statusResponse.StatusCode == System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
// Export ready - download
|
||||
exportTask.Value = 90;
|
||||
exportTask.Description = "[green]Downloading bundle[/]";
|
||||
|
||||
await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write);
|
||||
await using var downloadStream = await statusResponse.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
var buffer = new byte[81920];
|
||||
long totalBytesRead = 0;
|
||||
var contentLength = statusResponse.Content.Headers.ContentLength ?? 0;
|
||||
|
||||
int bytesRead;
|
||||
while ((bytesRead = await downloadStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||
totalBytesRead += bytesRead;
|
||||
|
||||
if (contentLength > 0)
|
||||
{
|
||||
exportTask.Value = 90 + (10.0 * totalBytesRead / contentLength);
|
||||
}
|
||||
}
|
||||
|
||||
exportTask.Value = 100;
|
||||
exportTask.Description = "[green]Export complete[/]";
|
||||
break;
|
||||
}
|
||||
|
||||
if (statusResponse.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||
{
|
||||
var statusDto = await statusResponse.Content.ReadFromJsonAsync<ExportStatusDto>(cancellationToken);
|
||||
if (statusDto is not null)
|
||||
{
|
||||
exportTask.Value = statusDto.Progress;
|
||||
exportTask.Description = $"[yellow]{statusDto.Status}: {statusDto.Progress}%[/]";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = await statusResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[red]Export failed:[/] {statusResponse.StatusCode} - {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogError(ex, "Export failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
var fileInfo = new FileInfo(outputPath);
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine($"[green]Exported to:[/] {outputPath}");
|
||||
AnsiConsole.MarkupLine($"[dim]Size: {FormatSize(fileInfo.Length)}[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleVerifyAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string path,
|
||||
bool offline,
|
||||
bool skipSignatures,
|
||||
string outputFormat,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var results = new List<VerificationResult>();
|
||||
|
||||
await AnsiConsole.Status()
|
||||
.AutoRefresh(true)
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.StartAsync("Verifying evidence bundle...", async ctx =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Extract to temp directory
|
||||
var extractDir = Path.Combine(Path.GetTempPath(), $"evidence-verify-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(extractDir);
|
||||
|
||||
ctx.Status("Extracting bundle...");
|
||||
await ExtractTarGzAsync(path, extractDir, cancellationToken);
|
||||
|
||||
// Check 1: Verify checksums file exists
|
||||
var checksumsPath = Path.Combine(extractDir, "checksums.sha256");
|
||||
if (!File.Exists(checksumsPath))
|
||||
{
|
||||
results.Add(new VerificationResult("Checksums file", false, "checksums.sha256 not found"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check 2: Verify all checksums
|
||||
ctx.Status("Verifying checksums...");
|
||||
var checksumResult = await VerifyChecksumsAsync(extractDir, checksumsPath, cancellationToken);
|
||||
results.Add(checksumResult);
|
||||
}
|
||||
|
||||
// Check 3: Verify manifest
|
||||
var manifestPath = Path.Combine(extractDir, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
results.Add(new VerificationResult("Manifest", false, "manifest.json not found"));
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.Status("Verifying manifest...");
|
||||
var manifestResult = await VerifyManifestAsync(manifestPath, extractDir, cancellationToken);
|
||||
results.Add(manifestResult);
|
||||
}
|
||||
|
||||
// Check 4: Verify DSSE signatures (unless skipped)
|
||||
if (!skipSignatures)
|
||||
{
|
||||
ctx.Status("Verifying signatures...");
|
||||
var attestDir = Path.Combine(extractDir, "attestations");
|
||||
var keysDir = Path.Combine(extractDir, "keys");
|
||||
|
||||
if (Directory.Exists(attestDir))
|
||||
{
|
||||
var sigResult = await VerifySignaturesAsync(attestDir, keysDir, verbose, cancellationToken);
|
||||
results.Add(sigResult);
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new VerificationResult("Signatures", true, "No attestations to verify"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new VerificationResult("Signatures", true, "Skipped (--skip-signatures)"));
|
||||
}
|
||||
|
||||
// Check 5: Verify Rekor proofs (unless offline)
|
||||
if (!offline)
|
||||
{
|
||||
ctx.Status("Verifying Rekor proofs...");
|
||||
var rekorDir = Path.Combine(extractDir, "attestations", "rekor-proofs");
|
||||
if (Directory.Exists(rekorDir) && Directory.GetFiles(rekorDir).Length > 0)
|
||||
{
|
||||
var rekorResult = await VerifyRekorProofsAsync(rekorDir, verbose, cancellationToken);
|
||||
results.Add(rekorResult);
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new VerificationResult("Rekor proofs", true, "No proofs to verify"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new VerificationResult("Rekor proofs", true, "Skipped (offline mode)"));
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
try
|
||||
{
|
||||
Directory.Delete(extractDir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new VerificationResult("Extraction", false, $"Failed: {ex.Message}"));
|
||||
}
|
||||
});
|
||||
|
||||
// Output results
|
||||
if (outputFormat == "json")
|
||||
{
|
||||
var jsonResults = JsonSerializer.Serialize(new
|
||||
{
|
||||
path,
|
||||
verified = results.All(r => r.Passed),
|
||||
results = results.Select(r => new { check = r.Check, passed = r.Passed, message = r.Message })
|
||||
}, JsonOptions);
|
||||
Console.WriteLine(jsonResults);
|
||||
}
|
||||
else
|
||||
{
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.AddColumn("Check")
|
||||
.AddColumn("Status")
|
||||
.AddColumn("Details");
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
var status = result.Passed ? "[green]PASS[/]" : "[red]FAIL[/]";
|
||||
table.AddRow(result.Check, status, result.Message);
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var allPassed = results.All(r => r.Passed);
|
||||
if (allPassed)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Verification PASSED[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Verification FAILED[/]");
|
||||
}
|
||||
}
|
||||
|
||||
return results.All(r => r.Passed) ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleStatusAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string exportId,
|
||||
string? bundleId,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var client = httpClientFactory.CreateClient("EvidenceLocker");
|
||||
|
||||
var backendUrl = options.BackendUrl
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_EVIDENCE_URL")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL")
|
||||
?? "http://localhost:5000";
|
||||
|
||||
// If bundle ID is provided, use specific endpoint
|
||||
var statusUrl = !string.IsNullOrEmpty(bundleId)
|
||||
? $"{backendUrl}/api/v1/bundles/{bundleId}/export/{exportId}"
|
||||
: $"{backendUrl}/api/v1/exports/{exportId}";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync(statusUrl, cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Export complete[/]: Ready for download");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||
{
|
||||
var status = await response.Content.ReadFromJsonAsync<ExportStatusDto>(cancellationToken);
|
||||
if (status is not null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Status:[/] {status.Status}");
|
||||
AnsiConsole.MarkupLine($"[dim]Progress: {status.Progress}%[/]");
|
||||
if (!string.IsNullOrEmpty(status.EstimatedTimeRemaining))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]ETA: {status.EstimatedTimeRemaining}[/]");
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Export not found:[/] {exportId}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {response.StatusCode} - {error}");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExtractTarGzAsync(string archivePath, string extractDir, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var fileStream = File.OpenRead(archivePath);
|
||||
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
|
||||
await TarFile.ExtractToDirectoryAsync(gzipStream, extractDir, overwriteFiles: true, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<VerificationResult> VerifyChecksumsAsync(
|
||||
string extractDir,
|
||||
string checksumsPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(checksumsPath, cancellationToken);
|
||||
var failedFiles = new List<string>();
|
||||
var verifiedCount = 0;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||
continue;
|
||||
|
||||
// Parse BSD format: SHA256 (filename) = digest
|
||||
var match = System.Text.RegularExpressions.Regex.Match(line, @"^SHA256 \(([^)]+)\) = ([a-f0-9]+)$");
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var fileName = match.Groups[1].Value;
|
||||
var expectedDigest = match.Groups[2].Value;
|
||||
var filePath = Path.Combine(extractDir, fileName);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
failedFiles.Add($"{fileName} (missing)");
|
||||
continue;
|
||||
}
|
||||
|
||||
var actualDigest = await ComputeSha256Async(filePath, cancellationToken);
|
||||
if (!string.Equals(actualDigest, expectedDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failedFiles.Add($"{fileName} (mismatch)");
|
||||
}
|
||||
else
|
||||
{
|
||||
verifiedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failedFiles.Count > 0)
|
||||
{
|
||||
return new VerificationResult("Checksums", false, $"Failed: {string.Join(", ", failedFiles.Take(3))}");
|
||||
}
|
||||
|
||||
return new VerificationResult("Checksums", true, $"Verified {verifiedCount} files");
|
||||
}
|
||||
|
||||
private static async Task<VerificationResult> VerifyManifestAsync(
|
||||
string manifestPath,
|
||||
string extractDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
|
||||
var manifest = JsonSerializer.Deserialize<ManifestDto>(manifestJson);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return new VerificationResult("Manifest", false, "Invalid manifest JSON");
|
||||
}
|
||||
|
||||
// Verify all referenced artifacts exist
|
||||
var missingArtifacts = new List<string>();
|
||||
var allArtifacts = (manifest.Sboms ?? [])
|
||||
.Concat(manifest.VexStatements ?? [])
|
||||
.Concat(manifest.Attestations ?? [])
|
||||
.Concat(manifest.PolicyVerdicts ?? [])
|
||||
.Concat(manifest.ScanResults ?? []);
|
||||
|
||||
foreach (var artifact in allArtifacts)
|
||||
{
|
||||
var artifactPath = Path.Combine(extractDir, artifact.Path);
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
missingArtifacts.Add(artifact.Path);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingArtifacts.Count > 0)
|
||||
{
|
||||
return new VerificationResult("Manifest", false, $"Missing artifacts: {string.Join(", ", missingArtifacts.Take(3))}");
|
||||
}
|
||||
|
||||
return new VerificationResult("Manifest", true, $"Bundle {manifest.BundleId}, {manifest.TotalArtifacts} artifacts");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VerificationResult("Manifest", false, $"Parse error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<VerificationResult> VerifySignaturesAsync(
|
||||
string attestDir,
|
||||
string keysDir,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// For now, just verify DSSE envelope structure exists
|
||||
// Full cryptographic verification would require loading keys and verifying signatures
|
||||
var dsseFiles = Directory.GetFiles(attestDir, "*.dsse.json");
|
||||
|
||||
if (dsseFiles.Length == 0)
|
||||
{
|
||||
return Task.FromResult(new VerificationResult("Signatures", true, "No DSSE envelopes found"));
|
||||
}
|
||||
|
||||
// Basic structure validation - check files are valid JSON with expected structure
|
||||
var validCount = 0;
|
||||
foreach (var file in dsseFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
var doc = JsonDocument.Parse(content);
|
||||
if (doc.RootElement.TryGetProperty("payloadType", out _) &&
|
||||
doc.RootElement.TryGetProperty("payload", out _))
|
||||
{
|
||||
validCount++;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid DSSE envelope
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new VerificationResult(
|
||||
"Signatures",
|
||||
validCount == dsseFiles.Length,
|
||||
$"Validated {validCount}/{dsseFiles.Length} DSSE envelopes"));
|
||||
}
|
||||
|
||||
private static Task<VerificationResult> VerifyRekorProofsAsync(
|
||||
string rekorDir,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Rekor verification requires network access and is complex
|
||||
// For now, verify proof files are valid JSON
|
||||
var proofFiles = Directory.GetFiles(rekorDir, "*.proof.json");
|
||||
|
||||
if (proofFiles.Length == 0)
|
||||
{
|
||||
return Task.FromResult(new VerificationResult("Rekor proofs", true, "No proofs to verify"));
|
||||
}
|
||||
|
||||
var validCount = 0;
|
||||
foreach (var file in proofFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
JsonDocument.Parse(content);
|
||||
validCount++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid proof
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new VerificationResult(
|
||||
"Rekor proofs",
|
||||
validCount == proofFiles.Length,
|
||||
$"Validated {validCount}/{proofFiles.Length} proof files (online verification not implemented)"));
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
string[] sizes = ["B", "KB", "MB", "GB"];
|
||||
var order = 0;
|
||||
double size = bytes;
|
||||
while (size >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
size /= 1024;
|
||||
}
|
||||
return $"{size:0.##} {sizes[order]}";
|
||||
}
|
||||
|
||||
// DTOs for API communication
|
||||
private sealed record ExportResponseDto
|
||||
{
|
||||
[JsonPropertyName("exportId")]
|
||||
public string ExportId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("estimatedSize")]
|
||||
public long EstimatedSize { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ExportStatusDto
|
||||
{
|
||||
[JsonPropertyName("exportId")]
|
||||
public string ExportId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("progress")]
|
||||
public int Progress { get; init; }
|
||||
|
||||
[JsonPropertyName("estimatedTimeRemaining")]
|
||||
public string? EstimatedTimeRemaining { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ManifestDto
|
||||
{
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string BundleId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("totalArtifacts")]
|
||||
public int TotalArtifacts { get; init; }
|
||||
|
||||
[JsonPropertyName("sboms")]
|
||||
public ArtifactRefDto[]? Sboms { get; init; }
|
||||
|
||||
[JsonPropertyName("vexStatements")]
|
||||
public ArtifactRefDto[]? VexStatements { get; init; }
|
||||
|
||||
[JsonPropertyName("attestations")]
|
||||
public ArtifactRefDto[]? Attestations { get; init; }
|
||||
|
||||
[JsonPropertyName("policyVerdicts")]
|
||||
public ArtifactRefDto[]? PolicyVerdicts { get; init; }
|
||||
|
||||
[JsonPropertyName("scanResults")]
|
||||
public ArtifactRefDto[]? ScanResults { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ArtifactRefDto
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record VerificationResult(string Check, bool Passed, string Message);
|
||||
}
|
||||
878
src/Cli/StellaOps.Cli/Commands/LayerSbomCommandGroup.cs
Normal file
878
src/Cli/StellaOps.Cli/Commands/LayerSbomCommandGroup.cs
Normal file
@@ -0,0 +1,878 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LayerSbomCommandGroup.cs
|
||||
// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
|
||||
// Task: T017, T018, T019 - Per-layer SBOM and composition recipe CLI commands
|
||||
// Description: CLI commands for per-layer SBOM export and composition recipe
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for per-layer SBOM and composition recipe operations.
|
||||
/// Implements `stella scan layers`, `stella scan sbom --layer`, and `stella scan recipe`.
|
||||
/// </summary>
|
||||
public static class LayerSbomCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the layers command for listing scan layers.
|
||||
/// </summary>
|
||||
public static Command BuildLayersCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scanIdArg = new Argument<string>("scan-id")
|
||||
{
|
||||
Description = "Scan ID to list layers for"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
|
||||
var layers = new Command("layers", "List layers in a scan with SBOM information")
|
||||
{
|
||||
scanIdArg,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
layers.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var scanId = parseResult.GetValue(scanIdArg) ?? string.Empty;
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleLayersAsync(services, options, scanId, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the layer-sbom command for getting per-layer SBOM.
|
||||
/// T017: stella scan sbom --layer <digest>
|
||||
/// </summary>
|
||||
public static Command BuildLayerSbomCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scanIdArg = new Argument<string>("scan-id")
|
||||
{
|
||||
Description = "Scan ID"
|
||||
};
|
||||
|
||||
var layerOption = new Option<string>("--layer", new[] { "-l" })
|
||||
{
|
||||
Description = "Layer digest (sha256:...)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "SBOM format: cdx (default), spdx"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file path (prints to stdout if not specified)"
|
||||
};
|
||||
|
||||
var layerSbom = new Command("layer-sbom", "Get per-layer SBOM for a specific layer")
|
||||
{
|
||||
scanIdArg,
|
||||
layerOption,
|
||||
formatOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
layerSbom.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var scanId = parseResult.GetValue(scanIdArg) ?? string.Empty;
|
||||
var layer = parseResult.GetValue(layerOption) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "cdx";
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleLayerSbomAsync(
|
||||
services, options, scanId, layer, format, outputPath, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return layerSbom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the recipe command for composition recipe operations.
|
||||
/// T018, T019: stella scan recipe
|
||||
/// </summary>
|
||||
public static Command BuildRecipeCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scanIdArg = new Argument<string>("scan-id")
|
||||
{
|
||||
Description = "Scan ID to get composition recipe for"
|
||||
};
|
||||
|
||||
var verifyOption = new Option<bool>("--verify")
|
||||
{
|
||||
Description = "Verify recipe against stored SBOMs (checks Merkle root and digests)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file path (prints to stdout if not specified)"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: json (default), summary"
|
||||
};
|
||||
|
||||
var recipe = new Command("recipe", "Get or verify SBOM composition recipe")
|
||||
{
|
||||
scanIdArg,
|
||||
verifyOption,
|
||||
outputOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
recipe.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var scanId = parseResult.GetValue(scanIdArg) ?? string.Empty;
|
||||
var verify = parseResult.GetValue(verifyOption);
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleRecipeAsync(
|
||||
services, options, scanId, verify, outputPath, format, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleLayersAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string scanId,
|
||||
string output,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(LayerSbomCommandGroup));
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Scan ID is required.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Listing layers for scan: {scanId}[/]");
|
||||
}
|
||||
|
||||
using var client = CreateHttpClient(services, options);
|
||||
var url = $"api/v1/scans/{Uri.EscapeDataString(scanId)}/layers";
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Calling: {client.BaseAddress}{url}[/]");
|
||||
}
|
||||
|
||||
var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponse(console, logger, response, "layers", ct, verbose);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var layers = await response.Content.ReadFromJsonAsync<LayersResponseDto>(JsonOptions, ct);
|
||||
|
||||
if (layers is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse layers response.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (output.ToLowerInvariant() == "json")
|
||||
{
|
||||
console.WriteLine(JsonSerializer.Serialize(layers, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteLayersTable(console, layers);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HandleException(console, logger, ex, "listing layers");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> HandleLayerSbomAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string scanId,
|
||||
string layerDigest,
|
||||
string format,
|
||||
string? outputPath,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(LayerSbomCommandGroup));
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Scan ID is required.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(layerDigest))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Layer digest is required (--layer).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Fetching {format} SBOM for layer: {layerDigest}[/]");
|
||||
}
|
||||
|
||||
using var client = CreateHttpClient(services, options);
|
||||
var url = $"api/v1/scans/{Uri.EscapeDataString(scanId)}/layers/{Uri.EscapeDataString(layerDigest)}/sbom?format={format}";
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Calling: {client.BaseAddress}{url}[/]");
|
||||
}
|
||||
|
||||
var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponse(console, logger, response, "layer SBOM", ct, verbose);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sbomContent = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
// Output SBOM
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, sbomContent, ct);
|
||||
console.MarkupLine($"[green]OK:[/] SBOM written to {outputPath}");
|
||||
|
||||
// Show digest
|
||||
var digest = ComputeSha256(sbomContent);
|
||||
console.MarkupLine($"[dim]Digest: sha256:{digest}[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.WriteLine(sbomContent);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HandleException(console, logger, ex, "fetching layer SBOM");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> HandleRecipeAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string scanId,
|
||||
bool verify,
|
||||
string? outputPath,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(LayerSbomCommandGroup));
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Scan ID is required.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Fetching composition recipe for scan: {scanId}[/]");
|
||||
}
|
||||
|
||||
using var client = CreateHttpClient(services, options);
|
||||
var url = $"api/v1/scans/{Uri.EscapeDataString(scanId)}/composition-recipe";
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Calling: {client.BaseAddress}{url}[/]");
|
||||
}
|
||||
|
||||
var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await HandleErrorResponse(console, logger, response, "composition recipe", ct, verbose);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var recipe = await response.Content.ReadFromJsonAsync<CompositionRecipeResponseDto>(JsonOptions, ct);
|
||||
|
||||
if (recipe is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse composition recipe response.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Verify if requested
|
||||
if (verify)
|
||||
{
|
||||
return await VerifyRecipeAsync(console, logger, client, scanId, recipe, verbose, ct);
|
||||
}
|
||||
|
||||
// Output recipe
|
||||
if (format.ToLowerInvariant() == "summary")
|
||||
{
|
||||
WriteRecipeSummary(console, recipe);
|
||||
}
|
||||
else
|
||||
{
|
||||
var json = JsonSerializer.Serialize(recipe, JsonOptions);
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, json, ct);
|
||||
console.MarkupLine($"[green]OK:[/] Recipe written to {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.WriteLine(json);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HandleException(console, logger, ex, "fetching composition recipe");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> VerifyRecipeAsync(
|
||||
IAnsiConsole console,
|
||||
ILogger? logger,
|
||||
HttpClient client,
|
||||
string scanId,
|
||||
CompositionRecipeResponseDto recipe,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
console.MarkupLine("[bold]Verifying Composition Recipe[/]");
|
||||
console.WriteLine();
|
||||
|
||||
var allPassed = true;
|
||||
var checks = new List<(string check, bool passed, string details)>();
|
||||
|
||||
// Check 1: Recipe has layers
|
||||
if (recipe.Recipe?.Layers is null or { Count: 0 })
|
||||
{
|
||||
checks.Add(("layers_exist", false, "Recipe has no layers"));
|
||||
allPassed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(("layers_exist", true, $"Recipe has {recipe.Recipe.Layers.Count} layers"));
|
||||
}
|
||||
|
||||
// Check 2: Verify Merkle root (if present)
|
||||
if (!string.IsNullOrWhiteSpace(recipe.Recipe?.MerkleRoot))
|
||||
{
|
||||
// Compute expected Merkle root from layer digests
|
||||
var layerDigests = recipe.Recipe.Layers?
|
||||
.OrderBy(l => l.Order)
|
||||
.Select(l => l.SbomDigests?.Cyclonedx ?? l.FragmentDigest)
|
||||
.Where(d => !string.IsNullOrEmpty(d))
|
||||
.ToList() ?? [];
|
||||
|
||||
if (layerDigests.Count > 0)
|
||||
{
|
||||
var computedRoot = ComputeMerkleRoot(layerDigests!);
|
||||
var expectedRoot = recipe.Recipe.MerkleRoot;
|
||||
|
||||
// Normalize for comparison
|
||||
var normalizedComputed = NormalizeDigest(computedRoot);
|
||||
var normalizedExpected = NormalizeDigest(expectedRoot);
|
||||
|
||||
if (normalizedComputed == normalizedExpected)
|
||||
{
|
||||
checks.Add(("merkle_root", true, $"Merkle root verified: {expectedRoot[..20]}..."));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(("merkle_root", false, $"Merkle root mismatch: expected {expectedRoot[..20]}..."));
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(("merkle_root", false, "No layer digests to verify Merkle root"));
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(("merkle_root", true, "Merkle root not present (skipped)"));
|
||||
}
|
||||
|
||||
// Check 3: Verify each layer SBOM is accessible
|
||||
if (recipe.Recipe?.Layers is { Count: > 0 })
|
||||
{
|
||||
var layerChecks = 0;
|
||||
var layerPassed = 0;
|
||||
|
||||
foreach (var layer in recipe.Recipe.Layers)
|
||||
{
|
||||
layerChecks++;
|
||||
try
|
||||
{
|
||||
var url = $"api/v1/scans/{Uri.EscapeDataString(scanId)}/layers/{Uri.EscapeDataString(layer.Digest)}/sbom?format=cdx";
|
||||
var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
layerPassed++;
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Layer {layer.Order}: {layer.Digest[..20]}... [green]OK[/][/]");
|
||||
}
|
||||
}
|
||||
else if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Layer {layer.Order}: {layer.Digest[..20]}... [red]FAIL[/][/]");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Layer {layer.Order}: {layer.Digest[..20]}... [red]ERROR[/][/]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (layerPassed == layerChecks)
|
||||
{
|
||||
checks.Add(("layer_sboms", true, $"All {layerChecks} layer SBOMs accessible"));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(("layer_sboms", false, $"Only {layerPassed}/{layerChecks} layer SBOMs accessible"));
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: Aggregated SBOM digests present
|
||||
if (recipe.Recipe?.AggregatedSbomDigests is not null)
|
||||
{
|
||||
var hasCdx = !string.IsNullOrEmpty(recipe.Recipe.AggregatedSbomDigests.Cyclonedx);
|
||||
var hasSpdx = !string.IsNullOrEmpty(recipe.Recipe.AggregatedSbomDigests.Spdx);
|
||||
|
||||
if (hasCdx || hasSpdx)
|
||||
{
|
||||
var formats = new List<string>();
|
||||
if (hasCdx) formats.Add("CycloneDX");
|
||||
if (hasSpdx) formats.Add("SPDX");
|
||||
checks.Add(("aggregated_sboms", true, $"Aggregated SBOMs: {string.Join(", ", formats)}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(("aggregated_sboms", false, "No aggregated SBOM digests"));
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(("aggregated_sboms", false, "Aggregated SBOM digests not present"));
|
||||
allPassed = false;
|
||||
}
|
||||
|
||||
// Output verification results
|
||||
console.WriteLine();
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.AddColumn("Check")
|
||||
.AddColumn("Status")
|
||||
.AddColumn("Details");
|
||||
|
||||
foreach (var (check, passed, details) in checks)
|
||||
{
|
||||
var status = passed ? "[green]PASS[/]" : "[red]FAIL[/]";
|
||||
table.AddRow(check, status, details);
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
console.WriteLine();
|
||||
|
||||
if (allPassed)
|
||||
{
|
||||
console.MarkupLine("[bold green]Verification PASSED[/]");
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[bold red]Verification FAILED[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IServiceProvider services, StellaOpsCliOptions options)
|
||||
{
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
var client = httpClientFactory?.CreateClient("ScannerService") ?? new HttpClient();
|
||||
|
||||
if (client.BaseAddress is null)
|
||||
{
|
||||
var scannerUrl = Environment.GetEnvironmentVariable("STELLAOPS_SCANNER_URL")
|
||||
?? options.BackendUrl
|
||||
?? "http://localhost:5070";
|
||||
client.BaseAddress = new Uri(scannerUrl);
|
||||
}
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task HandleErrorResponse(
|
||||
IAnsiConsole console,
|
||||
ILogger? logger,
|
||||
HttpResponseMessage response,
|
||||
string context,
|
||||
CancellationToken ct,
|
||||
bool verbose)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(ct);
|
||||
logger?.LogError("{Context} API returned {StatusCode}: {Content}",
|
||||
context, response.StatusCode, errorContent);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
console.MarkupLine($"[yellow]Not found:[/] {context} not available.");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine($"[red]Error:[/] Failed to retrieve {context}: {response.StatusCode}");
|
||||
if (verbose && !string.IsNullOrWhiteSpace(errorContent))
|
||||
{
|
||||
console.MarkupLine($"[dim]{errorContent}[/]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int HandleException(IAnsiConsole console, ILogger? logger, Exception ex, string context)
|
||||
{
|
||||
if (ex is HttpRequestException httpEx)
|
||||
{
|
||||
logger?.LogError(httpEx, "Network error during {Context}", context);
|
||||
console.MarkupLine($"[red]Error:[/] Network error: {httpEx.Message}");
|
||||
}
|
||||
else if (ex is TaskCanceledException tcEx && !tcEx.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger?.LogError(tcEx, "Request timed out during {Context}", context);
|
||||
console.MarkupLine("[red]Error:[/] Request timed out.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.LogError(ex, "Unexpected error during {Context}", context);
|
||||
console.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void WriteLayersTable(IAnsiConsole console, LayersResponseDto layers)
|
||||
{
|
||||
var header = new Panel(new Markup($"[bold]Scan Layers - {layers.ScanId}[/]"))
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Padding(1, 0);
|
||||
console.Write(header);
|
||||
|
||||
console.MarkupLine($"[dim]Image: {layers.ImageDigest}[/]");
|
||||
console.WriteLine();
|
||||
|
||||
if (layers.Layers is { Count: > 0 })
|
||||
{
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.AddColumn("Order")
|
||||
.AddColumn("Layer Digest")
|
||||
.AddColumn("Components")
|
||||
.AddColumn("Has SBOM");
|
||||
|
||||
foreach (var layer in layers.Layers.OrderBy(l => l.Order))
|
||||
{
|
||||
var shortDigest = layer.Digest.Length > 30
|
||||
? layer.Digest[..30] + "..."
|
||||
: layer.Digest;
|
||||
var hasSbom = layer.HasSbom ? "[green]Yes[/]" : "[dim]No[/]";
|
||||
|
||||
table.AddRow(
|
||||
layer.Order.ToString(),
|
||||
shortDigest,
|
||||
layer.ComponentCount.ToString(),
|
||||
hasSbom);
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[dim]No layers found.[/]");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteRecipeSummary(IAnsiConsole console, CompositionRecipeResponseDto recipe)
|
||||
{
|
||||
var header = new Panel(new Markup($"[bold]Composition Recipe - {recipe.ScanId}[/]"))
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Padding(1, 0);
|
||||
console.Write(header);
|
||||
|
||||
// Summary
|
||||
var summaryTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.AddColumn("Field")
|
||||
.AddColumn("Value");
|
||||
|
||||
summaryTable.AddRow("Image", recipe.ImageDigest ?? "N/A");
|
||||
summaryTable.AddRow("Created", recipe.CreatedAt?.ToString("O") ?? "N/A");
|
||||
summaryTable.AddRow("Generator", $"{recipe.Recipe?.GeneratorName ?? "N/A"} v{recipe.Recipe?.GeneratorVersion ?? "?"}");
|
||||
summaryTable.AddRow("Layers", recipe.Recipe?.Layers?.Count.ToString() ?? "0");
|
||||
summaryTable.AddRow("Merkle Root", TruncateDigest(recipe.Recipe?.MerkleRoot));
|
||||
|
||||
console.Write(summaryTable);
|
||||
|
||||
// Layer details
|
||||
if (recipe.Recipe?.Layers is { Count: > 0 })
|
||||
{
|
||||
console.WriteLine();
|
||||
var layerTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Layers[/]")
|
||||
.AddColumn("Order")
|
||||
.AddColumn("Layer Digest")
|
||||
.AddColumn("Fragment")
|
||||
.AddColumn("Components");
|
||||
|
||||
foreach (var layer in recipe.Recipe.Layers.OrderBy(l => l.Order))
|
||||
{
|
||||
layerTable.AddRow(
|
||||
layer.Order.ToString(),
|
||||
TruncateDigest(layer.Digest),
|
||||
TruncateDigest(layer.FragmentDigest),
|
||||
layer.ComponentCount.ToString());
|
||||
}
|
||||
|
||||
console.Write(layerTable);
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string? digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest)) return "N/A";
|
||||
return digest.Length > 25 ? digest[..25] + "..." : digest;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
// Remove sha256: prefix if present
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
digest = digest[7..];
|
||||
}
|
||||
return digest.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(List<string> digests)
|
||||
{
|
||||
// Simple RFC 6962-style Merkle tree computation
|
||||
if (digests.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var leaves = digests
|
||||
.Select(d => NormalizeDigest(d))
|
||||
.Select(d => Convert.FromHexString(d))
|
||||
.ToList();
|
||||
|
||||
while (leaves.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<byte[]>();
|
||||
for (int i = 0; i < leaves.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < leaves.Count)
|
||||
{
|
||||
// Combine two nodes
|
||||
var combined = new byte[1 + leaves[i].Length + leaves[i + 1].Length];
|
||||
combined[0] = 0x01; // Internal node prefix
|
||||
leaves[i].CopyTo(combined, 1);
|
||||
leaves[i + 1].CopyTo(combined, 1 + leaves[i].Length);
|
||||
nextLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node, carry up
|
||||
nextLevel.Add(leaves[i]);
|
||||
}
|
||||
}
|
||||
leaves = nextLevel;
|
||||
}
|
||||
|
||||
return "sha256:" + Convert.ToHexString(leaves[0]).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record LayersResponseDto
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("layers")]
|
||||
public IReadOnlyList<LayerInfoDto>? Layers { get; init; }
|
||||
}
|
||||
|
||||
private sealed record LayerInfoDto
|
||||
{
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; init; }
|
||||
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool HasSbom { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CompositionRecipeResponseDto
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("recipe")]
|
||||
public RecipeDto? Recipe { get; init; }
|
||||
}
|
||||
|
||||
private sealed record RecipeDto
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("generatorName")]
|
||||
public string? GeneratorName { get; init; }
|
||||
|
||||
[JsonPropertyName("generatorVersion")]
|
||||
public string? GeneratorVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("layers")]
|
||||
public IReadOnlyList<RecipeLayerDto>? Layers { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public string? MerkleRoot { get; init; }
|
||||
|
||||
[JsonPropertyName("aggregatedSbomDigests")]
|
||||
public SbomDigestsDto? AggregatedSbomDigests { get; init; }
|
||||
}
|
||||
|
||||
private sealed record RecipeLayerDto
|
||||
{
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentDigest")]
|
||||
public string? FragmentDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomDigests")]
|
||||
public SbomDigestsDto? SbomDigests { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SbomDigestsDto
|
||||
{
|
||||
[JsonPropertyName("cyclonedx")]
|
||||
public string? Cyclonedx { get; init; }
|
||||
|
||||
[JsonPropertyName("spdx")]
|
||||
public string? Spdx { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
570
src/Cli/StellaOps.Cli/Commands/ProveCommandGroup.cs
Normal file
570
src/Cli/StellaOps.Cli/Commands/ProveCommandGroup.cs
Normal file
@@ -0,0 +1,570 @@
|
||||
// <copyright file="ProveCommandGroup.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProveCommandGroup.cs
|
||||
// Sprint: SPRINT_20260105_002_001_REPLAY
|
||||
// Task: RPL-015 - Create ProveCommandGroup.cs with command structure
|
||||
// Description: CLI command for generating replay proofs for image verdicts.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Replay;
|
||||
using StellaOps.Replay.Core.Models;
|
||||
using StellaOps.Verdict;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for replay proof operations.
|
||||
/// Implements: stella prove --image sha256:... [--at timestamp] [--snapshot id] [--output format]
|
||||
/// </summary>
|
||||
public static class ProveCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the prove command tree.
|
||||
/// </summary>
|
||||
public static Command BuildProveCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imageOption = new Option<string>("--image", "-i")
|
||||
{
|
||||
Description = "Image digest (sha256:...) to generate proof for",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var atOption = new Option<string?>("--at", "-a")
|
||||
{
|
||||
Description = "Point-in-time for snapshot lookup (ISO 8601 format, e.g., 2026-01-05T10:00:00Z)"
|
||||
};
|
||||
|
||||
var snapshotOption = new Option<string?>("--snapshot", "-s")
|
||||
{
|
||||
Description = "Explicit snapshot ID to use instead of time lookup"
|
||||
};
|
||||
|
||||
var bundleOption = new Option<string?>("--bundle", "-b")
|
||||
{
|
||||
Description = "Path to local replay bundle directory (offline mode)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output format: compact, json, full"
|
||||
};
|
||||
outputOption.SetDefaultValue("compact");
|
||||
outputOption.FromAmong("compact", "json", "full");
|
||||
|
||||
var proveCommand = new Command("prove", "Generate replay proof for an image verdict")
|
||||
{
|
||||
imageOption,
|
||||
atOption,
|
||||
snapshotOption,
|
||||
bundleOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
proveCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageOption) ?? string.Empty;
|
||||
var at = parseResult.GetValue(atOption);
|
||||
var snapshot = parseResult.GetValue(snapshotOption);
|
||||
var bundle = parseResult.GetValue(bundleOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "compact";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleProveAsync(
|
||||
services,
|
||||
image,
|
||||
at,
|
||||
snapshot,
|
||||
bundle,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return proveCommand;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleProveAsync(
|
||||
IServiceProvider services,
|
||||
string imageDigest,
|
||||
string? atTimestamp,
|
||||
string? snapshotId,
|
||||
string? bundlePath,
|
||||
string outputFormat,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(ProveCommandGroup));
|
||||
|
||||
try
|
||||
{
|
||||
// Validate image digest format
|
||||
if (!imageDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) &&
|
||||
!imageDigest.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Image digest must start with sha256: or sha512:");
|
||||
return ProveExitCodes.InvalidInput;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Generating replay proof for image: {ImageDigest}", imageDigest);
|
||||
}
|
||||
|
||||
// Mode 1: Local bundle path specified (offline mode)
|
||||
if (!string.IsNullOrEmpty(bundlePath))
|
||||
{
|
||||
return await HandleLocalBundleProveAsync(
|
||||
services,
|
||||
bundlePath,
|
||||
imageDigest,
|
||||
outputFormat,
|
||||
verbose,
|
||||
logger,
|
||||
ct);
|
||||
}
|
||||
|
||||
// Mode 2: Resolve snapshot from timeline
|
||||
string resolvedSnapshotId;
|
||||
if (!string.IsNullOrEmpty(snapshotId))
|
||||
{
|
||||
resolvedSnapshotId = snapshotId;
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Using explicit snapshot ID: {SnapshotId}", snapshotId);
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(atTimestamp))
|
||||
{
|
||||
// Parse timestamp
|
||||
if (!DateTimeOffset.TryParse(atTimestamp, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal, out var pointInTime))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid timestamp format: {atTimestamp}");
|
||||
AnsiConsole.MarkupLine("[yellow]Expected:[/] ISO 8601 format (e.g., 2026-01-05T10:00:00Z)");
|
||||
return ProveExitCodes.InvalidInput;
|
||||
}
|
||||
|
||||
// Query timeline for snapshot at timestamp
|
||||
var timelineAdapter = services.GetService<ITimelineQueryAdapter>();
|
||||
if (timelineAdapter is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Timeline service not available.");
|
||||
AnsiConsole.MarkupLine("[yellow]Hint:[/] Use --bundle to specify a local bundle path for offline mode.");
|
||||
return ProveExitCodes.ServiceUnavailable;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Querying timeline for snapshot at {Timestamp}", pointInTime);
|
||||
}
|
||||
|
||||
var snapshotResult = await timelineAdapter.GetSnapshotAtAsync(imageDigest, pointInTime, ct);
|
||||
if (snapshotResult is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] No verdict snapshot found for image at {pointInTime:O}");
|
||||
return ProveExitCodes.SnapshotNotFound;
|
||||
}
|
||||
|
||||
resolvedSnapshotId = snapshotResult.SnapshotId;
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Resolved snapshot ID: {SnapshotId}", resolvedSnapshotId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get latest snapshot for image
|
||||
var timelineAdapter = services.GetService<ITimelineQueryAdapter>();
|
||||
if (timelineAdapter is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Timeline service not available.");
|
||||
AnsiConsole.MarkupLine("[yellow]Hint:[/] Use --bundle to specify a local bundle path for offline mode.");
|
||||
return ProveExitCodes.ServiceUnavailable;
|
||||
}
|
||||
|
||||
var latestSnapshot = await timelineAdapter.GetLatestSnapshotAsync(imageDigest, ct);
|
||||
if (latestSnapshot is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] No verdict snapshots found for image: {imageDigest}");
|
||||
return ProveExitCodes.SnapshotNotFound;
|
||||
}
|
||||
|
||||
resolvedSnapshotId = latestSnapshot.SnapshotId;
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Using latest snapshot ID: {SnapshotId}", resolvedSnapshotId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch bundle from CAS
|
||||
var bundleStore = services.GetService<IReplayBundleStoreAdapter>();
|
||||
if (bundleStore is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Replay bundle store not available.");
|
||||
return ProveExitCodes.ServiceUnavailable;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Fetching bundle for snapshot: {SnapshotId}", resolvedSnapshotId);
|
||||
}
|
||||
|
||||
var bundleInfo = await bundleStore.GetBundleAsync(resolvedSnapshotId, ct);
|
||||
if (bundleInfo is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle not found for snapshot: {resolvedSnapshotId}");
|
||||
return ProveExitCodes.BundleNotFound;
|
||||
}
|
||||
|
||||
// Execute replay and generate proof
|
||||
return await ExecuteReplayAndOutputProofAsync(
|
||||
services,
|
||||
bundleInfo.BundlePath,
|
||||
imageDigest,
|
||||
resolvedSnapshotId,
|
||||
bundleInfo.PolicyVersion,
|
||||
outputFormat,
|
||||
verbose,
|
||||
logger,
|
||||
ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
|
||||
return ProveExitCodes.Cancelled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Failed to generate replay proof");
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return ProveExitCodes.SystemError;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> HandleLocalBundleProveAsync(
|
||||
IServiceProvider services,
|
||||
string bundlePath,
|
||||
string imageDigest,
|
||||
string outputFormat,
|
||||
bool verbose,
|
||||
ILogger? logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
bundlePath = Path.GetFullPath(bundlePath);
|
||||
|
||||
if (!Directory.Exists(bundlePath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle directory not found: {bundlePath}");
|
||||
return ProveExitCodes.FileNotFound;
|
||||
}
|
||||
|
||||
// Load manifest to get policy version
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle manifest not found: {manifestPath}");
|
||||
return ProveExitCodes.FileNotFound;
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<ReplayBundleManifest>(manifestJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Failed to parse bundle manifest.");
|
||||
return ProveExitCodes.InvalidBundle;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Loaded local bundle: {BundleId}", manifest.BundleId);
|
||||
}
|
||||
|
||||
return await ExecuteReplayAndOutputProofAsync(
|
||||
services,
|
||||
bundlePath,
|
||||
imageDigest,
|
||||
manifest.BundleId,
|
||||
manifest.Scan.PolicyDigest,
|
||||
outputFormat,
|
||||
verbose,
|
||||
logger,
|
||||
ct);
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteReplayAndOutputProofAsync(
|
||||
IServiceProvider services,
|
||||
string bundlePath,
|
||||
string imageDigest,
|
||||
string snapshotId,
|
||||
string policyVersion,
|
||||
string outputFormat,
|
||||
bool verbose,
|
||||
ILogger? logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<ReplayBundleManifest>(manifestJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
}) ?? throw new InvalidOperationException("Failed to deserialize bundle manifest");
|
||||
|
||||
// Create VerdictBuilder and execute replay
|
||||
var verdictBuilder = new VerdictBuilderService(
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance.CreateLogger<VerdictBuilderService>(),
|
||||
signer: null);
|
||||
|
||||
var sbomPath = Path.Combine(bundlePath, manifest.Inputs.Sbom.Path);
|
||||
var feedsPath = manifest.Inputs.Feeds is not null
|
||||
? Path.Combine(bundlePath, manifest.Inputs.Feeds.Path)
|
||||
: null;
|
||||
var vexPath = manifest.Inputs.Vex is not null
|
||||
? Path.Combine(bundlePath, manifest.Inputs.Vex.Path)
|
||||
: null;
|
||||
var policyPath = manifest.Inputs.Policy is not null
|
||||
? Path.Combine(bundlePath, manifest.Inputs.Policy.Path)
|
||||
: null;
|
||||
|
||||
var replayRequest = new VerdictReplayRequest
|
||||
{
|
||||
SbomPath = sbomPath,
|
||||
FeedsPath = feedsPath,
|
||||
VexPath = vexPath,
|
||||
PolicyPath = policyPath,
|
||||
ImageDigest = manifest.Scan.ImageDigest,
|
||||
PolicyDigest = manifest.Scan.PolicyDigest,
|
||||
FeedSnapshotDigest = manifest.Scan.FeedSnapshotDigest
|
||||
};
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger?.LogDebug("Executing verdict replay...");
|
||||
}
|
||||
|
||||
var result = await verdictBuilder.ReplayFromBundleAsync(replayRequest, ct);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Replay failed: {result.Error}");
|
||||
return ProveExitCodes.ReplayFailed;
|
||||
}
|
||||
|
||||
// Compute bundle hash
|
||||
var bundleHash = await ComputeBundleHashAsync(bundlePath, ct);
|
||||
|
||||
// Check if verdict matches expected
|
||||
var verdictMatches = manifest.ExpectedOutputs?.VerdictHash is not null &&
|
||||
string.Equals(result.VerdictHash, manifest.ExpectedOutputs.VerdictHash, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Generate ReplayProof
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: bundleHash,
|
||||
policyVersion: policyVersion,
|
||||
verdictRoot: result.VerdictHash ?? "unknown",
|
||||
verdictMatches: verdictMatches,
|
||||
durationMs: stopwatch.ElapsedMilliseconds,
|
||||
replayedAt: DateTimeOffset.UtcNow,
|
||||
engineVersion: result.EngineVersion ?? "1.0.0",
|
||||
artifactDigest: imageDigest,
|
||||
signatureVerified: null,
|
||||
signatureKeyId: null,
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("snapshotId", snapshotId)
|
||||
.Add("bundleId", manifest.BundleId));
|
||||
|
||||
// Output proof based on format
|
||||
OutputProof(proof, outputFormat, verbose);
|
||||
|
||||
return verdictMatches ? ProveExitCodes.Success : ProveExitCodes.VerdictMismatch;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeBundleHashAsync(string bundlePath, CancellationToken ct)
|
||||
{
|
||||
var files = Directory.GetFiles(bundlePath, "*", SearchOption.AllDirectories)
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (files.Length == 0)
|
||||
{
|
||||
return "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
}
|
||||
|
||||
using var hasher = System.Security.Cryptography.SHA256.Create();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fileBytes = await File.ReadAllBytesAsync(file, ct);
|
||||
hasher.TransformBlock(fileBytes, 0, fileBytes.Length, null, 0);
|
||||
}
|
||||
|
||||
hasher.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
return $"sha256:{Convert.ToHexString(hasher.Hash!).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static void OutputProof(ReplayProof proof, string outputFormat, bool verbose)
|
||||
{
|
||||
switch (outputFormat.ToLowerInvariant())
|
||||
{
|
||||
case "compact":
|
||||
AnsiConsole.WriteLine(proof.ToCompactString());
|
||||
break;
|
||||
|
||||
case "json":
|
||||
var json = proof.ToCanonicalJson();
|
||||
AnsiConsole.WriteLine(json);
|
||||
break;
|
||||
|
||||
case "full":
|
||||
OutputFullProof(proof);
|
||||
break;
|
||||
|
||||
default:
|
||||
AnsiConsole.WriteLine(proof.ToCompactString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OutputFullProof(ReplayProof proof)
|
||||
{
|
||||
var table = new Table().AddColumns("Field", "Value");
|
||||
table.BorderColor(Color.Grey);
|
||||
|
||||
table.AddRow("Bundle Hash", proof.BundleHash);
|
||||
table.AddRow("Policy Version", proof.PolicyVersion);
|
||||
table.AddRow("Verdict Root", proof.VerdictRoot);
|
||||
table.AddRow("Duration", $"{proof.DurationMs}ms");
|
||||
|
||||
var matchDisplay = proof.VerdictMatches ? "[green]Yes[/]" : "[red]No[/]";
|
||||
table.AddRow("Verdict Matches", matchDisplay);
|
||||
|
||||
table.AddRow("Engine Version", proof.EngineVersion);
|
||||
table.AddRow("Replayed At", proof.ReplayedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
if (!string.IsNullOrEmpty(proof.ArtifactDigest))
|
||||
{
|
||||
table.AddRow("Artifact Digest", proof.ArtifactDigest);
|
||||
}
|
||||
|
||||
if (proof.SignatureVerified.HasValue)
|
||||
{
|
||||
var sigDisplay = proof.SignatureVerified.Value ? "[green]Yes[/]" : "[red]No[/]";
|
||||
table.AddRow("Signature Verified", sigDisplay);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(proof.SignatureKeyId))
|
||||
{
|
||||
table.AddRow("Signature Key ID", proof.SignatureKeyId);
|
||||
}
|
||||
|
||||
if (proof.Metadata is { Count: > 0 })
|
||||
{
|
||||
foreach (var kvp in proof.Metadata.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
table.AddRow($"[grey]meta:{kvp.Key}[/]", kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold]Compact Proof:[/]");
|
||||
AnsiConsole.WriteLine(proof.ToCompactString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exit codes for the prove command.
|
||||
/// </summary>
|
||||
internal static class ProveExitCodes
|
||||
{
|
||||
public const int Success = 0;
|
||||
public const int InvalidInput = 1;
|
||||
public const int SnapshotNotFound = 2;
|
||||
public const int BundleNotFound = 3;
|
||||
public const int ReplayFailed = 4;
|
||||
public const int VerdictMismatch = 5;
|
||||
public const int ServiceUnavailable = 6;
|
||||
public const int FileNotFound = 7;
|
||||
public const int InvalidBundle = 8;
|
||||
public const int SystemError = 99;
|
||||
public const int Cancelled = 130;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapter interface for timeline query operations in CLI context.
|
||||
/// RPL-016: Timeline query service adapter.
|
||||
/// </summary>
|
||||
public interface ITimelineQueryAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the snapshot ID for an image at a specific point in time.
|
||||
/// </summary>
|
||||
Task<SnapshotInfo?> GetSnapshotAtAsync(string imageDigest, DateTimeOffset pointInTime, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get the latest snapshot for an image.
|
||||
/// </summary>
|
||||
Task<SnapshotInfo?> GetLatestSnapshotAsync(string imageDigest, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot information returned by timeline queries.
|
||||
/// </summary>
|
||||
public sealed record SnapshotInfo(
|
||||
string SnapshotId,
|
||||
string ImageDigest,
|
||||
DateTimeOffset CreatedAt,
|
||||
string PolicyVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Adapter interface for replay bundle store operations in CLI context.
|
||||
/// RPL-017: Replay bundle store adapter.
|
||||
/// </summary>
|
||||
public interface IReplayBundleStoreAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Get bundle information and download path for a snapshot.
|
||||
/// </summary>
|
||||
Task<BundleInfo?> GetBundleAsync(string snapshotId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle information returned by the bundle store.
|
||||
/// </summary>
|
||||
public sealed record BundleInfo(
|
||||
string SnapshotId,
|
||||
string BundlePath,
|
||||
string BundleHash,
|
||||
string PolicyVersion,
|
||||
long SizeBytes);
|
||||
@@ -2,6 +2,7 @@
|
||||
// VerdictCommandGroup.cs
|
||||
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
|
||||
// Update: SPRINT_4300_0002_0002 (UATT-006) - Added uncertainty attestation verification.
|
||||
// Update: SPRINT_20260106_001_001 (VRR-021) - Added rationale command.
|
||||
// Description: CLI commands for verdict verification and inspection.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -22,6 +23,7 @@ internal static class VerdictCommandGroup
|
||||
verdict.Add(BuildVerdictVerifyCommand(services, verboseOption, cancellationToken));
|
||||
verdict.Add(BuildVerdictListCommand(services, verboseOption, cancellationToken));
|
||||
verdict.Add(BuildVerdictPushCommand(services, verboseOption, cancellationToken));
|
||||
verdict.Add(BuildVerdictRationaleCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return verdict;
|
||||
}
|
||||
@@ -264,4 +266,56 @@ internal static class VerdictCommandGroup
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the verdict rationale command.
|
||||
/// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
|
||||
/// Task: VRR-021
|
||||
/// </summary>
|
||||
private static Command BuildVerdictRationaleCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var findingIdArg = new Argument<string>("finding-id")
|
||||
{
|
||||
Description = "The finding ID to get rationale for"
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant", "-t")
|
||||
{
|
||||
Description = "Tenant ID (if multi-tenant)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output format: table, json, text, markdown"
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "text", "plaintext", "markdown");
|
||||
|
||||
var command = new Command("rationale", "Get the verdict rationale for a finding (4-line template: Evidence, Policy, Attestations, Decision).")
|
||||
{
|
||||
findingIdArg,
|
||||
tenantOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var findingId = parseResult.GetValue(findingIdArg) ?? string.Empty;
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleVerdictRationaleAsync(
|
||||
services,
|
||||
findingId,
|
||||
tenant,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
686
src/Cli/StellaOps.Cli/Commands/VexGateScanCommandGroup.cs
Normal file
686
src/Cli/StellaOps.Cli/Commands/VexGateScanCommandGroup.cs
Normal file
@@ -0,0 +1,686 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexGateScanCommandGroup.cs
|
||||
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
|
||||
// Task: T026, T027 - VEX gate CLI commands
|
||||
// Description: CLI commands for VEX gate policy and results under scan command
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for VEX gate operations under the scan command.
|
||||
/// Implements `stella scan gate-policy show` and `stella scan gate-results`.
|
||||
/// </summary>
|
||||
public static class VexGateScanCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build the VEX gate command group for scan commands.
|
||||
/// </summary>
|
||||
public static Command BuildVexGateCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gatePolicy = new Command("gate-policy", "VEX gate policy operations");
|
||||
gatePolicy.Add(BuildGatePolicyShowCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
return gatePolicy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the gate-results command for retrieving scan gate decisions.
|
||||
/// </summary>
|
||||
public static Command BuildGateResultsCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scanIdOption = new Option<string>("--scan-id", new[] { "-s" })
|
||||
{
|
||||
Description = "Scan ID to retrieve gate results for",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var decisionOption = new Option<string?>("--decision", new[] { "-d" })
|
||||
{
|
||||
Description = "Filter by decision: Pass, Warn, Block"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
|
||||
var limitOption = new Option<int?>("--limit", "-l")
|
||||
{
|
||||
Description = "Maximum number of results to display"
|
||||
};
|
||||
|
||||
var gateResults = new Command("gate-results", "Get VEX gate results for a scan")
|
||||
{
|
||||
scanIdOption,
|
||||
decisionOption,
|
||||
outputOption,
|
||||
limitOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
gateResults.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var scanId = parseResult.GetValue(scanIdOption) ?? string.Empty;
|
||||
var decision = parseResult.GetValue(decisionOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleGateResultsAsync(
|
||||
services,
|
||||
options,
|
||||
scanId,
|
||||
decision,
|
||||
output,
|
||||
limit,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return gateResults;
|
||||
}
|
||||
|
||||
private static Command BuildGatePolicyShowCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantOption = new Option<string?>("--tenant", "-t")
|
||||
{
|
||||
Description = "Tenant to show policy for (defaults to current)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json, yaml"
|
||||
};
|
||||
|
||||
var show = new Command("show", "Display current VEX gate policy")
|
||||
{
|
||||
tenantOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
show.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleGatePolicyShowAsync(
|
||||
services,
|
||||
options,
|
||||
tenant,
|
||||
output,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return show;
|
||||
}
|
||||
|
||||
private static async Task<int> HandleGatePolicyShowAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string? tenant,
|
||||
string output,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(VexGateScanCommandGroup));
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
try
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Retrieving VEX gate policy{(tenant is not null ? $" for tenant: {tenant}" : "")}[/]");
|
||||
}
|
||||
|
||||
// Call API
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
using var client = httpClientFactory?.CreateClient("ScannerService")
|
||||
?? new HttpClient();
|
||||
|
||||
// Configure base address if not set
|
||||
if (client.BaseAddress is null)
|
||||
{
|
||||
var scannerUrl = Environment.GetEnvironmentVariable("STELLAOPS_SCANNER_URL")
|
||||
?? options.BackendUrl
|
||||
?? "http://localhost:5070";
|
||||
client.BaseAddress = new Uri(scannerUrl);
|
||||
}
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
var url = "api/v1/vex-gate/policy";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
url += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Calling: {client.BaseAddress}{url}[/]");
|
||||
}
|
||||
|
||||
var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(ct);
|
||||
logger?.LogError("VEX gate policy API returned {StatusCode}: {Content}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
console.MarkupLine($"[red]Error:[/] Failed to retrieve gate policy: {response.StatusCode}");
|
||||
if (verbose && !string.IsNullOrWhiteSpace(errorContent))
|
||||
{
|
||||
console.MarkupLine($"[dim]{errorContent}[/]");
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
var policy = await response.Content.ReadFromJsonAsync<VexGatePolicyDto>(JsonOptions, ct);
|
||||
|
||||
if (policy is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse gate policy response.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Output results
|
||||
switch (output.ToLowerInvariant())
|
||||
{
|
||||
case "json":
|
||||
var json = JsonSerializer.Serialize(policy, JsonOptions);
|
||||
console.WriteLine(json);
|
||||
break;
|
||||
case "yaml":
|
||||
WriteYamlOutput(console, policy);
|
||||
break;
|
||||
default:
|
||||
WritePolicyTableOutput(console, policy, verbose);
|
||||
break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger?.LogError(ex, "Network error calling VEX gate policy API");
|
||||
console.MarkupLine($"[red]Error:[/] Network error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (ex.CancellationToken != ct)
|
||||
{
|
||||
logger?.LogError(ex, "VEX gate policy request timed out");
|
||||
console.MarkupLine("[red]Error:[/] Request timed out.");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Unexpected error retrieving VEX gate policy");
|
||||
console.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> HandleGateResultsAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string scanId,
|
||||
string? decision,
|
||||
string output,
|
||||
int? limit,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger(typeof(VexGateScanCommandGroup));
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Scan ID is required.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Retrieving VEX gate results for scan: {scanId}[/]");
|
||||
}
|
||||
|
||||
// Call API
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
using var client = httpClientFactory?.CreateClient("ScannerService")
|
||||
?? new HttpClient();
|
||||
|
||||
// Configure base address if not set
|
||||
if (client.BaseAddress is null)
|
||||
{
|
||||
var scannerUrl = Environment.GetEnvironmentVariable("STELLAOPS_SCANNER_URL")
|
||||
?? options.BackendUrl
|
||||
?? "http://localhost:5070";
|
||||
client.BaseAddress = new Uri(scannerUrl);
|
||||
}
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
var url = $"api/v1/scans/{Uri.EscapeDataString(scanId)}/gate-results";
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(decision))
|
||||
{
|
||||
queryParams.Add($"decision={Uri.EscapeDataString(decision)}");
|
||||
}
|
||||
if (limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={limit.Value}");
|
||||
}
|
||||
if (queryParams.Count > 0)
|
||||
{
|
||||
url += "?" + string.Join("&", queryParams);
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Calling: {client.BaseAddress}{url}[/]");
|
||||
}
|
||||
|
||||
var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(ct);
|
||||
logger?.LogError("VEX gate results API returned {StatusCode}: {Content}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
console.MarkupLine($"[yellow]Warning:[/] No gate results found for scan: {scanId}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.MarkupLine($"[red]Error:[/] Failed to retrieve gate results: {response.StatusCode}");
|
||||
if (verbose && !string.IsNullOrWhiteSpace(errorContent))
|
||||
{
|
||||
console.MarkupLine($"[dim]{errorContent}[/]");
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
var results = await response.Content.ReadFromJsonAsync<VexGateResultsDto>(JsonOptions, ct);
|
||||
|
||||
if (results is null)
|
||||
{
|
||||
console.MarkupLine("[red]Error:[/] Failed to parse gate results response.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Output results
|
||||
switch (output.ToLowerInvariant())
|
||||
{
|
||||
case "json":
|
||||
var json = JsonSerializer.Serialize(results, JsonOptions);
|
||||
console.WriteLine(json);
|
||||
break;
|
||||
default:
|
||||
WriteResultsTableOutput(console, results, verbose);
|
||||
break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger?.LogError(ex, "Network error calling VEX gate results API");
|
||||
console.MarkupLine($"[red]Error:[/] Network error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (ex.CancellationToken != ct)
|
||||
{
|
||||
logger?.LogError(ex, "VEX gate results request timed out");
|
||||
console.MarkupLine("[red]Error:[/] Request timed out.");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Unexpected error retrieving VEX gate results");
|
||||
console.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WritePolicyTableOutput(IAnsiConsole console, VexGatePolicyDto policy, bool verbose)
|
||||
{
|
||||
// Header
|
||||
var header = new Panel(new Markup($"[bold]VEX Gate Policy[/]"))
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Padding(1, 0);
|
||||
console.Write(header);
|
||||
|
||||
// Summary
|
||||
var summaryTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.AddColumn("Field")
|
||||
.AddColumn("Value");
|
||||
|
||||
summaryTable.AddRow("Policy ID", policy.PolicyId ?? "(default)");
|
||||
summaryTable.AddRow("Version", policy.Version ?? "1.0");
|
||||
summaryTable.AddRow("Default Decision", FormatDecision(policy.DefaultDecision ?? "Warn"));
|
||||
summaryTable.AddRow("Rules Count", policy.Rules?.Count.ToString() ?? "0");
|
||||
|
||||
console.Write(summaryTable);
|
||||
|
||||
// Rules table
|
||||
if (policy.Rules is { Count: > 0 })
|
||||
{
|
||||
console.WriteLine();
|
||||
var rulesTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Policy Rules[/]")
|
||||
.AddColumn("Priority")
|
||||
.AddColumn("Rule ID")
|
||||
.AddColumn("Decision")
|
||||
.AddColumn("Condition");
|
||||
|
||||
foreach (var rule in policy.Rules.OrderBy(r => r.Priority))
|
||||
{
|
||||
var conditionStr = FormatCondition(rule.Condition);
|
||||
rulesTable.AddRow(
|
||||
rule.Priority.ToString(),
|
||||
rule.RuleId ?? "unnamed",
|
||||
FormatDecision(rule.Decision ?? "Warn"),
|
||||
conditionStr);
|
||||
}
|
||||
|
||||
console.Write(rulesTable);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteYamlOutput(IAnsiConsole console, VexGatePolicyDto policy)
|
||||
{
|
||||
console.MarkupLine("[bold]vexGate:[/]");
|
||||
console.MarkupLine(" enabled: true");
|
||||
console.MarkupLine($" defaultDecision: {policy.DefaultDecision ?? "Warn"}");
|
||||
console.MarkupLine(" rules:");
|
||||
|
||||
if (policy.Rules is { Count: > 0 })
|
||||
{
|
||||
foreach (var rule in policy.Rules.OrderBy(r => r.Priority))
|
||||
{
|
||||
console.MarkupLine($" - ruleId: \"{rule.RuleId}\"");
|
||||
console.MarkupLine($" priority: {rule.Priority}");
|
||||
console.MarkupLine($" decision: {rule.Decision}");
|
||||
console.MarkupLine(" condition:");
|
||||
if (rule.Condition is not null)
|
||||
{
|
||||
if (rule.Condition.VendorStatus is not null)
|
||||
console.MarkupLine($" vendorStatus: {rule.Condition.VendorStatus}");
|
||||
if (rule.Condition.IsExploitable.HasValue)
|
||||
console.MarkupLine($" isExploitable: {rule.Condition.IsExploitable.Value.ToString().ToLower()}");
|
||||
if (rule.Condition.IsReachable.HasValue)
|
||||
console.MarkupLine($" isReachable: {rule.Condition.IsReachable.Value.ToString().ToLower()}");
|
||||
if (rule.Condition.HasCompensatingControl.HasValue)
|
||||
console.MarkupLine($" hasCompensatingControl: {rule.Condition.HasCompensatingControl.Value.ToString().ToLower()}");
|
||||
if (rule.Condition.SeverityLevels is { Length: > 0 })
|
||||
console.MarkupLine($" severityLevels: [{string.Join(", ", rule.Condition.SeverityLevels.Select(s => $"\"{s}\""))}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteResultsTableOutput(IAnsiConsole console, VexGateResultsDto results, bool verbose)
|
||||
{
|
||||
// Header
|
||||
var header = new Panel(new Markup($"[bold]VEX Gate Results - {results.ScanId}[/]"))
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Padding(1, 0);
|
||||
console.Write(header);
|
||||
|
||||
// Summary
|
||||
if (results.Summary is not null)
|
||||
{
|
||||
var summaryTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Summary[/]")
|
||||
.AddColumn("Metric")
|
||||
.AddColumn("Value");
|
||||
|
||||
summaryTable.AddRow("Total Findings", results.Summary.TotalFindings.ToString());
|
||||
summaryTable.AddRow("Passed", $"[green]{results.Summary.Passed}[/]");
|
||||
summaryTable.AddRow("Warned", $"[yellow]{results.Summary.Warned}[/]");
|
||||
summaryTable.AddRow("Blocked", $"[red]{results.Summary.Blocked}[/]");
|
||||
summaryTable.AddRow("Evaluated At", results.Summary.EvaluatedAt?.ToString("O") ?? "N/A");
|
||||
|
||||
console.Write(summaryTable);
|
||||
}
|
||||
|
||||
// Findings table
|
||||
if (results.GatedFindings is { Count: > 0 })
|
||||
{
|
||||
console.WriteLine();
|
||||
var findingsTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Gated Findings[/]")
|
||||
.AddColumn("CVE")
|
||||
.AddColumn("PURL")
|
||||
.AddColumn("Decision")
|
||||
.AddColumn("Rationale");
|
||||
|
||||
foreach (var finding in results.GatedFindings)
|
||||
{
|
||||
findingsTable.AddRow(
|
||||
finding.Cve ?? finding.FindingId ?? "unknown",
|
||||
TruncateString(finding.Purl, 40),
|
||||
FormatDecision(finding.Decision ?? "unknown"),
|
||||
TruncateString(finding.Rationale, 50));
|
||||
}
|
||||
|
||||
console.Write(findingsTable);
|
||||
}
|
||||
else
|
||||
{
|
||||
console.WriteLine();
|
||||
console.MarkupLine("[dim]No gated findings in this scan.[/]");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatDecision(string decision)
|
||||
{
|
||||
return decision.ToLowerInvariant() switch
|
||||
{
|
||||
"pass" => "[green]Pass[/]",
|
||||
"warn" => "[yellow]Warn[/]",
|
||||
"block" => "[red]Block[/]",
|
||||
_ => decision
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatCondition(VexGatePolicyConditionDto? condition)
|
||||
{
|
||||
if (condition is null)
|
||||
{
|
||||
return "(none)";
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
|
||||
if (condition.VendorStatus is not null)
|
||||
parts.Add($"vendor={condition.VendorStatus}");
|
||||
if (condition.IsExploitable.HasValue)
|
||||
parts.Add($"exploitable={condition.IsExploitable.Value}");
|
||||
if (condition.IsReachable.HasValue)
|
||||
parts.Add($"reachable={condition.IsReachable.Value}");
|
||||
if (condition.HasCompensatingControl.HasValue)
|
||||
parts.Add($"compensating={condition.HasCompensatingControl.Value}");
|
||||
if (condition.SeverityLevels is { Length: > 0 })
|
||||
parts.Add($"severity=[{string.Join(",", condition.SeverityLevels)}]");
|
||||
|
||||
return parts.Count > 0 ? string.Join(", ", parts) : "(none)";
|
||||
}
|
||||
|
||||
private static string TruncateString(string? s, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
return string.Empty;
|
||||
if (s.Length <= maxLength)
|
||||
return s;
|
||||
return s[..(maxLength - 3)] + "...";
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record VexGatePolicyDto
|
||||
{
|
||||
[JsonPropertyName("policyId")]
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("defaultDecision")]
|
||||
public string? DefaultDecision { get; init; }
|
||||
|
||||
[JsonPropertyName("rules")]
|
||||
public IReadOnlyList<VexGatePolicyRuleDto>? Rules { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VexGatePolicyRuleDto
|
||||
{
|
||||
[JsonPropertyName("ruleId")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("decision")]
|
||||
public string? Decision { get; init; }
|
||||
|
||||
[JsonPropertyName("condition")]
|
||||
public VexGatePolicyConditionDto? Condition { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VexGatePolicyConditionDto
|
||||
{
|
||||
[JsonPropertyName("vendorStatus")]
|
||||
public string? VendorStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("isExploitable")]
|
||||
public bool? IsExploitable { get; init; }
|
||||
|
||||
[JsonPropertyName("isReachable")]
|
||||
public bool? IsReachable { get; init; }
|
||||
|
||||
[JsonPropertyName("hasCompensatingControl")]
|
||||
public bool? HasCompensatingControl { get; init; }
|
||||
|
||||
[JsonPropertyName("severityLevels")]
|
||||
public string[]? SeverityLevels { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VexGateResultsDto
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("gateSummary")]
|
||||
public VexGateSummaryDto? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("gatedFindings")]
|
||||
public IReadOnlyList<GatedFindingDto>? GatedFindings { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VexGateSummaryDto
|
||||
{
|
||||
[JsonPropertyName("totalFindings")]
|
||||
public int TotalFindings { get; init; }
|
||||
|
||||
[JsonPropertyName("passed")]
|
||||
public int Passed { get; init; }
|
||||
|
||||
[JsonPropertyName("warned")]
|
||||
public int Warned { get; init; }
|
||||
|
||||
[JsonPropertyName("blocked")]
|
||||
public int Blocked { get; init; }
|
||||
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public DateTimeOffset? EvaluatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed record GatedFindingDto
|
||||
{
|
||||
[JsonPropertyName("findingId")]
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
[JsonPropertyName("cve")]
|
||||
public string? Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("decision")]
|
||||
public string? Decision { get; init; }
|
||||
|
||||
[JsonPropertyName("rationale")]
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
[JsonPropertyName("policyRuleMatched")]
|
||||
public string? PolicyRuleMatched { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public GatedFindingEvidenceDto? Evidence { get; init; }
|
||||
}
|
||||
|
||||
private sealed record GatedFindingEvidenceDto
|
||||
{
|
||||
[JsonPropertyName("vendorStatus")]
|
||||
public string? VendorStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("isReachable")]
|
||||
public bool? IsReachable { get; init; }
|
||||
|
||||
[JsonPropertyName("hasCompensatingControl")]
|
||||
public bool? HasCompensatingControl { get; init; }
|
||||
|
||||
[JsonPropertyName("confidenceScore")]
|
||||
public double? ConfidenceScore { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -223,14 +223,16 @@ internal static class CliErrorRenderer
|
||||
return false;
|
||||
}
|
||||
|
||||
string? tempCode;
|
||||
if ((!error.Metadata.TryGetValue("reason_code", out tempCode) || string.IsNullOrWhiteSpace(tempCode)) &&
|
||||
(!error.Metadata.TryGetValue("reasonCode", out tempCode) || string.IsNullOrWhiteSpace(tempCode)))
|
||||
string? code1 = null;
|
||||
string? code2 = null;
|
||||
|
||||
if ((!error.Metadata.TryGetValue("reason_code", out code1) || string.IsNullOrWhiteSpace(code1)) &&
|
||||
(!error.Metadata.TryGetValue("reasonCode", out code2) || string.IsNullOrWhiteSpace(code2)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
reasonCode = OfflineKitReasonCodes.Normalize(tempCode!) ?? "";
|
||||
reasonCode = OfflineKitReasonCodes.Normalize(code1 ?? code2 ?? "") ?? "";
|
||||
return reasonCode.Length > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.ExportCenter.Client;
|
||||
using StellaOps.ExportCenter.Core.EvidenceCache;
|
||||
using StellaOps.Verdict;
|
||||
#if DEBUG || STELLAOPS_ENABLE_SIMULATOR
|
||||
using StellaOps.Cryptography.Plugin.SimRemote.DependencyInjection;
|
||||
#endif
|
||||
@@ -247,6 +248,12 @@ internal static class Program
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbom-api");
|
||||
|
||||
// VRR-021: Rationale client for verdict rationale
|
||||
services.AddHttpClient<IRationaleClient, RationaleClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "triage-api");
|
||||
|
||||
// CLI-VERIFY-43-001: OCI registry client for verify image
|
||||
services.AddHttpClient<IOciRegistryClient, OciRegistryClient>(client =>
|
||||
{
|
||||
@@ -278,6 +285,32 @@ internal static class Program
|
||||
|
||||
services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
|
||||
|
||||
// RPL-003: VerdictBuilder for replay infrastructure (SPRINT_20260105_002_001_REPLAY)
|
||||
services.AddVerdictBuilderAirGap();
|
||||
|
||||
// RPL-016/017: Timeline and bundle store adapters for stella prove command
|
||||
services.AddHttpClient<StellaOps.Cli.Commands.ITimelineQueryAdapter,
|
||||
StellaOps.Cli.Replay.TimelineQueryAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "timeline-api");
|
||||
|
||||
services.AddHttpClient<StellaOps.Cli.Commands.IReplayBundleStoreAdapter,
|
||||
StellaOps.Cli.Replay.ReplayBundleStoreAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5); // Bundle downloads may take longer
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "replay-bundle-api");
|
||||
|
||||
// CLI-AIRGAP-56-001: Mirror bundle import service for air-gap operations
|
||||
services.AddSingleton<StellaOps.AirGap.Importer.Repositories.IBundleCatalogRepository,
|
||||
StellaOps.AirGap.Importer.Repositories.InMemoryBundleCatalogRepository>();
|
||||
|
||||
212
src/Cli/StellaOps.Cli/Replay/ReplayBundleStoreAdapter.cs
Normal file
212
src/Cli/StellaOps.Cli/Replay/ReplayBundleStoreAdapter.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
// <copyright file="ReplayBundleStoreAdapter.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayBundleStoreAdapter.cs
|
||||
// Sprint: SPRINT_20260105_002_001_REPLAY
|
||||
// Task: RPL-017 - Implement IReplayBundleStore adapter for bundle retrieval
|
||||
// Description: HTTP adapter for fetching replay bundles from CAS.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
|
||||
namespace StellaOps.Cli.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP adapter for replay bundle store operations.
|
||||
/// Fetches bundles from the Platform API and downloads to local cache.
|
||||
/// </summary>
|
||||
public sealed class ReplayBundleStoreAdapter : IReplayBundleStoreAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<ReplayBundleStoreAdapter> _logger;
|
||||
private readonly string _cacheDirectory;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public ReplayBundleStoreAdapter(HttpClient httpClient, ILogger<ReplayBundleStoreAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// Use temp directory for bundle cache
|
||||
_cacheDirectory = Path.Combine(Path.GetTempPath(), "stellaops-bundle-cache");
|
||||
Directory.CreateDirectory(_cacheDirectory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleInfo?> GetBundleAsync(string snapshotId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
|
||||
|
||||
try
|
||||
{
|
||||
// First, get bundle metadata
|
||||
var metadataUrl = $"/api/v1/replay/bundles/{Uri.EscapeDataString(snapshotId)}";
|
||||
|
||||
_logger.LogDebug("Fetching bundle metadata for snapshot: {SnapshotId}", snapshotId);
|
||||
|
||||
var metadataResponse = await _httpClient.GetAsync(metadataUrl, ct).ConfigureAwait(false);
|
||||
|
||||
if (metadataResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Bundle not found for snapshot: {SnapshotId}", snapshotId);
|
||||
return null;
|
||||
}
|
||||
|
||||
metadataResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var metadata = await metadataResponse.Content
|
||||
.ReadFromJsonAsync<BundleMetadataDto>(JsonOptions, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if bundle already exists in cache
|
||||
var localBundlePath = Path.Combine(_cacheDirectory, snapshotId);
|
||||
if (Directory.Exists(localBundlePath))
|
||||
{
|
||||
var manifestPath = Path.Combine(localBundlePath, "manifest.json");
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
_logger.LogDebug("Using cached bundle at: {BundlePath}", localBundlePath);
|
||||
return new BundleInfo(
|
||||
SnapshotId: snapshotId,
|
||||
BundlePath: localBundlePath,
|
||||
BundleHash: metadata.BundleHash,
|
||||
PolicyVersion: metadata.PolicyVersion,
|
||||
SizeBytes: metadata.SizeBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Download bundle
|
||||
var downloadUrl = $"/api/v1/replay/bundles/{Uri.EscapeDataString(snapshotId)}/download";
|
||||
|
||||
_logger.LogDebug("Downloading bundle from: {DownloadUrl}", downloadUrl);
|
||||
|
||||
var downloadResponse = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
downloadResponse.EnsureSuccessStatusCode();
|
||||
|
||||
// Create local directory
|
||||
Directory.CreateDirectory(localBundlePath);
|
||||
|
||||
// Check content type to determine if it's a tar.gz or directory listing
|
||||
var contentType = downloadResponse.Content.Headers.ContentType?.MediaType;
|
||||
|
||||
if (contentType == "application/gzip" || contentType == "application/x-gzip" ||
|
||||
downloadResponse.Content.Headers.ContentDisposition?.FileName?.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
// Download and extract tar.gz
|
||||
var tarGzPath = Path.Combine(_cacheDirectory, $"{snapshotId}.tar.gz");
|
||||
await using (var fs = File.Create(tarGzPath))
|
||||
{
|
||||
await downloadResponse.Content.CopyToAsync(fs, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Extract tar.gz
|
||||
await ExtractTarGzAsync(tarGzPath, localBundlePath, ct).ConfigureAwait(false);
|
||||
|
||||
// Clean up tar.gz
|
||||
File.Delete(tarGzPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Assume JSON response with file listings - download each file
|
||||
var filesResponse = await downloadResponse.Content
|
||||
.ReadFromJsonAsync<BundleFilesDto>(JsonOptions, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (filesResponse?.Files is not null)
|
||||
{
|
||||
foreach (var file in filesResponse.Files)
|
||||
{
|
||||
await DownloadFileAsync(snapshotId, file.Path, localBundlePath, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Bundle downloaded to: {BundlePath}", localBundlePath);
|
||||
|
||||
return new BundleInfo(
|
||||
SnapshotId: snapshotId,
|
||||
BundlePath: localBundlePath,
|
||||
BundleHash: metadata.BundleHash,
|
||||
PolicyVersion: metadata.PolicyVersion,
|
||||
SizeBytes: metadata.SizeBytes);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch bundle for snapshot: {SnapshotId}", snapshotId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadFileAsync(string snapshotId, string relativePath, string localBundlePath, CancellationToken ct)
|
||||
{
|
||||
var fileUrl = $"/api/v1/replay/bundles/{Uri.EscapeDataString(snapshotId)}/files/{Uri.EscapeDataString(relativePath)}";
|
||||
var localFilePath = Path.Combine(localBundlePath, relativePath);
|
||||
|
||||
var directory = Path.GetDirectoryName(localFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Downloading file: {RelativePath}", relativePath);
|
||||
|
||||
var response = await _httpClient.GetAsync(fileUrl, ct).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var fs = File.Create(localFilePath);
|
||||
await response.Content.CopyToAsync(fs, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ExtractTarGzAsync(string tarGzPath, string destinationPath, CancellationToken ct)
|
||||
{
|
||||
// Use System.Formats.Tar for extraction (available in .NET 7+)
|
||||
await using var fileStream = File.OpenRead(tarGzPath);
|
||||
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
|
||||
|
||||
// Read tar entries
|
||||
await System.Formats.Tar.TarFile.ExtractToDirectoryAsync(
|
||||
gzipStream,
|
||||
destinationPath,
|
||||
overwriteFiles: true,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed record BundleMetadataDto
|
||||
{
|
||||
public required string SnapshotId { get; init; }
|
||||
public required string BundleHash { get; init; }
|
||||
public required string PolicyVersion { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BundleFilesDto
|
||||
{
|
||||
public IReadOnlyList<BundleFileDto>? Files { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BundleFileDto
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required long Size { get; init; }
|
||||
public required string Sha256 { get; init; }
|
||||
}
|
||||
}
|
||||
134
src/Cli/StellaOps.Cli/Replay/TimelineQueryAdapter.cs
Normal file
134
src/Cli/StellaOps.Cli/Replay/TimelineQueryAdapter.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
// <copyright file="TimelineQueryAdapter.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimelineQueryAdapter.cs
|
||||
// Sprint: SPRINT_20260105_002_001_REPLAY
|
||||
// Task: RPL-016 - Implement ITimelineQueryService adapter for snapshot lookup
|
||||
// Description: HTTP adapter for querying timeline service from CLI.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
|
||||
namespace StellaOps.Cli.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP adapter for timeline query operations.
|
||||
/// Calls the Platform API to query verdict snapshots.
|
||||
/// </summary>
|
||||
public sealed class TimelineQueryAdapter : ITimelineQueryAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<TimelineQueryAdapter> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public TimelineQueryAdapter(HttpClient httpClient, ILogger<TimelineQueryAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SnapshotInfo?> GetSnapshotAtAsync(
|
||||
string imageDigest,
|
||||
DateTimeOffset pointInTime,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
try
|
||||
{
|
||||
var encodedDigest = Uri.EscapeDataString(imageDigest);
|
||||
var timestamp = pointInTime.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var url = $"/api/v1/timeline/snapshots/at?image={encodedDigest}×tamp={Uri.EscapeDataString(timestamp)}";
|
||||
|
||||
_logger.LogDebug("Querying timeline for snapshot at {Timestamp} for {ImageDigest}", timestamp, imageDigest);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("No snapshot found for image {ImageDigest} at {Timestamp}", imageDigest, timestamp);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var dto = await response.Content.ReadFromJsonAsync<SnapshotDto>(JsonOptions, ct).ConfigureAwait(false);
|
||||
if (dto is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SnapshotInfo(
|
||||
SnapshotId: dto.SnapshotId,
|
||||
ImageDigest: dto.ImageDigest,
|
||||
CreatedAt: dto.CreatedAt,
|
||||
PolicyVersion: dto.PolicyVersion);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to query timeline for snapshot at {PointInTime}", pointInTime);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SnapshotInfo?> GetLatestSnapshotAsync(string imageDigest, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
try
|
||||
{
|
||||
var encodedDigest = Uri.EscapeDataString(imageDigest);
|
||||
var url = $"/api/v1/timeline/snapshots/latest?image={encodedDigest}";
|
||||
|
||||
_logger.LogDebug("Querying timeline for latest snapshot for {ImageDigest}", imageDigest);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("No snapshots found for image {ImageDigest}", imageDigest);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var dto = await response.Content.ReadFromJsonAsync<SnapshotDto>(JsonOptions, ct).ConfigureAwait(false);
|
||||
if (dto is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SnapshotInfo(
|
||||
SnapshotId: dto.SnapshotId,
|
||||
ImageDigest: dto.ImageDigest,
|
||||
CreatedAt: dto.CreatedAt,
|
||||
PolicyVersion: dto.PolicyVersion);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to query timeline for latest snapshot");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SnapshotDto
|
||||
{
|
||||
public required string SnapshotId { get; init; }
|
||||
public required string ImageDigest { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required string PolicyVersion { get; init; }
|
||||
}
|
||||
}
|
||||
48
src/Cli/StellaOps.Cli/Services/IRationaleClient.cs
Normal file
48
src/Cli/StellaOps.Cli/Services/IRationaleClient.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IRationaleClient.cs
|
||||
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
|
||||
// Task: VRR-021 - Integrate into CLI triage commands
|
||||
// Description: Client interface for verdict rationale API.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for verdict rationale API operations.
|
||||
/// </summary>
|
||||
internal interface IRationaleClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the verdict rationale for a finding.
|
||||
/// </summary>
|
||||
/// <param name="findingId">The finding ID.</param>
|
||||
/// <param name="format">Output format: json, plaintext, or markdown.</param>
|
||||
/// <param name="tenant">Optional tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The rationale response, or null if not found.</returns>
|
||||
Task<VerdictRationaleResponse?> GetRationaleAsync(
|
||||
string findingId,
|
||||
string format,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the verdict rationale as plain text.
|
||||
/// </summary>
|
||||
Task<RationalePlainTextResponse?> GetRationalePlainTextAsync(
|
||||
string findingId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the verdict rationale as markdown.
|
||||
/// </summary>
|
||||
Task<RationalePlainTextResponse?> GetRationaleMarkdownAsync(
|
||||
string findingId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
189
src/Cli/StellaOps.Cli/Services/Models/RationaleModels.cs
Normal file
189
src/Cli/StellaOps.Cli/Services/Models/RationaleModels.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RationaleModels.cs
|
||||
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
|
||||
// Task: VRR-021 - Integrate into CLI triage commands
|
||||
// Description: CLI models for verdict rationale responses.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for verdict rationale.
|
||||
/// </summary>
|
||||
public sealed class VerdictRationaleResponse
|
||||
{
|
||||
[JsonPropertyName("findingId")]
|
||||
public string FindingId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rationaleId")]
|
||||
public string RationaleId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = "1.0";
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public RationaleEvidenceModel? Evidence { get; set; }
|
||||
|
||||
[JsonPropertyName("policyClause")]
|
||||
public RationalePolicyClauseModel? PolicyClause { get; set; }
|
||||
|
||||
[JsonPropertyName("attestations")]
|
||||
public RationaleAttestationsModel? Attestations { get; set; }
|
||||
|
||||
[JsonPropertyName("decision")]
|
||||
public RationaleDecisionModel? Decision { get; set; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("inputDigests")]
|
||||
public RationaleInputDigestsModel? InputDigests { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence section of the rationale.
|
||||
/// </summary>
|
||||
public sealed class RationaleEvidenceModel
|
||||
{
|
||||
[JsonPropertyName("cve")]
|
||||
public string? Cve { get; set; }
|
||||
|
||||
[JsonPropertyName("componentPurl")]
|
||||
public string? ComponentPurl { get; set; }
|
||||
|
||||
[JsonPropertyName("componentVersion")]
|
||||
public string? ComponentVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("vulnerableFunction")]
|
||||
public string? VulnerableFunction { get; set; }
|
||||
|
||||
[JsonPropertyName("entryPoint")]
|
||||
public string? EntryPoint { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy clause section of the rationale.
|
||||
/// </summary>
|
||||
public sealed class RationalePolicyClauseModel
|
||||
{
|
||||
[JsonPropertyName("clauseId")]
|
||||
public string? ClauseId { get; set; }
|
||||
|
||||
[JsonPropertyName("ruleDescription")]
|
||||
public string? RuleDescription { get; set; }
|
||||
|
||||
[JsonPropertyName("conditions")]
|
||||
public IReadOnlyList<string>? Conditions { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestations section of the rationale.
|
||||
/// </summary>
|
||||
public sealed class RationaleAttestationsModel
|
||||
{
|
||||
[JsonPropertyName("pathWitness")]
|
||||
public RationaleAttestationRefModel? PathWitness { get; set; }
|
||||
|
||||
[JsonPropertyName("vexStatements")]
|
||||
public IReadOnlyList<RationaleAttestationRefModel>? VexStatements { get; set; }
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public RationaleAttestationRefModel? Provenance { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an attestation.
|
||||
/// </summary>
|
||||
public sealed class RationaleAttestationRefModel
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; set; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision section of the rationale.
|
||||
/// </summary>
|
||||
public sealed class RationaleDecisionModel
|
||||
{
|
||||
[JsonPropertyName("verdict")]
|
||||
public string? Verdict { get; set; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; set; }
|
||||
|
||||
[JsonPropertyName("recommendation")]
|
||||
public string? Recommendation { get; set; }
|
||||
|
||||
[JsonPropertyName("mitigation")]
|
||||
public RationaleMitigationModel? Mitigation { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mitigation guidance.
|
||||
/// </summary>
|
||||
public sealed class RationaleMitigationModel
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string? Action { get; set; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input digests for reproducibility.
|
||||
/// </summary>
|
||||
public sealed class RationaleInputDigestsModel
|
||||
{
|
||||
[JsonPropertyName("verdictDigest")]
|
||||
public string? VerdictDigest { get; set; }
|
||||
|
||||
[JsonPropertyName("policyDigest")]
|
||||
public string? PolicyDigest { get; set; }
|
||||
|
||||
[JsonPropertyName("evidenceDigest")]
|
||||
public string? EvidenceDigest { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plain text rationale response.
|
||||
/// </summary>
|
||||
public sealed class RationalePlainTextResponse
|
||||
{
|
||||
[JsonPropertyName("findingId")]
|
||||
public string FindingId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rationaleId")]
|
||||
public string RationaleId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
274
src/Cli/StellaOps.Cli/Services/RationaleClient.cs
Normal file
274
src/Cli/StellaOps.Cli/Services/RationaleClient.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RationaleClient.cs
|
||||
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
|
||||
// Task: VRR-021 - Integrate into CLI triage commands
|
||||
// Description: Client implementation for verdict rationale API.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for verdict rationale API operations.
|
||||
/// </summary>
|
||||
internal sealed class RationaleClient : IRationaleClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<RationaleClient> _logger;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly object _tokenSync = new();
|
||||
|
||||
private string? _cachedAccessToken;
|
||||
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public RationaleClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<RationaleClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<VerdictRationaleResponse?> GetRationaleAsync(
|
||||
string findingId,
|
||||
string format,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/triage/findings/{Uri.EscapeDataString(findingId)}/rationale?format={Uri.EscapeDataString(format)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"&tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "triage.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Rationale not found for finding {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to get rationale (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<VerdictRationaleResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error while getting rationale for finding {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(ex, "Request timed out while getting rationale for finding {FindingId}", findingId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RationalePlainTextResponse?> GetRationalePlainTextAsync(
|
||||
string findingId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/triage/findings/{Uri.EscapeDataString(findingId)}/rationale?format=plaintext";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"&tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "triage.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to get rationale (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<RationalePlainTextResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error while getting rationale plaintext");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(ex, "Request timed out while getting rationale plaintext");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RationalePlainTextResponse?> GetRationaleMarkdownAsync(
|
||||
string findingId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/triage/findings/{Uri.EscapeDataString(findingId)}/rationale?format=markdown";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"&tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "triage.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to get rationale (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<RationalePlainTextResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error while getting rationale markdown");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(ex, "Request timed out while getting rationale markdown");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
if (_cachedAccessToken is not null && DateTimeOffset.UtcNow < _cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = result.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Token acquisition failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,15 @@ public sealed class HttpTransport : IStellaOpsTransport
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<HttpTransport> _logger;
|
||||
private readonly Func<double> _jitterSource;
|
||||
private bool _disposed;
|
||||
|
||||
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger)
|
||||
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger, Func<double>? jitterSource = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_jitterSource = jitterSource ?? Random.Shared.NextDouble;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
|
||||
{
|
||||
@@ -114,11 +116,11 @@ public sealed class HttpTransport : IStellaOpsTransport
|
||||
|| (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 500);
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
private TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
// Exponential backoff with jitter
|
||||
var baseDelay = Math.Pow(2, attempt);
|
||||
var jitter = Random.Shared.NextDouble() * 0.5;
|
||||
var jitter = _jitterSource() * 0.5;
|
||||
return TimeSpan.FromSeconds(baseDelay + jitter);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Canonicalization/StellaOps.Canonicalization.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DeltaVerdict/StellaOps.DeltaVerdict.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
@@ -94,6 +95,10 @@
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
|
||||
<!-- Secrets Bundle CLI (SPRINT_20260104_003_SCANNER) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<!-- Replay Infrastructure (SPRINT_20260105_002_001_REPLAY) -->
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<!-- Air-Gap Job Sync (SPRINT_20260105_002_003_ROUTER) -->
|
||||
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Sync/StellaOps.AirGap.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- GOST Crypto Plugins (Russia distribution) -->
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// <copyright file="ProveCommandTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProveCommandTests.cs
|
||||
// Sprint: SPRINT_20260105_002_001_REPLAY
|
||||
// Task: RPL-019 - Integration tests for stella prove command
|
||||
// Description: Tests for the prove command structure and local bundle mode.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
using StellaOps.Cli.Commands;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ProveCommandGroup and related functionality.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ProveCommandTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public ProveCommandTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"prove-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#region Command Structure Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildProveCommand_ReturnsCommandWithCorrectName()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = ProveCommandGroup.BuildProveCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
command.Name.Should().Be("prove");
|
||||
command.Description.Should().Contain("replay proof");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildProveCommand_HasRequiredImageOption()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = ProveCommandGroup.BuildProveCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var imageOption = command.Options.FirstOrDefault(o => o.Name == "image");
|
||||
imageOption.Should().NotBeNull();
|
||||
imageOption!.Required.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildProveCommand_HasOptionalAtOption()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = ProveCommandGroup.BuildProveCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var atOption = command.Options.FirstOrDefault(o => o.Name == "at");
|
||||
atOption.Should().NotBeNull();
|
||||
atOption!.Required.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildProveCommand_HasOptionalSnapshotOption()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = ProveCommandGroup.BuildProveCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var snapshotOption = command.Options.FirstOrDefault(o => o.Name == "snapshot");
|
||||
snapshotOption.Should().NotBeNull();
|
||||
snapshotOption!.Required.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildProveCommand_HasOptionalBundleOption()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = ProveCommandGroup.BuildProveCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var bundleOption = command.Options.FirstOrDefault(o => o.Name == "bundle");
|
||||
bundleOption.Should().NotBeNull();
|
||||
bundleOption!.Required.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildProveCommand_HasOutputOptionWithValidValues()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = ProveCommandGroup.BuildProveCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var outputOption = command.Options.FirstOrDefault(o => o.Name == "output");
|
||||
outputOption.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exit Code Tests
|
||||
|
||||
[Fact]
|
||||
public void ProveExitCodes_SuccessIsZero()
|
||||
{
|
||||
ProveExitCodes.Success.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProveExitCodes_CancelledIs130()
|
||||
{
|
||||
ProveExitCodes.Cancelled.Should().Be(130);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProveExitCodes_AllCodesAreUnique()
|
||||
{
|
||||
var codes = new[]
|
||||
{
|
||||
ProveExitCodes.Success,
|
||||
ProveExitCodes.InvalidInput,
|
||||
ProveExitCodes.SnapshotNotFound,
|
||||
ProveExitCodes.BundleNotFound,
|
||||
ProveExitCodes.ReplayFailed,
|
||||
ProveExitCodes.VerdictMismatch,
|
||||
ProveExitCodes.ServiceUnavailable,
|
||||
ProveExitCodes.FileNotFound,
|
||||
ProveExitCodes.InvalidBundle,
|
||||
ProveExitCodes.SystemError,
|
||||
ProveExitCodes.Cancelled
|
||||
};
|
||||
|
||||
codes.Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Adapter Interface Tests
|
||||
|
||||
[Fact]
|
||||
public void SnapshotInfo_CanBeCreated()
|
||||
{
|
||||
// Arrange & Act
|
||||
var snapshot = new SnapshotInfo(
|
||||
SnapshotId: "snap-123",
|
||||
ImageDigest: "sha256:abc123",
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
PolicyVersion: "v1.0.0");
|
||||
|
||||
// Assert
|
||||
snapshot.SnapshotId.Should().Be("snap-123");
|
||||
snapshot.ImageDigest.Should().Be("sha256:abc123");
|
||||
snapshot.PolicyVersion.Should().Be("v1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleInfo_CanBeCreated()
|
||||
{
|
||||
// Arrange & Act
|
||||
var bundle = new BundleInfo(
|
||||
SnapshotId: "snap-123",
|
||||
BundlePath: "/tmp/bundle",
|
||||
BundleHash: "sha256:bundlehash",
|
||||
PolicyVersion: "v1.0.0",
|
||||
SizeBytes: 1024);
|
||||
|
||||
// Assert
|
||||
bundle.SnapshotId.Should().Be("snap-123");
|
||||
bundle.BundlePath.Should().Be("/tmp/bundle");
|
||||
bundle.BundleHash.Should().Be("sha256:bundlehash");
|
||||
bundle.SizeBytes.Should().Be(1024);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private string CreateTestBundle(string bundleId = "test-bundle-001")
|
||||
{
|
||||
var bundlePath = Path.Combine(_testDir, bundleId);
|
||||
Directory.CreateDirectory(bundlePath);
|
||||
Directory.CreateDirectory(Path.Combine(bundlePath, "inputs"));
|
||||
Directory.CreateDirectory(Path.Combine(bundlePath, "outputs"));
|
||||
|
||||
// Create SBOM
|
||||
var sbomPath = Path.Combine(bundlePath, "inputs", "sbom.json");
|
||||
var sbomContent = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"version": 1,
|
||||
"components": []
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(sbomPath, sbomContent, Encoding.UTF8);
|
||||
|
||||
// Calculate SBOM hash
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var sbomBytes = Encoding.UTF8.GetBytes(sbomContent);
|
||||
var sbomHash = Convert.ToHexString(sha256.ComputeHash(sbomBytes)).ToLowerInvariant();
|
||||
|
||||
// Create verdict output
|
||||
var verdictPath = Path.Combine(bundlePath, "outputs", "verdict.json");
|
||||
var verdictContent = """
|
||||
{
|
||||
"decision": "pass",
|
||||
"score": 0.95,
|
||||
"findings": []
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(verdictPath, verdictContent, Encoding.UTF8);
|
||||
|
||||
var verdictBytes = Encoding.UTF8.GetBytes(verdictContent);
|
||||
var verdictHash = Convert.ToHexString(sha256.ComputeHash(verdictBytes)).ToLowerInvariant();
|
||||
|
||||
// Create manifest
|
||||
var manifest = new
|
||||
{
|
||||
schemaVersion = "2.0.0",
|
||||
bundleId = bundleId,
|
||||
createdAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
scan = new
|
||||
{
|
||||
id = "scan-001",
|
||||
imageDigest = "sha256:testimage123",
|
||||
policyDigest = "sha256:policy123",
|
||||
scorePolicyDigest = "sha256:scorepolicy123",
|
||||
feedSnapshotDigest = "sha256:feeds123",
|
||||
toolchain = "stellaops-1.0.0",
|
||||
analyzerSetDigest = "sha256:analyzers123"
|
||||
},
|
||||
inputs = new
|
||||
{
|
||||
sbom = new { path = "inputs/sbom.json", sha256 = sbomHash }
|
||||
},
|
||||
expectedOutputs = new
|
||||
{
|
||||
verdict = new { path = "outputs/verdict.json", sha256 = verdictHash },
|
||||
verdictHash = $"cgs:sha256:{verdictHash}"
|
||||
}
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexGateCommandTests.cs
|
||||
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
|
||||
// Task: T029 - CLI integration tests
|
||||
// Description: Unit tests for VEX gate CLI commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for VEX gate CLI commands under the scan command.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class VexGateCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
|
||||
public VexGateCommandTests()
|
||||
{
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddSingleton<ILogger<VexGateCommandTests>>(NullLogger<VexGateCommandTests>.Instance);
|
||||
_services = serviceCollection.BuildServiceProvider();
|
||||
|
||||
_options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "http://localhost:5070",
|
||||
};
|
||||
|
||||
_verboseOption = new Option<bool>("--verbose", "-v") { Description = "Enable verbose output" };
|
||||
}
|
||||
|
||||
#region gate-policy Command Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildVexGateCommand_CreatesGatePolicyCommandTree()
|
||||
{
|
||||
// Act
|
||||
var command = VexGateScanCommandGroup.BuildVexGateCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("gate-policy", command.Name);
|
||||
Assert.Contains("VEX gate policy", command.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildVexGateCommand_HasShowSubcommand()
|
||||
{
|
||||
// Act
|
||||
var command = VexGateScanCommandGroup.BuildVexGateCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
var showCommand = command.Subcommands.FirstOrDefault(c => c.Name == "show");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(showCommand);
|
||||
Assert.Contains("policy", showCommand.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowCommand_HasTenantOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildVexGateCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
var showCommand = command.Subcommands.First(c => c.Name == "show");
|
||||
|
||||
// Act - look for tenant option by -t alias
|
||||
var tenantOption = showCommand.Options.FirstOrDefault(o =>
|
||||
o.Aliases.Contains("-t"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(tenantOption);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowCommand_HasOutputOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildVexGateCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
var showCommand = command.Subcommands.First(c => c.Name == "show");
|
||||
|
||||
// Act
|
||||
var outputOption = showCommand.Options.FirstOrDefault(o =>
|
||||
o.Aliases.Contains("--output") || o.Aliases.Contains("-o"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(outputOption);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region gate-results Command Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildGateResultsCommand_CreatesGateResultsCommand()
|
||||
{
|
||||
// Act
|
||||
var command = VexGateScanCommandGroup.BuildGateResultsCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("gate-results", command.Name);
|
||||
Assert.Contains("gate results", command.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateResultsCommand_HasScanIdOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildGateResultsCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var scanIdOption = command.Options.FirstOrDefault(o =>
|
||||
o.Aliases.Contains("--scan-id") || o.Aliases.Contains("-s"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(scanIdOption);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateResultsCommand_ScanIdIsRequired()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildGateResultsCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var scanIdOption = command.Options.First(o =>
|
||||
o.Aliases.Contains("--scan-id") || o.Aliases.Contains("-s"));
|
||||
|
||||
// Assert - Check via arity (required options have min arity of 1)
|
||||
Assert.Equal(1, scanIdOption.Arity.MinimumNumberOfValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateResultsCommand_HasDecisionFilterOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildGateResultsCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var decisionOption = command.Options.FirstOrDefault(o =>
|
||||
o.Aliases.Contains("--decision") || o.Aliases.Contains("-d"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(decisionOption);
|
||||
Assert.Contains("Pass", decisionOption.Description);
|
||||
Assert.Contains("Warn", decisionOption.Description);
|
||||
Assert.Contains("Block", decisionOption.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateResultsCommand_HasOutputOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildGateResultsCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var outputOption = command.Options.FirstOrDefault(o =>
|
||||
o.Aliases.Contains("--output") || o.Aliases.Contains("-o"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(outputOption);
|
||||
Assert.Contains("table", outputOption.Description, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("json", outputOption.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateResultsCommand_HasLimitOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildGateResultsCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Act - look for limit option by -l alias
|
||||
var limitOption = command.Options.FirstOrDefault(o =>
|
||||
o.Aliases.Contains("-l"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(limitOption);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Command Structure Tests
|
||||
|
||||
[Fact]
|
||||
public void GatePolicyCommand_ShouldBeAddableToParentCommand()
|
||||
{
|
||||
// Arrange
|
||||
var scanCommand = new Command("scan", "Scanner operations");
|
||||
var gatePolicyCommand = VexGateScanCommandGroup.BuildVexGateCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
scanCommand.Add(gatePolicyCommand);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(scanCommand.Subcommands, c => c.Name == "gate-policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateResultsCommand_ShouldBeAddableToParentCommand()
|
||||
{
|
||||
// Arrange
|
||||
var scanCommand = new Command("scan", "Scanner operations");
|
||||
var gateResultsCommand = VexGateScanCommandGroup.BuildGateResultsCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
scanCommand.Add(gateResultsCommand);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(scanCommand.Subcommands, c => c.Name == "gate-results");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatePolicyCommand_HasHandler()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildVexGateCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
var showCommand = command.Subcommands.First(c => c.Name == "show");
|
||||
|
||||
// Assert - Handler is set via SetHandler in BuildGatePolicyShowCommand
|
||||
Assert.NotNull(showCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateResultsCommand_HasHandler()
|
||||
{
|
||||
// Arrange
|
||||
var command = VexGateScanCommandGroup.BuildGateResultsCommand(
|
||||
_services, _options, _verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert - Handler is set via SetHandler
|
||||
Assert.NotNull(command);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user