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:
932
src/Cli/StellaOps.Cli/Commands/Budget/RiskBudgetCommandGroup.cs
Normal file
932
src/Cli/StellaOps.Cli/Commands/Budget/RiskBudgetCommandGroup.cs
Normal 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 <id>
|
||||
/// 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 <id> --points <n> --reason <text>
|
||||
/// 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 <id> --points <n>
|
||||
/// 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 <id>
|
||||
/// 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
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
556
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Feeds.cs
Normal file
556
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Feeds.cs
Normal 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);
|
||||
}
|
||||
344
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Sign.cs
Normal file
344
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Sign.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
281
src/Cli/StellaOps.Cli/Commands/FeedsCommandGroup.cs
Normal file
281
src/Cli/StellaOps.Cli/Commands/FeedsCommandGroup.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
631
src/Cli/StellaOps.Cli/Commands/GateCommandGroup.cs
Normal file
631
src/Cli/StellaOps.Cli/Commands/GateCommandGroup.cs
Normal 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;
|
||||
}
|
||||
289
src/Cli/StellaOps.Cli/Commands/Proof/FuncProofCommandGroup.cs
Normal file
289
src/Cli/StellaOps.Cli/Commands/Proof/FuncProofCommandGroup.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
570
src/Cli/StellaOps.Cli/Commands/Proof/FuncProofCommandHandlers.cs
Normal file
570
src/Cli/StellaOps.Cli/Commands/Proof/FuncProofCommandHandlers.cs
Normal 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;
|
||||
}
|
||||
232
src/Cli/StellaOps.Cli/Commands/SignCommandGroup.cs
Normal file
232
src/Cli/StellaOps.Cli/Commands/SignCommandGroup.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user