Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,932 @@
// -----------------------------------------------------------------------------
// RiskBudgetCommandGroup.cs
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
// Task: BUDGET-08, BUDGET-09 - CLI budget commands
// Description: CLI commands for risk budget status and consumption management
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Commands.Budget;
/// <summary>
/// Command group for risk budget operations.
/// Implements `stella budget` commands for managing risk budgets.
/// </summary>
public static class RiskBudgetCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the budget command tree.
/// </summary>
public static Command BuildBudgetCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var budgetCommand = new Command("budget", "Risk budget management for release gates");
budgetCommand.Add(BuildStatusCommand(services, verboseOption, cancellationToken));
budgetCommand.Add(BuildConsumeCommand(services, verboseOption, cancellationToken));
budgetCommand.Add(BuildCheckCommand(services, verboseOption, cancellationToken));
budgetCommand.Add(BuildHistoryCommand(services, verboseOption, cancellationToken));
budgetCommand.Add(BuildListCommand(services, verboseOption, cancellationToken));
return budgetCommand;
}
/// <summary>
/// BUDGET-08: stella budget status --service &lt;id&gt;
/// Shows current budget state for a service.
/// </summary>
private static Command BuildStatusCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var serviceOption = new Option<string>("--service", new[] { "-s" })
{
Description = "Service ID to show budget status for",
IsRequired = true
};
var windowOption = new Option<string?>("--window", new[] { "-w" })
{
Description = "Budget window (e.g., '2025-01' for monthly). Defaults to current window."
};
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output format: text, json"
};
outputOption.SetDefaultValue("text");
var statusCommand = new Command("status", "Show current risk budget status for a service");
statusCommand.Add(serviceOption);
statusCommand.Add(windowOption);
statusCommand.Add(outputOption);
statusCommand.Add(verboseOption);
statusCommand.SetAction(async (parseResult, ct) =>
{
var serviceId = parseResult.GetValue(serviceOption) ?? string.Empty;
var window = parseResult.GetValue(windowOption);
var output = parseResult.GetValue(outputOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return await HandleStatusAsync(
services,
serviceId,
window,
output,
verbose,
cancellationToken);
});
return statusCommand;
}
/// <summary>
/// BUDGET-09: stella budget consume --service &lt;id&gt; --points &lt;n&gt; --reason &lt;text&gt;
/// Manually consumes budget points for a service.
/// </summary>
private static Command BuildConsumeCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var serviceOption = new Option<string>("--service", new[] { "-s" })
{
Description = "Service ID to consume budget from",
IsRequired = true
};
var pointsOption = new Option<int>("--points", new[] { "-p" })
{
Description = "Number of risk points to consume",
IsRequired = true
};
var reasonOption = new Option<string>("--reason", new[] { "-r" })
{
Description = "Reason for manual budget consumption",
IsRequired = true
};
var releaseIdOption = new Option<string?>("--release-id")
{
Description = "Optional release ID to associate with consumption"
};
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output format: text, json"
};
outputOption.SetDefaultValue("text");
var consumeCommand = new Command("consume", "Manually consume risk budget points");
consumeCommand.Add(serviceOption);
consumeCommand.Add(pointsOption);
consumeCommand.Add(reasonOption);
consumeCommand.Add(releaseIdOption);
consumeCommand.Add(outputOption);
consumeCommand.Add(verboseOption);
consumeCommand.SetAction(async (parseResult, ct) =>
{
var serviceId = parseResult.GetValue(serviceOption) ?? string.Empty;
var points = parseResult.GetValue(pointsOption);
var reason = parseResult.GetValue(reasonOption) ?? string.Empty;
var releaseId = parseResult.GetValue(releaseIdOption);
var output = parseResult.GetValue(outputOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return await HandleConsumeAsync(
services,
serviceId,
points,
reason,
releaseId,
output,
verbose,
cancellationToken);
});
return consumeCommand;
}
/// <summary>
/// stella budget check --service &lt;id&gt; --points &lt;n&gt;
/// Checks if a release would exceed the budget without consuming.
/// </summary>
private static Command BuildCheckCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var serviceOption = new Option<string>("--service", new[] { "-s" })
{
Description = "Service ID to check budget for",
IsRequired = true
};
var pointsOption = new Option<int>("--points", new[] { "-p" })
{
Description = "Number of risk points to check",
IsRequired = true
};
var failOnExceedOption = new Option<bool>("--fail-on-exceed")
{
Description = "Exit with error code if budget would be exceeded"
};
failOnExceedOption.SetDefaultValue(true);
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output format: text, json"
};
outputOption.SetDefaultValue("text");
var checkCommand = new Command("check", "Check if a release would exceed risk budget");
checkCommand.Add(serviceOption);
checkCommand.Add(pointsOption);
checkCommand.Add(failOnExceedOption);
checkCommand.Add(outputOption);
checkCommand.Add(verboseOption);
checkCommand.SetAction(async (parseResult, ct) =>
{
var serviceId = parseResult.GetValue(serviceOption) ?? string.Empty;
var points = parseResult.GetValue(pointsOption);
var failOnExceed = parseResult.GetValue(failOnExceedOption);
var output = parseResult.GetValue(outputOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return await HandleCheckAsync(
services,
serviceId,
points,
failOnExceed,
output,
verbose,
cancellationToken);
});
return checkCommand;
}
/// <summary>
/// stella budget history --service &lt;id&gt;
/// Shows consumption history for a service.
/// </summary>
private static Command BuildHistoryCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var serviceOption = new Option<string>("--service", new[] { "-s" })
{
Description = "Service ID to show history for",
IsRequired = true
};
var windowOption = new Option<string?>("--window", new[] { "-w" })
{
Description = "Budget window to show history for"
};
var limitOption = new Option<int>("--limit", new[] { "-l" })
{
Description = "Maximum number of entries to return"
};
limitOption.SetDefaultValue(20);
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output format: text, json"
};
outputOption.SetDefaultValue("text");
var historyCommand = new Command("history", "Show risk budget consumption history");
historyCommand.Add(serviceOption);
historyCommand.Add(windowOption);
historyCommand.Add(limitOption);
historyCommand.Add(outputOption);
historyCommand.Add(verboseOption);
historyCommand.SetAction(async (parseResult, ct) =>
{
var serviceId = parseResult.GetValue(serviceOption) ?? string.Empty;
var window = parseResult.GetValue(windowOption);
var limit = parseResult.GetValue(limitOption);
var output = parseResult.GetValue(outputOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return await HandleHistoryAsync(
services,
serviceId,
window,
limit,
output,
verbose,
cancellationToken);
});
return historyCommand;
}
/// <summary>
/// stella budget list
/// Lists all service budgets.
/// </summary>
private static Command BuildListCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var statusOption = new Option<string?>("--status")
{
Description = "Filter by status: green, yellow, red, exhausted"
};
var tierOption = new Option<int?>("--tier")
{
Description = "Filter by service tier (1-5)"
};
var limitOption = new Option<int>("--limit", new[] { "-l" })
{
Description = "Maximum number of results to return"
};
limitOption.SetDefaultValue(50);
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output format: text, json"
};
outputOption.SetDefaultValue("text");
var listCommand = new Command("list", "List all service risk budgets");
listCommand.Add(statusOption);
listCommand.Add(tierOption);
listCommand.Add(limitOption);
listCommand.Add(outputOption);
listCommand.Add(verboseOption);
listCommand.SetAction(async (parseResult, ct) =>
{
var status = parseResult.GetValue(statusOption);
var tier = parseResult.GetValue(tierOption);
var limit = parseResult.GetValue(limitOption);
var output = parseResult.GetValue(outputOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return await HandleListAsync(
services,
status,
tier,
limit,
output,
verbose,
cancellationToken);
});
return listCommand;
}
#region Command Handlers
private static async Task<int> HandleStatusAsync(
IServiceProvider services,
string serviceId,
string? window,
string output,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(RiskBudgetCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory is null)
{
Console.Error.WriteLine("Error: HTTP client not available");
return 1;
}
try
{
if (verbose)
{
logger?.LogDebug("Getting budget status for service {ServiceId}", serviceId);
}
var client = httpClientFactory.CreateClient("PolicyApi");
var query = $"/api/v1/policy/risk-budget/status/{Uri.EscapeDataString(serviceId)}";
if (!string.IsNullOrEmpty(window))
{
query += $"?window={Uri.EscapeDataString(window)}";
}
var response = await client.GetAsync(query, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
logger?.LogError("Failed to get budget status: {Status}", response.StatusCode);
Console.Error.WriteLine($"Error: Failed to get budget status ({response.StatusCode})");
return 1;
}
var status = await response.Content.ReadFromJsonAsync<RiskBudgetStatusDto>(JsonOptions, ct);
if (status is null)
{
Console.Error.WriteLine("Error: Empty response from server");
return 1;
}
OutputStatus(status, output);
return 0;
}
catch (Exception ex)
{
logger?.LogError(ex, "Budget status failed unexpectedly");
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
private static async Task<int> HandleConsumeAsync(
IServiceProvider services,
string serviceId,
int points,
string reason,
string? releaseId,
string output,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(RiskBudgetCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory is null)
{
Console.Error.WriteLine("Error: HTTP client not available");
return 1;
}
try
{
if (verbose)
{
logger?.LogDebug("Consuming {Points} points from service {ServiceId}", points, serviceId);
}
var client = httpClientFactory.CreateClient("PolicyApi");
var request = new ConsumeRequest(serviceId, points, reason, releaseId);
var response = await client.PostAsJsonAsync(
"/api/v1/policy/risk-budget/consume",
request,
JsonOptions,
ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
logger?.LogError("Failed to consume budget: {Status} - {Error}", response.StatusCode, error);
Console.Error.WriteLine($"Error: Failed to consume budget ({response.StatusCode})");
return 1;
}
var result = await response.Content.ReadFromJsonAsync<ConsumeResultDto>(JsonOptions, ct);
if (result is null)
{
Console.Error.WriteLine("Error: Empty response from server");
return 1;
}
OutputConsumeResult(result, output);
return 0;
}
catch (Exception ex)
{
logger?.LogError(ex, "Budget consume failed unexpectedly");
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
private static async Task<int> HandleCheckAsync(
IServiceProvider services,
string serviceId,
int points,
bool failOnExceed,
string output,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(RiskBudgetCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory is null)
{
Console.Error.WriteLine("Error: HTTP client not available");
return 1;
}
try
{
if (verbose)
{
logger?.LogDebug("Checking if {Points} points would exceed budget for {ServiceId}", points, serviceId);
}
var client = httpClientFactory.CreateClient("PolicyApi");
var request = new CheckRequest(serviceId, points);
var response = await client.PostAsJsonAsync(
"/api/v1/policy/risk-budget/check",
request,
JsonOptions,
ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
logger?.LogError("Failed to check budget: {Status}", response.StatusCode);
Console.Error.WriteLine($"Error: Failed to check budget ({response.StatusCode})");
return 1;
}
var result = await response.Content.ReadFromJsonAsync<CheckResultDto>(JsonOptions, ct);
if (result is null)
{
Console.Error.WriteLine("Error: Empty response from server");
return 1;
}
OutputCheckResult(result, output);
if (failOnExceed && !result.Allowed)
{
return 2; // Distinct exit code for budget exceeded
}
return 0;
}
catch (Exception ex)
{
logger?.LogError(ex, "Budget check failed unexpectedly");
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
private static async Task<int> HandleHistoryAsync(
IServiceProvider services,
string serviceId,
string? window,
int limit,
string output,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(RiskBudgetCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory is null)
{
Console.Error.WriteLine("Error: HTTP client not available");
return 1;
}
try
{
if (verbose)
{
logger?.LogDebug("Getting budget history for service {ServiceId}", serviceId);
}
var client = httpClientFactory.CreateClient("PolicyApi");
var query = $"/api/v1/policy/risk-budget/history/{Uri.EscapeDataString(serviceId)}?limit={limit}";
if (!string.IsNullOrEmpty(window))
{
query += $"&window={Uri.EscapeDataString(window)}";
}
var response = await client.GetAsync(query, ct);
if (!response.IsSuccessStatusCode)
{
logger?.LogError("Failed to get budget history: {Status}", response.StatusCode);
Console.Error.WriteLine($"Error: Failed to get budget history ({response.StatusCode})");
return 1;
}
var history = await response.Content.ReadFromJsonAsync<HistoryResponseDto>(JsonOptions, ct);
if (history is null)
{
Console.Error.WriteLine("Error: Empty response from server");
return 1;
}
OutputHistory(history, output);
return 0;
}
catch (Exception ex)
{
logger?.LogError(ex, "Budget history failed unexpectedly");
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
private static async Task<int> HandleListAsync(
IServiceProvider services,
string? status,
int? tier,
int limit,
string output,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(RiskBudgetCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory is null)
{
Console.Error.WriteLine("Error: HTTP client not available");
return 1;
}
try
{
if (verbose)
{
logger?.LogDebug("Listing budgets with status={Status}, tier={Tier}", status, tier);
}
var client = httpClientFactory.CreateClient("PolicyApi");
var query = $"/api/v1/policy/risk-budget?limit={limit}";
if (!string.IsNullOrEmpty(status))
{
query += $"&status={Uri.EscapeDataString(status)}";
}
if (tier.HasValue)
{
query += $"&tier={tier.Value}";
}
var response = await client.GetAsync(query, ct);
if (!response.IsSuccessStatusCode)
{
logger?.LogError("Failed to list budgets: {Status}", response.StatusCode);
Console.Error.WriteLine($"Error: Failed to list budgets ({response.StatusCode})");
return 1;
}
var list = await response.Content.ReadFromJsonAsync<BudgetListResponseDto>(JsonOptions, ct);
if (list is null)
{
Console.Error.WriteLine("Error: Empty response from server");
return 1;
}
OutputList(list, output);
return 0;
}
catch (Exception ex)
{
logger?.LogError(ex, "Budget list failed unexpectedly");
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
#endregion
#region Output Formatters
private static void OutputStatus(RiskBudgetStatusDto status, string format)
{
if (format == "json")
{
Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions));
return;
}
var statusColor = status.Status?.ToLowerInvariant() switch
{
"green" => ConsoleColor.Green,
"yellow" => ConsoleColor.Yellow,
"red" => ConsoleColor.Red,
"exhausted" => ConsoleColor.DarkRed,
_ => ConsoleColor.White
};
Console.WriteLine("Risk Budget Status");
Console.WriteLine(new string('=', 50));
Console.WriteLine($" Service: {status.ServiceId}");
Console.WriteLine($" Window: {status.Window}");
Console.WriteLine($" Tier: {status.Tier}");
Console.WriteLine($" Allocated: {status.Allocated} points");
Console.WriteLine($" Consumed: {status.Consumed} points");
Console.WriteLine($" Remaining: {status.Remaining} points");
Console.WriteLine($" Usage: {status.PercentageUsed:F1}%");
Console.Write(" Status: ");
Console.ForegroundColor = statusColor;
Console.WriteLine(status.Status?.ToUpperInvariant() ?? "UNKNOWN");
Console.ResetColor();
if (status.LastConsumedAt.HasValue)
{
Console.WriteLine($" Last Used: {status.LastConsumedAt:yyyy-MM-dd HH:mm:ss}");
}
}
private static void OutputConsumeResult(ConsumeResultDto result, string format)
{
if (format == "json")
{
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return;
}
if (result.Success)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Budget consumed successfully.");
Console.ResetColor();
Console.WriteLine($" Entry ID: {result.EntryId}");
Console.WriteLine($" Consumed: {result.PointsConsumed} points");
Console.WriteLine($" Remaining: {result.RemainingBudget} points");
Console.WriteLine($" New Status: {result.NewStatus?.ToUpperInvariant()}");
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Budget consumption failed.");
Console.ResetColor();
Console.WriteLine($" Error: {result.Error}");
}
}
private static void OutputCheckResult(CheckResultDto result, string format)
{
if (format == "json")
{
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return;
}
var status = result.Allowed ? "[ALLOWED]" : "[BLOCKED]";
Console.ForegroundColor = result.Allowed ? ConsoleColor.Green : ConsoleColor.Red;
Console.WriteLine($"{status} Release budget check");
Console.ResetColor();
Console.WriteLine($" Service: {result.ServiceId}");
Console.WriteLine($" Requested: {result.RequestedPoints} points");
Console.WriteLine($" Current Used: {result.CurrentConsumed} points");
Console.WriteLine($" Budget Limit: {result.BudgetLimit} points");
Console.WriteLine($" Would Use: {result.CurrentConsumed + result.RequestedPoints} points");
if (!result.Allowed)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" Reason: {result.BlockReason}");
Console.ResetColor();
}
}
private static void OutputHistory(HistoryResponseDto history, string format)
{
if (format == "json")
{
Console.WriteLine(JsonSerializer.Serialize(history, JsonOptions));
return;
}
Console.WriteLine($"Budget History: {history.ServiceId}");
Console.WriteLine(new string('=', 80));
if (history.Entries.Count == 0)
{
Console.WriteLine(" No consumption history found.");
return;
}
// Header
Console.WriteLine($"{"DATE",-20} {"POINTS",-8} {"REASON",-30} {"RELEASE"}");
Console.WriteLine(new string('-', 80));
foreach (var entry in history.Entries)
{
var date = entry.ConsumedAt.ToString("yyyy-MM-dd HH:mm");
var reason = entry.Reason?.Length > 30
? entry.Reason[..27] + "..."
: entry.Reason ?? "-";
var release = entry.ReleaseId ?? "-";
Console.WriteLine($"{date,-20} {entry.Points,-8} {reason,-30} {release}");
}
Console.WriteLine(new string('-', 80));
Console.WriteLine($"Total entries: {history.TotalCount}");
}
private static void OutputList(BudgetListResponseDto list, string format)
{
if (format == "json")
{
Console.WriteLine(JsonSerializer.Serialize(list, JsonOptions));
return;
}
Console.WriteLine($"Risk Budgets ({list.TotalCount} total, showing {list.Budgets.Count})");
Console.WriteLine(new string('=', 90));
if (list.Budgets.Count == 0)
{
Console.WriteLine(" No budgets found.");
return;
}
// Header
Console.WriteLine($"{"SERVICE",-30} {"TIER",-5} {"CONSUMED",-10} {"ALLOCATED",-10} {"STATUS",-10} {"USAGE"}");
Console.WriteLine(new string('-', 90));
foreach (var budget in list.Budgets)
{
var serviceId = budget.ServiceId.Length > 28
? budget.ServiceId[..25] + "..."
: budget.ServiceId;
var statusColor = budget.Status?.ToLowerInvariant() switch
{
"green" => ConsoleColor.Green,
"yellow" => ConsoleColor.Yellow,
"red" => ConsoleColor.Red,
"exhausted" => ConsoleColor.DarkRed,
_ => ConsoleColor.White
};
Console.Write($"{serviceId,-30} {budget.Tier,-5} {budget.Consumed,-10} {budget.Allocated,-10} ");
Console.ForegroundColor = statusColor;
Console.Write($"{budget.Status?.ToUpperInvariant(),-10}");
Console.ResetColor();
Console.WriteLine($" {budget.PercentageUsed:F1}%");
}
Console.WriteLine(new string('-', 90));
// Summary by status
var byStatus = list.Budgets.GroupBy(b => b.Status ?? "unknown").OrderBy(g => g.Key);
Console.WriteLine($"Summary: {string.Join(", ", byStatus.Select(g => $"{g.Key}: {g.Count()}"))}");
}
#endregion
#region DTOs
private sealed record ConsumeRequest(
string ServiceId,
int Points,
string Reason,
string? ReleaseId);
private sealed record CheckRequest(
string ServiceId,
int Points);
private sealed record RiskBudgetStatusDto
{
public string ServiceId { get; init; } = string.Empty;
public string? Window { get; init; }
public int Tier { get; init; }
public int Allocated { get; init; }
public int Consumed { get; init; }
public int Remaining { get; init; }
public decimal PercentageUsed { get; init; }
public string? Status { get; init; }
public DateTimeOffset? LastConsumedAt { get; init; }
}
private sealed record ConsumeResultDto
{
public bool Success { get; init; }
public string? EntryId { get; init; }
public int PointsConsumed { get; init; }
public int RemainingBudget { get; init; }
public string? NewStatus { get; init; }
public string? Error { get; init; }
}
private sealed record CheckResultDto
{
public string ServiceId { get; init; } = string.Empty;
public int RequestedPoints { get; init; }
public int CurrentConsumed { get; init; }
public int BudgetLimit { get; init; }
public bool Allowed { get; init; }
public string? BlockReason { get; init; }
}
private sealed record HistoryResponseDto
{
public string ServiceId { get; init; } = string.Empty;
public IReadOnlyList<HistoryEntryDto> Entries { get; init; } = [];
public int TotalCount { get; init; }
}
private sealed record HistoryEntryDto
{
public string EntryId { get; init; } = string.Empty;
public int Points { get; init; }
public string? Reason { get; init; }
public string? ReleaseId { get; init; }
public DateTimeOffset ConsumedAt { get; init; }
}
private sealed record BudgetListResponseDto
{
public IReadOnlyList<BudgetSummaryDto> Budgets { get; init; } = [];
public int TotalCount { get; init; }
}
private sealed record BudgetSummaryDto
{
public string ServiceId { get; init; } = string.Empty;
public int Tier { get; init; }
public int Allocated { get; init; }
public int Consumed { get; init; }
public decimal PercentageUsed { get; init; }
public string? Status { get; init; }
}
#endregion
}

View File

@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Commands.Admin;
using StellaOps.Cli.Commands.Budget;
using StellaOps.Cli.Commands.Proof;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
@@ -97,8 +98,12 @@ internal static class CommandFactory
root.Add(ProofCommandGroup.BuildProofCommand(services, verboseOption, cancellationToken));
root.Add(ReplayCommandGroup.BuildReplayCommand(services, verboseOption, cancellationToken));
root.Add(DeltaCommandGroup.BuildDeltaCommand(verboseOption, cancellationToken));
root.Add(RiskBudgetCommandGroup.BuildBudgetCommand(services, verboseOption, cancellationToken));
root.Add(ReachabilityCommandGroup.BuildReachabilityCommand(services, verboseOption, cancellationToken));
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration - Gate evaluation command
root.Add(GateCommandGroup.BuildGateCommand(services, options, verboseOption, cancellationToken));
// Sprint: SPRINT_8200_0014_0002 - Federation bundle export
root.Add(FederationCommandGroup.BuildFeedserCommand(services, verboseOption, cancellationToken));

View File

@@ -0,0 +1,556 @@
// -----------------------------------------------------------------------------
// CommandHandlers.Feeds.cs
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
// Task: DET-GAP-04
// Description: Command handlers for feed snapshot operations.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
namespace StellaOps.Cli.Commands;
internal static partial class CommandHandlers
{
private static readonly JsonSerializerOptions FeedsJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
internal static async Task<int> HandleFeedsSnapshotCreateAsync(
IServiceProvider services,
string? label,
string[]? sources,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
if (verbose)
{
AnsiConsole.MarkupLine("[blue]Creating feed snapshot...[/]");
if (!string.IsNullOrEmpty(label))
AnsiConsole.MarkupLine($" Label: [bold]{Markup.Escape(label)}[/]");
if (sources?.Length > 0)
AnsiConsole.MarkupLine($" Sources: [bold]{string.Join(", ", sources)}[/]");
}
try
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
return 1;
}
var client = httpClientFactory.CreateClient("Concelier");
var request = new
{
label,
sources
};
var content = new StringContent(
JsonSerializer.Serialize(request, FeedsJsonOptions),
System.Text.Encoding.UTF8,
"application/json");
using var response = await client.PostAsync("/api/v1/feeds/snapshot", content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(error)}[/]");
}
return 1;
}
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (json)
{
AnsiConsole.WriteLine(responseText);
}
else
{
var result = JsonSerializer.Deserialize<CreateSnapshotResponse>(responseText, FeedsJsonOptions);
if (result != null)
{
AnsiConsole.MarkupLine("[green]✓[/] Snapshot created successfully");
AnsiConsole.MarkupLine($" Snapshot ID: [bold]{result.SnapshotId}[/]");
AnsiConsole.MarkupLine($" Digest: [cyan]{result.CompositeDigest}[/]");
AnsiConsole.MarkupLine($" Created: {result.CreatedAt:u}");
AnsiConsole.MarkupLine($" Sources: {result.Sources?.Length ?? 0}");
if (result.Sources?.Length > 0)
{
var table = new Table()
.AddColumn("Source")
.AddColumn("Digest")
.AddColumn("Items");
foreach (var source in result.Sources)
{
table.AddRow(
source.SourceId ?? "-",
source.Digest?.Substring(0, Math.Min(16, source.Digest.Length)) + "..." ?? "-",
source.ItemCount.ToString());
}
AnsiConsole.Write(table);
}
}
}
return 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return 1;
}
}
internal static async Task<int> HandleFeedsSnapshotListAsync(
IServiceProvider services,
int limit,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
if (verbose)
{
AnsiConsole.MarkupLine("[blue]Listing feed snapshots...[/]");
AnsiConsole.MarkupLine($" Limit: {limit}");
}
try
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
return 1;
}
var client = httpClientFactory.CreateClient("Concelier");
using var response = await client.GetAsync($"/api/v1/feeds/snapshot?limit={limit}", cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(error)}[/]");
}
return 1;
}
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (json)
{
AnsiConsole.WriteLine(responseText);
}
else
{
var result = JsonSerializer.Deserialize<ListSnapshotsResponse>(responseText, FeedsJsonOptions);
if (result?.Snapshots != null)
{
var table = new Table()
.Title("Feed Snapshots")
.AddColumn("ID")
.AddColumn("Digest")
.AddColumn("Label")
.AddColumn("Created")
.AddColumn("Sources")
.AddColumn("Items");
foreach (var snapshot in result.Snapshots)
{
table.AddRow(
snapshot.SnapshotId ?? "-",
snapshot.CompositeDigest?.Substring(0, Math.Min(16, snapshot.CompositeDigest.Length)) + "..." ?? "-",
snapshot.Label ?? "-",
snapshot.CreatedAt.ToString("u"),
snapshot.SourceCount.ToString(),
snapshot.TotalItemCount.ToString());
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[grey]Total: {result.Snapshots.Length} snapshots[/]");
}
}
return 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return 1;
}
}
internal static async Task<int> HandleFeedsSnapshotExportAsync(
IServiceProvider services,
string snapshotId,
string output,
string? compression,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
if (verbose)
{
AnsiConsole.MarkupLine("[blue]Exporting feed snapshot...[/]");
AnsiConsole.MarkupLine($" Snapshot: [bold]{Markup.Escape(snapshotId)}[/]");
AnsiConsole.MarkupLine($" Output: [bold]{Markup.Escape(output)}[/]");
AnsiConsole.MarkupLine($" Compression: {compression ?? "zstd"}");
}
try
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
return 1;
}
var client = httpClientFactory.CreateClient("Concelier");
var format = compression ?? "zstd";
var url = $"/api/v1/feeds/snapshot/{Uri.EscapeDataString(snapshotId)}/export?format={format}";
await AnsiConsole.Progress()
.StartAsync(async ctx =>
{
var task = ctx.AddTask("[green]Downloading snapshot bundle[/]");
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
throw new CommandLineException($"Export failed: {response.StatusCode} - {error}");
}
var totalBytes = response.Content.Headers.ContentLength ?? 0;
task.MaxValue = totalBytes > 0 ? totalBytes : 100;
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var fileStream = File.Create(output);
var buffer = new byte[81920];
long totalRead = 0;
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalRead += bytesRead;
task.Value = totalBytes > 0 ? totalRead : Math.Min(totalRead, 100);
}
task.Value = task.MaxValue;
});
var fileInfo = new FileInfo(output);
if (json)
{
var metadata = new
{
snapshotId,
outputPath = output,
sizeBytes = fileInfo.Length,
compression = compression ?? "zstd"
};
AnsiConsole.WriteLine(JsonSerializer.Serialize(metadata, FeedsJsonOptions));
}
else
{
AnsiConsole.MarkupLine("[green]✓[/] Snapshot exported successfully");
AnsiConsole.MarkupLine($" Output: [bold]{output}[/]");
AnsiConsole.MarkupLine($" Size: {FormatBytes(fileInfo.Length)}");
}
return 0;
}
catch (CommandLineException ex)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
return 1;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return 1;
}
}
internal static async Task<int> HandleFeedsSnapshotImportAsync(
IServiceProvider services,
string input,
bool validate,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
if (verbose)
{
AnsiConsole.MarkupLine("[blue]Importing feed snapshot...[/]");
AnsiConsole.MarkupLine($" Input: [bold]{Markup.Escape(input)}[/]");
AnsiConsole.MarkupLine($" Validate: {validate}");
}
if (!File.Exists(input))
{
AnsiConsole.MarkupLine($"[red]Error: File not found: {Markup.Escape(input)}[/]");
return 1;
}
try
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
return 1;
}
var client = httpClientFactory.CreateClient("Concelier");
await using var fileStream = File.OpenRead(input);
var content = new StreamContent(fileStream);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
var form = new MultipartFormDataContent
{
{ content, "file", Path.GetFileName(input) }
};
var url = $"/api/v1/feeds/snapshot/import?validate={validate.ToString().ToLowerInvariant()}";
using var response = await client.PostAsync(url, form, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(error)}[/]");
}
return 1;
}
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (json)
{
AnsiConsole.WriteLine(responseText);
}
else
{
var result = JsonSerializer.Deserialize<ImportSnapshotResponse>(responseText, FeedsJsonOptions);
if (result != null)
{
AnsiConsole.MarkupLine("[green]✓[/] Snapshot imported successfully");
AnsiConsole.MarkupLine($" Snapshot ID: [bold]{result.SnapshotId}[/]");
AnsiConsole.MarkupLine($" Digest: [cyan]{result.CompositeDigest}[/]");
AnsiConsole.MarkupLine($" Sources: {result.SourceCount}");
}
}
return 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return 1;
}
}
internal static async Task<int> HandleFeedsSnapshotValidateAsync(
IServiceProvider services,
string snapshotId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
if (verbose)
{
AnsiConsole.MarkupLine("[blue]Validating feed snapshot...[/]");
AnsiConsole.MarkupLine($" Snapshot: [bold]{Markup.Escape(snapshotId)}[/]");
}
try
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
if (httpClientFactory == null)
{
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
return 1;
}
var client = httpClientFactory.CreateClient("Concelier");
var url = $"/api/v1/feeds/snapshot/{Uri.EscapeDataString(snapshotId)}/validate";
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(error)}[/]");
}
return 1;
}
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (json)
{
AnsiConsole.WriteLine(responseText);
}
else
{
var result = JsonSerializer.Deserialize<ValidateSnapshotResponse>(responseText, FeedsJsonOptions);
if (result != null)
{
if (result.IsValid)
{
AnsiConsole.MarkupLine("[green]✓[/] Snapshot is valid and can be replayed");
AnsiConsole.MarkupLine($" Snapshot Digest: [cyan]{result.SnapshotDigest}[/]");
AnsiConsole.MarkupLine($" Current Digest: [cyan]{result.CurrentDigest}[/]");
}
else
{
AnsiConsole.MarkupLine("[red]✗[/] Snapshot has drifted from current state");
AnsiConsole.MarkupLine($" Snapshot Digest: [cyan]{result.SnapshotDigest}[/]");
AnsiConsole.MarkupLine($" Current Digest: [yellow]{result.CurrentDigest}[/]");
if (result.DriftedSources?.Length > 0)
{
AnsiConsole.MarkupLine("\n[yellow]Drifted Sources:[/]");
var table = new Table()
.AddColumn("Source")
.AddColumn("Snapshot Digest")
.AddColumn("Current Digest")
.AddColumn("+Added")
.AddColumn("-Removed")
.AddColumn("~Modified");
foreach (var drift in result.DriftedSources)
{
table.AddRow(
drift.SourceId ?? "-",
drift.SnapshotDigest?.Substring(0, 12) + "..." ?? "-",
drift.CurrentDigest?.Substring(0, 12) + "..." ?? "-",
$"[green]+{drift.AddedItems}[/]",
$"[red]-{drift.RemovedItems}[/]",
$"[yellow]~{drift.ModifiedItems}[/]");
}
AnsiConsole.Write(table);
}
}
}
}
return 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return 1;
}
}
private static string FormatBytes(long bytes)
{
string[] sizes = ["B", "KB", "MB", "GB", "TB"];
int order = 0;
double size = bytes;
while (size >= 1024 && order < sizes.Length - 1)
{
order++;
size /= 1024;
}
return $"{size:0.##} {sizes[order]}";
}
// DTO types for JSON deserialization
private sealed record CreateSnapshotResponse(
string SnapshotId,
string CompositeDigest,
DateTimeOffset CreatedAt,
SourceSnapshotSummary[]? Sources);
private sealed record SourceSnapshotSummary(
string SourceId,
string Digest,
int ItemCount);
private sealed record ListSnapshotsResponse(
SnapshotListItem[] Snapshots);
private sealed record SnapshotListItem(
string SnapshotId,
string CompositeDigest,
string? Label,
DateTimeOffset CreatedAt,
int SourceCount,
int TotalItemCount);
private sealed record ImportSnapshotResponse(
string SnapshotId,
string CompositeDigest,
DateTimeOffset CreatedAt,
int SourceCount);
private sealed record ValidateSnapshotResponse(
bool IsValid,
string SnapshotDigest,
string CurrentDigest,
DriftedSourceInfo[]? DriftedSources);
private sealed record DriftedSourceInfo(
string SourceId,
string SnapshotDigest,
string CurrentDigest,
int AddedItems,
int RemovedItems,
int ModifiedItems);
}

View File

@@ -0,0 +1,344 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
// Task: DET-GAP-08 - CLI handlers for keyless signing
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
using StellaOps.Cli.Output;
using StellaOps.Signer.Infrastructure.Sigstore;
namespace StellaOps.Cli.Commands;
internal static partial class CommandHandlers
{
/// <summary>
/// Handle keyless signing via Sigstore (Fulcio + Rekor).
/// </summary>
public static async Task<int> HandleSignKeylessAsync(
IServiceProvider services,
string input,
string? output,
string? identityToken,
bool useRekor,
string? fulcioUrl,
string? rekorUrl,
string? oidcIssuer,
string bundleFormat,
string? caBundle,
bool insecure,
bool verbose,
CancellationToken cancellationToken)
{
if (!File.Exists(input))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Input file not found: {input}");
return CliExitCodes.InputFileNotFound;
}
try
{
// Resolve output path
var outputPath = output ?? $"{input}.sigstore";
// Get or detect identity token
var token = identityToken ?? await DetectAmbientIdentityTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token))
{
AnsiConsole.MarkupLine("[red]Error:[/] No identity token provided and ambient detection failed.");
AnsiConsole.MarkupLine("[dim]Provide --identity-token or run in a CI environment with OIDC support.[/]");
return CliExitCodes.MissingRequiredOption;
}
// Read artifact
var artifactBytes = await File.ReadAllBytesAsync(input, cancellationToken);
if (verbose)
{
AnsiConsole.MarkupLine($"[dim]Input:[/] {input} ({artifactBytes.Length} bytes)");
AnsiConsole.MarkupLine($"[dim]Output:[/] {outputPath}");
AnsiConsole.MarkupLine($"[dim]Rekor:[/] {(useRekor ? "enabled" : "disabled")}");
if (fulcioUrl != null) AnsiConsole.MarkupLine($"[dim]Fulcio URL:[/] {fulcioUrl}");
if (rekorUrl != null) AnsiConsole.MarkupLine($"[dim]Rekor URL:[/] {rekorUrl}");
}
// Get signing service (with option overrides)
var sigstoreService = services.GetService<ISigstoreSigningService>();
if (sigstoreService is null)
{
AnsiConsole.MarkupLine("[red]Error:[/] Sigstore signing service not configured.");
AnsiConsole.MarkupLine("[dim]Ensure Sigstore is enabled in configuration.[/]");
return CliExitCodes.ServiceNotConfigured;
}
AnsiConsole.MarkupLine("[blue]Signing artifact with Sigstore keyless signing...[/]");
var result = await sigstoreService.SignKeylessAsync(
artifactBytes,
token,
cancellationToken);
// Write bundle based on format
var bundle = CreateSignatureBundle(result, bundleFormat);
await File.WriteAllTextAsync(outputPath, bundle, cancellationToken);
AnsiConsole.MarkupLine($"[green]✓[/] Signature bundle written to: [cyan]{outputPath}[/]");
AnsiConsole.MarkupLine($"[dim]Subject:[/] {result.Certificate.Subject}");
AnsiConsole.MarkupLine($"[dim]Issuer:[/] {result.Certificate.Issuer}");
AnsiConsole.MarkupLine($"[dim]Certificate expires:[/] {result.Certificate.ExpiresAtUtc:u}");
if (result.RekorEntry != null)
{
AnsiConsole.MarkupLine($"[dim]Rekor log index:[/] {result.RekorEntry.LogIndex}");
AnsiConsole.MarkupLine($"[dim]Rekor UUID:[/] {result.RekorEntry.Uuid}");
}
return CliExitCodes.Success;
}
catch (SigstoreException ex)
{
AnsiConsole.MarkupLine($"[red]Sigstore error:[/] {ex.Message}");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return CliExitCodes.SigningFailed;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return CliExitCodes.UnexpectedError;
}
}
/// <summary>
/// Handle keyless signature verification.
/// </summary>
public static async Task<int> HandleVerifyKeylessAsync(
IServiceProvider services,
string input,
string? bundlePath,
string? certificatePath,
string? signaturePath,
string? rekorUuid,
string? rekorUrl,
string? expectedIssuer,
string? expectedSubject,
string? caBundle,
bool verbose,
CancellationToken cancellationToken)
{
if (!File.Exists(input))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Input file not found: {input}");
return CliExitCodes.InputFileNotFound;
}
try
{
// Resolve bundle or certificate+signature paths
var resolvedBundlePath = bundlePath ?? $"{input}.sigstore";
string certificate;
byte[] signature;
if (File.Exists(resolvedBundlePath))
{
// Parse bundle
var bundleJson = await File.ReadAllTextAsync(resolvedBundlePath, cancellationToken);
var bundle = JsonDocument.Parse(bundleJson);
certificate = bundle.RootElement.GetProperty("certificate").GetString() ?? string.Empty;
var sigBase64 = bundle.RootElement.GetProperty("signature").GetString() ?? string.Empty;
signature = Convert.FromBase64String(sigBase64);
if (bundle.RootElement.TryGetProperty("rekorEntry", out var rekorEntry))
{
rekorUuid ??= rekorEntry.GetProperty("uuid").GetString();
}
}
else if (certificatePath != null && signaturePath != null)
{
certificate = await File.ReadAllTextAsync(certificatePath, cancellationToken);
signature = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
}
else
{
AnsiConsole.MarkupLine("[red]Error:[/] No bundle found and --certificate/--signature not provided.");
return CliExitCodes.MissingRequiredOption;
}
var artifactBytes = await File.ReadAllBytesAsync(input, cancellationToken);
if (verbose)
{
AnsiConsole.MarkupLine($"[dim]Input:[/] {input} ({artifactBytes.Length} bytes)");
AnsiConsole.MarkupLine($"[dim]Certificate:[/] {(certificatePath ?? resolvedBundlePath)}");
if (rekorUuid != null) AnsiConsole.MarkupLine($"[dim]Rekor UUID:[/] {rekorUuid}");
}
var sigstoreService = services.GetService<ISigstoreSigningService>();
if (sigstoreService is null)
{
AnsiConsole.MarkupLine("[red]Error:[/] Sigstore signing service not configured.");
return CliExitCodes.ServiceNotConfigured;
}
AnsiConsole.MarkupLine("[blue]Verifying keyless signature...[/]");
var isValid = await sigstoreService.VerifyKeylessAsync(
artifactBytes,
signature,
certificate,
rekorUuid,
cancellationToken);
if (isValid)
{
AnsiConsole.MarkupLine("[green]✓[/] Signature verification [green]PASSED[/]");
// Additional policy checks
if (expectedIssuer != null || expectedSubject != null)
{
var cert = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificate);
var (subject, issuer) = ExtractCertificateIdentity(cert);
if (expectedIssuer != null && !string.Equals(issuer, expectedIssuer, StringComparison.OrdinalIgnoreCase))
{
AnsiConsole.MarkupLine($"[yellow]⚠[/] Issuer mismatch: expected '{expectedIssuer}', got '{issuer}'");
return CliExitCodes.PolicyViolation;
}
if (expectedSubject != null && !subject.Contains(expectedSubject, StringComparison.OrdinalIgnoreCase))
{
AnsiConsole.MarkupLine($"[yellow]⚠[/] Subject mismatch: expected '{expectedSubject}', got '{subject}'");
return CliExitCodes.PolicyViolation;
}
AnsiConsole.MarkupLine($"[dim]Subject:[/] {subject}");
AnsiConsole.MarkupLine($"[dim]Issuer:[/] {issuer}");
}
return CliExitCodes.Success;
}
else
{
AnsiConsole.MarkupLine("[red]✗[/] Signature verification [red]FAILED[/]");
return CliExitCodes.VerificationFailed;
}
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return CliExitCodes.UnexpectedError;
}
}
/// <summary>
/// Attempts to detect ambient identity token from CI environment.
/// </summary>
private static Task<string?> DetectAmbientIdentityTokenAsync(CancellationToken cancellationToken)
{
// Check common CI environment variables for OIDC tokens
// Gitea Actions
var giteaToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
if (!string.IsNullOrEmpty(giteaToken))
{
return Task.FromResult<string?>(giteaToken);
}
// GitHub Actions
var githubToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
if (!string.IsNullOrEmpty(githubToken))
{
return Task.FromResult<string?>(githubToken);
}
// GitLab CI
var gitlabToken = Environment.GetEnvironmentVariable("CI_JOB_JWT_V2")
?? Environment.GetEnvironmentVariable("CI_JOB_JWT");
if (!string.IsNullOrEmpty(gitlabToken))
{
return Task.FromResult<string?>(gitlabToken);
}
// Kubernetes service account token
var k8sTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
if (File.Exists(k8sTokenPath))
{
var k8sToken = File.ReadAllText(k8sTokenPath);
return Task.FromResult<string?>(k8sToken);
}
return Task.FromResult<string?>(null);
}
/// <summary>
/// Creates signature bundle in specified format.
/// </summary>
private static string CreateSignatureBundle(SigstoreSigningResult result, string format)
{
var bundle = new
{
mediaType = "application/vnd.dev.sigstore.bundle+json;version=0.2",
certificate = result.Certificate.Certificate,
certificateChain = result.Certificate.CertificateChain,
signature = result.Signature,
publicKey = result.PublicKey,
algorithm = result.Algorithm,
sct = result.Certificate.SignedCertificateTimestamp,
rekorEntry = result.RekorEntry is not null ? new
{
uuid = result.RekorEntry.Uuid,
logIndex = result.RekorEntry.LogIndex,
integratedTime = result.RekorEntry.IntegratedTime,
logId = result.RekorEntry.LogId,
signedEntryTimestamp = result.RekorEntry.SignedEntryTimestamp
} : null,
signedAt = DateTimeOffset.UtcNow.ToString("o"),
subject = result.Certificate.Subject,
issuer = result.Certificate.Issuer
};
return JsonSerializer.Serialize(bundle, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
}
/// <summary>
/// Extracts OIDC identity from Fulcio certificate.
/// </summary>
private static (string Subject, string Issuer) ExtractCertificateIdentity(
System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
{
var issuer = "unknown";
var subject = cert.Subject;
foreach (var ext in cert.Extensions)
{
// Fulcio OIDC issuer extension
if (ext.Oid?.Value == "1.3.6.1.4.1.57264.1.1")
{
issuer = System.Text.Encoding.UTF8.GetString(ext.RawData).Trim('\0');
}
// Fulcio OIDC subject extension
else if (ext.Oid?.Value == "1.3.6.1.4.1.57264.1.7")
{
subject = System.Text.Encoding.UTF8.GetString(ext.RawData).Trim('\0');
}
}
return (subject, issuer);
}
}

View File

@@ -0,0 +1,281 @@
// -----------------------------------------------------------------------------
// FeedsCommandGroup.cs
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
// Task: DET-GAP-04
// Description: CLI commands for feed snapshot operations for offline/deterministic replay.
// -----------------------------------------------------------------------------
using System.CommandLine;
using StellaOps.Cli.Extensions;
namespace StellaOps.Cli.Commands;
/// <summary>
/// CLI commands for feed snapshot operations.
/// Per DET-GAP-04 in SPRINT_20251226_007_BE_determinism_gaps.
/// </summary>
internal static class FeedsCommandGroup
{
/// <summary>
/// Builds the feeds command group.
/// </summary>
internal static Command BuildFeedsCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var feeds = new Command("feeds", "Feed snapshot operations for deterministic replay.");
feeds.Add(BuildSnapshotCommand(services, verboseOption, cancellationToken));
return feeds;
}
private static Command BuildSnapshotCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var snapshot = new Command("snapshot", "Feed snapshot operations.");
snapshot.Add(BuildSnapshotCreateCommand(services, verboseOption, cancellationToken));
snapshot.Add(BuildSnapshotListCommand(services, verboseOption, cancellationToken));
snapshot.Add(BuildSnapshotExportCommand(services, verboseOption, cancellationToken));
snapshot.Add(BuildSnapshotImportCommand(services, verboseOption, cancellationToken));
snapshot.Add(BuildSnapshotValidateCommand(services, verboseOption, cancellationToken));
return snapshot;
}
private static Command BuildSnapshotCreateCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var labelOption = new Option<string?>("--label", new[] { "-l" })
{
Description = "Human-readable label for the snapshot."
};
var sourcesOption = new Option<string[]?>("--sources", new[] { "-s" })
{
Description = "Specific feed sources to include (default: all).",
AllowMultipleArgumentsPerToken = true
};
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var command = new Command("create", "Create an atomic feed snapshot.")
{
labelOption,
sourcesOption,
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var label = parseResult.GetValue(labelOption);
var sources = parseResult.GetValue(sourcesOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleFeedsSnapshotCreateAsync(
services,
label,
sources,
json,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildSnapshotListCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var limitOption = new Option<int>("--limit", new[] { "-n" })
{
Description = "Maximum number of snapshots to list."
};
limitOption.SetDefaultValue(25);
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var command = new Command("list", "List available feed snapshots.")
{
limitOption,
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var limit = parseResult.GetValue(limitOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleFeedsSnapshotListAsync(
services,
limit,
json,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildSnapshotExportCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var snapshotIdArgument = new Argument<string>("snapshot-id")
{
Description = "Snapshot ID or composite digest."
};
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output file path.",
IsRequired = true
};
var compressionOption = new Option<string>("--compression", new[] { "-c" })
{
Description = "Compression algorithm (zstd, gzip, none)."
};
compressionOption.SetDefaultValue("zstd");
var jsonOption = new Option<bool>("--json")
{
Description = "Output metadata as JSON."
};
var command = new Command("export", "Export a feed snapshot bundle for offline use.")
{
snapshotIdArgument,
outputOption,
compressionOption,
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var snapshotId = parseResult.GetValue(snapshotIdArgument);
var output = parseResult.GetValue(outputOption);
var compression = parseResult.GetValue(compressionOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleFeedsSnapshotExportAsync(
services,
snapshotId,
output!,
compression,
json,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildSnapshotImportCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var inputArgument = new Argument<string>("input-file")
{
Description = "Path to the snapshot bundle file."
};
var validateOption = new Option<bool>("--validate")
{
Description = "Validate digests during import."
};
validateOption.SetDefaultValue(true);
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var command = new Command("import", "Import a feed snapshot bundle.")
{
inputArgument,
validateOption,
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var input = parseResult.GetValue(inputArgument);
var validate = parseResult.GetValue(validateOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleFeedsSnapshotImportAsync(
services,
input,
validate,
json,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildSnapshotValidateCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var snapshotIdArgument = new Argument<string>("snapshot-id")
{
Description = "Snapshot ID or composite digest to validate."
};
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var command = new Command("validate", "Validate a feed snapshot for drift.")
{
snapshotIdArgument,
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var snapshotId = parseResult.GetValue(snapshotIdArgument);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleFeedsSnapshotValidateAsync(
services,
snapshotId,
json,
verbose,
cancellationToken);
});
return command;
}
}

View File

@@ -0,0 +1,631 @@
// -----------------------------------------------------------------------------
// GateCommandGroup.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-05 - CLI command stella gate evaluate
// Description: CLI commands for CI/CD gate evaluation
// -----------------------------------------------------------------------------
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 CI/CD gate evaluation.
/// Implements `stella gate evaluate` for release gating in CI pipelines.
/// </summary>
public static class GateCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the gate command group.
/// </summary>
public static Command BuildGateCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var gate = new Command("gate", "CI/CD release gate operations");
gate.Add(BuildEvaluateCommand(services, options, verboseOption, cancellationToken));
gate.Add(BuildStatusCommand(services, options, verboseOption, cancellationToken));
return gate;
}
private static Command BuildEvaluateCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image", "-i")
{
Description = "Image digest to evaluate (e.g., sha256:abc123...)",
Required = true
};
var baselineOption = new Option<string?>("--baseline", "-b")
{
Description = "Baseline reference for comparison (snapshot ID, digest, or 'last-approved')"
};
var policyOption = new Option<string?>("--policy", "-p")
{
Description = "Policy ID to use for evaluation"
};
var overrideOption = new Option<bool>("--allow-override")
{
Description = "Allow override of blocking gates"
};
var justificationOption = new Option<string?>("--justification", "-j")
{
Description = "Justification for override (required if --allow-override is used)"
};
var branchOption = new Option<string?>("--branch")
{
Description = "Git branch name for context"
};
var commitOption = new Option<string?>("--commit")
{
Description = "Git commit SHA for context"
};
var pipelineOption = new Option<string?>("--pipeline")
{
Description = "CI/CD pipeline ID for tracking"
};
var envOption = new Option<string?>("--env")
{
Description = "Target environment (e.g., production, staging)"
};
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output format: table (default), json, exit-code-only"
};
var timeoutOption = new Option<int?>("--timeout")
{
Description = "Request timeout in seconds (default: 60)"
};
var evaluate = new Command("evaluate", "Evaluate a CI/CD gate for an image")
{
imageOption,
baselineOption,
policyOption,
overrideOption,
justificationOption,
branchOption,
commitOption,
pipelineOption,
envOption,
outputOption,
timeoutOption,
verboseOption
};
evaluate.SetAction(async (parseResult, _) =>
{
var image = parseResult.GetValue(imageOption) ?? string.Empty;
var baseline = parseResult.GetValue(baselineOption);
var policy = parseResult.GetValue(policyOption);
var allowOverride = parseResult.GetValue(overrideOption);
var justification = parseResult.GetValue(justificationOption);
var branch = parseResult.GetValue(branchOption);
var commit = parseResult.GetValue(commitOption);
var pipeline = parseResult.GetValue(pipelineOption);
var env = parseResult.GetValue(envOption);
var output = parseResult.GetValue(outputOption) ?? "table";
var timeout = parseResult.GetValue(timeoutOption) ?? 60;
var verbose = parseResult.GetValue(verboseOption);
return await HandleEvaluateAsync(
services,
options,
image,
baseline,
policy,
allowOverride,
justification,
branch,
commit,
pipeline,
env,
output,
timeout,
verbose,
cancellationToken);
});
return evaluate;
}
private static Command BuildStatusCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var decisionIdOption = new Option<string>("--decision-id", "-d")
{
Description = "Decision ID to retrieve status for",
Required = true
};
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output format: table (default), json"
};
var status = new Command("status", "Get status of a previous gate evaluation")
{
decisionIdOption,
outputOption,
verboseOption
};
status.SetAction(async (parseResult, _) =>
{
var decisionId = parseResult.GetValue(decisionIdOption) ?? string.Empty;
var output = parseResult.GetValue(outputOption) ?? "table";
var verbose = parseResult.GetValue(verboseOption);
return await HandleStatusAsync(
services,
options,
decisionId,
output,
verbose,
cancellationToken);
});
return status;
}
private static async Task<int> HandleEvaluateAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string image,
string? baseline,
string? policy,
bool allowOverride,
string? justification,
string? branch,
string? commit,
string? pipeline,
string? env,
string output,
int timeout,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(GateCommandGroup));
var console = AnsiConsole.Console;
try
{
if (string.IsNullOrWhiteSpace(image))
{
console.MarkupLine("[red]Error:[/] Image digest is required.");
return GateExitCodes.InputError;
}
if (allowOverride && string.IsNullOrWhiteSpace(justification))
{
console.MarkupLine("[red]Error:[/] Justification is required when using --allow-override.");
return GateExitCodes.InputError;
}
if (verbose)
{
console.MarkupLine($"[dim]Evaluating gate for image: {image}[/]");
if (!string.IsNullOrWhiteSpace(baseline))
{
console.MarkupLine($"[dim]Baseline: {baseline}[/]");
}
}
// Build request
var request = new GateEvaluateRequest
{
ImageDigest = image,
BaselineRef = baseline,
PolicyId = policy,
AllowOverride = allowOverride,
OverrideJustification = justification,
Context = new GateEvaluationContext
{
Branch = branch,
CommitSha = commit,
PipelineId = pipeline,
Environment = env,
Actor = Environment.UserName
}
};
// Call API
var httpClientFactory = services.GetService<IHttpClientFactory>();
using var client = httpClientFactory?.CreateClient("PolicyGateway")
?? new HttpClient();
// Configure base address if not set
if (client.BaseAddress is null)
{
var gatewayUrl = options.PolicyGateway?.BaseUrl
?? Environment.GetEnvironmentVariable("STELLAOPS_POLICY_GATEWAY_URL")
?? "http://localhost:5080";
client.BaseAddress = new Uri(gatewayUrl);
}
client.Timeout = TimeSpan.FromSeconds(timeout);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (verbose)
{
console.MarkupLine($"[dim]Calling: {client.BaseAddress}api/v1/policy/gate/evaluate[/]");
}
var response = await client.PostAsJsonAsync(
"api/v1/policy/gate/evaluate",
request,
JsonOptions,
ct);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(ct);
logger?.LogError("Gate evaluation API returned {StatusCode}: {Content}",
response.StatusCode, errorContent);
console.MarkupLine($"[red]Error:[/] Gate evaluation failed with status {response.StatusCode}");
if (verbose && !string.IsNullOrWhiteSpace(errorContent))
{
console.MarkupLine($"[dim]{errorContent}[/]");
}
return GateExitCodes.NetworkError;
}
var result = await response.Content.ReadFromJsonAsync<GateEvaluateResponse>(JsonOptions, ct);
if (result is null)
{
console.MarkupLine("[red]Error:[/] Failed to parse gate evaluation response.");
return GateExitCodes.PolicyError;
}
// Output results
switch (output.ToLowerInvariant())
{
case "json":
var json = JsonSerializer.Serialize(result, JsonOptions);
console.WriteLine(json);
break;
case "exit-code-only":
// No output, just return exit code
break;
default:
WriteTableOutput(console, result, verbose);
break;
}
return result.ExitCode;
}
catch (HttpRequestException ex)
{
logger?.LogError(ex, "Network error calling gate evaluation API");
console.MarkupLine($"[red]Error:[/] Network error: {ex.Message}");
return GateExitCodes.NetworkError;
}
catch (TaskCanceledException ex) when (ex.CancellationToken != ct)
{
logger?.LogError(ex, "Gate evaluation request timed out");
console.MarkupLine("[red]Error:[/] Request timed out.");
return GateExitCodes.NetworkError;
}
catch (Exception ex)
{
logger?.LogError(ex, "Unexpected error in gate evaluation");
console.MarkupLine($"[red]Error:[/] {ex.Message}");
return GateExitCodes.UnknownError;
}
}
private static async Task<int> HandleStatusAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string decisionId,
string output,
bool verbose,
CancellationToken ct)
{
var console = AnsiConsole.Console;
console.MarkupLine($"[yellow]Gate status lookup not yet implemented.[/]");
console.MarkupLine($"[dim]Decision ID: {decisionId}[/]");
await Task.CompletedTask;
return 0;
}
private static void WriteTableOutput(IAnsiConsole console, GateEvaluateResponse result, bool verbose)
{
var statusColor = result.Status switch
{
GateStatus.Pass => "green",
GateStatus.Warn => "yellow",
GateStatus.Fail => "red",
_ => "white"
};
var statusIcon = result.Status switch
{
GateStatus.Pass => "✓",
GateStatus.Warn => "⚠",
GateStatus.Fail => "✗",
_ => "?"
};
// Header
var header = new Panel(new Markup($"[bold]Gate Evaluation Result[/]"))
.Border(BoxBorder.Rounded)
.Padding(1, 0);
console.Write(header);
// Summary
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn("Field")
.AddColumn("Value");
table.AddRow("Decision ID", result.DecisionId);
table.AddRow("Status", $"[{statusColor}]{statusIcon} {result.Status}[/]");
table.AddRow("Exit Code", result.ExitCode.ToString());
table.AddRow("Image", result.ImageDigest);
table.AddRow("Baseline", result.BaselineRef ?? "(default)");
table.AddRow("Decided At", result.DecidedAt.ToString("O"));
if (!string.IsNullOrWhiteSpace(result.Summary))
{
table.AddRow("Summary", result.Summary);
}
console.Write(table);
// Blocked info
if (result.Status == GateStatus.Fail)
{
console.WriteLine();
console.MarkupLine($"[red bold]Blocked by:[/] {result.BlockedBy ?? "Unknown gate"}");
if (!string.IsNullOrWhiteSpace(result.BlockReason))
{
console.MarkupLine($"[red]Reason:[/] {result.BlockReason}");
}
if (!string.IsNullOrWhiteSpace(result.Suggestion))
{
console.MarkupLine($"[yellow]Suggestion:[/] {result.Suggestion}");
}
}
// Advisory
if (!string.IsNullOrWhiteSpace(result.Advisory))
{
console.WriteLine();
console.MarkupLine($"[cyan]Advisory:[/] {result.Advisory}");
}
// Gate details (verbose only)
if (verbose && result.Gates is { Count: > 0 })
{
console.WriteLine();
var gateTable = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Gate Results[/]")
.AddColumn("Gate")
.AddColumn("Result")
.AddColumn("Reason");
foreach (var gate in result.Gates)
{
var gateColor = gate.Result switch
{
"pass" => "green",
"warn" => "yellow",
"fail" or "block" => "red",
_ => "white"
};
gateTable.AddRow(
gate.Name,
$"[{gateColor}]{gate.Result}[/]",
gate.Reason);
}
console.Write(gateTable);
}
// Delta summary (verbose only)
if (verbose && result.DeltaSummary is not null)
{
console.WriteLine();
console.MarkupLine("[bold]Delta Summary:[/]");
console.MarkupLine($" Added findings: {result.DeltaSummary.Added}");
console.MarkupLine($" Removed findings: {result.DeltaSummary.Removed}");
console.MarkupLine($" Unchanged: {result.DeltaSummary.Unchanged}");
}
}
#region DTOs
private sealed record GateEvaluateRequest
{
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("baselineRef")]
public string? BaselineRef { get; init; }
[JsonPropertyName("policyId")]
public string? PolicyId { get; init; }
[JsonPropertyName("allowOverride")]
public bool AllowOverride { get; init; }
[JsonPropertyName("overrideJustification")]
public string? OverrideJustification { get; init; }
[JsonPropertyName("context")]
public GateEvaluationContext? Context { get; init; }
}
private sealed record GateEvaluationContext
{
[JsonPropertyName("branch")]
public string? Branch { get; init; }
[JsonPropertyName("commitSha")]
public string? CommitSha { get; init; }
[JsonPropertyName("pipelineId")]
public string? PipelineId { get; init; }
[JsonPropertyName("environment")]
public string? Environment { get; init; }
[JsonPropertyName("actor")]
public string? Actor { get; init; }
}
private sealed record GateEvaluateResponse
{
[JsonPropertyName("decisionId")]
public required string DecisionId { get; init; }
[JsonPropertyName("status")]
public required GateStatus Status { get; init; }
[JsonPropertyName("exitCode")]
public required int ExitCode { get; init; }
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
[JsonPropertyName("baselineRef")]
public string? BaselineRef { get; init; }
[JsonPropertyName("decidedAt")]
public required DateTimeOffset DecidedAt { get; init; }
[JsonPropertyName("summary")]
public string? Summary { get; init; }
[JsonPropertyName("advisory")]
public string? Advisory { get; init; }
[JsonPropertyName("gates")]
public IReadOnlyList<GateResultDto>? Gates { get; init; }
[JsonPropertyName("blockedBy")]
public string? BlockedBy { get; init; }
[JsonPropertyName("blockReason")]
public string? BlockReason { get; init; }
[JsonPropertyName("suggestion")]
public string? Suggestion { get; init; }
[JsonPropertyName("overrideApplied")]
public bool OverrideApplied { get; init; }
[JsonPropertyName("deltaSummary")]
public DeltaSummaryDto? DeltaSummary { get; init; }
}
private sealed record GateResultDto
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("result")]
public required string Result { get; init; }
[JsonPropertyName("reason")]
public required string Reason { get; init; }
[JsonPropertyName("note")]
public string? Note { get; init; }
[JsonPropertyName("condition")]
public string? Condition { get; init; }
}
private sealed record DeltaSummaryDto
{
[JsonPropertyName("added")]
public int Added { get; init; }
[JsonPropertyName("removed")]
public int Removed { get; init; }
[JsonPropertyName("unchanged")]
public int Unchanged { get; init; }
}
private enum GateStatus
{
Pass = 0,
Warn = 1,
Fail = 2
}
#endregion
}
/// <summary>
/// Exit codes for gate evaluation command.
/// </summary>
public static class GateExitCodes
{
/// <summary>Gate passed - proceed with deployment.</summary>
public const int Pass = 0;
/// <summary>Gate produced warnings - configurable pass-through.</summary>
public const int Warn = 1;
/// <summary>Gate blocked - do not proceed.</summary>
public const int Fail = 2;
/// <summary>Input error - invalid parameters.</summary>
public const int InputError = 10;
/// <summary>Network error - unable to reach gate service.</summary>
public const int NetworkError = 11;
/// <summary>Policy error - gate evaluation failed.</summary>
public const int PolicyError = 12;
/// <summary>Unknown error.</summary>
public const int UnknownError = 99;
}

View File

@@ -0,0 +1,289 @@
// -----------------------------------------------------------------------------
// FuncProofCommandGroup.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Tasks: FUNC-16, FUNC-17
// Description: CLI commands for function-level proof generation and verification.
// -----------------------------------------------------------------------------
using System.CommandLine;
using StellaOps.Cli.Extensions;
namespace StellaOps.Cli.Commands.Proof;
/// <summary>
/// CLI command group for function-level proof operations.
/// Enables binary composition attestation and auditor replay verification.
/// </summary>
internal static class FuncProofCommandGroup
{
internal static Command BuildFuncProofCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var funcproof = new Command("funcproof", "Function-level proof operations for binary reachability evidence.");
funcproof.Add(BuildGenerateCommand(services, verboseOption, cancellationToken));
funcproof.Add(BuildVerifyCommand(services, verboseOption, cancellationToken));
funcproof.Add(BuildInfoCommand(services, verboseOption, cancellationToken));
funcproof.Add(BuildExportCommand(services, verboseOption, cancellationToken));
return funcproof;
}
private static Command BuildGenerateCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var binaryOption = new Option<string>("--binary", new[] { "-b" })
{
Description = "Path to binary file for function analysis.",
Required = true
};
var buildIdOption = new Option<string?>("--build-id")
{
Description = "Build identifier (e.g., git commit SHA). Auto-detected from ELF if not specified."
};
var signOption = new Option<bool>("--sign")
{
Description = "Sign the FuncProof with DSSE envelope."
};
var transparencyOption = new Option<bool>("--transparency")
{
Description = "Submit signed FuncProof to Rekor transparency log."
};
var registryOption = new Option<string?>("--registry", new[] { "-r" })
{
Description = "OCI registry to push FuncProof as referrer artifact (e.g., ghcr.io/myorg/proofs)."
};
var subjectOption = new Option<string?>("--subject")
{
Description = "Subject digest for OCI referrer relationship (sha256:...)."
};
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Output path for the generated FuncProof JSON. Defaults to stdout."
};
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: json (default), summary."
}.SetDefaultValue("json").FromAmong("json", "summary");
var detectMethodOption = new Option<string>("--detect-method")
{
Description = "Function detection method: auto (default), dwarf, symbols, heuristic."
}.SetDefaultValue("auto").FromAmong("auto", "dwarf", "symbols", "heuristic");
var command = new Command("generate", "Generate function-level proof from a binary.")
{
binaryOption,
buildIdOption,
signOption,
transparencyOption,
registryOption,
subjectOption,
outputOption,
formatOption,
detectMethodOption,
verboseOption
};
command.SetAction(parseResult =>
{
var binaryPath = parseResult.GetValue(binaryOption) ?? string.Empty;
var buildId = parseResult.GetValue(buildIdOption);
var sign = parseResult.GetValue(signOption);
var transparency = parseResult.GetValue(transparencyOption);
var registry = parseResult.GetValue(registryOption);
var subject = parseResult.GetValue(subjectOption);
var output = parseResult.GetValue(outputOption);
var format = parseResult.GetValue(formatOption) ?? "json";
var detectMethod = parseResult.GetValue(detectMethodOption) ?? "auto";
var verbose = parseResult.GetValue(verboseOption);
return FuncProofCommandHandlers.HandleGenerateAsync(
services,
binaryPath,
buildId,
sign,
transparency,
registry,
subject,
output,
format,
detectMethod,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildVerifyCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var proofOption = new Option<string>("--proof", new[] { "-p" })
{
Description = "Path to FuncProof JSON file or DSSE envelope.",
Required = true
};
var binaryOption = new Option<string?>("--binary", new[] { "-b" })
{
Description = "Path to binary file for replay verification (optional, enables full replay)."
};
var offlineOption = new Option<bool>("--offline")
{
Description = "Offline mode (skip transparency log verification)."
};
var strictOption = new Option<bool>("--strict")
{
Description = "Strict mode (fail on any untrusted signature or missing evidence)."
};
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: text (default), json."
}.SetDefaultValue("text").FromAmong("text", "json");
var command = new Command("verify", "Verify a function-level proof and optionally replay against binary.")
{
proofOption,
binaryOption,
offlineOption,
strictOption,
formatOption,
verboseOption
};
command.SetAction(parseResult =>
{
var proofPath = parseResult.GetValue(proofOption) ?? string.Empty;
var binaryPath = parseResult.GetValue(binaryOption);
var offline = parseResult.GetValue(offlineOption);
var strict = parseResult.GetValue(strictOption);
var format = parseResult.GetValue(formatOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
return FuncProofCommandHandlers.HandleVerifyAsync(
services,
proofPath,
binaryPath,
offline,
strict,
format,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildInfoCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var proofArg = new Argument<string>("proof")
{
Description = "FuncProof ID, file path, or OCI reference."
};
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: text (default), json."
}.SetDefaultValue("text").FromAmong("text", "json");
var command = new Command("info", "Display FuncProof information and statistics.")
{
proofArg,
formatOption,
verboseOption
};
command.SetAction(parseResult =>
{
var proof = parseResult.GetValue(proofArg)!;
var format = parseResult.GetValue(formatOption)!;
var verbose = parseResult.GetValue(verboseOption);
return FuncProofCommandHandlers.HandleInfoAsync(
services,
proof,
format,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildExportCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var proofArg = new Argument<string>("proof")
{
Description = "FuncProof ID, file path, or OCI reference."
};
var outputOption = new Option<string>("--output", new[] { "-o" })
{
Description = "Output directory for exported artifacts.",
Required = true
};
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Export format: bundle (default), evidence-locker."
}.SetDefaultValue("bundle").FromAmong("bundle", "evidence-locker");
var includeOption = new Option<string[]>("--include", new[] { "-i" })
{
Description = "Include additional artifacts: dsse, tlog-receipt, raw-proof.",
AllowMultipleArgumentsPerToken = true
};
var command = new Command("export", "Export FuncProof and related artifacts.")
{
proofArg,
outputOption,
formatOption,
includeOption,
verboseOption
};
command.SetAction(parseResult =>
{
var proof = parseResult.GetValue(proofArg)!;
var output = parseResult.GetValue(outputOption)!;
var format = parseResult.GetValue(formatOption)!;
var include = parseResult.GetValue(includeOption) ?? Array.Empty<string>();
var verbose = parseResult.GetValue(verboseOption);
return FuncProofCommandHandlers.HandleExportAsync(
services,
proof,
output,
format,
include,
verbose,
cancellationToken);
});
return command;
}
}

View File

@@ -0,0 +1,570 @@
// -----------------------------------------------------------------------------
// FuncProofCommandHandlers.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Tasks: FUNC-16, FUNC-17
// Description: CLI command handlers for function-level proof operations.
// -----------------------------------------------------------------------------
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Commands.Proof;
/// <summary>
/// Command handlers for FuncProof CLI operations.
/// </summary>
internal static class FuncProofCommandHandlers
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Generate a FuncProof from a binary file.
/// </summary>
public static async Task<int> HandleGenerateAsync(
IServiceProvider services,
string binaryPath,
string? buildId,
bool sign,
bool transparency,
string? registry,
string? subject,
string? output,
string format,
string detectMethod,
bool verbose,
CancellationToken ct)
{
var logger = services.GetRequiredService<ILogger<FuncProofCommandGroup>>();
if (!File.Exists(binaryPath))
{
Console.Error.WriteLine($"Error: Binary file not found: {binaryPath}");
return FuncProofExitCodes.FileNotFound;
}
logger.LogInformation("Generating FuncProof for {BinaryPath}", binaryPath);
try
{
// Read binary and compute file hash
var binaryBytes = await File.ReadAllBytesAsync(binaryPath, ct);
var fileSha256 = ComputeSha256(binaryBytes);
if (verbose)
{
Console.WriteLine($"Binary: {binaryPath}");
Console.WriteLine($"Size: {binaryBytes.Length:N0} bytes");
Console.WriteLine($"SHA-256: {fileSha256}");
}
// TODO: Integrate with FunctionBoundaryDetector and FuncProofBuilder
// For now, create a placeholder proof structure
var proof = new FuncProofOutput
{
SchemaVersion = "1.0.0",
ProofId = $"funcproof-{fileSha256[..16]}",
BuildId = buildId ?? ExtractBuildId(binaryBytes) ?? "unknown",
FileSha256 = fileSha256,
FileSize = binaryBytes.Length,
FunctionCount = 0, // Placeholder
Metadata = new FuncProofMetadataOutput
{
CreatedAt = DateTimeOffset.UtcNow.ToString("O"),
Tool = "stella-cli",
ToolVersion = "0.1.0",
DetectionMethod = detectMethod
}
};
if (format == "summary")
{
WriteSummary(proof);
}
else
{
var json = JsonSerializer.Serialize(proof, JsonOptions);
if (string.IsNullOrEmpty(output))
{
Console.WriteLine(json);
}
else
{
await File.WriteAllTextAsync(output, json, ct);
Console.WriteLine($"FuncProof written to: {output}");
}
}
// Handle signing
if (sign)
{
logger.LogInformation("Signing FuncProof with DSSE envelope");
// TODO: Integrate with FuncProofDsseService
Console.WriteLine("DSSE signing: enabled (integration pending)");
}
// Handle transparency log submission
if (transparency)
{
if (!sign)
{
Console.Error.WriteLine("Error: --transparency requires --sign");
return FuncProofExitCodes.InvalidArguments;
}
logger.LogInformation("Submitting to transparency log");
// TODO: Integrate with FuncProofTransparencyService
Console.WriteLine("Transparency log: submission pending");
}
// Handle OCI registry push
if (!string.IsNullOrEmpty(registry))
{
if (string.IsNullOrEmpty(subject))
{
Console.Error.WriteLine("Error: --registry requires --subject for referrer relationship");
return FuncProofExitCodes.InvalidArguments;
}
logger.LogInformation("Pushing FuncProof to OCI registry {Registry}", registry);
// TODO: Integrate with FuncProofOciPublisher
Console.WriteLine($"OCI push: to {registry} (integration pending)");
}
return FuncProofExitCodes.Success;
}
catch (Exception ex)
{
logger.LogError(ex, "FuncProof generation failed");
Console.Error.WriteLine($"Error: {ex.Message}");
return FuncProofExitCodes.GenerationFailed;
}
}
/// <summary>
/// Verify a FuncProof document.
/// </summary>
public static async Task<int> HandleVerifyAsync(
IServiceProvider services,
string proofPath,
string? binaryPath,
bool offline,
bool strict,
string format,
bool verbose,
CancellationToken ct)
{
var logger = services.GetRequiredService<ILogger<FuncProofCommandGroup>>();
if (!File.Exists(proofPath))
{
Console.Error.WriteLine($"Error: Proof file not found: {proofPath}");
return FuncProofExitCodes.FileNotFound;
}
logger.LogInformation("Verifying FuncProof: {ProofPath}", proofPath);
try
{
var proofJson = await File.ReadAllTextAsync(proofPath, ct);
var proof = JsonSerializer.Deserialize<FuncProofOutput>(proofJson, JsonOptions);
if (proof is null)
{
Console.Error.WriteLine("Error: Invalid FuncProof JSON");
return FuncProofExitCodes.InvalidProof;
}
var result = new VerificationResult
{
ProofId = proof.ProofId ?? "unknown",
IsValid = true,
Checks = new List<VerificationCheck>()
};
// Schema validation
result.Checks.Add(new VerificationCheck
{
Name = "schema",
Status = !string.IsNullOrEmpty(proof.SchemaVersion) ? "pass" : "fail",
Details = $"Schema version: {proof.SchemaVersion ?? "missing"}"
});
// Proof ID validation
result.Checks.Add(new VerificationCheck
{
Name = "proof_id",
Status = !string.IsNullOrEmpty(proof.ProofId) ? "pass" : "fail",
Details = $"Proof ID: {proof.ProofId ?? "missing"}"
});
// File hash validation (if binary provided)
if (!string.IsNullOrEmpty(binaryPath))
{
if (File.Exists(binaryPath))
{
var binaryBytes = await File.ReadAllBytesAsync(binaryPath, ct);
var computedHash = ComputeSha256(binaryBytes);
var hashMatch = string.Equals(computedHash, proof.FileSha256, StringComparison.OrdinalIgnoreCase);
result.Checks.Add(new VerificationCheck
{
Name = "file_hash",
Status = hashMatch ? "pass" : "fail",
Details = hashMatch
? $"File hash matches: {computedHash[..16]}..."
: $"Hash mismatch: expected {proof.FileSha256?[..16]}..., got {computedHash[..16]}..."
});
if (!hashMatch)
{
result.IsValid = false;
}
}
else
{
result.Checks.Add(new VerificationCheck
{
Name = "file_hash",
Status = "skip",
Details = "Binary file not found for replay verification"
});
}
}
// Signature validation
// TODO: Integrate with FuncProofDsseService
result.Checks.Add(new VerificationCheck
{
Name = "signature",
Status = "skip",
Details = "DSSE signature verification: integration pending"
});
// Transparency log validation
if (!offline)
{
// TODO: Integrate with FuncProofTransparencyService
result.Checks.Add(new VerificationCheck
{
Name = "transparency",
Status = "skip",
Details = "Transparency log verification: integration pending"
});
}
// Output results
if (format == "json")
{
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
}
else
{
WriteVerificationText(result, verbose);
}
// Determine exit code
if (!result.IsValid)
{
return FuncProofExitCodes.VerificationFailed;
}
if (strict && result.Checks.Any(c => c.Status == "skip"))
{
Console.Error.WriteLine("Warning: Some checks were skipped (strict mode)");
return FuncProofExitCodes.StrictChecksFailed;
}
return FuncProofExitCodes.Success;
}
catch (JsonException ex)
{
Console.Error.WriteLine($"Error: Invalid JSON in proof file: {ex.Message}");
return FuncProofExitCodes.InvalidProof;
}
catch (Exception ex)
{
logger.LogError(ex, "FuncProof verification failed");
Console.Error.WriteLine($"Error: {ex.Message}");
return FuncProofExitCodes.VerificationFailed;
}
}
/// <summary>
/// Display FuncProof information.
/// </summary>
public static async Task<int> HandleInfoAsync(
IServiceProvider services,
string proof,
string format,
bool verbose,
CancellationToken ct)
{
var logger = services.GetRequiredService<ILogger<FuncProofCommandGroup>>();
try
{
FuncProofOutput? proofData = null;
// Try to load from file
if (File.Exists(proof))
{
var json = await File.ReadAllTextAsync(proof, ct);
proofData = JsonSerializer.Deserialize<FuncProofOutput>(json, JsonOptions);
}
// TODO: Add support for loading by ID from database or OCI registry
if (proofData is null)
{
Console.Error.WriteLine($"Error: Could not load FuncProof: {proof}");
return FuncProofExitCodes.FileNotFound;
}
if (format == "json")
{
Console.WriteLine(JsonSerializer.Serialize(proofData, JsonOptions));
}
else
{
WriteInfo(proofData, verbose);
}
return FuncProofExitCodes.Success;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to load FuncProof info");
Console.Error.WriteLine($"Error: {ex.Message}");
return FuncProofExitCodes.GenerationFailed;
}
}
/// <summary>
/// Export FuncProof and related artifacts.
/// </summary>
public static async Task<int> HandleExportAsync(
IServiceProvider services,
string proof,
string output,
string format,
string[] include,
bool verbose,
CancellationToken ct)
{
var logger = services.GetRequiredService<ILogger<FuncProofCommandGroup>>();
try
{
FuncProofOutput? proofData = null;
// Try to load from file
if (File.Exists(proof))
{
var json = await File.ReadAllTextAsync(proof, ct);
proofData = JsonSerializer.Deserialize<FuncProofOutput>(json, JsonOptions);
}
if (proofData is null)
{
Console.Error.WriteLine($"Error: Could not load FuncProof: {proof}");
return FuncProofExitCodes.FileNotFound;
}
// Create output directory
Directory.CreateDirectory(output);
// Write main proof file
var proofPath = Path.Combine(output, $"{proofData.ProofId ?? "funcproof"}.json");
await File.WriteAllTextAsync(proofPath, JsonSerializer.Serialize(proofData, JsonOptions), ct);
Console.WriteLine($"Exported: {proofPath}");
// Handle additional includes
foreach (var inc in include)
{
switch (inc.ToLowerInvariant())
{
case "dsse":
// TODO: Export DSSE envelope
Console.WriteLine("DSSE envelope export: integration pending");
break;
case "tlog-receipt":
// TODO: Export transparency log receipt
Console.WriteLine("Transparency log receipt export: integration pending");
break;
case "raw-proof":
// Raw proof is the main export
break;
default:
Console.Error.WriteLine($"Warning: Unknown include option: {inc}");
break;
}
}
// Write manifest
var manifest = new ExportManifest
{
ExportedAt = DateTimeOffset.UtcNow.ToString("O"),
Format = format,
ProofId = proofData.ProofId,
Files = new List<string> { Path.GetFileName(proofPath) }
};
var manifestPath = Path.Combine(output, "manifest.json");
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest, JsonOptions), ct);
Console.WriteLine($"Export complete: {output}");
return FuncProofExitCodes.Success;
}
catch (Exception ex)
{
logger.LogError(ex, "FuncProof export failed");
Console.Error.WriteLine($"Error: {ex.Message}");
return FuncProofExitCodes.GenerationFailed;
}
}
private static string ComputeSha256(byte[] data)
{
var hash = System.Security.Cryptography.SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string? ExtractBuildId(byte[] binary)
{
// Simple ELF build-id extraction (looks for .note.gnu.build-id section)
// Full implementation in BinaryIdentity.cs
if (binary.Length < 16)
return null;
// Check ELF magic
if (binary[0] == 0x7f && binary[1] == 'E' && binary[2] == 'L' && binary[3] == 'F')
{
// ELF file - would need full section parsing for build-id
return null; // Placeholder
}
return null;
}
private static void WriteSummary(FuncProofOutput proof)
{
Console.WriteLine("FuncProof Summary");
Console.WriteLine(new string('=', 50));
Console.WriteLine($" Proof ID: {proof.ProofId ?? "N/A"}");
Console.WriteLine($" Build ID: {proof.BuildId ?? "N/A"}");
Console.WriteLine($" File SHA-256: {proof.FileSha256?[..16]}...");
Console.WriteLine($" File Size: {proof.FileSize:N0} bytes");
Console.WriteLine($" Functions: {proof.FunctionCount:N0}");
Console.WriteLine($" Created: {proof.Metadata?.CreatedAt ?? "N/A"}");
Console.WriteLine($" Tool: {proof.Metadata?.Tool ?? "N/A"} {proof.Metadata?.ToolVersion ?? ""}");
}
private static void WriteInfo(FuncProofOutput proof, bool verbose)
{
Console.WriteLine("FuncProof Information");
Console.WriteLine(new string('=', 50));
Console.WriteLine($" Proof ID: {proof.ProofId ?? "N/A"}");
Console.WriteLine($" Schema Version: {proof.SchemaVersion ?? "N/A"}");
Console.WriteLine($" Build ID: {proof.BuildId ?? "N/A"}");
Console.WriteLine($" File SHA-256: {proof.FileSha256 ?? "N/A"}");
Console.WriteLine($" File Size: {proof.FileSize:N0} bytes");
Console.WriteLine($" Functions: {proof.FunctionCount:N0}");
if (verbose && proof.Metadata is not null)
{
Console.WriteLine();
Console.WriteLine("Metadata:");
Console.WriteLine($" Created: {proof.Metadata.CreatedAt ?? "N/A"}");
Console.WriteLine($" Tool: {proof.Metadata.Tool ?? "N/A"}");
Console.WriteLine($" Tool Version: {proof.Metadata.ToolVersion ?? "N/A"}");
Console.WriteLine($" Detection: {proof.Metadata.DetectionMethod ?? "N/A"}");
}
}
private static void WriteVerificationText(VerificationResult result, bool verbose)
{
var statusSymbol = result.IsValid ? "✓" : "✗";
Console.WriteLine($"FuncProof Verification: {statusSymbol} {(result.IsValid ? "PASSED" : "FAILED")}");
Console.WriteLine(new string('=', 50));
Console.WriteLine($" Proof ID: {result.ProofId}");
Console.WriteLine();
foreach (var check in result.Checks)
{
var checkSymbol = check.Status switch
{
"pass" => "✓",
"fail" => "✗",
"skip" => "○",
_ => "?"
};
Console.WriteLine($" {checkSymbol} {check.Name}: {check.Status}");
if (verbose && !string.IsNullOrEmpty(check.Details))
{
Console.WriteLine($" {check.Details}");
}
}
}
#region DTOs
private sealed class FuncProofOutput
{
public string? SchemaVersion { get; set; }
public string? ProofId { get; set; }
public string? BuildId { get; set; }
public string? FileSha256 { get; set; }
public long FileSize { get; set; }
public int FunctionCount { get; set; }
public FuncProofMetadataOutput? Metadata { get; set; }
}
private sealed class FuncProofMetadataOutput
{
public string? CreatedAt { get; set; }
public string? Tool { get; set; }
public string? ToolVersion { get; set; }
public string? DetectionMethod { get; set; }
}
private sealed class VerificationResult
{
public string ProofId { get; set; } = string.Empty;
public bool IsValid { get; set; }
public List<VerificationCheck> Checks { get; set; } = new();
}
private sealed class VerificationCheck
{
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string? Details { get; set; }
}
private sealed class ExportManifest
{
public string? ExportedAt { get; set; }
public string? Format { get; set; }
public string? ProofId { get; set; }
public List<string>? Files { get; set; }
}
#endregion
}
/// <summary>
/// Exit codes for FuncProof CLI commands.
/// </summary>
internal static class FuncProofExitCodes
{
public const int Success = 0;
public const int FileNotFound = 1;
public const int InvalidArguments = 2;
public const int GenerationFailed = 3;
public const int InvalidProof = 4;
public const int VerificationFailed = 5;
public const int StrictChecksFailed = 6;
}

View File

@@ -0,0 +1,232 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
// Task: DET-GAP-08 - CLI command `stella sign --keyless --rekor` for CI pipelines
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Cli.Commands;
/// <summary>
/// CLI commands for Sigstore keyless signing operations.
/// Supports self-hosted Sigstore (Fulcio + Rekor) for on-premise deployments.
/// </summary>
internal static class SignCommandGroup
{
/// <summary>
/// Build the sign command with keyless/traditional subcommands.
/// </summary>
public static Command BuildSignCommand(
IServiceProvider serviceProvider,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var command = new Command("sign", "Sign artifacts (keyless via Sigstore or traditional key-based)");
command.Add(BuildKeylessCommand(serviceProvider, verboseOption, cancellationToken));
command.Add(BuildVerifyKeylessCommand(serviceProvider, verboseOption, cancellationToken));
return command;
}
private static Command BuildKeylessCommand(
IServiceProvider serviceProvider,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var command = new Command("keyless", "Sign artifact using Sigstore keyless signing (Fulcio + Rekor)");
var inputOption = new Option<string>("--input")
{
Description = "Path to file or artifact to sign",
Required = true
};
command.Add(inputOption);
var outputOption = new Option<string?>("--output")
{
Description = "Output path for signature bundle (defaults to <input>.sigstore)"
};
command.Add(outputOption);
var identityTokenOption = new Option<string?>("--identity-token")
{
Description = "OIDC identity token (JWT). If not provided, attempts ambient credential detection."
};
command.Add(identityTokenOption);
var rekorOption = new Option<bool>("--rekor")
{
Description = "Upload signature to Rekor transparency log (default: true)",
DefaultValue = true
};
command.Add(rekorOption);
var fulcioUrlOption = new Option<string?>("--fulcio-url")
{
Description = "Override Fulcio URL (for self-hosted Sigstore)"
};
command.Add(fulcioUrlOption);
var rekorUrlOption = new Option<string?>("--rekor-url")
{
Description = "Override Rekor URL (for self-hosted Sigstore)"
};
command.Add(rekorUrlOption);
var oidcIssuerOption = new Option<string?>("--oidc-issuer")
{
Description = "OIDC issuer URL for identity verification"
};
command.Add(oidcIssuerOption);
var bundleFormatOption = new Option<string>("--bundle-format")
{
Description = "Output bundle format: sigstore, cosign-bundle, dsse (default: sigstore)",
DefaultValue = "sigstore"
};
command.Add(bundleFormatOption);
var caBundleOption = new Option<string?>("--ca-bundle")
{
Description = "Path to custom CA certificate bundle for self-hosted TLS"
};
command.Add(caBundleOption);
var insecureOption = new Option<bool>("--insecure-skip-verify")
{
Description = "Skip TLS verification (NOT for production)",
DefaultValue = false
};
command.Add(insecureOption);
command.Add(verboseOption);
command.SetAction(async (parseResult, ct) =>
{
var input = parseResult.GetValue(inputOption) ?? string.Empty;
var output = parseResult.GetValue(outputOption);
var identityToken = parseResult.GetValue(identityTokenOption);
var useRekor = parseResult.GetValue(rekorOption);
var fulcioUrl = parseResult.GetValue(fulcioUrlOption);
var rekorUrl = parseResult.GetValue(rekorUrlOption);
var oidcIssuer = parseResult.GetValue(oidcIssuerOption);
var bundleFormat = parseResult.GetValue(bundleFormatOption) ?? "sigstore";
var caBundle = parseResult.GetValue(caBundleOption);
var insecure = parseResult.GetValue(insecureOption);
var verbose = parseResult.GetValue(verboseOption);
return await CommandHandlers.HandleSignKeylessAsync(
serviceProvider,
input,
output,
identityToken,
useRekor,
fulcioUrl,
rekorUrl,
oidcIssuer,
bundleFormat,
caBundle,
insecure,
verbose,
ct);
});
return command;
}
private static Command BuildVerifyKeylessCommand(
IServiceProvider serviceProvider,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var command = new Command("verify-keyless", "Verify a keyless signature against Sigstore");
var inputOption = new Option<string>("--input")
{
Description = "Path to file or artifact to verify",
Required = true
};
command.Add(inputOption);
var bundleOption = new Option<string?>("--bundle")
{
Description = "Path to Sigstore bundle file (defaults to <input>.sigstore)"
};
command.Add(bundleOption);
var certificateOption = new Option<string?>("--certificate")
{
Description = "Path to signing certificate (PEM format)"
};
command.Add(certificateOption);
var signatureOption = new Option<string?>("--signature")
{
Description = "Path to detached signature"
};
command.Add(signatureOption);
var rekorUuidOption = new Option<string?>("--rekor-uuid")
{
Description = "Rekor entry UUID for transparency verification"
};
command.Add(rekorUuidOption);
var rekorUrlOption = new Option<string?>("--rekor-url")
{
Description = "Override Rekor URL (for self-hosted Sigstore)"
};
command.Add(rekorUrlOption);
var issuerOption = new Option<string?>("--certificate-issuer")
{
Description = "Expected OIDC issuer in certificate"
};
command.Add(issuerOption);
var subjectOption = new Option<string?>("--certificate-subject")
{
Description = "Expected subject (email/identity) in certificate"
};
command.Add(subjectOption);
var caBundleOption = new Option<string?>("--ca-bundle")
{
Description = "Path to custom CA certificate bundle for self-hosted TLS"
};
command.Add(caBundleOption);
command.Add(verboseOption);
command.SetAction(async (parseResult, ct) =>
{
var input = parseResult.GetValue(inputOption) ?? string.Empty;
var bundle = parseResult.GetValue(bundleOption);
var certificate = parseResult.GetValue(certificateOption);
var signature = parseResult.GetValue(signatureOption);
var rekorUuid = parseResult.GetValue(rekorUuidOption);
var rekorUrl = parseResult.GetValue(rekorUrlOption);
var issuer = parseResult.GetValue(issuerOption);
var subject = parseResult.GetValue(subjectOption);
var caBundle = parseResult.GetValue(caBundleOption);
var verbose = parseResult.GetValue(verboseOption);
return await CommandHandlers.HandleVerifyKeylessAsync(
serviceProvider,
input,
bundle,
certificate,
signature,
rekorUuid,
rekorUrl,
issuer,
subject,
caBundle,
verbose,
ct);
});
return command;
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
StellaOps.Cli.Plugins.Vex.csproj
Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
Task: AUTOVEX-15 - CLI command: stella vex auto-downgrade
Description: CLI plugin for VEX management and auto-downgrade commands
-->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Vex\'))</PluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.48.0" />
</ItemGroup>
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<MakeDir Directories="$(PluginOutputDirectory)" />
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
DestinationFolder="$(PluginOutputDirectory)"
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
</Target>
</Project>

View File

@@ -0,0 +1,844 @@
// -----------------------------------------------------------------------------
// VexCliCommandModule.cs
// Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
// Task: AUTOVEX-15 — CLI command: stella vex auto-downgrade --check <image>
// Description: CLI plugin module for VEX management commands including auto-downgrade.
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
namespace StellaOps.Cli.Plugins.Vex;
/// <summary>
/// CLI plugin module for VEX management commands.
/// Provides 'stella vex auto-downgrade', 'stella vex check', 'stella vex list' commands.
/// </summary>
public sealed class VexCliCommandModule : ICliCommandModule
{
public string Name => "stellaops.cli.plugins.vex";
public bool IsAvailable(IServiceProvider services) => true;
public void RegisterCommands(
RootCommand root,
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(verboseOption);
root.Add(BuildVexCommand(services, verboseOption, options, cancellationToken));
}
private static Command BuildVexCommand(
IServiceProvider services,
Option<bool> verboseOption,
StellaOpsCliOptions options,
CancellationToken cancellationToken)
{
var vex = new Command("vex", "VEX management and auto-downgrade commands.");
// Add subcommands
vex.Add(BuildAutoDowngradeCommand(services, verboseOption, options, cancellationToken));
vex.Add(BuildCheckCommand(services, verboseOption, cancellationToken));
vex.Add(BuildListCommand(services, verboseOption, cancellationToken));
vex.Add(BuildNotReachableCommand(services, verboseOption, options, cancellationToken));
return vex;
}
private static Command BuildAutoDowngradeCommand(
IServiceProvider services,
Option<bool> verboseOption,
StellaOpsCliOptions options,
CancellationToken cancellationToken)
{
var cmd = new Command("auto-downgrade", "Auto-downgrade VEX based on runtime observations.");
var imageOption = new Option<string>("--image")
{
Description = "Container image digest or reference to check",
IsRequired = false
};
var checkOption = new Option<string>("--check")
{
Description = "Image to check for hot vulnerable symbols",
IsRequired = false
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Dry run mode - show what would be downgraded without making changes"
};
var minObservationsOption = new Option<int>("--min-observations")
{
Description = "Minimum observation count threshold",
};
minObservationsOption.SetDefaultValue(10);
var minCpuOption = new Option<double>("--min-cpu")
{
Description = "Minimum CPU percentage threshold",
};
minCpuOption.SetDefaultValue(1.0);
var minConfidenceOption = new Option<double>("--min-confidence")
{
Description = "Minimum confidence threshold (0.0-1.0)",
};
minConfidenceOption.SetDefaultValue(0.7);
var outputOption = new Option<string?>("--output")
{
Description = "Output file path for results (default: stdout)"
};
var formatOption = new Option<OutputFormat>("--format")
{
Description = "Output format"
};
formatOption.SetDefaultValue(OutputFormat.Table);
cmd.AddOption(imageOption);
cmd.AddOption(checkOption);
cmd.AddOption(dryRunOption);
cmd.AddOption(minObservationsOption);
cmd.AddOption(minCpuOption);
cmd.AddOption(minConfidenceOption);
cmd.AddOption(outputOption);
cmd.AddOption(formatOption);
cmd.AddOption(verboseOption);
cmd.SetHandler(async (context) =>
{
var image = context.ParseResult.GetValueForOption(imageOption);
var check = context.ParseResult.GetValueForOption(checkOption);
var dryRun = context.ParseResult.GetValueForOption(dryRunOption);
var minObs = context.ParseResult.GetValueForOption(minObservationsOption);
var minCpu = context.ParseResult.GetValueForOption(minCpuOption);
var minConf = context.ParseResult.GetValueForOption(minConfidenceOption);
var output = context.ParseResult.GetValueForOption(outputOption);
var format = context.ParseResult.GetValueForOption(formatOption);
var verbose = context.ParseResult.GetValueForOption(verboseOption);
// Use --check if --image not provided
var targetImage = image ?? check;
if (string.IsNullOrWhiteSpace(targetImage))
{
AnsiConsole.MarkupLine("[red]Error:[/] Either --image or --check must be specified.");
context.ExitCode = 1;
return;
}
var logger = services.GetService<ILogger<VexCliCommandModule>>();
logger?.LogInformation("Running auto-downgrade check for image {Image}", targetImage);
await RunAutoDowngradeAsync(
services,
targetImage,
dryRun,
minObs,
minCpu,
minConf,
output,
format,
verbose,
options,
cancellationToken);
context.ExitCode = 0;
});
return cmd;
}
private static async Task RunAutoDowngradeAsync(
IServiceProvider services,
string image,
bool dryRun,
int minObservations,
double minCpu,
double minConfidence,
string? outputPath,
OutputFormat format,
bool verbose,
StellaOpsCliOptions options,
CancellationToken cancellationToken)
{
var logger = services.GetService<ILogger<VexCliCommandModule>>();
await AnsiConsole.Status()
.StartAsync("Checking for hot vulnerable symbols...", async ctx =>
{
ctx.Spinner(Spinner.Known.Dots);
// Create client and check for downgrades
var client = CreateAutoVexClient(services, options);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Image: {image}[/]");
AnsiConsole.MarkupLine($"[grey]Min observations: {minObservations}[/]");
AnsiConsole.MarkupLine($"[grey]Min CPU%: {minCpu}[/]");
AnsiConsole.MarkupLine($"[grey]Min confidence: {minConfidence}[/]");
}
var result = await client.CheckAutoDowngradeAsync(
image,
minObservations,
minCpu,
minConfidence,
cancellationToken);
ctx.Status("Processing results...");
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {result.Error}");
return;
}
// Display results
if (format == OutputFormat.Json)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
});
if (!string.IsNullOrWhiteSpace(outputPath))
{
await File.WriteAllTextAsync(outputPath, json, cancellationToken);
AnsiConsole.MarkupLine($"[green]Results written to:[/] {outputPath}");
}
else
{
AnsiConsole.WriteLine(json);
}
}
else
{
RenderTableResults(result, dryRun);
}
// Execute downgrades if not dry run
if (!dryRun && result.Candidates?.Count > 0)
{
ctx.Status("Generating VEX downgrades...");
var downgradeResult = await client.ExecuteAutoDowngradeAsync(
result.Candidates,
cancellationToken);
if (downgradeResult.Success)
{
AnsiConsole.MarkupLine(
$"[green]✓[/] Generated {downgradeResult.DowngradeCount} VEX downgrade(s)");
if (downgradeResult.Notifications > 0)
{
AnsiConsole.MarkupLine(
$"[blue]📨[/] Sent {downgradeResult.Notifications} notification(s)");
}
}
else
{
AnsiConsole.MarkupLine($"[red]Error during downgrade:[/] {downgradeResult.Error}");
}
}
else if (dryRun && result.Candidates?.Count > 0)
{
AnsiConsole.MarkupLine($"[yellow]Dry run:[/] {result.Candidates.Count} candidate(s) would be downgraded");
}
});
}
private static void RenderTableResults(AutoDowngradeCheckResult result, bool dryRun)
{
if (result.Candidates == null || result.Candidates.Count == 0)
{
AnsiConsole.MarkupLine("[green]✓[/] No hot vulnerable symbols detected");
return;
}
var table = new Table();
table.Border = TableBorder.Rounded;
table.Title = new TableTitle(
dryRun ? "[yellow]Auto-Downgrade Candidates (Dry Run)[/]" : "[red]Hot Vulnerable Symbols[/]");
table.AddColumn("CVE");
table.AddColumn("Symbol");
table.AddColumn("CPU%");
table.AddColumn("Observations");
table.AddColumn("Confidence");
table.AddColumn("Status");
foreach (var candidate in result.Candidates)
{
var cpuColor = candidate.CpuPercentage >= 10.0 ? "red" :
candidate.CpuPercentage >= 5.0 ? "yellow" : "white";
var confidenceColor = candidate.Confidence >= 0.9 ? "green" :
candidate.Confidence >= 0.7 ? "yellow" : "red";
table.AddRow(
$"[bold]{candidate.CveId}[/]",
candidate.Symbol.Length > 40
? candidate.Symbol[..37] + "..."
: candidate.Symbol,
$"[{cpuColor}]{candidate.CpuPercentage:F1}%[/]",
candidate.ObservationCount.ToString(),
$"[{confidenceColor}]{candidate.Confidence:F2}[/]",
dryRun ? "[yellow]pending[/]" : "[red]downgrade[/]"
);
}
AnsiConsole.Write(table);
// Summary
var panel = new Panel(
$"Total candidates: {result.Candidates.Count}\n" +
$"Highest CPU: {result.Candidates.Max(c => c.CpuPercentage):F1}%\n" +
$"Image: {result.ImageDigest}")
.Header("[bold]Summary[/]")
.Border(BoxBorder.Rounded);
AnsiConsole.Write(panel);
}
private static Command BuildCheckCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var cmd = new Command("check", "Check VEX status for an image or CVE.");
var imageOption = new Option<string?>("--image")
{
Description = "Container image to check"
};
var cveOption = new Option<string?>("--cve")
{
Description = "CVE identifier to check"
};
cmd.AddOption(imageOption);
cmd.AddOption(cveOption);
cmd.AddOption(verboseOption);
cmd.SetHandler(async (context) =>
{
var image = context.ParseResult.GetValueForOption(imageOption);
var cve = context.ParseResult.GetValueForOption(cveOption);
var verbose = context.ParseResult.GetValueForOption(verboseOption);
if (string.IsNullOrWhiteSpace(image) && string.IsNullOrWhiteSpace(cve))
{
AnsiConsole.MarkupLine("[red]Error:[/] Either --image or --cve must be specified.");
context.ExitCode = 1;
return;
}
AnsiConsole.MarkupLine("[grey]VEX check not yet implemented[/]");
context.ExitCode = 0;
});
return cmd;
}
private static Command BuildListCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var cmd = new Command("list", "List VEX statements.");
var productOption = new Option<string?>("--product")
{
Description = "Filter by product identifier"
};
var statusOption = new Option<string?>("--status")
{
Description = "Filter by VEX status (affected, not_affected, fixed, under_investigation)"
};
var limitOption = new Option<int>("--limit")
{
Description = "Maximum number of results"
};
limitOption.SetDefaultValue(100);
cmd.AddOption(productOption);
cmd.AddOption(statusOption);
cmd.AddOption(limitOption);
cmd.AddOption(verboseOption);
cmd.SetHandler(async (context) =>
{
var product = context.ParseResult.GetValueForOption(productOption);
var status = context.ParseResult.GetValueForOption(statusOption);
var limit = context.ParseResult.GetValueForOption(limitOption);
AnsiConsole.MarkupLine("[grey]VEX list not yet implemented[/]");
context.ExitCode = 0;
});
return cmd;
}
private static Command BuildNotReachableCommand(
IServiceProvider services,
Option<bool> verboseOption,
StellaOpsCliOptions options,
CancellationToken cancellationToken)
{
var cmd = new Command("not-reachable", "Generate VEX with not_reachable_at_runtime justification.");
var imageOption = new Option<string>("--image")
{
Description = "Container image to analyze",
IsRequired = true
};
var windowOption = new Option<int>("--window")
{
Description = "Observation window in hours"
};
windowOption.SetDefaultValue(24);
var minConfidenceOption = new Option<double>("--min-confidence")
{
Description = "Minimum confidence threshold"
};
minConfidenceOption.SetDefaultValue(0.6);
var outputOption = new Option<string?>("--output")
{
Description = "Output file path for generated VEX statements"
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Dry run - analyze but don't generate VEX"
};
cmd.AddOption(imageOption);
cmd.AddOption(windowOption);
cmd.AddOption(minConfidenceOption);
cmd.AddOption(outputOption);
cmd.AddOption(dryRunOption);
cmd.AddOption(verboseOption);
cmd.SetHandler(async (context) =>
{
var image = context.ParseResult.GetValueForOption(imageOption);
var window = context.ParseResult.GetValueForOption(windowOption);
var minConf = context.ParseResult.GetValueForOption(minConfidenceOption);
var output = context.ParseResult.GetValueForOption(outputOption);
var dryRun = context.ParseResult.GetValueForOption(dryRunOption);
var verbose = context.ParseResult.GetValueForOption(verboseOption);
if (string.IsNullOrWhiteSpace(image))
{
AnsiConsole.MarkupLine("[red]Error:[/] --image is required.");
context.ExitCode = 1;
return;
}
await RunNotReachableAnalysisAsync(
services,
image,
TimeSpan.FromHours(window),
minConf,
output,
dryRun,
verbose,
options,
cancellationToken);
context.ExitCode = 0;
});
return cmd;
}
private static async Task RunNotReachableAnalysisAsync(
IServiceProvider services,
string image,
TimeSpan window,
double minConfidence,
string? outputPath,
bool dryRun,
bool verbose,
StellaOpsCliOptions options,
CancellationToken cancellationToken)
{
await AnsiConsole.Status()
.StartAsync("Analyzing unreached vulnerable symbols...", async ctx =>
{
ctx.Spinner(Spinner.Known.Dots);
var client = CreateAutoVexClient(services, options);
var result = await client.AnalyzeNotReachableAsync(
image,
window,
minConfidence,
cancellationToken);
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {result.Error}");
return;
}
if (result.Analyses == null || result.Analyses.Count == 0)
{
AnsiConsole.MarkupLine("[green]✓[/] No unreached vulnerable symbols found requiring VEX");
return;
}
// Display results
var table = new Table();
table.Border = TableBorder.Rounded;
table.Title = new TableTitle("[green]Symbols Not Reachable at Runtime[/]");
table.AddColumn("CVE");
table.AddColumn("Symbol");
table.AddColumn("Component");
table.AddColumn("Confidence");
table.AddColumn("Reason");
foreach (var analysis in result.Analyses)
{
var reason = analysis.PrimaryReason ?? "Unknown";
table.AddRow(
$"[bold]{analysis.CveId}[/]",
analysis.Symbol.Length > 30 ? analysis.Symbol[..27] + "..." : analysis.Symbol,
analysis.ComponentPath.Length > 25 ? "..." + analysis.ComponentPath[^22..] : analysis.ComponentPath,
$"[green]{analysis.Confidence:F2}[/]",
reason
);
}
AnsiConsole.Write(table);
if (!dryRun)
{
ctx.Status("Generating VEX statements...");
var vexResult = await client.GenerateNotReachableVexAsync(
result.Analyses,
cancellationToken);
if (vexResult.Success)
{
AnsiConsole.MarkupLine(
$"[green]✓[/] Generated {vexResult.StatementCount} VEX statement(s)");
if (!string.IsNullOrWhiteSpace(outputPath))
{
var json = JsonSerializer.Serialize(vexResult.Statements, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(outputPath, json, cancellationToken);
AnsiConsole.MarkupLine($"[green]Written to:[/] {outputPath}");
}
}
else
{
AnsiConsole.MarkupLine($"[red]Error:[/] {vexResult.Error}");
}
}
else
{
AnsiConsole.MarkupLine($"[yellow]Dry run:[/] Would generate {result.Analyses.Count} VEX statement(s)");
}
});
}
private static IAutoVexClient CreateAutoVexClient(IServiceProvider services, StellaOpsCliOptions options)
{
// Try to get from DI first
var client = services.GetService<IAutoVexClient>();
if (client != null)
{
return client;
}
// Create HTTP client for API calls
var httpClient = services.GetService<IHttpClientFactory>()?.CreateClient("autovex")
?? new HttpClient();
var baseUrl = options.ExcititorApiBaseUrl
?? Environment.GetEnvironmentVariable("STELLAOPS_EXCITITOR_URL")
?? "http://localhost:5080";
return new AutoVexHttpClient(httpClient, baseUrl);
}
}
/// <summary>
/// Output format for CLI commands.
/// </summary>
public enum OutputFormat
{
Table,
Json,
Csv
}
/// <summary>
/// Client interface for auto-VEX operations.
/// </summary>
public interface IAutoVexClient
{
Task<AutoDowngradeCheckResult> CheckAutoDowngradeAsync(
string image,
int minObservations,
double minCpu,
double minConfidence,
CancellationToken cancellationToken = default);
Task<AutoDowngradeExecuteResult> ExecuteAutoDowngradeAsync(
IReadOnlyList<AutoDowngradeCandidate> candidates,
CancellationToken cancellationToken = default);
Task<NotReachableAnalysisResult> AnalyzeNotReachableAsync(
string image,
TimeSpan window,
double minConfidence,
CancellationToken cancellationToken = default);
Task<NotReachableVexGenerationResult> GenerateNotReachableVexAsync(
IReadOnlyList<NotReachableAnalysisEntry> analyses,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of checking for auto-downgrade candidates.
/// </summary>
public sealed record AutoDowngradeCheckResult
{
public bool Success { get; init; }
public string? ImageDigest { get; init; }
public IReadOnlyList<AutoDowngradeCandidate>? Candidates { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// A candidate for auto-downgrade.
/// </summary>
public sealed record AutoDowngradeCandidate
{
public required string CveId { get; init; }
public required string ProductId { get; init; }
public required string Symbol { get; init; }
public required string ComponentPath { get; init; }
public required double CpuPercentage { get; init; }
public required int ObservationCount { get; init; }
public required double Confidence { get; init; }
public required string BuildId { get; init; }
}
/// <summary>
/// Result of executing auto-downgrades.
/// </summary>
public sealed record AutoDowngradeExecuteResult
{
public bool Success { get; init; }
public int DowngradeCount { get; init; }
public int Notifications { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Result of not-reachable analysis.
/// </summary>
public sealed record NotReachableAnalysisResult
{
public bool Success { get; init; }
public IReadOnlyList<NotReachableAnalysisEntry>? Analyses { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Entry for not-reachable analysis.
/// </summary>
public sealed record NotReachableAnalysisEntry
{
public required string CveId { get; init; }
public required string ProductId { get; init; }
public required string Symbol { get; init; }
public required string ComponentPath { get; init; }
public required double Confidence { get; init; }
public string? PrimaryReason { get; init; }
}
/// <summary>
/// Result of generating not-reachable VEX statements.
/// </summary>
public sealed record NotReachableVexGenerationResult
{
public bool Success { get; init; }
public int StatementCount { get; init; }
public IReadOnlyList<object>? Statements { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// HTTP client implementation for auto-VEX API.
/// </summary>
internal sealed class AutoVexHttpClient : IAutoVexClient
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
public AutoVexHttpClient(HttpClient httpClient, string baseUrl)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_baseUrl = baseUrl?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(baseUrl));
}
public async Task<AutoDowngradeCheckResult> CheckAutoDowngradeAsync(
string image,
int minObservations,
double minCpu,
double minConfidence,
CancellationToken cancellationToken = default)
{
try
{
var url = $"{_baseUrl}/api/v1/vex/auto-downgrade/check?" +
$"image={Uri.EscapeDataString(image)}&" +
$"minObservations={minObservations}&" +
$"minCpu={minCpu}&" +
$"minConfidence={minConfidence}";
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<AutoDowngradeCheckResult>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new AutoDowngradeCheckResult { Success = false, Error = "Failed to deserialize response" };
}
catch (Exception ex)
{
return new AutoDowngradeCheckResult
{
Success = false,
Error = ex.Message
};
}
}
public async Task<AutoDowngradeExecuteResult> ExecuteAutoDowngradeAsync(
IReadOnlyList<AutoDowngradeCandidate> candidates,
CancellationToken cancellationToken = default)
{
try
{
var url = $"{_baseUrl}/api/v1/vex/auto-downgrade/execute";
var content = new StringContent(
JsonSerializer.Serialize(candidates),
System.Text.Encoding.UTF8,
"application/json");
var response = await _httpClient.PostAsync(url, content, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<AutoDowngradeExecuteResult>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new AutoDowngradeExecuteResult { Success = false, Error = "Failed to deserialize response" };
}
catch (Exception ex)
{
return new AutoDowngradeExecuteResult
{
Success = false,
Error = ex.Message
};
}
}
public async Task<NotReachableAnalysisResult> AnalyzeNotReachableAsync(
string image,
TimeSpan window,
double minConfidence,
CancellationToken cancellationToken = default)
{
try
{
var url = $"{_baseUrl}/api/v1/vex/not-reachable/analyze?" +
$"image={Uri.EscapeDataString(image)}&" +
$"windowHours={window.TotalHours}&" +
$"minConfidence={minConfidence}";
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<NotReachableAnalysisResult>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new NotReachableAnalysisResult { Success = false, Error = "Failed to deserialize response" };
}
catch (Exception ex)
{
return new NotReachableAnalysisResult
{
Success = false,
Error = ex.Message
};
}
}
public async Task<NotReachableVexGenerationResult> GenerateNotReachableVexAsync(
IReadOnlyList<NotReachableAnalysisEntry> analyses,
CancellationToken cancellationToken = default)
{
try
{
var url = $"{_baseUrl}/api/v1/vex/not-reachable/generate";
var content = new StringContent(
JsonSerializer.Serialize(analyses),
System.Text.Encoding.UTF8,
"application/json");
var response = await _httpClient.PostAsync(url, content, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<NotReachableVexGenerationResult>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new NotReachableVexGenerationResult { Success = false, Error = "Failed to deserialize response" };
}
catch (Exception ex)
{
return new NotReachableVexGenerationResult
{
Success = false,
Error = ex.Message
};
}
}
}