feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration
- Add RateLimitConfig for configuration management with YAML binding support. - Introduce RateLimitDecision to encapsulate the result of rate limit checks. - Implement RateLimitMetrics for OpenTelemetry metrics tracking. - Create RateLimitMiddleware for enforcing rate limits on incoming requests. - Develop RateLimitService to orchestrate instance and environment rate limit checks. - Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
475
src/Cli/StellaOps.Cli/Commands/BenchCommandBuilder.cs
Normal file
475
src/Cli/StellaOps.Cli/Commands/BenchCommandBuilder.cs
Normal file
@@ -0,0 +1,475 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BenchCommandBuilder.cs
|
||||
// Sprint: SPRINT_3500_0003_0001_ground_truth_corpus_ci_gates
|
||||
// Task: CORPUS-007 - Add `stellaops bench run --corpus <path>` CLI command
|
||||
// Task: CORPUS-008 - Add `stellaops bench check --baseline <path>` regression checker
|
||||
// Task: CORPUS-011 - Implement baseline update tool
|
||||
// Description: CLI commands for running and managing reachability benchmarks
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Benchmarks;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Builds CLI commands for benchmark operations.
|
||||
/// </summary>
|
||||
internal static class BenchCommandBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
internal static Command BuildBenchCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bench = new Command("bench", "Run and manage reachability benchmarks");
|
||||
|
||||
bench.Add(BuildRunCommand(services, verboseOption, cancellationToken));
|
||||
bench.Add(BuildCheckCommand(services, verboseOption, cancellationToken));
|
||||
bench.Add(BuildBaselineCommand(services, verboseOption, cancellationToken));
|
||||
bench.Add(BuildReportCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return bench;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the `bench run` command.
|
||||
/// </summary>
|
||||
private static Command BuildRunCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var corpusOption = new Option<string>("--corpus", "Path to corpus.json index file")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
var outputOption = new Option<string?>("--output", "Output path for results JSON");
|
||||
var categoryOption = new Option<string[]?>("--category", "Filter to specific categories");
|
||||
var sampleOption = new Option<string[]?>("--sample", "Filter to specific sample IDs");
|
||||
var parallelOption = new Option<int>("--parallel", () => 1, "Number of parallel workers");
|
||||
var timeoutOption = new Option<int>("--timeout", () => 30000, "Timeout per sample in milliseconds");
|
||||
var determinismOption = new Option<bool>("--check-determinism", () => true, "Run determinism checks");
|
||||
var runsOption = new Option<int>("--determinism-runs", () => 3, "Number of runs for determinism check");
|
||||
var formatOption = new Option<string>("--format", () => "json", "Output format: json, markdown");
|
||||
|
||||
var run = new Command("run", "Run the ground-truth corpus benchmark");
|
||||
run.Add(corpusOption);
|
||||
run.Add(outputOption);
|
||||
run.Add(categoryOption);
|
||||
run.Add(sampleOption);
|
||||
run.Add(parallelOption);
|
||||
run.Add(timeoutOption);
|
||||
run.Add(determinismOption);
|
||||
run.Add(runsOption);
|
||||
run.Add(formatOption);
|
||||
|
||||
run.SetAction(async parseResult =>
|
||||
{
|
||||
var corpusPath = parseResult.GetValue(corpusOption)!;
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var categories = parseResult.GetValue(categoryOption);
|
||||
var samples = parseResult.GetValue(sampleOption);
|
||||
var parallel = parseResult.GetValue(parallelOption);
|
||||
var timeout = parseResult.GetValue(timeoutOption);
|
||||
var checkDeterminism = parseResult.GetValue(determinismOption);
|
||||
var determinismRuns = parseResult.GetValue(runsOption);
|
||||
var format = parseResult.GetValue(formatOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!File.Exists(corpusPath))
|
||||
{
|
||||
throw new CommandLineException($"Corpus file not found: {corpusPath}");
|
||||
}
|
||||
|
||||
var options = new CorpusRunOptions
|
||||
{
|
||||
Categories = categories,
|
||||
SampleIds = samples,
|
||||
Parallelism = parallel,
|
||||
TimeoutMs = timeout,
|
||||
CheckDeterminism = checkDeterminism,
|
||||
DeterminismRuns = determinismRuns
|
||||
};
|
||||
|
||||
Console.WriteLine($"Running benchmark corpus: {corpusPath}");
|
||||
Console.WriteLine($"Options: parallel={parallel}, timeout={timeout}ms, determinism={checkDeterminism}");
|
||||
|
||||
var runner = services.GetRequiredService<ICorpusRunner>();
|
||||
var result = await runner.RunAsync(corpusPath, options, cancellationToken);
|
||||
|
||||
// Output results
|
||||
if (format == "markdown")
|
||||
{
|
||||
var markdown = FormatMarkdownReport(result);
|
||||
if (outputPath is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, markdown, cancellationToken);
|
||||
Console.WriteLine($"Markdown report written to: {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(markdown);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var json = JsonSerializer.Serialize(result, JsonOptions);
|
||||
if (outputPath is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, json, cancellationToken);
|
||||
Console.WriteLine($"Results written to: {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Benchmark Summary ===");
|
||||
Console.WriteLine($"Precision: {result.Metrics.Precision:P1}");
|
||||
Console.WriteLine($"Recall: {result.Metrics.Recall:P1}");
|
||||
Console.WriteLine($"F1 Score: {result.Metrics.F1:P1}");
|
||||
Console.WriteLine($"Determinism: {result.Metrics.DeterministicReplay:P0}");
|
||||
Console.WriteLine($"Duration: {result.DurationMs}ms");
|
||||
});
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the `bench check` command.
|
||||
/// </summary>
|
||||
private static Command BuildCheckCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resultsOption = new Option<string>("--results", "Path to benchmark results JSON")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
var baselineOption = new Option<string>("--baseline", "Path to baseline JSON")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
var strictOption = new Option<bool>("--strict", () => false, "Fail on any metric degradation");
|
||||
var outputOption = new Option<string?>("--output", "Output path for regression report");
|
||||
|
||||
var check = new Command("check", "Check benchmark results against baseline");
|
||||
check.Add(resultsOption);
|
||||
check.Add(baselineOption);
|
||||
check.Add(strictOption);
|
||||
check.Add(outputOption);
|
||||
|
||||
check.SetAction(async parseResult =>
|
||||
{
|
||||
var resultsPath = parseResult.GetValue(resultsOption)!;
|
||||
var baselinePath = parseResult.GetValue(baselineOption)!;
|
||||
var strict = parseResult.GetValue(strictOption);
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!File.Exists(resultsPath))
|
||||
{
|
||||
throw new CommandLineException($"Results file not found: {resultsPath}");
|
||||
}
|
||||
if (!File.Exists(baselinePath))
|
||||
{
|
||||
throw new CommandLineException($"Baseline file not found: {baselinePath}");
|
||||
}
|
||||
|
||||
var resultsJson = await File.ReadAllTextAsync(resultsPath, cancellationToken);
|
||||
var baselineJson = await File.ReadAllTextAsync(baselinePath, cancellationToken);
|
||||
|
||||
var result = JsonSerializer.Deserialize<BenchmarkResult>(resultsJson, JsonOptions)
|
||||
?? throw new CommandLineException("Failed to parse results JSON");
|
||||
var baseline = JsonSerializer.Deserialize<BenchmarkBaseline>(baselineJson, JsonOptions)
|
||||
?? throw new CommandLineException("Failed to parse baseline JSON");
|
||||
|
||||
var checkResult = result.CheckRegression(baseline);
|
||||
|
||||
Console.WriteLine("=== Regression Check Results ===");
|
||||
Console.WriteLine($"Status: {(checkResult.Passed ? "PASSED" : "FAILED")}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (checkResult.Issues.Count > 0)
|
||||
{
|
||||
Console.WriteLine("Issues:");
|
||||
foreach (var issue in checkResult.Issues)
|
||||
{
|
||||
var icon = issue.Severity == IssueSeverity.Error ? "❌" : "⚠️";
|
||||
Console.WriteLine($" {icon} [{issue.Metric}] {issue.Message}");
|
||||
Console.WriteLine($" Baseline: {issue.BaselineValue:F4}, Current: {issue.CurrentValue:F4}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No regressions detected.");
|
||||
}
|
||||
|
||||
// Write report if requested
|
||||
if (outputPath is not null)
|
||||
{
|
||||
var report = JsonSerializer.Serialize(checkResult, JsonOptions);
|
||||
await File.WriteAllTextAsync(outputPath, report, cancellationToken);
|
||||
Console.WriteLine($"\nReport written to: {outputPath}");
|
||||
}
|
||||
|
||||
// Exit with error if failed
|
||||
if (!checkResult.Passed)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return check;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the `bench baseline` command group.
|
||||
/// </summary>
|
||||
private static Command BuildBaselineCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseline = new Command("baseline", "Manage benchmark baselines");
|
||||
|
||||
// baseline update
|
||||
var resultsOption = new Option<string>("--results", "Path to benchmark results JSON")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
var outputOption = new Option<string>("--output", "Output path for new baseline")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
var noteOption = new Option<string?>("--note", "Note explaining the baseline update");
|
||||
|
||||
var update = new Command("update", "Update baseline from benchmark results");
|
||||
update.Add(resultsOption);
|
||||
update.Add(outputOption);
|
||||
update.Add(noteOption);
|
||||
|
||||
update.SetAction(async parseResult =>
|
||||
{
|
||||
var resultsPath = parseResult.GetValue(resultsOption)!;
|
||||
var outputPath = parseResult.GetValue(outputOption)!;
|
||||
var note = parseResult.GetValue(noteOption);
|
||||
|
||||
if (!File.Exists(resultsPath))
|
||||
{
|
||||
throw new CommandLineException($"Results file not found: {resultsPath}");
|
||||
}
|
||||
|
||||
var resultsJson = await File.ReadAllTextAsync(resultsPath, cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<BenchmarkResult>(resultsJson, JsonOptions)
|
||||
?? throw new CommandLineException("Failed to parse results JSON");
|
||||
|
||||
var newBaseline = new BenchmarkBaseline(
|
||||
Version: "1.0.0",
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
CorpusVersion: result.CorpusVersion,
|
||||
ScannerVersion: result.ScannerVersion,
|
||||
Precision: result.Metrics.Precision,
|
||||
Recall: result.Metrics.Recall,
|
||||
F1: result.Metrics.F1,
|
||||
TtfrpP95Ms: result.Metrics.TtfrpP95Ms,
|
||||
DeterministicReplay: result.Metrics.DeterministicReplay,
|
||||
Note: note);
|
||||
|
||||
var baselineJson = JsonSerializer.Serialize(newBaseline, JsonOptions);
|
||||
await File.WriteAllTextAsync(outputPath, baselineJson, cancellationToken);
|
||||
|
||||
Console.WriteLine($"Baseline updated: {outputPath}");
|
||||
Console.WriteLine($" Precision: {newBaseline.Precision:P1}");
|
||||
Console.WriteLine($" Recall: {newBaseline.Recall:P1}");
|
||||
Console.WriteLine($" F1: {newBaseline.F1:P1}");
|
||||
Console.WriteLine($" TTFRP p95: {newBaseline.TtfrpP95Ms}ms");
|
||||
Console.WriteLine($" Determinism: {newBaseline.DeterministicReplay:P0}");
|
||||
});
|
||||
|
||||
baseline.Add(update);
|
||||
|
||||
// baseline show
|
||||
var baselinePathOption = new Option<string>("--path", "Path to baseline JSON")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
|
||||
var show = new Command("show", "Display baseline metrics");
|
||||
show.Add(baselinePathOption);
|
||||
|
||||
show.SetAction(async parseResult =>
|
||||
{
|
||||
var path = parseResult.GetValue(baselinePathOption)!;
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new CommandLineException($"Baseline file not found: {path}");
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var baseline = JsonSerializer.Deserialize<BenchmarkBaseline>(json, JsonOptions)
|
||||
?? throw new CommandLineException("Failed to parse baseline JSON");
|
||||
|
||||
Console.WriteLine($"=== Baseline: {path} ===");
|
||||
Console.WriteLine($"Version: {baseline.Version}");
|
||||
Console.WriteLine($"Created: {baseline.CreatedAt:O}");
|
||||
Console.WriteLine($"Corpus: {baseline.CorpusVersion}");
|
||||
Console.WriteLine($"Scanner: {baseline.ScannerVersion}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Metrics:");
|
||||
Console.WriteLine($" Precision: {baseline.Precision:P1}");
|
||||
Console.WriteLine($" Recall: {baseline.Recall:P1}");
|
||||
Console.WriteLine($" F1: {baseline.F1:P1}");
|
||||
Console.WriteLine($" TTFRP p95: {baseline.TtfrpP95Ms}ms");
|
||||
Console.WriteLine($" Determinism: {baseline.DeterministicReplay:P0}");
|
||||
|
||||
if (baseline.Note is not null)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Note: {baseline.Note}");
|
||||
}
|
||||
});
|
||||
|
||||
baseline.Add(show);
|
||||
|
||||
return baseline;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the `bench report` command.
|
||||
/// </summary>
|
||||
private static Command BuildReportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resultsOption = new Option<string>("--results", "Path to benchmark results JSON")
|
||||
{
|
||||
IsRequired = true
|
||||
};
|
||||
var formatOption = new Option<string>("--format", () => "markdown", "Output format: markdown, html");
|
||||
var outputOption = new Option<string?>("--output", "Output path for report");
|
||||
|
||||
var report = new Command("report", "Generate benchmark report");
|
||||
report.Add(resultsOption);
|
||||
report.Add(formatOption);
|
||||
report.Add(outputOption);
|
||||
|
||||
report.SetAction(async parseResult =>
|
||||
{
|
||||
var resultsPath = parseResult.GetValue(resultsOption)!;
|
||||
var format = parseResult.GetValue(formatOption);
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
|
||||
if (!File.Exists(resultsPath))
|
||||
{
|
||||
throw new CommandLineException($"Results file not found: {resultsPath}");
|
||||
}
|
||||
|
||||
var resultsJson = await File.ReadAllTextAsync(resultsPath, cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<BenchmarkResult>(resultsJson, JsonOptions)
|
||||
?? throw new CommandLineException("Failed to parse results JSON");
|
||||
|
||||
var reportContent = format == "html"
|
||||
? FormatHtmlReport(result)
|
||||
: FormatMarkdownReport(result);
|
||||
|
||||
if (outputPath is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, reportContent, cancellationToken);
|
||||
Console.WriteLine($"Report written to: {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(reportContent);
|
||||
}
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
private static string FormatMarkdownReport(BenchmarkResult result)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
sb.AppendLine("# Reachability Benchmark Report");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Run ID:** {result.RunId}");
|
||||
sb.AppendLine($"**Timestamp:** {result.Timestamp:O}");
|
||||
sb.AppendLine($"**Corpus Version:** {result.CorpusVersion}");
|
||||
sb.AppendLine($"**Scanner Version:** {result.ScannerVersion}");
|
||||
sb.AppendLine($"**Duration:** {result.DurationMs}ms");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Summary Metrics");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Metric | Value |");
|
||||
sb.AppendLine("|--------|-------|");
|
||||
sb.AppendLine($"| Precision | {result.Metrics.Precision:P1} |");
|
||||
sb.AppendLine($"| Recall | {result.Metrics.Recall:P1} |");
|
||||
sb.AppendLine($"| F1 Score | {result.Metrics.F1:P1} |");
|
||||
sb.AppendLine($"| TTFRP p50 | {result.Metrics.TtfrpP50Ms}ms |");
|
||||
sb.AppendLine($"| TTFRP p95 | {result.Metrics.TtfrpP95Ms}ms |");
|
||||
sb.AppendLine($"| Deterministic Replay | {result.Metrics.DeterministicReplay:P0} |");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Sample Results");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Sample | Expected | Actual | Match | Duration |");
|
||||
sb.AppendLine("|--------|----------|--------|-------|----------|");
|
||||
|
||||
foreach (var sample in result.SampleResults)
|
||||
{
|
||||
var match = sample.MatchedExpected ? "✅" : "❌";
|
||||
sb.AppendLine($"| {sample.SampleId} | {sample.ExpectedReachability} | {sample.ActualReachability} | {match} | {sample.DurationMs}ms |");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatHtmlReport(BenchmarkResult result)
|
||||
{
|
||||
// Basic HTML report
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html><head><title>Benchmark Report</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine("body { font-family: system-ui; max-width: 900px; margin: 0 auto; padding: 20px; }");
|
||||
sb.AppendLine("table { border-collapse: collapse; width: 100%; }");
|
||||
sb.AppendLine("th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }");
|
||||
sb.AppendLine("th { background-color: #f2f2f2; }");
|
||||
sb.AppendLine(".pass { color: green; }");
|
||||
sb.AppendLine(".fail { color: red; }");
|
||||
sb.AppendLine("</style></head><body>");
|
||||
|
||||
sb.AppendLine($"<h1>Reachability Benchmark Report</h1>");
|
||||
sb.AppendLine($"<p><strong>Run ID:</strong> {result.RunId}</p>");
|
||||
sb.AppendLine($"<p><strong>Timestamp:</strong> {result.Timestamp:O}</p>");
|
||||
|
||||
sb.AppendLine("<h2>Summary Metrics</h2>");
|
||||
sb.AppendLine("<table>");
|
||||
sb.AppendLine("<tr><th>Metric</th><th>Value</th></tr>");
|
||||
sb.AppendLine($"<tr><td>Precision</td><td>{result.Metrics.Precision:P1}</td></tr>");
|
||||
sb.AppendLine($"<tr><td>Recall</td><td>{result.Metrics.Recall:P1}</td></tr>");
|
||||
sb.AppendLine($"<tr><td>F1 Score</td><td>{result.Metrics.F1:P1}</td></tr>");
|
||||
sb.AppendLine($"<tr><td>Determinism</td><td>{result.Metrics.DeterministicReplay:P0}</td></tr>");
|
||||
sb.AppendLine("</table>");
|
||||
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildAdviseCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildConfigCommand(options));
|
||||
root.Add(BuildKmsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildKeyCommand(services, loggerFactory, verboseOption, cancellationToken));
|
||||
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildVexCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildDecisionCommand(services, verboseOption, cancellationToken));
|
||||
@@ -292,6 +293,56 @@ internal static class CommandFactory
|
||||
|
||||
scan.Add(entryTrace);
|
||||
|
||||
// SARIF export command (Task SDIFF-BIN-030)
|
||||
var sarifExport = new Command("sarif", "Export scan results in SARIF 2.1.0 format for CI/CD integration.");
|
||||
var sarifScanIdOption = new Option<string>("--scan-id")
|
||||
{
|
||||
Description = "Scan identifier.",
|
||||
Required = true
|
||||
};
|
||||
var sarifOutputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file path (defaults to stdout)."
|
||||
};
|
||||
var sarifPrettyOption = new Option<bool>("--pretty")
|
||||
{
|
||||
Description = "Pretty-print JSON output."
|
||||
};
|
||||
var sarifIncludeHardeningOption = new Option<bool>("--include-hardening")
|
||||
{
|
||||
Description = "Include binary hardening flags in SARIF output."
|
||||
};
|
||||
var sarifIncludeReachabilityOption = new Option<bool>("--include-reachability")
|
||||
{
|
||||
Description = "Include reachability analysis in SARIF output."
|
||||
};
|
||||
var sarifMinSeverityOption = new Option<string?>("--min-severity")
|
||||
{
|
||||
Description = "Minimum severity to include (none, note, warning, error)."
|
||||
};
|
||||
|
||||
sarifExport.Add(sarifScanIdOption);
|
||||
sarifExport.Add(sarifOutputOption);
|
||||
sarifExport.Add(sarifPrettyOption);
|
||||
sarifExport.Add(sarifIncludeHardeningOption);
|
||||
sarifExport.Add(sarifIncludeReachabilityOption);
|
||||
sarifExport.Add(sarifMinSeverityOption);
|
||||
|
||||
sarifExport.SetAction((parseResult, _) =>
|
||||
{
|
||||
var scanId = parseResult.GetValue(sarifScanIdOption) ?? string.Empty;
|
||||
var output = parseResult.GetValue(sarifOutputOption);
|
||||
var pretty = parseResult.GetValue(sarifPrettyOption);
|
||||
var includeHardening = parseResult.GetValue(sarifIncludeHardeningOption);
|
||||
var includeReachability = parseResult.GetValue(sarifIncludeReachabilityOption);
|
||||
var minSeverity = parseResult.GetValue(sarifMinSeverityOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleScanSarifExportAsync(
|
||||
services, scanId, output, pretty, includeHardening, includeReachability, minSeverity, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
scan.Add(sarifExport);
|
||||
|
||||
scan.Add(run);
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
@@ -638,6 +689,18 @@ internal static class CommandFactory
|
||||
return kms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds key rotation and management commands.
|
||||
/// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
|
||||
/// Task: PROOF-KEY-0011
|
||||
/// </summary>
|
||||
private static Command BuildKeyCommand(IServiceProvider services, ILoggerFactory loggerFactory, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var keyLogger = loggerFactory.CreateLogger<Proof.KeyRotationCommandGroup>();
|
||||
var keyCommandGroup = new Proof.KeyRotationCommandGroup(keyLogger);
|
||||
return keyCommandGroup.BuildCommand();
|
||||
}
|
||||
|
||||
private static Command BuildDatabaseCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var db = new Command("db", "Trigger Concelier database operations via backend jobs.");
|
||||
|
||||
@@ -713,6 +713,93 @@ internal static partial class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export scan results in SARIF 2.1.0 format.
|
||||
/// Task: SDIFF-BIN-030 - CLI option --output-format sarif
|
||||
/// </summary>
|
||||
public static async Task HandleScanSarifExportAsync(
|
||||
IServiceProvider services,
|
||||
string scanId,
|
||||
string? outputPath,
|
||||
bool prettyPrint,
|
||||
bool includeHardening,
|
||||
bool includeReachability,
|
||||
string? minSeverity,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scan-sarif");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.sarif", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scan sarif");
|
||||
activity?.SetTag("stellaops.cli.scan_id", scanId);
|
||||
activity?.SetTag("stellaops.cli.include_hardening", includeHardening);
|
||||
activity?.SetTag("stellaops.cli.include_reachability", includeReachability);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan sarif");
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch SARIF from backend
|
||||
var sarifContent = await client.GetScanSarifAsync(
|
||||
scanId,
|
||||
includeHardening,
|
||||
includeReachability,
|
||||
minSeverity,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (sarifContent is null)
|
||||
{
|
||||
logger.LogWarning("No SARIF data available for scan {ScanId}.", scanId);
|
||||
Console.Error.WriteLine($"No SARIF data available for scan {scanId}.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pretty print if requested
|
||||
if (prettyPrint)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonDoc = System.Text.Json.JsonDocument.Parse(sarifContent);
|
||||
var options = new System.Text.Json.JsonSerializerOptions { WriteIndented = true };
|
||||
sarifContent = System.Text.Json.JsonSerializer.Serialize(jsonDoc.RootElement, options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If parsing fails, output as-is
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file or stdout
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, sarifContent, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("SARIF output written to {OutputPath}.", outputPath);
|
||||
Console.WriteLine($"SARIF output written to {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(sarifContent);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to export SARIF for scan {ScanId}.", scanId);
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScanUploadAsync(
|
||||
IServiceProvider services,
|
||||
string file,
|
||||
|
||||
564
src/Cli/StellaOps.Cli/Commands/Proof/KeyRotationCommandGroup.cs
Normal file
564
src/Cli/StellaOps.Cli/Commands/Proof/KeyRotationCommandGroup.cs
Normal file
@@ -0,0 +1,564 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Proof;
|
||||
|
||||
/// <summary>
|
||||
/// Command group for key rotation operations.
|
||||
/// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
|
||||
/// Task: PROOF-KEY-0011
|
||||
/// Implements advisory §8.2 key rotation commands.
|
||||
/// </summary>
|
||||
public class KeyRotationCommandGroup
|
||||
{
|
||||
private readonly ILogger<KeyRotationCommandGroup> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public KeyRotationCommandGroup(ILogger<KeyRotationCommandGroup> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the key rotation command tree.
|
||||
/// </summary>
|
||||
public Command BuildCommand()
|
||||
{
|
||||
var keyCommand = new Command("key", "Key management and rotation commands");
|
||||
|
||||
keyCommand.AddCommand(BuildListCommand());
|
||||
keyCommand.AddCommand(BuildAddCommand());
|
||||
keyCommand.AddCommand(BuildRevokeCommand());
|
||||
keyCommand.AddCommand(BuildRotateCommand());
|
||||
keyCommand.AddCommand(BuildStatusCommand());
|
||||
keyCommand.AddCommand(BuildHistoryCommand());
|
||||
keyCommand.AddCommand(BuildVerifyCommand());
|
||||
|
||||
return keyCommand;
|
||||
}
|
||||
|
||||
private Command BuildListCommand()
|
||||
{
|
||||
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
|
||||
var includeRevokedOption = new Option<bool>(
|
||||
name: "--include-revoked",
|
||||
getDefaultValue: () => false,
|
||||
description: "Include revoked keys in output");
|
||||
var outputOption = new Option<string>(
|
||||
name: "--output",
|
||||
getDefaultValue: () => "text",
|
||||
description: "Output format: text, json");
|
||||
|
||||
var listCommand = new Command("list", "List keys for a trust anchor")
|
||||
{
|
||||
anchorArg,
|
||||
includeRevokedOption,
|
||||
outputOption
|
||||
};
|
||||
|
||||
listCommand.SetHandler(async (context) =>
|
||||
{
|
||||
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
|
||||
var includeRevoked = context.ParseResult.GetValueForOption(includeRevokedOption);
|
||||
var output = context.ParseResult.GetValueForOption(outputOption) ?? "text";
|
||||
context.ExitCode = await ListKeysAsync(anchorId, includeRevoked, output, context.GetCancellationToken());
|
||||
});
|
||||
|
||||
return listCommand;
|
||||
}
|
||||
|
||||
private Command BuildAddCommand()
|
||||
{
|
||||
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
|
||||
var keyIdArg = new Argument<string>("keyId", "New key ID");
|
||||
var algorithmOption = new Option<string>(
|
||||
aliases: ["-a", "--algorithm"],
|
||||
getDefaultValue: () => "Ed25519",
|
||||
description: "Key algorithm: Ed25519, ES256, ES384, RS256");
|
||||
var publicKeyOption = new Option<string?>(
|
||||
name: "--public-key",
|
||||
description: "Path to public key file (PEM format)");
|
||||
var notesOption = new Option<string?>(
|
||||
name: "--notes",
|
||||
description: "Human-readable notes about the key");
|
||||
|
||||
var addCommand = new Command("add", "Add a new key to a trust anchor")
|
||||
{
|
||||
anchorArg,
|
||||
keyIdArg,
|
||||
algorithmOption,
|
||||
publicKeyOption,
|
||||
notesOption
|
||||
};
|
||||
|
||||
addCommand.SetHandler(async (context) =>
|
||||
{
|
||||
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
|
||||
var keyId = context.ParseResult.GetValueForArgument(keyIdArg);
|
||||
var algorithm = context.ParseResult.GetValueForOption(algorithmOption) ?? "Ed25519";
|
||||
var publicKeyPath = context.ParseResult.GetValueForOption(publicKeyOption);
|
||||
var notes = context.ParseResult.GetValueForOption(notesOption);
|
||||
context.ExitCode = await AddKeyAsync(anchorId, keyId, algorithm, publicKeyPath, notes, context.GetCancellationToken());
|
||||
});
|
||||
|
||||
return addCommand;
|
||||
}
|
||||
|
||||
private Command BuildRevokeCommand()
|
||||
{
|
||||
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
|
||||
var keyIdArg = new Argument<string>("keyId", "Key ID to revoke");
|
||||
var reasonOption = new Option<string>(
|
||||
aliases: ["-r", "--reason"],
|
||||
getDefaultValue: () => "rotation-complete",
|
||||
description: "Reason for revocation");
|
||||
var effectiveOption = new Option<DateTimeOffset?>(
|
||||
name: "--effective-at",
|
||||
description: "Effective revocation time (default: now). ISO-8601 format.");
|
||||
var forceOption = new Option<bool>(
|
||||
name: "--force",
|
||||
getDefaultValue: () => false,
|
||||
description: "Skip confirmation prompt");
|
||||
|
||||
var revokeCommand = new Command("revoke", "Revoke a key from a trust anchor")
|
||||
{
|
||||
anchorArg,
|
||||
keyIdArg,
|
||||
reasonOption,
|
||||
effectiveOption,
|
||||
forceOption
|
||||
};
|
||||
|
||||
revokeCommand.SetHandler(async (context) =>
|
||||
{
|
||||
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
|
||||
var keyId = context.ParseResult.GetValueForArgument(keyIdArg);
|
||||
var reason = context.ParseResult.GetValueForOption(reasonOption) ?? "rotation-complete";
|
||||
var effectiveAt = context.ParseResult.GetValueForOption(effectiveOption) ?? DateTimeOffset.UtcNow;
|
||||
var force = context.ParseResult.GetValueForOption(forceOption);
|
||||
context.ExitCode = await RevokeKeyAsync(anchorId, keyId, reason, effectiveAt, force, context.GetCancellationToken());
|
||||
});
|
||||
|
||||
return revokeCommand;
|
||||
}
|
||||
|
||||
private Command BuildRotateCommand()
|
||||
{
|
||||
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
|
||||
var oldKeyIdArg = new Argument<string>("oldKeyId", "Old key ID to replace");
|
||||
var newKeyIdArg = new Argument<string>("newKeyId", "New key ID");
|
||||
var algorithmOption = new Option<string>(
|
||||
aliases: ["-a", "--algorithm"],
|
||||
getDefaultValue: () => "Ed25519",
|
||||
description: "Key algorithm: Ed25519, ES256, ES384, RS256");
|
||||
var publicKeyOption = new Option<string?>(
|
||||
name: "--public-key",
|
||||
description: "Path to new public key file (PEM format)");
|
||||
var overlapOption = new Option<int>(
|
||||
name: "--overlap-days",
|
||||
getDefaultValue: () => 30,
|
||||
description: "Days to keep both keys active before revoking old");
|
||||
|
||||
var rotateCommand = new Command("rotate", "Rotate a key (add new, schedule old revocation)")
|
||||
{
|
||||
anchorArg,
|
||||
oldKeyIdArg,
|
||||
newKeyIdArg,
|
||||
algorithmOption,
|
||||
publicKeyOption,
|
||||
overlapOption
|
||||
};
|
||||
|
||||
rotateCommand.SetHandler(async (context) =>
|
||||
{
|
||||
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
|
||||
var oldKeyId = context.ParseResult.GetValueForArgument(oldKeyIdArg);
|
||||
var newKeyId = context.ParseResult.GetValueForArgument(newKeyIdArg);
|
||||
var algorithm = context.ParseResult.GetValueForOption(algorithmOption) ?? "Ed25519";
|
||||
var publicKeyPath = context.ParseResult.GetValueForOption(publicKeyOption);
|
||||
var overlapDays = context.ParseResult.GetValueForOption(overlapOption);
|
||||
context.ExitCode = await RotateKeyAsync(anchorId, oldKeyId, newKeyId, algorithm, publicKeyPath, overlapDays, context.GetCancellationToken());
|
||||
});
|
||||
|
||||
return rotateCommand;
|
||||
}
|
||||
|
||||
private Command BuildStatusCommand()
|
||||
{
|
||||
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
|
||||
var outputOption = new Option<string>(
|
||||
name: "--output",
|
||||
getDefaultValue: () => "text",
|
||||
description: "Output format: text, json");
|
||||
|
||||
var statusCommand = new Command("status", "Show key rotation status and warnings")
|
||||
{
|
||||
anchorArg,
|
||||
outputOption
|
||||
};
|
||||
|
||||
statusCommand.SetHandler(async (context) =>
|
||||
{
|
||||
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
|
||||
var output = context.ParseResult.GetValueForOption(outputOption) ?? "text";
|
||||
context.ExitCode = await ShowStatusAsync(anchorId, output, context.GetCancellationToken());
|
||||
});
|
||||
|
||||
return statusCommand;
|
||||
}
|
||||
|
||||
private Command BuildHistoryCommand()
|
||||
{
|
||||
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
|
||||
var keyIdOption = new Option<string?>(
|
||||
aliases: ["-k", "--key-id"],
|
||||
description: "Filter by specific key ID");
|
||||
var limitOption = new Option<int>(
|
||||
name: "--limit",
|
||||
getDefaultValue: () => 50,
|
||||
description: "Maximum entries to show");
|
||||
var outputOption = new Option<string>(
|
||||
name: "--output",
|
||||
getDefaultValue: () => "text",
|
||||
description: "Output format: text, json");
|
||||
|
||||
var historyCommand = new Command("history", "Show key audit history")
|
||||
{
|
||||
anchorArg,
|
||||
keyIdOption,
|
||||
limitOption,
|
||||
outputOption
|
||||
};
|
||||
|
||||
historyCommand.SetHandler(async (context) =>
|
||||
{
|
||||
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
|
||||
var keyId = context.ParseResult.GetValueForOption(keyIdOption);
|
||||
var limit = context.ParseResult.GetValueForOption(limitOption);
|
||||
var output = context.ParseResult.GetValueForOption(outputOption) ?? "text";
|
||||
context.ExitCode = await ShowHistoryAsync(anchorId, keyId, limit, output, context.GetCancellationToken());
|
||||
});
|
||||
|
||||
return historyCommand;
|
||||
}
|
||||
|
||||
private Command BuildVerifyCommand()
|
||||
{
|
||||
var anchorArg = new Argument<Guid>("anchorId", "Trust anchor ID");
|
||||
var keyIdArg = new Argument<string>("keyId", "Key ID to verify");
|
||||
var signedAtOption = new Option<DateTimeOffset?>(
|
||||
aliases: ["-t", "--signed-at"],
|
||||
description: "Verify key was valid at this time (ISO-8601)");
|
||||
|
||||
var verifyCommand = new Command("verify", "Verify a key's validity at a point in time")
|
||||
{
|
||||
anchorArg,
|
||||
keyIdArg,
|
||||
signedAtOption
|
||||
};
|
||||
|
||||
verifyCommand.SetHandler(async (context) =>
|
||||
{
|
||||
var anchorId = context.ParseResult.GetValueForArgument(anchorArg);
|
||||
var keyId = context.ParseResult.GetValueForArgument(keyIdArg);
|
||||
var signedAt = context.ParseResult.GetValueForOption(signedAtOption) ?? DateTimeOffset.UtcNow;
|
||||
context.ExitCode = await VerifyKeyAsync(anchorId, keyId, signedAt, context.GetCancellationToken());
|
||||
});
|
||||
|
||||
return verifyCommand;
|
||||
}
|
||||
|
||||
#region Handler Implementations
|
||||
|
||||
private async Task<int> ListKeysAsync(Guid anchorId, bool includeRevoked, string output, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Listing keys for anchor {AnchorId}, includeRevoked={IncludeRevoked}",
|
||||
anchorId, includeRevoked);
|
||||
|
||||
// TODO: Wire up to IKeyRotationService when DI is available
|
||||
|
||||
if (output == "json")
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
anchorId = anchorId.ToString(),
|
||||
activeKeys = Array.Empty<object>(),
|
||||
revokedKeys = includeRevoked ? Array.Empty<object>() : null
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Keys for Trust Anchor: {anchorId}");
|
||||
Console.WriteLine("═════════════════════════════════════════════");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Active Keys:");
|
||||
Console.WriteLine(" (No active keys found - connect to service)");
|
||||
if (includeRevoked)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Revoked Keys:");
|
||||
Console.WriteLine(" (No revoked keys found - connect to service)");
|
||||
}
|
||||
}
|
||||
|
||||
return ProofExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list keys for anchor {AnchorId}", anchorId);
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> AddKeyAsync(Guid anchorId, string keyId, string algorithm, string? publicKeyPath, string? notes, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Adding key {KeyId} to anchor {AnchorId}", keyId, anchorId);
|
||||
|
||||
string? publicKey = null;
|
||||
if (publicKeyPath != null)
|
||||
{
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Public key file not found: {publicKeyPath}");
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
publicKey = await File.ReadAllTextAsync(publicKeyPath, ct);
|
||||
}
|
||||
|
||||
// TODO: Wire up to IKeyRotationService.AddKeyAsync
|
||||
|
||||
Console.WriteLine("Adding key to trust anchor...");
|
||||
Console.WriteLine($" Anchor: {anchorId}");
|
||||
Console.WriteLine($" Key ID: {keyId}");
|
||||
Console.WriteLine($" Algorithm: {algorithm}");
|
||||
Console.WriteLine($" Public Key: {(publicKey != null ? "Provided" : "Not specified")}");
|
||||
if (notes != null)
|
||||
Console.WriteLine($" Notes: {notes}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Key added successfully (simulation)");
|
||||
|
||||
return ProofExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to add key {KeyId} to anchor {AnchorId}", keyId, anchorId);
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> RevokeKeyAsync(Guid anchorId, string keyId, string reason, DateTimeOffset effectiveAt, bool force, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Revoking key {KeyId} from anchor {AnchorId}", keyId, anchorId);
|
||||
|
||||
if (!force)
|
||||
{
|
||||
Console.Write($"Revoke key '{keyId}' from anchor {anchorId}? [y/N] ");
|
||||
var response = Console.ReadLine();
|
||||
if (response?.ToLowerInvariant() != "y")
|
||||
{
|
||||
Console.WriteLine("Cancelled.");
|
||||
return ProofExitCodes.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Wire up to IKeyRotationService.RevokeKeyAsync
|
||||
|
||||
Console.WriteLine("Revoking key...");
|
||||
Console.WriteLine($" Anchor: {anchorId}");
|
||||
Console.WriteLine($" Key ID: {keyId}");
|
||||
Console.WriteLine($" Reason: {reason}");
|
||||
Console.WriteLine($" Effective At: {effectiveAt:O}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Key revoked successfully (simulation)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Note: Proofs signed before revocation remain valid.");
|
||||
|
||||
return ProofExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to revoke key {KeyId} from anchor {AnchorId}", keyId, anchorId);
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> RotateKeyAsync(Guid anchorId, string oldKeyId, string newKeyId, string algorithm, string? publicKeyPath, int overlapDays, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Rotating key {OldKeyId} -> {NewKeyId} for anchor {AnchorId}",
|
||||
oldKeyId, newKeyId, anchorId);
|
||||
|
||||
string? publicKey = null;
|
||||
if (publicKeyPath != null)
|
||||
{
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Public key file not found: {publicKeyPath}");
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
publicKey = await File.ReadAllTextAsync(publicKeyPath, ct);
|
||||
}
|
||||
|
||||
var revokeAt = DateTimeOffset.UtcNow.AddDays(overlapDays);
|
||||
|
||||
// TODO: Wire up to IKeyRotationService
|
||||
|
||||
Console.WriteLine("Key Rotation Plan");
|
||||
Console.WriteLine("═════════════════");
|
||||
Console.WriteLine($" Anchor: {anchorId}");
|
||||
Console.WriteLine($" Old Key: {oldKeyId}");
|
||||
Console.WriteLine($" New Key: {newKeyId}");
|
||||
Console.WriteLine($" Algorithm: {algorithm}");
|
||||
Console.WriteLine($" Overlap Period: {overlapDays} days");
|
||||
Console.WriteLine($" Old Key Revokes At: {revokeAt:O}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Step 1: Add new key to allowedKeyIds...");
|
||||
Console.WriteLine(" ✓ Key added (simulation)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Step 2: Schedule old key revocation...");
|
||||
Console.WriteLine($" ✓ Old key will be revoked on {revokeAt:yyyy-MM-dd} (simulation)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✓ Key rotation initiated successfully");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Next Steps:");
|
||||
Console.WriteLine($" 1. Start using '{newKeyId}' for new signatures");
|
||||
Console.WriteLine($" 2. Old key remains valid until {revokeAt:yyyy-MM-dd}");
|
||||
Console.WriteLine($" 3. Run 'stellaops key status {anchorId}' to check rotation warnings");
|
||||
|
||||
return ProofExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to rotate key {OldKeyId} -> {NewKeyId} for anchor {AnchorId}",
|
||||
oldKeyId, newKeyId, anchorId);
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> ShowStatusAsync(Guid anchorId, string output, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Showing key status for anchor {AnchorId}", anchorId);
|
||||
|
||||
// TODO: Wire up to IKeyRotationService.GetRotationWarningsAsync
|
||||
|
||||
if (output == "json")
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
anchorId = anchorId.ToString(),
|
||||
status = "healthy",
|
||||
warnings = Array.Empty<object>()
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Key Status for Trust Anchor: {anchorId}");
|
||||
Console.WriteLine("═════════════════════════════════════════════");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Overall Status: ✓ Healthy (simulation)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Active Keys: 0");
|
||||
Console.WriteLine("Revoked Keys: 0");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Rotation Warnings: None");
|
||||
}
|
||||
|
||||
return ProofExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to show status for anchor {AnchorId}", anchorId);
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> ShowHistoryAsync(Guid anchorId, string? keyId, int limit, string output, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Showing key history for anchor {AnchorId}, keyId={KeyId}, limit={Limit}",
|
||||
anchorId, keyId, limit);
|
||||
|
||||
// TODO: Wire up to IKeyRotationService.GetKeyHistoryAsync
|
||||
|
||||
if (output == "json")
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
anchorId = anchorId.ToString(),
|
||||
keyId = keyId,
|
||||
entries = Array.Empty<object>()
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Key Audit History for Trust Anchor: {anchorId}");
|
||||
if (keyId != null)
|
||||
Console.WriteLine($" Filtered by Key: {keyId}");
|
||||
Console.WriteLine("═════════════════════════════════════════════");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Timestamp | Operation | Key ID | Operator");
|
||||
Console.WriteLine("───────────────────────────────────────────────────────────────────");
|
||||
Console.WriteLine("(No history entries - connect to service)");
|
||||
}
|
||||
|
||||
return ProofExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to show history for anchor {AnchorId}", anchorId);
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> VerifyKeyAsync(Guid anchorId, string keyId, DateTimeOffset signedAt, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Verifying key {KeyId} validity at {SignedAt} for anchor {AnchorId}",
|
||||
keyId, signedAt, anchorId);
|
||||
|
||||
// TODO: Wire up to IKeyRotationService.CheckKeyValidityAsync
|
||||
|
||||
Console.WriteLine($"Key Validity Check");
|
||||
Console.WriteLine("═════════════════════════════════════════════");
|
||||
Console.WriteLine($" Anchor: {anchorId}");
|
||||
Console.WriteLine($" Key ID: {keyId}");
|
||||
Console.WriteLine($" Time: {signedAt:O}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Result: ⚠ Unknown (connect to service for verification)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Temporal validation checks:");
|
||||
Console.WriteLine(" [ ] Key existed at specified time");
|
||||
Console.WriteLine(" [ ] Key was not revoked before specified time");
|
||||
Console.WriteLine(" [ ] Key algorithm is currently trusted");
|
||||
|
||||
return ProofExitCodes.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to verify key {KeyId} for anchor {AnchorId}", keyId, anchorId);
|
||||
return ProofExitCodes.SystemError;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -3,6 +3,7 @@ namespace StellaOps.Cli.Output;
|
||||
/// <summary>
|
||||
/// Output format for CLI commands.
|
||||
/// Per CLI-CORE-41-001, supports json/yaml/table formats.
|
||||
/// Task SDIFF-BIN-030: Added SARIF format for CI/CD integration.
|
||||
/// </summary>
|
||||
public enum OutputFormat
|
||||
{
|
||||
@@ -13,5 +14,8 @@ public enum OutputFormat
|
||||
Json,
|
||||
|
||||
/// <summary>YAML format for configuration/scripting.</summary>
|
||||
Yaml
|
||||
Yaml,
|
||||
|
||||
/// <summary>SARIF 2.1.0 format for CI/CD integration (GitHub, GitLab, Azure DevOps).</summary>
|
||||
Sarif
|
||||
}
|
||||
|
||||
@@ -4750,6 +4750,50 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return result ?? new SdkListResponse { Success = false, Error = "Empty response" };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get SARIF 2.1.0 output for a scan.
|
||||
/// Task: SDIFF-BIN-030 - CLI option --output-format sarif
|
||||
/// </summary>
|
||||
public async Task<string?> GetScanSarifAsync(
|
||||
string scanId,
|
||||
bool includeHardening,
|
||||
bool includeReachability,
|
||||
string? minSeverity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
OfflineModeGuard.ThrowIfOffline("scan sarif");
|
||||
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (includeHardening)
|
||||
queryParams.Add("includeHardening=true");
|
||||
|
||||
if (includeReachability)
|
||||
queryParams.Add("includeReachability=true");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(minSeverity))
|
||||
queryParams.Add($"minSeverity={Uri.EscapeDataString(minSeverity)}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : "";
|
||||
var relative = $"api/scans/{Uri.EscapeDataString(scanId)}/sarif{query}";
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, relative);
|
||||
httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/sarif+json"));
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports VEX decisions as OpenVEX documents with optional DSSE signing.
|
||||
/// </summary>
|
||||
|
||||
@@ -133,4 +133,7 @@ internal interface IBackendOperationsClient
|
||||
// CLI-SDK-64-001: SDK update
|
||||
Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken);
|
||||
Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// SDIFF-BIN-030: SARIF export
|
||||
Task<string?> GetScanSarifAsync(string scanId, bool includeHardening, bool includeReachability, string? minSeverity, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user