using System;
using System.Buffers;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Spectre.Console;
using Spectre.Console.Rendering;
using StellaOps.Auth.Client;
using StellaOps.ExportCenter.Client;
using StellaOps.ExportCenter.Client.Models;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Output;
using StellaOps.Cli.Prompts;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Bun;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Telemetry;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Kms;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.Policy.Scoring.Policies;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Java;
using StellaOps.Scanner.Analyzers.Lang.Node;
using StellaOps.Scanner.Analyzers.Lang.Python;
using StellaOps.Scanner.Analyzers.Lang.Ruby;
using StellaOps.Scanner.Analyzers.Lang.Php;
using StellaOps.Scanner.Analyzers.Lang.Bun;
using StellaOps.Policy;
using StellaOps.PolicyDsl;
namespace StellaOps.Cli.Commands;
internal static class CommandHandlers
{
private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE";
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
///
/// Standard JSON serializer options for CLI output.
///
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
///
/// JSON serializer options for output (alias for JsonOptions).
///
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
private static readonly JsonSerializerOptions CompactJson = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
///
/// Sets the verbosity level for logging.
///
private static void SetVerbosity(IServiceProvider services, bool verbose)
{
// Configure logging level based on verbose flag
var loggerFactory = services.GetService();
if (loggerFactory is not null && verbose)
{
// Enable debug logging when verbose is true
var logger = loggerFactory.CreateLogger("StellaOps.Cli.Commands.CommandHandlers");
logger.LogDebug("Verbose logging enabled");
}
}
public static async Task HandleCvssScoreAsync(
IServiceProvider services,
string vulnerabilityId,
string policyPath,
string vector,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("cvss-score");
var verbosity = scope.ServiceProvider.GetRequiredService();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
var loader = new CvssPolicyLoader();
var policyResult = loader.Load(policyJson, cancellationToken);
if (!policyResult.IsValid || policyResult.Policy is null || string.IsNullOrWhiteSpace(policyResult.Hash))
{
var errors = string.Join("; ", policyResult.Errors.Select(e => $"{e.Path}: {e.Message}"));
throw new InvalidOperationException($"Policy invalid: {errors}");
}
var policy = policyResult.Policy with { Hash = policyResult.Hash };
var engine = scope.ServiceProvider.GetRequiredService();
var parsed = engine.ParseVector(vector);
var client = scope.ServiceProvider.GetRequiredService();
var request = new CreateCvssReceipt(
vulnerabilityId,
policy,
parsed.BaseMetrics,
parsed.ThreatMetrics,
parsed.EnvironmentalMetrics,
parsed.SupplementalMetrics,
Array.Empty(),
SigningKey: null,
CreatedBy: "cli",
CreatedAt: DateTimeOffset.UtcNow);
var receipt = await client.CreateReceiptAsync(request, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("CVSS receipt creation failed.");
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
}
else
{
Console.WriteLine($"✔ CVSS receipt {receipt.ReceiptId} created | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
Console.WriteLine($"Vector: {receipt.VectorString}");
Console.WriteLine($"Policy: {receipt.PolicyRef.PolicyId} v{receipt.PolicyRef.Version} ({receipt.PolicyRef.Hash})");
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create CVSS receipt");
Environment.ExitCode = 1;
if (json)
{
var problem = new { error = "cvss_score_failed", message = ex.Message };
Console.WriteLine(JsonSerializer.Serialize(problem, CompactJson));
}
}
}
public static async Task HandleCvssShowAsync(
IServiceProvider services,
string receiptId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("cvss-show");
var verbosity = scope.ServiceProvider.GetRequiredService();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService();
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
Environment.ExitCode = 5;
Console.WriteLine(json
? JsonSerializer.Serialize(new { error = "not_found", receiptId }, CompactJson)
: $"✖ Receipt {receiptId} not found");
return;
}
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
}
else
{
Console.WriteLine($"Receipt {receipt.ReceiptId} | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
Console.WriteLine($"Created {receipt.CreatedAt:u} by {receipt.CreatedBy}");
Console.WriteLine($"Vector: {receipt.VectorString}");
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch CVSS receipt {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
public static async Task HandleCvssHistoryAsync(
IServiceProvider services,
string receiptId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("cvss-history");
var verbosity = scope.ServiceProvider.GetRequiredService();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService();
var history = await client.GetHistoryAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(history, CompactJson));
}
else
{
if (history.Count == 0)
{
Console.WriteLine("(no history)");
}
else
{
foreach (var entry in history.OrderBy(h => h.Timestamp))
{
Console.WriteLine($"{entry.Timestamp:u} | {entry.Actor} | {entry.ChangeType} {entry.Field} => {entry.NewValue ?? ""} ({entry.Reason})");
}
}
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch CVSS receipt history {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
public static async Task HandleCvssExportAsync(
IServiceProvider services,
string receiptId,
string format,
string? output,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("cvss-export");
var verbosity = scope.ServiceProvider.GetRequiredService();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService();
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
Environment.ExitCode = 5;
Console.WriteLine($"✖ Receipt {receiptId} not found");
return;
}
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = 9;
Console.WriteLine("Only json export is supported at this time.");
return;
}
var targetPath = string.IsNullOrWhiteSpace(output)
? $"cvss-receipt-{receipt.ReceiptId}.json"
: output!;
var jsonPayload = JsonSerializer.Serialize(receipt, CompactJson);
await File.WriteAllTextAsync(targetPath, jsonPayload, cancellationToken).ConfigureAwait(false);
Console.WriteLine($"✔ Exported receipt to {targetPath}");
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export CVSS receipt {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
{
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
var shaPath = path + ".sha256";
if (!File.Exists(shaPath))
{
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
Environment.ExitCode = 21;
throw new InvalidOperationException("Checksum file missing");
}
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actual = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
Environment.ExitCode = 22;
throw new InvalidOperationException("Checksum verification failed");
}
logger.LogInformation("Checksum verified for {Bundle}", path);
}
public static async Task HandleScannerDownloadAsync(
IServiceProvider services,
string channel,
string? output,
bool overwrite,
bool install,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-download");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "scanner download");
activity?.SetTag("stellaops.cli.channel", channel);
using var duration = CliMetrics.MeasureCommandDuration("scanner download");
try
{
var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
if (result.FromCache)
{
logger.LogInformation("Using cached scanner at {Path}.", result.Path);
}
else
{
logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
}
CliMetrics.RecordScannerDownload(channel, result.FromCache);
if (install)
{
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
var installer = scope.ServiceProvider.GetRequiredService();
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordScannerInstall(channel);
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download scanner bundle.");
if (Environment.ExitCode == 0)
{
Environment.ExitCode = 1;
}
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleTaskRunnerSimulateAsync(
IServiceProvider services,
string manifestPath,
string? inputsPath,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("task-runner-simulate");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.taskrunner.simulate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "task-runner simulate");
using var duration = CliMetrics.MeasureCommandDuration("task-runner simulate");
try
{
if (string.IsNullOrWhiteSpace(manifestPath))
{
throw new ArgumentException("Manifest path must be provided.", nameof(manifestPath));
}
var manifestFullPath = Path.GetFullPath(manifestPath);
if (!File.Exists(manifestFullPath))
{
throw new FileNotFoundException("Manifest file not found.", manifestFullPath);
}
activity?.SetTag("stellaops.cli.manifest_path", manifestFullPath);
var manifest = await File.ReadAllTextAsync(manifestFullPath, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(manifest))
{
throw new InvalidOperationException("Manifest file was empty.");
}
JsonObject? inputsObject = null;
if (!string.IsNullOrWhiteSpace(inputsPath))
{
var inputsFullPath = Path.GetFullPath(inputsPath!);
if (!File.Exists(inputsFullPath))
{
throw new FileNotFoundException("Inputs file not found.", inputsFullPath);
}
await using var stream = File.OpenRead(inputsFullPath);
var parsed = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (parsed is JsonObject obj)
{
inputsObject = obj;
}
else
{
throw new InvalidOperationException("Simulation inputs must be a JSON object.");
}
activity?.SetTag("stellaops.cli.inputs_path", inputsFullPath);
}
var request = new TaskRunnerSimulationRequest(manifest, inputsObject);
var result = await client.SimulateTaskRunnerAsync(request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.plan_hash", result.PlanHash);
activity?.SetTag("stellaops.cli.pending_approvals", result.HasPendingApprovals);
activity?.SetTag("stellaops.cli.step_count", result.Steps.Count);
var outputFormat = DetermineTaskRunnerSimulationFormat(format, outputPath);
var payload = BuildTaskRunnerSimulationPayload(result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteSimulationOutputAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Simulation payload written to {Path}.", Path.GetFullPath(outputPath!));
}
if (outputFormat == TaskRunnerSimulationOutputFormat.Json)
{
Console.WriteLine(JsonSerializer.Serialize(payload, SimulationJsonOptions));
}
else
{
RenderTaskRunnerSimulationResult(result);
}
var outcome = result.HasPendingApprovals ? "pending-approvals" : "ok";
CliMetrics.RecordTaskRunnerSimulation(outcome);
Environment.ExitCode = 0;
}
catch (FileNotFoundException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 66;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 64;
}
catch (InvalidOperationException ex)
{
logger.LogError(ex, "Task Runner simulation failed.");
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Task Runner simulation failed.");
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderEntryTrace(EntryTraceResponseModel result, bool includeNdjson)
{
var console = AnsiConsole.Console;
console.MarkupLine($"[bold]Scan[/]: {result.ScanId}");
console.MarkupLine($"Image: {result.ImageDigest}");
console.MarkupLine($"Generated: {result.GeneratedAt:O}");
console.MarkupLine($"Outcome: {result.Graph.Outcome}");
if (result.BestPlan is not null)
{
console.MarkupLine($"Best Terminal: {result.BestPlan.TerminalPath} (conf {result.BestPlan.Confidence:F1}, user {result.BestPlan.User}, cwd {result.BestPlan.WorkingDirectory})");
}
var planTable = new Table()
.AddColumn("Terminal")
.AddColumn("Runtime")
.AddColumn("Type")
.AddColumn("Confidence")
.AddColumn("User")
.AddColumn("Workdir");
foreach (var plan in result.Graph.Plans.OrderByDescending(p => p.Confidence))
{
var confidence = plan.Confidence.ToString("F1", CultureInfo.InvariantCulture);
planTable.AddRow(
plan.TerminalPath,
plan.Runtime ?? "-",
plan.Type.ToString(),
confidence,
plan.User,
plan.WorkingDirectory);
}
if (planTable.Rows.Count > 0)
{
console.Write(planTable);
}
else
{
console.MarkupLine("[italic]No entry trace plans recorded.[/]");
}
if (result.Graph.Diagnostics.Length > 0)
{
var diagTable = new Table()
.AddColumn("Severity")
.AddColumn("Reason")
.AddColumn("Message");
foreach (var diagnostic in result.Graph.Diagnostics)
{
diagTable.AddRow(
diagnostic.Severity.ToString(),
diagnostic.Reason.ToString(),
diagnostic.Message);
}
console.Write(diagTable);
}
if (includeNdjson && result.Ndjson.Count > 0)
{
console.MarkupLine("[bold]NDJSON Output[/]");
foreach (var line in result.Ndjson)
{
console.WriteLine(line);
}
}
}
public static async Task HandleScannerRunAsync(
IServiceProvider services,
string runner,
string entry,
string targetDirectory,
IReadOnlyList arguments,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var executor = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-run");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal);
activity?.SetTag("stellaops.cli.command", "scan run");
activity?.SetTag("stellaops.cli.runner", runner);
activity?.SetTag("stellaops.cli.entry", entry);
activity?.SetTag("stellaops.cli.target", targetDirectory);
using var duration = CliMetrics.MeasureCommandDuration("scan run");
try
{
var options = scope.ServiceProvider.GetRequiredService();
var resultsDirectory = options.ResultsDirectory;
var executionResult = await executor.RunAsync(
runner,
entry,
targetDirectory,
resultsDirectory,
arguments,
verbose,
cancellationToken).ConfigureAwait(false);
Environment.ExitCode = executionResult.ExitCode;
CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
if (executionResult.ExitCode == 0)
{
var backend = scope.ServiceProvider.GetRequiredService();
logger.LogInformation("Uploading scan artefact {Path}...", executionResult.ResultsPath);
await backend.UploadScanResultsAsync(executionResult.ResultsPath, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Scan artefact uploaded.");
activity?.SetTag("stellaops.cli.results", executionResult.ResultsPath);
}
else
{
logger.LogWarning("Skipping automatic upload because scan exited with code {Code}.", executionResult.ExitCode);
}
logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath);
activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath);
}
catch (Exception ex)
{
logger.LogError(ex, "Scanner execution failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleScanUploadAsync(
IServiceProvider services,
string file,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-upload");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "scan upload");
activity?.SetTag("stellaops.cli.file", file);
using var duration = CliMetrics.MeasureCommandDuration("scan upload");
try
{
var pathFull = Path.GetFullPath(file);
await client.UploadScanResultsAsync(pathFull, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Scan results uploaded successfully.");
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to upload scan results.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleScanEntryTraceAsync(
IServiceProvider services,
string scanId,
bool includeNdjson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scan-entrytrace");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.entrytrace", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "scan entrytrace");
activity?.SetTag("stellaops.cli.scan_id", scanId);
using var duration = CliMetrics.MeasureCommandDuration("scan entrytrace");
try
{
var result = await client.GetEntryTraceAsync(scanId, cancellationToken).ConfigureAwait(false);
if (result is null)
{
logger.LogWarning("No EntryTrace data available for scan {ScanId}.", scanId);
var console = AnsiConsole.Console;
console.MarkupLine("[yellow]No EntryTrace data available for scan {0}.[/]", Markup.Escape(scanId));
console.Write(new Text($"No EntryTrace data available for scan {scanId}.{Environment.NewLine}"));
Console.WriteLine($"No EntryTrace data available for scan {scanId}.");
Environment.ExitCode = 1;
return;
}
RenderEntryTrace(result, includeNdjson);
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch EntryTrace for scan {ScanId}.", scanId);
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleAdviseRunAsync(
IServiceProvider services,
AdvisoryAiTaskType taskType,
string advisoryKey,
string? artifactId,
string? artifactPurl,
string? policyVersion,
string profile,
IReadOnlyList preferredSections,
bool forceRefresh,
int timeoutSeconds,
AdvisoryOutputFormat outputFormat,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("advise-run");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.run", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "advise run");
activity?.SetTag("stellaops.cli.task", taskType.ToString());
using var duration = CliMetrics.MeasureCommandDuration("advisory run");
activity?.SetTag("stellaops.cli.force_refresh", forceRefresh);
var outcome = "error";
try
{
var normalizedKey = advisoryKey?.Trim();
if (string.IsNullOrWhiteSpace(normalizedKey))
{
throw new ArgumentException("Advisory key is required.", nameof(advisoryKey));
}
activity?.SetTag("stellaops.cli.advisory.key", normalizedKey);
var normalizedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
activity?.SetTag("stellaops.cli.profile", normalizedProfile);
var normalizedSections = NormalizeSections(preferredSections);
var request = new AdvisoryPipelinePlanRequestModel
{
TaskType = taskType,
AdvisoryKey = normalizedKey,
ArtifactId = string.IsNullOrWhiteSpace(artifactId) ? null : artifactId!.Trim(),
ArtifactPurl = string.IsNullOrWhiteSpace(artifactPurl) ? null : artifactPurl!.Trim(),
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion!.Trim(),
Profile = normalizedProfile,
PreferredSections = normalizedSections.Length > 0 ? normalizedSections : null,
ForceRefresh = forceRefresh
};
logger.LogInformation("Requesting advisory plan for {TaskType} (advisory={AdvisoryKey}).", taskType, normalizedKey);
var plan = await client.CreateAdvisoryPipelinePlanAsync(taskType, request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.advisory.cache_key", plan.CacheKey);
RenderAdvisoryPlan(plan);
logger.LogInformation("Plan {CacheKey} queued with {Chunks} chunks and {Vectors} vectors.",
plan.CacheKey,
plan.Chunks.Count,
plan.Vectors.Count);
var pollDelay = TimeSpan.FromSeconds(1);
var shouldWait = timeoutSeconds > 0;
var deadline = shouldWait ? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(timeoutSeconds) : DateTimeOffset.UtcNow;
AdvisoryPipelineOutputModel? output = null;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
output = await client
.TryGetAdvisoryPipelineOutputAsync(plan.CacheKey, taskType, normalizedProfile, cancellationToken)
.ConfigureAwait(false);
if (output is not null)
{
break;
}
if (!shouldWait || DateTimeOffset.UtcNow >= deadline)
{
break;
}
logger.LogDebug("Advisory output pending for {CacheKey}; retrying in {DelaySeconds}s.", plan.CacheKey, pollDelay.TotalSeconds);
await Task.Delay(pollDelay, cancellationToken).ConfigureAwait(false);
}
if (output is null)
{
logger.LogError("Timed out after {Timeout}s waiting for advisory output (cache key {CacheKey}).",
Math.Max(timeoutSeconds, 0),
plan.CacheKey);
activity?.SetStatus(ActivityStatusCode.Error, "timeout");
outcome = "timeout";
Environment.ExitCode = Environment.ExitCode == 0 ? 70 : Environment.ExitCode;
return;
}
activity?.SetTag("stellaops.cli.advisory.generated_at", output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture));
activity?.SetTag("stellaops.cli.advisory.cache_hit", output.PlanFromCache);
logger.LogInformation("Advisory output ready (cache key {CacheKey}).", output.CacheKey);
var rendered = RenderAdvisoryOutput(output, outputFormat);
if (!string.IsNullOrWhiteSpace(outputPath) && rendered is not null)
{
var fullPath = Path.GetFullPath(outputPath!);
await File.WriteAllTextAsync(fullPath, rendered, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Advisory output written to {Path}.", fullPath);
}
if (rendered is not null)
{
// Surface the rendered advisory to the active console so users (and tests) can see it even when also writing to disk.
AnsiConsole.Console.WriteLine(rendered);
}
if (output.Guardrail.Blocked)
{
logger.LogError("Guardrail blocked advisory output (cache key {CacheKey}).", output.CacheKey);
activity?.SetStatus(ActivityStatusCode.Error, "guardrail_blocked");
outcome = "blocked";
Environment.ExitCode = Environment.ExitCode == 0 ? 65 : Environment.ExitCode;
return;
}
activity?.SetStatus(ActivityStatusCode.Ok);
outcome = output.PlanFromCache ? "cache-hit" : "ok";
Environment.ExitCode = 0;
}
catch (OperationCanceledException)
{
outcome = "cancelled";
activity?.SetStatus(ActivityStatusCode.Error, "cancelled");
Environment.ExitCode = Environment.ExitCode == 0 ? 130 : Environment.ExitCode;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
logger.LogError(ex, "Failed to run advisory task.");
outcome = "error";
Environment.ExitCode = Environment.ExitCode == 0 ? 1 : Environment.ExitCode;
}
finally
{
activity?.SetTag("stellaops.cli.advisory.outcome", outcome);
CliMetrics.RecordAdvisoryRun(taskType.ToString(), outcome);
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleAdviseBatchAsync(
IServiceProvider services,
AdvisoryAiTaskType taskType,
IReadOnlyList advisoryKeys,
string? artifactId,
string? artifactPurl,
string? policyVersion,
string profile,
IReadOnlyList preferredSections,
bool forceRefresh,
int timeoutSeconds,
AdvisoryOutputFormat outputFormat,
string? outputDirectory,
bool verbose,
CancellationToken cancellationToken)
{
if (advisoryKeys.Count == 0)
{
throw new ArgumentException("At least one advisory key is required.", nameof(advisoryKeys));
}
var outputDir = string.IsNullOrWhiteSpace(outputDirectory) ? null : Path.GetFullPath(outputDirectory!);
if (outputDir is not null)
{
Directory.CreateDirectory(outputDir);
}
var results = new List<(string Advisory, int ExitCode)>();
var overallExit = 0;
foreach (var key in advisoryKeys)
{
var sanitized = string.IsNullOrWhiteSpace(key) ? "unknown" : key.Trim();
var ext = outputFormat switch
{
AdvisoryOutputFormat.Json => ".json",
AdvisoryOutputFormat.Markdown => ".md",
_ => ".txt"
};
var outputPath = outputDir is null ? null : Path.Combine(outputDir, $"{SanitizeFileName(sanitized)}-{taskType.ToString().ToLowerInvariant()}{ext}");
Environment.ExitCode = 0; // reset per advisory to capture individual result
await HandleAdviseRunAsync(
services,
taskType,
sanitized,
artifactId,
artifactPurl,
policyVersion,
profile,
preferredSections,
forceRefresh,
timeoutSeconds,
outputFormat,
outputPath,
verbose,
cancellationToken);
var code = Environment.ExitCode;
results.Add((sanitized, code));
overallExit = overallExit == 0 ? code : overallExit; // retain first non-zero if any
}
if (results.Count > 1)
{
var table = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Advisory Batch[/]");
table.AddColumn("Advisory");
table.AddColumn("Task");
table.AddColumn("Exit Code");
foreach (var result in results)
{
var exitText = result.ExitCode == 0 ? "[green]0[/]" : $"[red]{result.ExitCode}[/]";
table.AddRow(Markup.Escape(result.Advisory), taskType.ToString(), exitText);
}
AnsiConsole.Console.Write(table);
}
Environment.ExitCode = overallExit;
}
public static async Task HandleSourcesIngestAsync(
IServiceProvider services,
bool dryRun,
string source,
string input,
string? tenantOverride,
string format,
bool disableColor,
string? output,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("sources-ingest");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.sources.ingest.dry_run", ActivityKind.Client);
var statusMetric = "unknown";
using var duration = CliMetrics.MeasureCommandDuration("sources ingest dry-run");
try
{
if (!dryRun)
{
statusMetric = "unsupported";
logger.LogError("Only --dry-run mode is supported for 'stella sources ingest' at this time.");
Environment.ExitCode = 1;
return;
}
source = source?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(source))
{
throw new InvalidOperationException("Source identifier must be provided.");
}
var formatNormalized = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (formatNormalized is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var tenant = ResolveTenant(tenantOverride);
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided via --tenant or STELLA_TENANT.");
}
var payload = await LoadIngestInputAsync(services, input, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Executing ingestion dry-run for source {Source} using input {Input}.", source, payload.Name);
activity?.SetTag("stellaops.cli.command", "sources ingest dry-run");
activity?.SetTag("stellaops.cli.source", source);
activity?.SetTag("stellaops.cli.tenant", tenant);
activity?.SetTag("stellaops.cli.format", formatNormalized);
activity?.SetTag("stellaops.cli.input_kind", payload.Kind);
var request = new AocIngestDryRunRequest
{
Tenant = tenant,
Source = source,
Document = new AocIngestDryRunDocument
{
Name = payload.Name,
Content = payload.Content,
ContentType = payload.ContentType,
ContentEncoding = payload.ContentEncoding
}
};
var response = await client.ExecuteAocIngestDryRunAsync(request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.status", response.Status ?? "unknown");
if (!string.IsNullOrWhiteSpace(output))
{
var reportPath = await WriteJsonReportAsync(response, output, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Dry-run report written to {Path}.", reportPath);
}
if (formatNormalized == "json")
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
}
else
{
RenderDryRunTable(response, !disableColor);
}
var exitCode = DetermineDryRunExitCode(response);
Environment.ExitCode = exitCode;
statusMetric = exitCode == 0 ? "ok" : "violation";
activity?.SetTag("stellaops.cli.exit_code", exitCode);
}
catch (Exception ex)
{
statusMetric = "transport_error";
logger.LogError(ex, "Dry-run ingestion failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordSourcesDryRun(statusMetric);
}
}
public static async Task HandleAocVerifyAsync(
IServiceProvider services,
string? sinceOption,
int? limitOption,
string? sourcesOption,
string? codesOption,
string format,
string? exportPath,
string? tenantOverride,
bool disableColor,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("aoc-verify");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.aoc.verify", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("aoc verify");
var outcome = "unknown";
try
{
var tenant = ResolveTenant(tenantOverride);
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided via --tenant or STELLA_TENANT.");
}
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var since = DetermineVerificationSince(sinceOption);
var sinceIso = since.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
var limit = NormalizeLimit(limitOption);
var sources = ParseCommaSeparatedList(sourcesOption);
var codes = ParseCommaSeparatedList(codesOption);
var normalizedSources = sources.Count == 0
? Array.Empty()
: sources.Select(item => item.ToLowerInvariant()).ToArray();
var normalizedCodes = codes.Count == 0
? Array.Empty()
: codes.Select(item => item.ToUpperInvariant()).ToArray();
activity?.SetTag("stellaops.cli.command", "aoc verify");
activity?.SetTag("stellaops.cli.tenant", tenant);
activity?.SetTag("stellaops.cli.since", sinceIso);
activity?.SetTag("stellaops.cli.limit", limit);
activity?.SetTag("stellaops.cli.format", normalizedFormat);
if (normalizedSources.Length > 0)
{
activity?.SetTag("stellaops.cli.sources", string.Join(",", normalizedSources));
}
if (normalizedCodes.Length > 0)
{
activity?.SetTag("stellaops.cli.codes", string.Join(",", normalizedCodes));
}
var request = new AocVerifyRequest
{
Tenant = tenant,
Since = sinceIso,
Limit = limit,
Sources = normalizedSources.Length == 0 ? null : normalizedSources,
Codes = normalizedCodes.Length == 0 ? null : normalizedCodes
};
var response = await client.ExecuteAocVerifyAsync(request, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(exportPath))
{
var reportPath = await WriteJsonReportAsync(response, exportPath, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Verification report written to {Path}.", reportPath);
}
if (normalizedFormat == "json")
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
}
else
{
RenderAocVerifyTable(response, !disableColor, limit);
}
var exitCode = DetermineVerifyExitCode(response);
Environment.ExitCode = exitCode;
activity?.SetTag("stellaops.cli.exit_code", exitCode);
outcome = exitCode switch
{
0 => "ok",
>= 11 and <= 17 => "violations",
18 => "truncated",
_ => "unknown"
};
}
catch (InvalidOperationException ex)
{
outcome = "usage_error";
logger.LogError(ex, "Verification failed: {Message}", ex.Message);
Console.Error.WriteLine(ex.Message);
Environment.ExitCode = 71;
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
}
catch (Exception ex)
{
outcome = "transport_error";
logger.LogError(ex, "Verification request failed.");
Console.Error.WriteLine(ex.Message);
Environment.ExitCode = 70;
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordAocVerify(outcome);
}
}
public static async Task HandleConnectorJobAsync(
IServiceProvider services,
string source,
string stage,
string? mode,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-connector");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "db fetch");
activity?.SetTag("stellaops.cli.source", source);
activity?.SetTag("stellaops.cli.stage", stage);
if (!string.IsNullOrWhiteSpace(mode))
{
activity?.SetTag("stellaops.cli.mode", mode);
}
using var duration = CliMetrics.MeasureCommandDuration("db fetch");
try
{
var jobKind = $"source:{source}:{stage}";
var parameters = new Dictionary(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(mode))
{
parameters["mode"] = mode;
}
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Connector job failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleMergeJobAsync(
IServiceProvider services,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-merge");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "db merge");
using var duration = CliMetrics.MeasureCommandDuration("db merge");
try
{
await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Merge job failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleExportJobAsync(
IServiceProvider services,
string format,
bool delta,
bool? publishFull,
bool? publishDelta,
bool? includeFull,
bool? includeDelta,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-export");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "db export");
activity?.SetTag("stellaops.cli.format", format);
activity?.SetTag("stellaops.cli.delta", delta);
using var duration = CliMetrics.MeasureCommandDuration("db export");
activity?.SetTag("stellaops.cli.publish_full", publishFull);
activity?.SetTag("stellaops.cli.publish_delta", publishDelta);
activity?.SetTag("stellaops.cli.include_full", includeFull);
activity?.SetTag("stellaops.cli.include_delta", includeDelta);
try
{
var jobKind = format switch
{
"trivy-db" or "trivy" => "export:trivy-db",
_ => "export:json"
};
var isTrivy = jobKind == "export:trivy-db";
if (isTrivy
&& !publishFull.HasValue
&& !publishDelta.HasValue
&& !includeFull.HasValue
&& !includeDelta.HasValue
&& AnsiConsole.Profile.Capabilities.Interactive)
{
var overrides = TrivyDbExportPrompt.PromptOverrides();
publishFull = overrides.publishFull;
publishDelta = overrides.publishDelta;
includeFull = overrides.includeFull;
includeDelta = overrides.includeDelta;
}
var parameters = new Dictionary(StringComparer.Ordinal)
{
["delta"] = delta
};
if (publishFull.HasValue)
{
parameters["publishFull"] = publishFull.Value;
}
if (publishDelta.HasValue)
{
parameters["publishDelta"] = publishDelta.Value;
}
if (includeFull.HasValue)
{
parameters["includeFull"] = includeFull.Value;
}
if (includeDelta.HasValue)
{
parameters["includeDelta"] = includeDelta.Value;
}
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Export job failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static Task HandleExcititorInitAsync(
IServiceProvider services,
IReadOnlyList providers,
bool resume,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (resume)
{
payload["resume"] = true;
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor init",
verbose,
new Dictionary
{
["providers"] = normalizedProviders.Count,
["resume"] = resume
},
client => client.ExecuteExcititorOperationAsync("init", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorPullAsync(
IServiceProvider services,
IReadOnlyList providers,
DateTimeOffset? since,
TimeSpan? window,
bool force,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (since.HasValue)
{
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
if (window.HasValue)
{
payload["window"] = window.Value.ToString("c", CultureInfo.InvariantCulture);
}
if (force)
{
payload["force"] = true;
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor pull",
verbose,
new Dictionary
{
["providers"] = normalizedProviders.Count,
["force"] = force,
["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
["window"] = window?.ToString("c", CultureInfo.InvariantCulture)
},
client => client.ExecuteExcititorOperationAsync("ingest/run", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorResumeAsync(
IServiceProvider services,
IReadOnlyList providers,
string? checkpoint,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (!string.IsNullOrWhiteSpace(checkpoint))
{
payload["checkpoint"] = checkpoint.Trim();
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor resume",
verbose,
new Dictionary
{
["providers"] = normalizedProviders.Count,
["checkpoint"] = checkpoint
},
client => client.ExecuteExcititorOperationAsync("ingest/resume", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static async Task HandleExcititorListProvidersAsync(
IServiceProvider services,
bool includeDisabled,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("excititor-list-providers");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.list-providers", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "excititor list-providers");
activity?.SetTag("stellaops.cli.include_disabled", includeDisabled);
using var duration = CliMetrics.MeasureCommandDuration("excititor list-providers");
try
{
var providers = await client.GetExcititorProvidersAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
Environment.ExitCode = 0;
logger.LogInformation("Providers returned: {Count}", providers.Count);
if (providers.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().Border(TableBorder.Rounded).AddColumns("Provider", "Kind", "Trust", "Enabled", "Last Ingested");
foreach (var provider in providers)
{
table.AddRow(
provider.Id,
provider.Kind,
string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier,
provider.Enabled ? "yes" : "no",
provider.LastIngestedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "unknown");
}
AnsiConsole.Write(table);
}
else
{
foreach (var provider in providers)
{
logger.LogInformation("{ProviderId} [{Kind}] Enabled={Enabled} Trust={Trust} LastIngested={LastIngested}",
provider.Id,
provider.Kind,
provider.Enabled ? "yes" : "no",
string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier,
provider.LastIngestedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown");
}
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list Excititor providers.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleExcititorExportAsync(
IServiceProvider services,
string format,
bool delta,
string? scope,
DateTimeOffset? since,
string? provider,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scopeHandle = services.CreateAsyncScope();
var client = scopeHandle.ServiceProvider.GetRequiredService();
var logger = scopeHandle.ServiceProvider.GetRequiredService().CreateLogger("excititor-export");
var options = scopeHandle.ServiceProvider.GetRequiredService();
var verbosity = scopeHandle.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.export", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "excititor export");
activity?.SetTag("stellaops.cli.format", format);
activity?.SetTag("stellaops.cli.delta", delta);
if (!string.IsNullOrWhiteSpace(scope))
{
activity?.SetTag("stellaops.cli.scope", scope);
}
if (since.HasValue)
{
activity?.SetTag("stellaops.cli.since", since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(provider))
{
activity?.SetTag("stellaops.cli.provider", provider);
}
if (!string.IsNullOrWhiteSpace(outputPath))
{
activity?.SetTag("stellaops.cli.output", outputPath);
}
using var duration = CliMetrics.MeasureCommandDuration("excititor export");
try
{
var payload = new Dictionary(StringComparer.Ordinal)
{
["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(),
["delta"] = delta
};
if (!string.IsNullOrWhiteSpace(scope))
{
payload["scope"] = scope.Trim();
}
if (since.HasValue)
{
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
if (!string.IsNullOrWhiteSpace(provider))
{
payload["provider"] = provider.Trim();
}
var result = await client.ExecuteExcititorOperationAsync(
"export",
HttpMethod.Post,
RemoveNullValues(payload),
cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Excititor export failed." : result.Message);
Environment.ExitCode = 1;
return;
}
Environment.ExitCode = 0;
var manifest = TryParseExportManifest(result.Payload);
if (!string.IsNullOrWhiteSpace(result.Message)
&& (manifest is null || !string.Equals(result.Message, "ok", StringComparison.OrdinalIgnoreCase)))
{
logger.LogInformation(result.Message);
}
if (manifest is not null)
{
activity?.SetTag("stellaops.cli.export_id", manifest.ExportId);
if (!string.IsNullOrWhiteSpace(manifest.Format))
{
activity?.SetTag("stellaops.cli.export_format", manifest.Format);
}
if (manifest.FromCache.HasValue)
{
activity?.SetTag("stellaops.cli.export_cached", manifest.FromCache.Value);
}
if (manifest.SizeBytes.HasValue)
{
activity?.SetTag("stellaops.cli.export_size", manifest.SizeBytes.Value);
}
if (manifest.FromCache == true)
{
logger.LogInformation("Reusing cached export {ExportId} ({Format}).", manifest.ExportId, manifest.Format ?? "unknown");
}
else
{
logger.LogInformation("Export ready: {ExportId} ({Format}).", manifest.ExportId, manifest.Format ?? "unknown");
}
if (manifest.CreatedAt.HasValue)
{
logger.LogInformation("Created at {CreatedAt}.", manifest.CreatedAt.Value.ToString("u", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(manifest.Digest))
{
var digestDisplay = BuildDigestDisplay(manifest.Algorithm, manifest.Digest);
if (manifest.SizeBytes.HasValue)
{
logger.LogInformation("Digest {Digest} ({Size}).", digestDisplay, FormatSize(manifest.SizeBytes.Value));
}
else
{
logger.LogInformation("Digest {Digest}.", digestDisplay);
}
}
if (!string.IsNullOrWhiteSpace(manifest.RekorLocation))
{
if (!string.IsNullOrWhiteSpace(manifest.RekorIndex))
{
logger.LogInformation("Rekor entry: {Location} (index {Index}).", manifest.RekorLocation, manifest.RekorIndex);
}
else
{
logger.LogInformation("Rekor entry: {Location}.", manifest.RekorLocation);
}
}
if (!string.IsNullOrWhiteSpace(manifest.RekorInclusionUrl)
&& !string.Equals(manifest.RekorInclusionUrl, manifest.RekorLocation, StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("Rekor inclusion proof: {Url}.", manifest.RekorInclusionUrl);
}
if (!string.IsNullOrWhiteSpace(outputPath))
{
var resolvedPath = ResolveExportOutputPath(outputPath!, manifest);
var download = await client.DownloadExcititorExportAsync(
manifest.ExportId,
resolvedPath,
manifest.Algorithm,
manifest.Digest,
cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.export_path", download.Path);
if (download.FromCache)
{
logger.LogInformation("Export already cached at {Path} ({Size}).", download.Path, FormatSize(download.SizeBytes));
}
else
{
logger.LogInformation("Export saved to {Path} ({Size}).", download.Path, FormatSize(download.SizeBytes));
}
}
else if (!string.IsNullOrWhiteSpace(result.Location))
{
var downloadUrl = ResolveLocationUrl(options, result.Location);
if (!string.IsNullOrWhiteSpace(downloadUrl))
{
logger.LogInformation("Download URL: {Url}", downloadUrl);
}
else
{
logger.LogInformation("Download location: {Location}", result.Location);
}
}
}
else
{
if (!string.IsNullOrWhiteSpace(result.Location))
{
var downloadUrl = ResolveLocationUrl(options, result.Location);
if (!string.IsNullOrWhiteSpace(downloadUrl))
{
logger.LogInformation("Download URL: {Url}", downloadUrl);
}
else
{
logger.LogInformation("Location: {Location}", result.Location);
}
}
else if (string.IsNullOrWhiteSpace(result.Message))
{
logger.LogInformation("Export request accepted.");
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Excititor export failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static Task HandleExcititorBackfillStatementsAsync(
IServiceProvider services,
DateTimeOffset? retrievedSince,
bool force,
int batchSize,
int? maxDocuments,
bool verbose,
CancellationToken cancellationToken)
{
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be greater than zero.");
}
if (maxDocuments.HasValue && maxDocuments.Value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(maxDocuments), "Max documents must be greater than zero when specified.");
}
var payload = new Dictionary(StringComparer.Ordinal)
{
["force"] = force,
["batchSize"] = batchSize,
["maxDocuments"] = maxDocuments
};
if (retrievedSince.HasValue)
{
payload["retrievedSince"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
var activityTags = new Dictionary(StringComparer.Ordinal)
{
["stellaops.cli.force"] = force,
["stellaops.cli.batch_size"] = batchSize,
["stellaops.cli.max_documents"] = maxDocuments
};
if (retrievedSince.HasValue)
{
activityTags["stellaops.cli.retrieved_since"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor backfill-statements",
verbose,
activityTags,
client => client.ExecuteExcititorOperationAsync(
"admin/backfill-statements",
HttpMethod.Post,
RemoveNullValues(payload),
cancellationToken),
cancellationToken);
}
public static Task HandleExcititorVerifyAsync(
IServiceProvider services,
string? exportId,
string? digest,
string? attestationPath,
bool verbose,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exportId) && string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(attestationPath))
{
var logger = services.GetRequiredService().CreateLogger("excititor-verify");
logger.LogError("At least one of --export-id, --digest, or --attestation must be provided.");
Environment.ExitCode = 1;
return Task.CompletedTask;
}
var payload = new Dictionary(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(exportId))
{
payload["exportId"] = exportId.Trim();
}
if (!string.IsNullOrWhiteSpace(digest))
{
payload["digest"] = digest.Trim();
}
if (!string.IsNullOrWhiteSpace(attestationPath))
{
var fullPath = Path.GetFullPath(attestationPath);
if (!File.Exists(fullPath))
{
var logger = services.GetRequiredService().CreateLogger("excititor-verify");
logger.LogError("Attestation file not found at {Path}.", fullPath);
Environment.ExitCode = 1;
return Task.CompletedTask;
}
var bytes = File.ReadAllBytes(fullPath);
payload["attestation"] = new Dictionary(StringComparer.Ordinal)
{
["fileName"] = Path.GetFileName(fullPath),
["base64"] = Convert.ToBase64String(bytes)
};
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor verify",
verbose,
new Dictionary
{
["export_id"] = exportId,
["digest"] = digest,
["attestation_path"] = attestationPath
},
client => client.ExecuteExcititorOperationAsync("verify", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorReconcileAsync(
IServiceProvider services,
IReadOnlyList providers,
TimeSpan? maxAge,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (maxAge.HasValue)
{
payload["maxAge"] = maxAge.Value.ToString("c", CultureInfo.InvariantCulture);
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor reconcile",
verbose,
new Dictionary
{
["providers"] = normalizedProviders.Count,
["max_age"] = maxAge?.ToString("c", CultureInfo.InvariantCulture)
},
client => client.ExecuteExcititorOperationAsync("reconcile", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static async Task HandleRuntimePolicyTestAsync(
IServiceProvider services,
string? namespaceValue,
IReadOnlyList imageArguments,
string? filePath,
IReadOnlyList labelArguments,
bool outputJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("runtime-policy-test");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.runtime.policy.test", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "runtime policy test");
if (!string.IsNullOrWhiteSpace(namespaceValue))
{
activity?.SetTag("stellaops.cli.namespace", namespaceValue);
}
using var duration = CliMetrics.MeasureCommandDuration("runtime policy test");
try
{
IReadOnlyList images;
try
{
images = await GatherImageDigestsAsync(imageArguments, filePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or FileNotFoundException)
{
logger.LogError(ex, "Failed to gather image digests: {Message}", ex.Message);
Environment.ExitCode = 9;
return;
}
if (images.Count == 0)
{
logger.LogError("No image digests provided. Use --image, --file, or pipe digests via stdin.");
Environment.ExitCode = 9;
return;
}
IReadOnlyDictionary labels;
try
{
labels = ParseLabelSelectors(labelArguments);
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
Environment.ExitCode = 9;
return;
}
activity?.SetTag("stellaops.cli.images", images.Count);
activity?.SetTag("stellaops.cli.labels", labels.Count);
var request = new RuntimePolicyEvaluationRequest(namespaceValue, labels, images);
var result = await client.EvaluateRuntimePolicyAsync(request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.ttl_seconds", result.TtlSeconds);
Environment.ExitCode = 0;
if (outputJson)
{
var json = BuildRuntimePolicyJson(result, images);
Console.WriteLine(json);
return;
}
if (result.ExpiresAtUtc.HasValue)
{
logger.LogInformation("Decision TTL: {TtlSeconds}s (expires {ExpiresAt})", result.TtlSeconds, result.ExpiresAtUtc.Value.ToString("u", CultureInfo.InvariantCulture));
}
else
{
logger.LogInformation("Decision TTL: {TtlSeconds}s", result.TtlSeconds);
}
if (!string.IsNullOrWhiteSpace(result.PolicyRevision))
{
logger.LogInformation("Policy revision: {Revision}", result.PolicyRevision);
}
DisplayRuntimePolicyResults(logger, result, images);
}
catch (Exception ex)
{
logger.LogError(ex, "Runtime policy evaluation failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleAuthLoginAsync(
IServiceProvider services,
StellaOpsCliOptions options,
bool verbose,
bool force,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-login");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var tokenClient = scope.ServiceProvider.GetService();
if (tokenClient is null)
{
logger.LogError("Authority client is not available. Ensure AddStellaOpsAuthClient is registered in Program.cs.");
Environment.ExitCode = 1;
return;
}
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
if (string.IsNullOrWhiteSpace(cacheKey))
{
logger.LogError("Authority configuration is incomplete; unable to determine cache key.");
Environment.ExitCode = 1;
return;
}
try
{
if (force)
{
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
var scopeName = AuthorityTokenUtilities.ResolveScope(options);
StellaOpsTokenResult token;
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
{
if (string.IsNullOrWhiteSpace(options.Authority.Password))
{
logger.LogError("Authority password must be provided when username is configured.");
Environment.ExitCode = 1;
return;
}
token = await tokenClient.RequestPasswordTokenAsync(
options.Authority.Username,
options.Authority.Password!,
scopeName,
null,
cancellationToken).ConfigureAwait(false);
}
else
{
token = await tokenClient.RequestClientCredentialsTokenAsync(scopeName, null, cancellationToken).ConfigureAwait(false);
}
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
if (verbose)
{
logger.LogInformation("Authenticated with {Authority} (scopes: {Scopes}).", options.Authority.Url, string.Join(", ", token.Scopes));
}
logger.LogInformation("Login successful. Access token expires at {Expires}.", token.ExpiresAtUtc.ToString("u"));
}
catch (Exception ex)
{
logger.LogError(ex, "Authentication failed: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleAuthLogoutAsync(
IServiceProvider services,
StellaOpsCliOptions options,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-logout");
Environment.ExitCode = 0;
var tokenClient = scope.ServiceProvider.GetService();
if (tokenClient is null)
{
logger.LogInformation("No authority client registered; nothing to remove.");
return;
}
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
if (string.IsNullOrWhiteSpace(cacheKey))
{
logger.LogInformation("Authority configuration missing; no cached tokens to remove.");
return;
}
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (verbose)
{
logger.LogInformation("Cleared cached token for {Authority}.", options.Authority?.Url ?? "authority");
}
}
public static async Task HandleAuthStatusAsync(
IServiceProvider services,
StellaOpsCliOptions options,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-status");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'.");
Environment.ExitCode = 1;
return;
}
var tokenClient = scope.ServiceProvider.GetService();
if (tokenClient is null)
{
logger.LogInformation("Authority client not registered; no cached tokens available.");
Environment.ExitCode = 1;
return;
}
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
if (string.IsNullOrWhiteSpace(cacheKey))
{
logger.LogInformation("Authority configuration incomplete; no cached tokens available.");
Environment.ExitCode = 1;
return;
}
var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url);
Environment.ExitCode = 1;
return;
}
logger.LogInformation("Cached token for {Authority} expires at {Expires}.", options.Authority.Url, entry.ExpiresAtUtc.ToString("u"));
if (verbose)
{
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
}
}
public static async Task HandleAuthWhoAmIAsync(
IServiceProvider services,
StellaOpsCliOptions options,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-whoami");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'.");
Environment.ExitCode = 1;
return;
}
var tokenClient = scope.ServiceProvider.GetService();
if (tokenClient is null)
{
logger.LogInformation("Authority client not registered; no cached tokens available.");
Environment.ExitCode = 1;
return;
}
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
if (string.IsNullOrWhiteSpace(cacheKey))
{
logger.LogInformation("Authority configuration incomplete; no cached tokens available.");
Environment.ExitCode = 1;
return;
}
var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url);
Environment.ExitCode = 1;
return;
}
var grantType = string.IsNullOrWhiteSpace(options.Authority.Username) ? "client_credentials" : "password";
var now = DateTimeOffset.UtcNow;
var remaining = entry.ExpiresAtUtc - now;
if (remaining < TimeSpan.Zero)
{
remaining = TimeSpan.Zero;
}
logger.LogInformation("Authority: {Authority}", options.Authority.Url);
logger.LogInformation("Grant type: {GrantType}", grantType);
logger.LogInformation("Token type: {TokenType}", entry.TokenType);
logger.LogInformation("Expires: {Expires} ({Remaining})", entry.ExpiresAtUtc.ToString("u"), FormatDuration(remaining));
if (entry.Scopes.Count > 0)
{
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
}
if (TryExtractJwtClaims(entry.AccessToken, out var claims, out var issuedAt, out var notBefore))
{
if (claims.TryGetValue("sub", out var subject) && !string.IsNullOrWhiteSpace(subject))
{
logger.LogInformation("Subject: {Subject}", subject);
}
if (claims.TryGetValue("client_id", out var clientId) && !string.IsNullOrWhiteSpace(clientId))
{
logger.LogInformation("Client ID (token): {ClientId}", clientId);
}
if (claims.TryGetValue("aud", out var audience) && !string.IsNullOrWhiteSpace(audience))
{
logger.LogInformation("Audience: {Audience}", audience);
}
if (claims.TryGetValue("iss", out var issuer) && !string.IsNullOrWhiteSpace(issuer))
{
logger.LogInformation("Issuer: {Issuer}", issuer);
}
if (issuedAt is not null)
{
logger.LogInformation("Issued at: {IssuedAt}", issuedAt.Value.ToString("u"));
}
if (notBefore is not null)
{
logger.LogInformation("Not before: {NotBefore}", notBefore.Value.ToString("u"));
}
var extraClaims = CollectAdditionalClaims(claims);
if (extraClaims.Count > 0 && verbose)
{
logger.LogInformation("Additional claims: {Claims}", string.Join(", ", extraClaims));
}
}
else
{
logger.LogInformation("Access token appears opaque; claims are unavailable.");
}
}
public static async Task HandleAuthRevokeExportAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string? outputDirectory,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-revoke-export");
Environment.ExitCode = 0;
try
{
var client = scope.ServiceProvider.GetRequiredService();
var result = await client.ExportAsync(verbose, cancellationToken).ConfigureAwait(false);
var directory = string.IsNullOrWhiteSpace(outputDirectory)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(outputDirectory);
Directory.CreateDirectory(directory);
var bundlePath = Path.Combine(directory, "revocation-bundle.json");
var signaturePath = Path.Combine(directory, "revocation-bundle.json.jws");
var digestPath = Path.Combine(directory, "revocation-bundle.json.sha256");
await File.WriteAllBytesAsync(bundlePath, result.BundleBytes, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(signaturePath, result.Signature, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(digestPath, $"sha256:{result.Digest}", cancellationToken).ConfigureAwait(false);
var computedDigest = Convert.ToHexString(SHA256.HashData(result.BundleBytes)).ToLowerInvariant();
if (!string.Equals(computedDigest, result.Digest, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Digest mismatch. Expected {Expected} but computed {Actual}.", result.Digest, computedDigest);
Environment.ExitCode = 1;
return;
}
logger.LogInformation(
"Revocation bundle exported to {Directory} (sequence {Sequence}, issued {Issued:u}, signing key {KeyId}, provider {Provider}).",
directory,
result.Sequence,
result.IssuedAt,
string.IsNullOrWhiteSpace(result.SigningKeyId) ? "" : result.SigningKeyId,
string.IsNullOrWhiteSpace(result.SigningProvider) ? "default" : result.SigningProvider);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export revocation bundle.");
Environment.ExitCode = 1;
}
}
public static async Task HandleAuthRevokeVerifyAsync(
string bundlePath,
string signaturePath,
string keyPath,
bool verbose,
CancellationToken cancellationToken)
{
var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(options =>
{
options.SingleLine = true;
options.TimestampFormat = "HH:mm:ss ";
}));
var logger = loggerFactory.CreateLogger("auth-revoke-verify");
Environment.ExitCode = 0;
try
{
if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(signaturePath) || string.IsNullOrWhiteSpace(keyPath))
{
logger.LogError("Arguments --bundle, --signature, and --key are required.");
Environment.ExitCode = 1;
return;
}
var bundleBytes = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
var signatureContent = (await File.ReadAllTextAsync(signaturePath, cancellationToken).ConfigureAwait(false)).Trim();
var keyPem = await File.ReadAllTextAsync(keyPath, cancellationToken).ConfigureAwait(false);
var digest = Convert.ToHexString(SHA256.HashData(bundleBytes)).ToLowerInvariant();
logger.LogInformation("Bundle digest sha256:{Digest}", digest);
if (!TryParseDetachedJws(signatureContent, out var encodedHeader, out var encodedSignature))
{
logger.LogError("Signature is not in detached JWS format.");
Environment.ExitCode = 1;
return;
}
var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(encodedHeader));
using var headerDocument = JsonDocument.Parse(headerJson);
var header = headerDocument.RootElement;
if (!header.TryGetProperty("b64", out var b64Element) || b64Element.GetBoolean())
{
logger.LogError("Detached JWS header must include '\"b64\": false'.");
Environment.ExitCode = 1;
return;
}
var algorithm = header.TryGetProperty("alg", out var algElement) ? algElement.GetString() : SignatureAlgorithms.Es256;
if (string.IsNullOrWhiteSpace(algorithm))
{
algorithm = SignatureAlgorithms.Es256;
}
var providerHint = header.TryGetProperty("provider", out var providerElement)
? providerElement.GetString()
: null;
var keyId = header.TryGetProperty("kid", out var kidElement) ? kidElement.GetString() : null;
if (string.IsNullOrWhiteSpace(keyId))
{
keyId = Path.GetFileNameWithoutExtension(keyPath);
logger.LogWarning("JWS header missing 'kid'; using fallback key id {KeyId}.", keyId);
}
CryptoSigningKey signingKey;
try
{
signingKey = CreateVerificationSigningKey(keyId!, algorithm!, providerHint, keyPem, keyPath);
}
catch (Exception ex) when (ex is InvalidOperationException or CryptographicException)
{
logger.LogError(ex, "Failed to load verification key material.");
Environment.ExitCode = 1;
return;
}
var providers = new List
{
new DefaultCryptoProvider()
};
#if STELLAOPS_CRYPTO_SODIUM
providers.Add(new LibsodiumCryptoProvider());
#endif
foreach (var provider in providers)
{
if (provider.Supports(CryptoCapability.Verification, algorithm!))
{
provider.UpsertSigningKey(signingKey);
}
}
var preferredOrder = !string.IsNullOrWhiteSpace(providerHint)
? new[] { providerHint! }
: Array.Empty();
var registry = new CryptoProviderRegistry(providers, preferredOrder);
CryptoSignerResolution resolution;
try
{
resolution = registry.ResolveSigner(
CryptoCapability.Verification,
algorithm!,
signingKey.Reference,
providerHint);
}
catch (Exception ex)
{
logger.LogError(ex, "No crypto provider available for verification (algorithm {Algorithm}).", algorithm);
Environment.ExitCode = 1;
return;
}
var signingInputLength = encodedHeader.Length + 1 + bundleBytes.Length;
var buffer = ArrayPool.Shared.Rent(signingInputLength);
try
{
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length);
buffer[headerBytes.Length] = (byte)'.';
Buffer.BlockCopy(bundleBytes, 0, buffer, headerBytes.Length + 1, bundleBytes.Length);
var signatureBytes = Base64UrlDecode(encodedSignature);
var verified = await resolution.Signer.VerifyAsync(
new ReadOnlyMemory(buffer, 0, signingInputLength),
signatureBytes,
cancellationToken).ConfigureAwait(false);
if (!verified)
{
logger.LogError("Signature verification failed.");
Environment.ExitCode = 1;
return;
}
}
finally
{
ArrayPool.Shared.Return(buffer);
}
if (!string.IsNullOrWhiteSpace(providerHint) && !string.Equals(providerHint, resolution.ProviderName, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning(
"Preferred provider '{Preferred}' unavailable; verification used '{Provider}'.",
providerHint,
resolution.ProviderName);
}
logger.LogInformation(
"Signature verified using algorithm {Algorithm} via provider {Provider} (kid {KeyId}).",
algorithm,
resolution.ProviderName,
signingKey.Reference.KeyId);
if (verbose)
{
logger.LogInformation("JWS header: {Header}", headerJson);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify revocation bundle.");
Environment.ExitCode = 1;
}
finally
{
loggerFactory.Dispose();
}
}
public static async Task HandleTenantsListAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("tenants-list");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService();
if (client is null)
{
logger.LogError("Authority console client is not available. Ensure Authority is configured and services are registered.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (string.IsNullOrWhiteSpace(effectiveTenant))
{
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT environment variable, or run 'stella tenants use '.");
Environment.ExitCode = 1;
return;
}
try
{
var tenants = await client.ListTenantsAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
if (json)
{
var output = new { tenants = tenants };
var jsonText = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonText);
}
else
{
if (tenants.Count == 0)
{
logger.LogInformation("No tenants available for the authenticated principal.");
return;
}
logger.LogInformation("Available tenants ({Count}):", tenants.Count);
foreach (var t in tenants)
{
var status = string.Equals(t.Status, "active", StringComparison.OrdinalIgnoreCase) ? "" : $" ({t.Status})";
logger.LogInformation(" {Id}: {DisplayName}{Status}", t.Id, t.DisplayName, status);
if (verbose)
{
logger.LogInformation(" Isolation: {IsolationMode}", t.IsolationMode);
if (t.DefaultRoles.Count > 0)
{
logger.LogInformation(" Default roles: {Roles}", string.Join(", ", t.DefaultRoles));
}
if (t.Projects.Count > 0)
{
logger.LogInformation(" Projects: {Projects}", string.Join(", ", t.Projects));
}
}
}
}
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
logger.LogError("Authentication required. Run 'stella auth login' first.");
Environment.ExitCode = 1;
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
logger.LogError("Access denied. The authenticated principal does not have permission to list tenants.");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to retrieve tenant list: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleTenantsUseAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string tenantId,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("tenants-use");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(tenantId))
{
logger.LogError("Tenant identifier is required.");
Environment.ExitCode = 1;
return;
}
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
string? displayName = null;
if (!string.IsNullOrWhiteSpace(options.Authority?.Url))
{
var client = scope.ServiceProvider.GetService();
if (client is not null)
{
try
{
var tenants = await client.ListTenantsAsync(normalizedTenant, cancellationToken).ConfigureAwait(false);
var match = tenants.FirstOrDefault(t =>
string.Equals(t.Id, normalizedTenant, StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
displayName = match.DisplayName;
if (verbose)
{
logger.LogDebug("Validated tenant '{TenantId}' with display name '{DisplayName}'.", normalizedTenant, displayName);
}
}
else if (verbose)
{
logger.LogWarning("Tenant '{TenantId}' not found in available tenants. Setting anyway.", normalizedTenant);
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
if (verbose)
{
logger.LogWarning("Could not validate tenant against Authority: {Message}", ex.Message);
}
}
}
}
try
{
await TenantProfileStore.SetActiveTenantAsync(normalizedTenant, displayName, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Active tenant set to '{TenantId}'.", normalizedTenant);
if (!string.IsNullOrWhiteSpace(displayName))
{
logger.LogInformation("Tenant display name: {DisplayName}", displayName);
}
logger.LogInformation("Profile saved to: {Path}", TenantProfileStore.GetProfilePath());
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to save tenant profile: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleTenantsCurrentAsync(
bool json,
bool verbose,
CancellationToken cancellationToken)
{
Environment.ExitCode = 0;
try
{
var profile = await TenantProfileStore.LoadAsync(cancellationToken).ConfigureAwait(false);
if (json)
{
var output = profile ?? new TenantProfile();
var jsonText = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonText);
return;
}
if (profile is null || string.IsNullOrWhiteSpace(profile.ActiveTenant))
{
Console.WriteLine("No active tenant configured.");
Console.WriteLine("Use 'stella tenants use ' to set one.");
return;
}
Console.WriteLine($"Active tenant: {profile.ActiveTenant}");
if (!string.IsNullOrWhiteSpace(profile.ActiveTenantDisplayName))
{
Console.WriteLine($"Display name: {profile.ActiveTenantDisplayName}");
}
if (profile.LastUpdated.HasValue)
{
Console.WriteLine($"Last updated: {profile.LastUpdated.Value:u}");
}
if (verbose)
{
Console.WriteLine($"Profile path: {TenantProfileStore.GetProfilePath()}");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to load tenant profile: {ex.Message}");
Environment.ExitCode = 1;
}
}
public static async Task HandleTenantsClearAsync(CancellationToken cancellationToken)
{
Environment.ExitCode = 0;
try
{
await TenantProfileStore.ClearActiveTenantAsync(cancellationToken).ConfigureAwait(false);
Console.WriteLine("Active tenant cleared.");
Console.WriteLine("Subsequent commands will require --tenant or STELLAOPS_TENANT environment variable.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to clear tenant profile: {ex.Message}");
Environment.ExitCode = 1;
}
}
// CLI-TEN-49-001: Token minting and delegation handlers
public static async Task HandleTokenMintAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string serviceAccount,
string[] scopes,
int? expiresIn,
string? tenant,
string? reason,
bool raw,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("token-mint");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService();
if (client is null)
{
logger.LogError("Authority console client is not available.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
try
{
var request = new TokenMintRequest(
serviceAccount,
scopes.Length > 0 ? scopes : new[] { "stellaops:read" },
expiresIn,
effectiveTenant,
reason);
if (verbose)
{
logger.LogDebug("Minting token for service account '{ServiceAccount}' with scopes: {Scopes}", serviceAccount, string.Join(", ", request.Scopes));
}
var response = await client.MintTokenAsync(request, cancellationToken).ConfigureAwait(false);
if (raw)
{
Console.WriteLine(response.AccessToken);
}
else
{
logger.LogInformation("Token minted successfully.");
logger.LogInformation("Service Account: {ServiceAccount}", serviceAccount);
logger.LogInformation("Token Type: {TokenType}", response.TokenType);
logger.LogInformation("Expires At: {ExpiresAt:u}", response.ExpiresAt);
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", response.Scopes));
if (!string.IsNullOrWhiteSpace(response.TokenId))
{
logger.LogInformation("Token ID: {TokenId}", response.TokenId);
}
if (verbose)
{
logger.LogInformation("Access Token: {Token}", response.AccessToken);
}
}
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
logger.LogError("Authentication required. Run 'stella auth login' first.");
Environment.ExitCode = 1;
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
logger.LogError("Access denied. Insufficient permissions to mint tokens.");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to mint token: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleTokenDelegateAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string delegateTo,
string[] scopes,
int? expiresIn,
string? tenant,
string? reason,
bool raw,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("token-delegate");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService();
if (client is null)
{
logger.LogError("Authority console client is not available.");
Environment.ExitCode = 1;
return;
}
if (string.IsNullOrWhiteSpace(reason))
{
logger.LogError("Delegation reason is required (--reason). This is recorded in audit logs.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
try
{
var request = new TokenDelegateRequest(
delegateTo,
scopes.Length > 0 ? scopes : Array.Empty(),
expiresIn,
effectiveTenant,
reason);
if (verbose)
{
logger.LogDebug("Delegating token to '{DelegateTo}' with reason: {Reason}", delegateTo, reason);
}
var response = await client.DelegateTokenAsync(request, cancellationToken).ConfigureAwait(false);
if (raw)
{
Console.WriteLine(response.AccessToken);
}
else
{
logger.LogInformation("Token delegated successfully.");
logger.LogInformation("Delegation ID: {DelegationId}", response.DelegationId);
logger.LogInformation("Original Subject: {OriginalSubject}", response.OriginalSubject);
logger.LogInformation("Delegated To: {DelegatedSubject}", response.DelegatedSubject);
logger.LogInformation("Token Type: {TokenType}", response.TokenType);
logger.LogInformation("Expires At: {ExpiresAt:u}", response.ExpiresAt);
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", response.Scopes));
logger.LogWarning("Delegation tokens should be treated with care. All actions performed with this token will be attributed to '{DelegatedSubject}' acting on behalf of '{OriginalSubject}'.",
response.DelegatedSubject, response.OriginalSubject);
if (verbose)
{
logger.LogInformation("Access Token: {Token}", response.AccessToken);
}
}
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
logger.LogError("Authentication required. Run 'stella auth login' first.");
Environment.ExitCode = 1;
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
logger.LogError("Access denied. Insufficient permissions to delegate tokens.");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delegate token: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
///
/// Checks and displays impersonation banner if operating under a delegated token.
/// Call this from commands that need audit-aware impersonation notices (CLI-TEN-49-001).
///
internal static async Task CheckAndDisplayImpersonationBannerAsync(
IAuthorityConsoleClient client,
ILogger logger,
string? tenant,
CancellationToken cancellationToken)
{
try
{
var introspection = await client.IntrospectTokenAsync(tenant, cancellationToken).ConfigureAwait(false);
if (introspection is null || !introspection.Active)
{
return;
}
if (!string.IsNullOrWhiteSpace(introspection.DelegatedBy))
{
logger.LogWarning("=== IMPERSONATION NOTICE ===");
logger.LogWarning("Operating as '{Subject}' delegated by '{DelegatedBy}'.", introspection.Subject, introspection.DelegatedBy);
if (!string.IsNullOrWhiteSpace(introspection.DelegationReason))
{
logger.LogWarning("Delegation reason: {Reason}", introspection.DelegationReason);
}
logger.LogWarning("All actions in this session are audit-logged under the delegation context.");
logger.LogWarning("============================");
}
}
catch
{
// Silently ignore introspection failures - don't block operations
}
}
public static async Task HandleVulnObservationsAsync(
IServiceProvider services,
string tenant,
IReadOnlyList observationIds,
IReadOnlyList aliases,
IReadOnlyList purls,
IReadOnlyList cpes,
int? limit,
string? cursor,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("vuln-observations");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.observations", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vuln observations");
activity?.SetTag("stellaops.cli.tenant", tenant);
using var duration = CliMetrics.MeasureCommandDuration("vuln observations");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
var query = new AdvisoryObservationsQuery(
tenant,
NormalizeSet(observationIds, toLower: false),
NormalizeSet(aliases, toLower: true),
NormalizeSet(purls, toLower: false),
NormalizeSet(cpes, toLower: false),
limit,
cursor);
var response = await client.GetObservationsAsync(query, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
RenderObservationTable(response);
if (!emitJson && response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
var escapedCursor = Markup.Escape(response.NextCursor);
AnsiConsole.MarkupLine($"[yellow]More observations available. Continue with[/] [cyan]--cursor[/] [grey]{escapedCursor}[/]");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch observations from Concelier.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static IReadOnlyList NormalizeSet(IReadOnlyList values, bool toLower)
{
if (values is null || values.Count == 0)
{
return Array.Empty();
}
var set = new HashSet(StringComparer.Ordinal);
foreach (var raw in values)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var normalized = raw.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
set.Add(normalized);
}
return set.Count == 0 ? Array.Empty() : set.ToArray();
}
static void RenderObservationTable(AdvisoryObservationsResponse response)
{
var observations = response.Observations ?? Array.Empty();
if (observations.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No observations matched the provided filters.[/]");
return;
}
var table = new Table()
.Centered()
.Border(TableBorder.Rounded);
table.AddColumn("Observation");
table.AddColumn("Source");
table.AddColumn("Upstream Id");
table.AddColumn("Aliases");
table.AddColumn("PURLs");
table.AddColumn("CPEs");
table.AddColumn("Created (UTC)");
foreach (var observation in observations)
{
var sourceVendor = observation.Source?.Vendor ?? "(unknown)";
var upstreamId = observation.Upstream?.UpstreamId ?? "(unknown)";
var aliasesText = FormatList(observation.Linkset?.Aliases);
var purlsText = FormatList(observation.Linkset?.Purls);
var cpesText = FormatList(observation.Linkset?.Cpes);
table.AddRow(
Markup.Escape(observation.ObservationId),
Markup.Escape(sourceVendor),
Markup.Escape(upstreamId),
Markup.Escape(aliasesText),
Markup.Escape(purlsText),
Markup.Escape(cpesText),
observation.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture));
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine(
"[green]{0}[/] observation(s). Aliases: [green]{1}[/], PURLs: [green]{2}[/], CPEs: [green]{3}[/].",
observations.Count,
response.Linkset?.Aliases?.Count ?? 0,
response.Linkset?.Purls?.Count ?? 0,
response.Linkset?.Cpes?.Count ?? 0);
}
static string FormatList(IReadOnlyList? values)
{
if (values is null || values.Count == 0)
{
return "(none)";
}
const int MaxItems = 3;
if (values.Count <= MaxItems)
{
return string.Join(", ", values);
}
var preview = values.Take(MaxItems);
return $"{string.Join(", ", preview)} (+{values.Count - MaxItems})";
}
}
public static async Task HandleOfflineKitPullAsync(
IServiceProvider services,
string? bundleId,
string? destinationDirectory,
bool overwrite,
bool resume,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var options = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("offline-kit-pull");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.pull", ActivityKind.Client);
activity?.SetTag("stellaops.cli.bundle_id", string.IsNullOrWhiteSpace(bundleId) ? "latest" : bundleId);
using var duration = CliMetrics.MeasureCommandDuration("offline kit pull");
try
{
var targetDirectory = string.IsNullOrWhiteSpace(destinationDirectory)
? options.Offline?.KitsDirectory ?? Path.Combine(Environment.CurrentDirectory, "offline-kits")
: destinationDirectory;
targetDirectory = Path.GetFullPath(targetDirectory);
Directory.CreateDirectory(targetDirectory);
var result = await client.DownloadOfflineKitAsync(bundleId, targetDirectory, overwrite, resume, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Bundle {BundleId} stored at {Path} (captured {Captured:u}, sha256:{Digest}).",
result.Descriptor.BundleId,
result.BundlePath,
result.Descriptor.CapturedAt,
result.Descriptor.BundleSha256);
logger.LogInformation("Manifest saved to {Manifest}.", result.ManifestPath);
if (!string.IsNullOrWhiteSpace(result.MetadataPath))
{
logger.LogDebug("Metadata recorded at {Metadata}.", result.MetadataPath);
}
if (result.BundleSignaturePath is not null)
{
logger.LogInformation("Bundle signature saved to {Signature}.", result.BundleSignaturePath);
}
if (result.ManifestSignaturePath is not null)
{
logger.LogInformation("Manifest signature saved to {Signature}.", result.ManifestSignaturePath);
}
CliMetrics.RecordOfflineKitDownload(result.Descriptor.Kind ?? "unknown", result.FromCache);
activity?.SetTag("stellaops.cli.bundle_cache", result.FromCache);
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download offline kit bundle.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicyFindingsListAsync(
IServiceProvider services,
string policyId,
string[] sbomFilters,
string[] statusFilters,
string[] severityFilters,
string? since,
string? cursor,
int? page,
int? pageSize,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("policy-findings-ls");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.findings.list", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("policy findings list");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (page.HasValue && page.Value < 1)
{
throw new ArgumentException("--page must be greater than or equal to 1.", nameof(page));
}
if (pageSize.HasValue && (pageSize.Value < 1 || pageSize.Value > 500))
{
throw new ArgumentException("--page-size must be between 1 and 500.", nameof(pageSize));
}
var normalizedPolicyId = policyId.Trim();
var sboms = NormalizePolicyFilterValues(sbomFilters);
var statuses = NormalizePolicyFilterValues(statusFilters, toLower: true);
var severities = NormalizePolicyFilterValues(severityFilters);
var sinceValue = ParsePolicySince(since);
var cursorValue = string.IsNullOrWhiteSpace(cursor) ? null : cursor.Trim();
var query = new PolicyFindingsQuery(
normalizedPolicyId,
sboms,
statuses,
severities,
cursorValue,
page,
pageSize,
sinceValue);
activity?.SetTag("stellaops.cli.policy_id", normalizedPolicyId);
if (sboms.Count > 0)
{
activity?.SetTag("stellaops.cli.findings.sbom_filters", string.Join(",", sboms));
}
if (statuses.Count > 0)
{
activity?.SetTag("stellaops.cli.findings.status_filters", string.Join(",", statuses));
}
if (severities.Count > 0)
{
activity?.SetTag("stellaops.cli.findings.severity_filters", string.Join(",", severities));
}
if (!string.IsNullOrWhiteSpace(cursorValue))
{
activity?.SetTag("stellaops.cli.findings.cursor", cursorValue);
}
if (page.HasValue)
{
activity?.SetTag("stellaops.cli.findings.page", page.Value);
}
if (pageSize.HasValue)
{
activity?.SetTag("stellaops.cli.findings.page_size", pageSize.Value);
}
if (sinceValue.HasValue)
{
activity?.SetTag("stellaops.cli.findings.since", sinceValue.Value.ToString("o", CultureInfo.InvariantCulture));
}
var result = await client.GetPolicyFindingsAsync(query, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.findings.count", result.Items.Count);
if (!string.IsNullOrWhiteSpace(result.NextCursor))
{
activity?.SetTag("stellaops.cli.findings.next_cursor", result.NextCursor);
}
var payload = BuildPolicyFindingsPayload(normalizedPolicyId, query, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteJsonPayloadAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Results written to {Path}.", Path.GetFullPath(outputPath!));
}
var outputFormat = DeterminePolicyFindingsFormat(format, outputPath);
if (outputFormat == PolicyFindingsOutputFormat.Json)
{
var json = JsonSerializer.Serialize(payload, SimulationJsonOptions);
Console.WriteLine(json);
}
else
{
RenderPolicyFindingsTable(logger, result);
}
CliMetrics.RecordPolicyFindingsList(result.Items.Count == 0 ? "empty" : "ok");
Environment.ExitCode = 0;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicyFindingsList("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicyFindingsFailure(ex, logger, CliMetrics.RecordPolicyFindingsList);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list policy findings.");
CliMetrics.RecordPolicyFindingsList("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicyFindingsGetAsync(
IServiceProvider services,
string policyId,
string findingId,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("policy-findings-get");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.findings.get", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("policy findings get");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (string.IsNullOrWhiteSpace(findingId))
{
throw new ArgumentException("Finding identifier must be provided.", nameof(findingId));
}
var normalizedPolicyId = policyId.Trim();
var normalizedFindingId = findingId.Trim();
activity?.SetTag("stellaops.cli.policy_id", normalizedPolicyId);
activity?.SetTag("stellaops.cli.finding_id", normalizedFindingId);
var result = await client.GetPolicyFindingAsync(normalizedPolicyId, normalizedFindingId, cancellationToken).ConfigureAwait(false);
var payload = BuildPolicyFindingPayload(normalizedPolicyId, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteJsonPayloadAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Finding written to {Path}.", Path.GetFullPath(outputPath!));
}
var outputFormat = DeterminePolicyFindingsFormat(format, outputPath);
if (outputFormat == PolicyFindingsOutputFormat.Json)
{
Console.WriteLine(JsonSerializer.Serialize(payload, SimulationJsonOptions));
}
else
{
RenderPolicyFindingDetails(logger, result);
}
var outcome = string.IsNullOrWhiteSpace(result.Status) ? "unknown" : result.Status.ToLowerInvariant();
CliMetrics.RecordPolicyFindingsGet(outcome);
Environment.ExitCode = 0;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicyFindingsGet("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicyFindingsFailure(ex, logger, CliMetrics.RecordPolicyFindingsGet);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to retrieve policy finding.");
CliMetrics.RecordPolicyFindingsGet("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicyFindingsExplainAsync(
IServiceProvider services,
string policyId,
string findingId,
string? mode,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("policy-findings-explain");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.findings.explain", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("policy findings explain");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (string.IsNullOrWhiteSpace(findingId))
{
throw new ArgumentException("Finding identifier must be provided.", nameof(findingId));
}
var normalizedPolicyId = policyId.Trim();
var normalizedFindingId = findingId.Trim();
var normalizedMode = NormalizeExplainMode(mode);
activity?.SetTag("stellaops.cli.policy_id", normalizedPolicyId);
activity?.SetTag("stellaops.cli.finding_id", normalizedFindingId);
if (!string.IsNullOrWhiteSpace(normalizedMode))
{
activity?.SetTag("stellaops.cli.findings.mode", normalizedMode);
}
var result = await client.GetPolicyFindingExplainAsync(normalizedPolicyId, normalizedFindingId, normalizedMode, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.findings.step_count", result.Steps.Count);
var payload = BuildPolicyFindingExplainPayload(normalizedPolicyId, normalizedFindingId, normalizedMode, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteJsonPayloadAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Explain trace written to {Path}.", Path.GetFullPath(outputPath!));
}
var outputFormat = DeterminePolicyFindingsFormat(format, outputPath);
if (outputFormat == PolicyFindingsOutputFormat.Json)
{
Console.WriteLine(JsonSerializer.Serialize(payload, SimulationJsonOptions));
}
else
{
RenderPolicyFindingExplain(logger, result);
}
CliMetrics.RecordPolicyFindingsExplain(result.Steps.Count == 0 ? "empty" : "ok");
Environment.ExitCode = 0;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicyFindingsExplain("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicyFindingsFailure(ex, logger, CliMetrics.RecordPolicyFindingsExplain);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch policy explain trace.");
CliMetrics.RecordPolicyFindingsExplain("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicyActivateAsync(
IServiceProvider services,
string policyId,
int version,
string? note,
bool runNow,
string? scheduledAt,
string? priority,
bool rollback,
string? incidentId,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("policy-activate");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.activate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "policy activate");
using var duration = CliMetrics.MeasureCommandDuration("policy activate");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (version <= 0)
{
throw new ArgumentOutOfRangeException(nameof(version), "Version must be greater than zero.");
}
var normalizedPolicyId = policyId.Trim();
DateTimeOffset? scheduled = null;
if (!string.IsNullOrWhiteSpace(scheduledAt))
{
if (!DateTimeOffset.TryParse(scheduledAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
{
throw new ArgumentException("Scheduled timestamp must be a valid ISO-8601 value.", nameof(scheduledAt));
}
scheduled = parsed;
}
var request = new PolicyActivationRequest(
runNow,
scheduled,
NormalizePolicyPriority(priority),
rollback,
string.IsNullOrWhiteSpace(incidentId) ? null : incidentId.Trim(),
string.IsNullOrWhiteSpace(note) ? null : note.Trim());
activity?.SetTag("stellaops.cli.policy_id", normalizedPolicyId);
activity?.SetTag("stellaops.cli.policy_version", version);
if (request.RunNow)
{
activity?.SetTag("stellaops.cli.policy_run_now", true);
}
if (request.ScheduledAt.HasValue)
{
activity?.SetTag("stellaops.cli.policy_scheduled_at", request.ScheduledAt.Value.ToString("o", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(request.Priority))
{
activity?.SetTag("stellaops.cli.policy_priority", request.Priority);
}
if (request.Rollback)
{
activity?.SetTag("stellaops.cli.policy_rollback", true);
}
var result = await client.ActivatePolicyRevisionAsync(normalizedPolicyId, version, request, cancellationToken).ConfigureAwait(false);
var outcome = NormalizePolicyActivationOutcome(result.Status);
CliMetrics.RecordPolicyActivation(outcome);
RenderPolicyActivationResult(result, request);
var exitCode = DeterminePolicyActivationExitCode(outcome);
Environment.ExitCode = exitCode;
if (exitCode == 0)
{
logger.LogInformation("Policy {PolicyId} v{Version} activation status: {Status}.", result.Revision.PolicyId, result.Revision.Version, outcome);
}
else
{
logger.LogWarning("Policy {PolicyId} v{Version} requires additional approval (status: {Status}).", result.Revision.PolicyId, result.Revision.Version, outcome);
}
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicyActivation("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicyActivationFailure(ex, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Policy activation failed.");
CliMetrics.RecordPolicyActivation("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicySimulateAsync(
IServiceProvider services,
string policyId,
int? baseVersion,
int? candidateVersion,
IReadOnlyList sbomArguments,
IReadOnlyList environmentArguments,
string? format,
string? outputPath,
bool explain,
bool failOnDiff,
IReadOnlyList withExceptions,
IReadOnlyList withoutExceptions,
string? mode,
IReadOnlyList sbomSelectors,
bool includeHeatmap,
bool manifestDownload,
IReadOnlyList reachabilityStates,
IReadOnlyList reachabilityScores,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("policy-simulate");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.simulate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "policy simulate");
activity?.SetTag("stellaops.cli.policy_id", policyId);
if (baseVersion.HasValue)
{
activity?.SetTag("stellaops.cli.base_version", baseVersion.Value);
}
if (candidateVersion.HasValue)
{
activity?.SetTag("stellaops.cli.candidate_version", candidateVersion.Value);
}
// CLI-EXC-25-002: Track exception preview usage
if (withExceptions.Count > 0)
{
activity?.SetTag("stellaops.cli.with_exceptions_count", withExceptions.Count);
}
if (withoutExceptions.Count > 0)
{
activity?.SetTag("stellaops.cli.without_exceptions_count", withoutExceptions.Count);
}
using var duration = CliMetrics.MeasureCommandDuration("policy simulate");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
var normalizedPolicyId = policyId.Trim();
var sbomSet = NormalizePolicySbomSet(sbomArguments);
var environment = ParsePolicyEnvironment(environmentArguments);
// CLI-EXC-25-002: Normalize exception IDs and validate no overlap
var normalizedWithExceptions = withExceptions.Select(e => e.Trim()).Where(e => !string.IsNullOrEmpty(e)).ToList();
var normalizedWithoutExceptions = withoutExceptions.Select(e => e.Trim()).Where(e => !string.IsNullOrEmpty(e)).ToList();
var overlap = normalizedWithExceptions.Intersect(normalizedWithoutExceptions).ToList();
if (overlap.Count > 0)
{
throw new ArgumentException($"Exception IDs cannot appear in both --with-exception and --without-exception: {string.Join(", ", overlap)}");
}
if (verbose && (normalizedWithExceptions.Count > 0 || normalizedWithoutExceptions.Count > 0))
{
if (normalizedWithExceptions.Count > 0)
{
logger.LogInformation("Simulating WITH exceptions: {Exceptions}", string.Join(", ", normalizedWithExceptions));
}
if (normalizedWithoutExceptions.Count > 0)
{
logger.LogInformation("Simulating WITHOUT exceptions: {Exceptions}", string.Join(", ", normalizedWithoutExceptions));
}
}
// CLI-POLICY-27-003: Parse simulation mode
PolicySimulationMode? simulationMode = null;
if (!string.IsNullOrWhiteSpace(mode))
{
simulationMode = mode.Trim().ToLowerInvariant() switch
{
"quick" => PolicySimulationMode.Quick,
"batch" => PolicySimulationMode.Batch,
_ => throw new ArgumentException($"Invalid mode '{mode}'. Use 'quick' or 'batch'.")
};
if (verbose)
{
logger.LogInformation("Simulation mode: {Mode}", mode);
}
}
// CLI-POLICY-27-003: Normalize SBOM selectors
var normalizedSbomSelectors = sbomSelectors
.Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
if (verbose && normalizedSbomSelectors.Count > 0)
{
logger.LogInformation("SBOM selectors: {Selectors}", string.Join(", ", normalizedSbomSelectors));
}
// CLI-SIG-26-002: Parse reachability overrides
var reachabilityOverrides = ParseReachabilityOverrides(reachabilityStates, reachabilityScores);
if (verbose && reachabilityOverrides.Count > 0)
{
logger.LogInformation("Reachability overrides: {Count} items", reachabilityOverrides.Count);
foreach (var ro in reachabilityOverrides)
{
var target = ro.VulnerabilityId ?? ro.PackagePurl ?? "unknown";
if (!string.IsNullOrWhiteSpace(ro.State))
{
logger.LogDebug(" {Target}: state={State}", target, ro.State);
}
if (ro.Score.HasValue)
{
logger.LogDebug(" {Target}: score={Score}", target, ro.Score);
}
}
}
var input = new PolicySimulationInput(
baseVersion,
candidateVersion,
sbomSet,
environment,
explain,
normalizedWithExceptions.Count > 0 ? normalizedWithExceptions : null,
normalizedWithoutExceptions.Count > 0 ? normalizedWithoutExceptions : null,
simulationMode,
normalizedSbomSelectors.Count > 0 ? normalizedSbomSelectors : null,
includeHeatmap,
manifestDownload,
reachabilityOverrides.Count > 0 ? reachabilityOverrides : null);
var result = await client.SimulatePolicyAsync(normalizedPolicyId, input, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.diff_added", result.Diff.Added);
activity?.SetTag("stellaops.cli.diff_removed", result.Diff.Removed);
if (result.Diff.BySeverity.Count > 0)
{
activity?.SetTag("stellaops.cli.severity_buckets", result.Diff.BySeverity.Count);
}
if (result.Heatmap is not null)
{
activity?.SetTag("stellaops.cli.heatmap_present", true);
}
if (!string.IsNullOrWhiteSpace(result.ManifestDownloadUri))
{
activity?.SetTag("stellaops.cli.manifest_download_available", true);
}
var outputFormat = DeterminePolicySimulationFormat(format, outputPath);
var payload = BuildPolicySimulationPayload(normalizedPolicyId, baseVersion, candidateVersion, sbomSet, environment, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
if (outputFormat == PolicySimulationOutputFormat.Markdown)
{
await WriteMarkdownSimulationOutputAsync(outputPath!, normalizedPolicyId, result, cancellationToken).ConfigureAwait(false);
}
else
{
await WriteSimulationOutputAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
}
logger.LogInformation("Simulation results written to {Path}.", Path.GetFullPath(outputPath!));
}
RenderPolicySimulationResult(logger, payload, result, outputFormat);
var exitCode = DetermineSimulationExitCode(result, failOnDiff);
Environment.ExitCode = exitCode;
var outcome = exitCode == 20
? "diff_blocked"
: (result.Diff.Added + result.Diff.Removed) > 0 ? "diff" : "clean";
CliMetrics.RecordPolicySimulation(outcome);
if (exitCode == 20)
{
logger.LogWarning("Differences detected; exiting with code 20 due to --fail-on-diff.");
}
if (!string.IsNullOrWhiteSpace(result.ExplainUri))
{
activity?.SetTag("stellaops.cli.explain_uri", result.ExplainUri);
}
// CLI-POLICY-27-003: Show manifest download info if available
if (!string.IsNullOrWhiteSpace(result.ManifestDownloadUri))
{
logger.LogInformation("Manifest download available at: {Uri}", result.ManifestDownloadUri);
if (!string.IsNullOrWhiteSpace(result.ManifestDigest))
{
logger.LogInformation("Manifest digest: {Digest}", result.ManifestDigest);
}
}
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicySimulation("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicySimulationFailure(ex, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Policy simulation failed.");
CliMetrics.RecordPolicySimulation("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleOfflineKitImportAsync(
IServiceProvider services,
string bundlePath,
string? manifestPath,
string? bundleSignaturePath,
string? manifestSignaturePath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var options = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("offline-kit-import");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.import", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("offline kit import");
try
{
if (string.IsNullOrWhiteSpace(bundlePath))
{
logger.LogError("Bundle path is required.");
Environment.ExitCode = 1;
return;
}
bundlePath = Path.GetFullPath(bundlePath);
if (!File.Exists(bundlePath))
{
logger.LogError("Bundle file {Path} not found.", bundlePath);
Environment.ExitCode = 1;
return;
}
var metadata = await LoadOfflineKitMetadataAsync(bundlePath, cancellationToken).ConfigureAwait(false);
if (metadata is not null)
{
manifestPath ??= metadata.ManifestPath;
bundleSignaturePath ??= metadata.BundleSignaturePath;
manifestSignaturePath ??= metadata.ManifestSignaturePath;
}
manifestPath = NormalizeFilePath(manifestPath);
bundleSignaturePath = NormalizeFilePath(bundleSignaturePath);
manifestSignaturePath = NormalizeFilePath(manifestSignaturePath);
if (manifestPath is null)
{
manifestPath = TryInferManifestPath(bundlePath);
if (manifestPath is not null)
{
logger.LogDebug("Using inferred manifest path {Path}.", manifestPath);
}
}
if (manifestPath is not null && !File.Exists(manifestPath))
{
logger.LogError("Manifest file {Path} not found.", manifestPath);
Environment.ExitCode = 1;
return;
}
if (bundleSignaturePath is not null && !File.Exists(bundleSignaturePath))
{
logger.LogWarning("Bundle signature {Path} not found; skipping.", bundleSignaturePath);
bundleSignaturePath = null;
}
if (manifestSignaturePath is not null && !File.Exists(manifestSignaturePath))
{
logger.LogWarning("Manifest signature {Path} not found; skipping.", manifestSignaturePath);
manifestSignaturePath = null;
}
if (metadata is not null)
{
var computedBundleDigest = await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false);
if (!DigestsEqual(computedBundleDigest, metadata.BundleSha256))
{
logger.LogError("Bundle digest mismatch. Expected sha256:{Expected} but computed sha256:{Actual}.", metadata.BundleSha256, computedBundleDigest);
Environment.ExitCode = 1;
return;
}
if (manifestPath is not null)
{
var computedManifestDigest = await ComputeSha256Async(manifestPath, cancellationToken).ConfigureAwait(false);
if (!DigestsEqual(computedManifestDigest, metadata.ManifestSha256))
{
logger.LogError("Manifest digest mismatch. Expected sha256:{Expected} but computed sha256:{Actual}.", metadata.ManifestSha256, computedManifestDigest);
Environment.ExitCode = 1;
return;
}
}
}
var request = new OfflineKitImportRequest(
bundlePath,
manifestPath,
bundleSignaturePath,
manifestSignaturePath,
metadata?.BundleId,
metadata?.BundleSha256,
metadata?.BundleSize,
metadata?.CapturedAt,
metadata?.Channel,
metadata?.Kind,
metadata?.IsDelta,
metadata?.BaseBundleId,
metadata?.ManifestSha256,
metadata?.ManifestSize);
var result = await client.ImportOfflineKitAsync(request, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordOfflineKitImport(result.Status);
logger.LogInformation(
"Import {ImportId} submitted at {Submitted:u} with status {Status}.",
string.IsNullOrWhiteSpace(result.ImportId) ? "" : result.ImportId,
result.SubmittedAt,
string.IsNullOrWhiteSpace(result.Status) ? "queued" : result.Status);
if (!string.IsNullOrWhiteSpace(result.Message))
{
logger.LogInformation(result.Message);
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Offline kit import failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleOfflineKitStatusAsync(
IServiceProvider services,
bool asJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("offline-kit-status");
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.status", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("offline kit status");
try
{
var status = await client.GetOfflineKitStatusAsync(cancellationToken).ConfigureAwait(false);
if (asJson)
{
var payload = new
{
bundleId = status.BundleId,
channel = status.Channel,
kind = status.Kind,
isDelta = status.IsDelta,
baseBundleId = status.BaseBundleId,
capturedAt = status.CapturedAt,
importedAt = status.ImportedAt,
sha256 = status.BundleSha256,
sizeBytes = status.BundleSize,
components = status.Components.Select(component => new
{
component.Name,
component.Version,
component.Digest,
component.CapturedAt,
component.SizeBytes
})
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
AnsiConsole.Console.WriteLine(json);
}
else
{
if (string.IsNullOrWhiteSpace(status.BundleId))
{
logger.LogInformation("No offline kit bundle has been imported yet.");
}
else
{
logger.LogInformation(
"Current bundle {BundleId} ({Kind}) captured {Captured:u}, imported {Imported:u}, sha256:{Digest}, size {Size}.",
status.BundleId,
status.Kind ?? "unknown",
status.CapturedAt ?? default,
status.ImportedAt ?? default,
status.BundleSha256 ?? "",
status.BundleSize.HasValue ? status.BundleSize.Value.ToString("N0", CultureInfo.InvariantCulture) : "");
}
if (status.Components.Count > 0)
{
var table = new Table().AddColumns("Component", "Version", "Digest", "Captured", "Size (bytes)");
foreach (var component in status.Components)
{
table.AddRow(
component.Name,
string.IsNullOrWhiteSpace(component.Version) ? "-" : component.Version!,
string.IsNullOrWhiteSpace(component.Digest) ? "-" : $"sha256:{component.Digest}",
component.CapturedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "-",
component.SizeBytes.HasValue ? component.SizeBytes.Value.ToString("N0", CultureInfo.InvariantCulture) : "-");
}
AnsiConsole.Write(table);
}
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to read offline kit status.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static async Task LoadOfflineKitMetadataAsync(string bundlePath, CancellationToken cancellationToken)
{
var metadataPath = bundlePath + ".metadata.json";
if (!File.Exists(metadataPath))
{
return null;
}
try
{
await using var stream = File.OpenRead(metadataPath);
return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
}
private static string? NormalizeFilePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
return Path.GetFullPath(path);
}
private static string? TryInferManifestPath(string bundlePath)
{
var directory = Path.GetDirectoryName(bundlePath);
if (string.IsNullOrWhiteSpace(directory))
{
return null;
}
var baseName = Path.GetFileName(bundlePath);
if (string.IsNullOrWhiteSpace(baseName))
{
return null;
}
baseName = Path.GetFileNameWithoutExtension(baseName);
if (baseName.EndsWith(".tar", StringComparison.OrdinalIgnoreCase))
{
baseName = Path.GetFileNameWithoutExtension(baseName);
}
var candidates = new[]
{
Path.Combine(directory, $"offline-manifest-{baseName}.json"),
Path.Combine(directory, "offline-manifest.json")
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return Path.GetFullPath(candidate);
}
}
return Directory.EnumerateFiles(directory, "offline-manifest*.json").FirstOrDefault();
}
private static bool DigestsEqual(string computed, string? expected)
{
if (string.IsNullOrWhiteSpace(expected))
{
return true;
}
return string.Equals(NormalizeDigest(computed), NormalizeDigest(expected), StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeDigest(string digest)
{
var value = digest.Trim();
if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
value = value.Substring("sha256:".Length);
}
return value.ToLowerInvariant();
}
private static async Task ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature)
{
encodedHeader = string.Empty;
encodedSignature = string.Empty;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var parts = value.Split('.');
if (parts.Length != 3)
{
return false;
}
encodedHeader = parts[0];
encodedSignature = parts[2];
return parts[1].Length == 0;
}
private static byte[] Base64UrlDecode(string value)
{
var normalized = value.Replace('-', '+').Replace('_', '/');
var padding = normalized.Length % 4;
if (padding == 2)
{
normalized += "==";
}
else if (padding == 3)
{
normalized += "=";
}
else if (padding == 1)
{
throw new FormatException("Invalid Base64Url value.");
}
return Convert.FromBase64String(normalized);
}
private static CryptoSigningKey CreateVerificationSigningKey(
string keyId,
string algorithm,
string? providerHint,
string keyPem,
string keyPath)
{
if (string.IsNullOrWhiteSpace(keyPem))
{
throw new InvalidOperationException("Verification key PEM content is empty.");
}
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(keyPem);
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
if (parameters.D is null || parameters.D.Length == 0)
{
parameters.D = new byte[] { 0x01 };
}
var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["source"] = Path.GetFullPath(keyPath),
["verificationOnly"] = "true"
};
return new CryptoSigningKey(
new CryptoKeyReference(keyId, providerHint),
algorithm,
in parameters,
DateTimeOffset.UtcNow,
metadata: metadata);
}
private static string FormatDuration(TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return "expired";
}
if (duration.TotalDays >= 1)
{
var days = (int)duration.TotalDays;
var hours = duration.Hours;
return hours > 0
? FormattableString.Invariant($"{days}d {hours}h")
: FormattableString.Invariant($"{days}d");
}
if (duration.TotalHours >= 1)
{
return FormattableString.Invariant($"{(int)duration.TotalHours}h {duration.Minutes}m");
}
if (duration.TotalMinutes >= 1)
{
return FormattableString.Invariant($"{(int)duration.TotalMinutes}m {duration.Seconds}s");
}
return FormattableString.Invariant($"{duration.Seconds}s");
}
private static bool TryExtractJwtClaims(
string accessToken,
out Dictionary claims,
out DateTimeOffset? issuedAt,
out DateTimeOffset? notBefore)
{
claims = new Dictionary(StringComparer.OrdinalIgnoreCase);
issuedAt = null;
notBefore = null;
if (string.IsNullOrWhiteSpace(accessToken))
{
return false;
}
var parts = accessToken.Split('.');
if (parts.Length < 2)
{
return false;
}
if (!TryDecodeBase64Url(parts[1], out var payloadBytes))
{
return false;
}
try
{
using var document = JsonDocument.Parse(payloadBytes);
foreach (var property in document.RootElement.EnumerateObject())
{
var value = FormatJsonValue(property.Value);
claims[property.Name] = value;
if (issuedAt is null && property.NameEquals("iat") && TryParseUnixSeconds(property.Value, out var parsedIat))
{
issuedAt = parsedIat;
}
if (notBefore is null && property.NameEquals("nbf") && TryParseUnixSeconds(property.Value, out var parsedNbf))
{
notBefore = parsedNbf;
}
}
return true;
}
catch (JsonException)
{
claims.Clear();
issuedAt = null;
notBefore = null;
return false;
}
}
private static bool TryDecodeBase64Url(string value, out byte[] bytes)
{
bytes = Array.Empty();
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Replace('-', '+').Replace('_', '/');
var padding = normalized.Length % 4;
if (padding is 2 or 3)
{
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
}
else if (padding == 1)
{
return false;
}
try
{
bytes = Convert.FromBase64String(normalized);
return true;
}
catch (FormatException)
{
return false;
}
}
private static string FormatJsonValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString() ?? string.Empty,
JsonValueKind.Number => element.TryGetInt64(out var longValue)
? longValue.ToString(CultureInfo.InvariantCulture)
: element.GetDouble().ToString(CultureInfo.InvariantCulture),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
JsonValueKind.Array => FormatArray(element),
JsonValueKind.Object => element.GetRawText(),
_ => element.GetRawText()
};
}
private static string FormatArray(JsonElement array)
{
var values = new List();
foreach (var item in array.EnumerateArray())
{
values.Add(FormatJsonValue(item));
}
return string.Join(", ", values);
}
private static bool TryParseUnixSeconds(JsonElement element, out DateTimeOffset value)
{
value = default;
if (element.ValueKind == JsonValueKind.Number)
{
if (element.TryGetInt64(out var seconds))
{
value = DateTimeOffset.FromUnixTimeSeconds(seconds);
return true;
}
if (element.TryGetDouble(out var doubleValue))
{
value = DateTimeOffset.FromUnixTimeSeconds((long)doubleValue);
return true;
}
}
if (element.ValueKind == JsonValueKind.String)
{
var text = element.GetString();
if (!string.IsNullOrWhiteSpace(text) && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
{
value = DateTimeOffset.FromUnixTimeSeconds(seconds);
return true;
}
}
return false;
}
private static List CollectAdditionalClaims(Dictionary claims)
{
var result = new List();
foreach (var pair in claims)
{
if (CommonClaimNames.Contains(pair.Key))
{
continue;
}
result.Add(FormattableString.Invariant($"{pair.Key}={pair.Value}"));
}
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
private static readonly HashSet CommonClaimNames = new(StringComparer.OrdinalIgnoreCase)
{
"aud",
"client_id",
"exp",
"iat",
"iss",
"nbf",
"scope",
"scopes",
"sub",
"token_type",
"jti"
};
private static async Task ExecuteExcititorCommandAsync(
IServiceProvider services,
string commandName,
bool verbose,
IDictionary? activityTags,
Func> operation,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger(commandName.Replace(' ', '-'));
var verbosity = scope.ServiceProvider.GetRequiredService();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity($"cli.{commandName.Replace(' ', '.')}" , ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", commandName);
if (activityTags is not null)
{
foreach (var tag in activityTags)
{
activity?.SetTag(tag.Key, tag.Value);
}
}
using var duration = CliMetrics.MeasureCommandDuration(commandName);
try
{
var result = await operation(client).ConfigureAwait(false);
if (result.Success)
{
if (!string.IsNullOrWhiteSpace(result.Message))
{
logger.LogInformation(result.Message);
}
else
{
logger.LogInformation("Operation completed successfully.");
}
if (!string.IsNullOrWhiteSpace(result.Location))
{
logger.LogInformation("Location: {Location}", result.Location);
}
if (result.Payload is JsonElement payload && payload.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
{
logger.LogDebug("Response payload: {Payload}", payload.ToString());
}
Environment.ExitCode = 0;
}
else
{
logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Operation failed." : result.Message);
Environment.ExitCode = 1;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Excititor operation failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static async Task> GatherImageDigestsAsync(
IReadOnlyList inline,
string? filePath,
CancellationToken cancellationToken)
{
var results = new List();
var seen = new HashSet(StringComparer.Ordinal);
void AddCandidates(string? candidate)
{
foreach (var image in SplitImageCandidates(candidate))
{
if (seen.Add(image))
{
results.Add(image);
}
}
}
if (inline is not null)
{
foreach (var entry in inline)
{
AddCandidates(entry);
}
}
if (!string.IsNullOrWhiteSpace(filePath))
{
var path = Path.GetFullPath(filePath);
if (!File.Exists(path))
{
throw new FileNotFoundException("Input file not found.", path);
}
foreach (var line in File.ReadLines(path))
{
cancellationToken.ThrowIfCancellationRequested();
AddCandidates(line);
}
}
if (Console.IsInputRedirected)
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await Console.In.ReadLineAsync().ConfigureAwait(false);
if (line is null)
{
break;
}
AddCandidates(line);
}
}
return new ReadOnlyCollection(results);
}
private static IEnumerable SplitImageCandidates(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
yield break;
}
var candidate = raw.Trim();
var commentIndex = candidate.IndexOf('#');
if (commentIndex >= 0)
{
candidate = candidate[..commentIndex].Trim();
}
if (candidate.Length == 0)
{
yield break;
}
var tokens = candidate.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var token in tokens)
{
var trimmed = token.Trim();
if (trimmed.Length > 0)
{
yield return trimmed;
}
}
}
private static IReadOnlyDictionary ParseLabelSelectors(IReadOnlyList labelArguments)
{
if (labelArguments is null || labelArguments.Count == 0)
{
return EmptyLabelSelectors;
}
var labels = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var raw in labelArguments)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var trimmed = raw.Trim();
var delimiter = trimmed.IndexOf('=');
if (delimiter <= 0 || delimiter == trimmed.Length - 1)
{
throw new ArgumentException($"Invalid label '{raw}'. Expected key=value format.");
}
var key = trimmed[..delimiter].Trim();
var value = trimmed[(delimiter + 1)..].Trim();
if (key.Length == 0)
{
throw new ArgumentException($"Invalid label '{raw}'. Label key cannot be empty.");
}
labels[key] = value;
}
return labels.Count == 0 ? EmptyLabelSelectors : new ReadOnlyDictionary(labels);
}
private sealed record ExcititorExportManifestSummary(
string ExportId,
string? Format,
string? Algorithm,
string? Digest,
long? SizeBytes,
bool? FromCache,
DateTimeOffset? CreatedAt,
string? RekorLocation,
string? RekorIndex,
string? RekorInclusionUrl);
private static ExcititorExportManifestSummary? TryParseExportManifest(JsonElement? payload)
{
if (payload is null || payload.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
return null;
}
var element = payload.Value;
var exportId = GetStringProperty(element, "exportId");
if (string.IsNullOrWhiteSpace(exportId))
{
return null;
}
var format = GetStringProperty(element, "format");
var algorithm = default(string?);
var digest = default(string?);
if (TryGetPropertyCaseInsensitive(element, "artifact", out var artifact) && artifact.ValueKind == JsonValueKind.Object)
{
algorithm = GetStringProperty(artifact, "algorithm");
digest = GetStringProperty(artifact, "digest");
}
var sizeBytes = GetInt64Property(element, "sizeBytes");
var fromCache = GetBooleanProperty(element, "fromCache");
var createdAt = GetDateTimeOffsetProperty(element, "createdAt");
string? rekorLocation = null;
string? rekorIndex = null;
string? rekorInclusion = null;
if (TryGetPropertyCaseInsensitive(element, "attestation", out var attestation) && attestation.ValueKind == JsonValueKind.Object)
{
if (TryGetPropertyCaseInsensitive(attestation, "rekor", out var rekor) && rekor.ValueKind == JsonValueKind.Object)
{
rekorLocation = GetStringProperty(rekor, "location");
rekorIndex = GetStringProperty(rekor, "logIndex");
var inclusion = GetStringProperty(rekor, "inclusionProofUri");
if (!string.IsNullOrWhiteSpace(inclusion))
{
rekorInclusion = inclusion;
}
}
}
return new ExcititorExportManifestSummary(
exportId.Trim(),
format,
algorithm,
digest,
sizeBytes,
fromCache,
createdAt,
rekorLocation,
rekorIndex,
rekorInclusion);
}
private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property))
{
return true;
}
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var candidate in element.EnumerateObject())
{
if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
property = candidate.Value;
return true;
}
}
}
property = default;
return false;
}
private static string? GetStringProperty(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.ToString(),
_ => null
};
}
return null;
}
private static bool? GetBooleanProperty(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed,
_ => null
};
}
return null;
}
private static long? GetInt64Property(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
{
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
{
return value;
}
if (property.ValueKind == JsonValueKind.String
&& long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
}
return null;
}
private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)
&& property.ValueKind == JsonValueKind.String
&& DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value))
{
return value.ToUniversalTime();
}
return null;
}
private static string BuildDigestDisplay(string? algorithm, string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return string.Empty;
}
if (digest.Contains(':', StringComparison.Ordinal))
{
return digest;
}
if (string.IsNullOrWhiteSpace(algorithm) || algorithm.Equals("sha256", StringComparison.OrdinalIgnoreCase))
{
return $"sha256:{digest}";
}
return $"{algorithm}:{digest}";
}
private static string FormatSize(long sizeBytes)
{
if (sizeBytes < 0)
{
return $"{sizeBytes} bytes";
}
string[] units = { "bytes", "KB", "MB", "GB", "TB" };
double size = sizeBytes;
var unit = 0;
while (size >= 1024 && unit < units.Length - 1)
{
size /= 1024;
unit++;
}
return unit == 0 ? $"{sizeBytes} bytes" : $"{size:0.##} {units[unit]}";
}
private static string ResolveExportOutputPath(string outputPath, ExcititorExportManifestSummary manifest)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
throw new ArgumentException("Output path must be provided.", nameof(outputPath));
}
var fullPath = Path.GetFullPath(outputPath);
if (Directory.Exists(fullPath)
|| outputPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|| outputPath.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal))
{
return Path.Combine(fullPath, BuildExportFileName(manifest));
}
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
return fullPath;
}
private static string BuildExportFileName(ExcititorExportManifestSummary manifest)
{
var token = !string.IsNullOrWhiteSpace(manifest.Digest)
? manifest.Digest!
: manifest.ExportId;
token = SanitizeToken(token);
if (token.Length > 40)
{
token = token[..40];
}
var extension = DetermineExportExtension(manifest.Format);
return $"stellaops-excititor-{token}{extension}";
}
private static string DetermineExportExtension(string? format)
{
if (string.IsNullOrWhiteSpace(format))
{
return ".bin";
}
return format switch
{
not null when format.Equals("jsonl", StringComparison.OrdinalIgnoreCase) => ".jsonl",
not null when format.Equals("json", StringComparison.OrdinalIgnoreCase) => ".json",
not null when format.Equals("openvex", StringComparison.OrdinalIgnoreCase) => ".json",
not null when format.Equals("csaf", StringComparison.OrdinalIgnoreCase) => ".json",
_ => ".bin"
};
}
private static string SanitizeToken(string token)
{
var builder = new StringBuilder(token.Length);
foreach (var ch in token)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(char.ToLowerInvariant(ch));
}
}
if (builder.Length == 0)
{
builder.Append("export");
}
return builder.ToString();
}
private static string? ResolveLocationUrl(StellaOpsCliOptions options, string location)
{
if (string.IsNullOrWhiteSpace(location))
{
return null;
}
if (Uri.TryCreate(location, UriKind.Absolute, out var absolute))
{
return absolute.ToString();
}
if (!string.IsNullOrWhiteSpace(options?.BackendUrl) && Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
{
if (!location.StartsWith("/", StringComparison.Ordinal))
{
location = "/" + location;
}
return new Uri(baseUri, location).ToString();
}
return location;
}
private static string BuildRuntimePolicyJson(RuntimePolicyEvaluationResult result, IReadOnlyList requestedImages)
{
var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys);
var results = new Dictionary(StringComparer.Ordinal);
foreach (var image in orderedImages)
{
if (result.Decisions.TryGetValue(image, out var decision))
{
results[image] = BuildDecisionMap(decision);
}
}
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var payload = new Dictionary(StringComparer.Ordinal)
{
["ttlSeconds"] = result.TtlSeconds,
["expiresAtUtc"] = result.ExpiresAtUtc?.ToString("O", CultureInfo.InvariantCulture),
["policyRevision"] = result.PolicyRevision,
["results"] = results
};
return JsonSerializer.Serialize(payload, options);
}
private static IDictionary BuildDecisionMap(RuntimePolicyImageDecision decision)
{
var map = new Dictionary(StringComparer.Ordinal)
{
["policyVerdict"] = decision.PolicyVerdict,
["signed"] = decision.Signed,
["hasSbomReferrers"] = decision.HasSbomReferrers
};
if (decision.Reasons.Count > 0)
{
map["reasons"] = decision.Reasons;
}
if (decision.Rekor is not null)
{
var rekorMap = new Dictionary(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid))
{
rekorMap["uuid"] = decision.Rekor.Uuid;
}
if (!string.IsNullOrWhiteSpace(decision.Rekor.Url))
{
rekorMap["url"] = decision.Rekor.Url;
}
if (decision.Rekor.Verified.HasValue)
{
rekorMap["verified"] = decision.Rekor.Verified;
}
if (rekorMap.Count > 0)
{
map["rekor"] = rekorMap;
}
}
foreach (var kvp in decision.AdditionalProperties)
{
map[kvp.Key] = kvp.Value;
}
return map;
}
private static void DisplayRuntimePolicyResults(ILogger logger, RuntimePolicyEvaluationResult result, IReadOnlyList requestedImages)
{
var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys);
var summary = new Dictionary(StringComparer.OrdinalIgnoreCase);
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().Border(TableBorder.Rounded)
.AddColumns("Image", "Verdict", "Signed", "SBOM Ref", "Quieted", "Confidence", "Reasons", "Attestation");
foreach (var image in orderedImages)
{
if (result.Decisions.TryGetValue(image, out var decision))
{
table.AddRow(
image,
decision.PolicyVerdict,
FormatBoolean(decision.Signed),
FormatBoolean(decision.HasSbomReferrers),
FormatQuietedDisplay(decision.AdditionalProperties),
FormatConfidenceDisplay(decision.AdditionalProperties),
decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-",
FormatAttestation(decision.Rekor));
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
if (decision.AdditionalProperties.Count > 0)
{
var metadata = string.Join(", ", decision.AdditionalProperties.Select(kvp => $"{kvp.Key}={FormatAdditionalValue(kvp.Value)}"));
logger.LogDebug("Metadata for {Image}: {Metadata}", image, metadata);
}
}
else
{
table.AddRow(image, "", "-", "-", "-", "-", "-", "-");
}
}
AnsiConsole.Write(table);
}
else
{
foreach (var image in orderedImages)
{
if (result.Decisions.TryGetValue(image, out var decision))
{
var reasons = decision.Reasons.Count > 0 ? string.Join(", ", decision.Reasons) : "none";
logger.LogInformation(
"{Image} -> verdict={Verdict} signed={Signed} sbomRef={Sbom} quieted={Quieted} confidence={Confidence} attestation={Attestation} reasons={Reasons}",
image,
decision.PolicyVerdict,
FormatBoolean(decision.Signed),
FormatBoolean(decision.HasSbomReferrers),
FormatQuietedDisplay(decision.AdditionalProperties),
FormatConfidenceDisplay(decision.AdditionalProperties),
FormatAttestation(decision.Rekor),
reasons);
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
if (decision.AdditionalProperties.Count > 0)
{
var metadata = string.Join(", ", decision.AdditionalProperties.Select(kvp => $"{kvp.Key}={FormatAdditionalValue(kvp.Value)}"));
logger.LogDebug("Metadata for {Image}: {Metadata}", image, metadata);
}
}
else
{
logger.LogWarning("{Image} -> no decision returned by backend.", image);
}
}
}
if (summary.Count > 0)
{
var summaryText = string.Join(", ", summary.Select(kvp => $"{kvp.Key}:{kvp.Value}"));
logger.LogInformation("Verdict summary: {Summary}", summaryText);
}
}
private static IReadOnlyList BuildImageOrder(IReadOnlyList requestedImages, IEnumerable actual)
{
var order = new List();
var seen = new HashSet(StringComparer.Ordinal);
if (requestedImages is not null)
{
foreach (var image in requestedImages)
{
if (!string.IsNullOrWhiteSpace(image))
{
var trimmed = image.Trim();
if (seen.Add(trimmed))
{
order.Add(trimmed);
}
}
}
}
foreach (var image in actual)
{
if (!string.IsNullOrWhiteSpace(image))
{
var trimmed = image.Trim();
if (seen.Add(trimmed))
{
order.Add(trimmed);
}
}
}
return new ReadOnlyCollection(order);
}
private static string FormatBoolean(bool? value)
=> value is null ? "unknown" : value.Value ? "yes" : "no";
private static string FormatQuietedDisplay(IReadOnlyDictionary metadata)
{
var quieted = GetMetadataBoolean(metadata, "quieted", "quiet");
var quietedBy = GetMetadataString(metadata, "quietedBy", "quietedReason");
if (quieted is true)
{
return string.IsNullOrWhiteSpace(quietedBy) ? "yes" : $"yes ({quietedBy})";
}
if (quieted is false)
{
return "no";
}
return string.IsNullOrWhiteSpace(quietedBy) ? "-" : $"? ({quietedBy})";
}
private static string FormatConfidenceDisplay(IReadOnlyDictionary metadata)
{
var confidence = GetMetadataDouble(metadata, "confidence");
var confidenceBand = GetMetadataString(metadata, "confidenceBand", "confidenceTier");
if (confidence.HasValue && !string.IsNullOrWhiteSpace(confidenceBand))
{
return string.Format(CultureInfo.InvariantCulture, "{0:0.###} ({1})", confidence.Value, confidenceBand);
}
if (confidence.HasValue)
{
return confidence.Value.ToString("0.###", CultureInfo.InvariantCulture);
}
if (!string.IsNullOrWhiteSpace(confidenceBand))
{
return confidenceBand!;
}
return "-";
}
private static string FormatAttestation(RuntimePolicyRekorReference? rekor)
{
if (rekor is null)
{
return "-";
}
var uuid = string.IsNullOrWhiteSpace(rekor.Uuid) ? null : rekor.Uuid;
var url = string.IsNullOrWhiteSpace(rekor.Url) ? null : rekor.Url;
var verified = rekor.Verified;
var core = uuid ?? url;
if (!string.IsNullOrEmpty(core))
{
if (verified.HasValue)
{
var suffix = verified.Value ? " (verified)" : " (unverified)";
return core + suffix;
}
return core!;
}
if (verified.HasValue)
{
return verified.Value ? "verified" : "unverified";
}
return "-";
}
private static bool? GetMetadataBoolean(IReadOnlyDictionary metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && value is not null)
{
switch (value)
{
case bool b:
return b;
case string s when bool.TryParse(s, out var parsed):
return parsed;
}
}
}
return null;
}
private static string? GetMetadataString(IReadOnlyDictionary metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && value is not null)
{
if (value is string s)
{
return string.IsNullOrWhiteSpace(s) ? null : s;
}
}
}
return null;
}
private static double? GetMetadataDouble(IReadOnlyDictionary metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && value is not null)
{
switch (value)
{
case double d:
return d;
case float f:
return f;
case decimal m:
return (double)m;
case long l:
return l;
case int i:
return i;
case string s when double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed):
return parsed;
}
}
}
return null;
}
private static TaskRunnerSimulationOutputFormat DetermineTaskRunnerSimulationFormat(string? value, string? outputPath)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant() switch
{
"table" => TaskRunnerSimulationOutputFormat.Table,
"json" => TaskRunnerSimulationOutputFormat.Json,
_ => throw new ArgumentException("Invalid format. Use 'table' or 'json'.")
};
}
if (!string.IsNullOrWhiteSpace(outputPath))
{
return TaskRunnerSimulationOutputFormat.Json;
}
return TaskRunnerSimulationOutputFormat.Table;
}
private static object BuildTaskRunnerSimulationPayload(TaskRunnerSimulationResult result)
=> new
{
planHash = result.PlanHash,
failurePolicy = new
{
result.FailurePolicy.MaxAttempts,
result.FailurePolicy.BackoffSeconds,
result.FailurePolicy.ContinueOnError
},
hasPendingApprovals = result.HasPendingApprovals,
steps = result.Steps,
outputs = result.Outputs
};
private static void RenderTaskRunnerSimulationResult(TaskRunnerSimulationResult result)
{
var console = AnsiConsole.Console;
var table = new Table
{
Border = TableBorder.Rounded
};
table.AddColumn("Step");
table.AddColumn("Kind");
table.AddColumn("Status");
table.AddColumn("Reason");
table.AddColumn("MaxParallel");
table.AddColumn("ContinueOnError");
table.AddColumn("Approval");
foreach (var (step, depth) in FlattenTaskRunnerSimulationSteps(result.Steps))
{
var indent = new string(' ', depth * 2);
table.AddRow(
Markup.Escape($"{indent}{step.Id}"),
Markup.Escape(step.Kind),
Markup.Escape(step.Status),
Markup.Escape(string.IsNullOrWhiteSpace(step.StatusReason) ? "-" : step.StatusReason!),
step.MaxParallel?.ToString(CultureInfo.InvariantCulture) ?? "-",
step.ContinueOnError ? "yes" : "no",
Markup.Escape(string.IsNullOrWhiteSpace(step.ApprovalId) ? "-" : step.ApprovalId!));
}
console.Write(table);
if (result.Outputs.Count > 0)
{
var outputsTable = new Table
{
Border = TableBorder.Rounded
};
outputsTable.AddColumn("Name");
outputsTable.AddColumn("Type");
outputsTable.AddColumn("Requires Runtime");
outputsTable.AddColumn("Path");
outputsTable.AddColumn("Expression");
foreach (var output in result.Outputs)
{
outputsTable.AddRow(
Markup.Escape(output.Name),
Markup.Escape(output.Type),
output.RequiresRuntimeValue ? "yes" : "no",
Markup.Escape(string.IsNullOrWhiteSpace(output.PathExpression) ? "-" : output.PathExpression!),
Markup.Escape(string.IsNullOrWhiteSpace(output.ValueExpression) ? "-" : output.ValueExpression!));
}
console.WriteLine();
console.Write(outputsTable);
}
console.WriteLine();
console.MarkupLine($"[grey]Plan Hash:[/] {Markup.Escape(result.PlanHash)}");
console.MarkupLine($"[grey]Pending Approvals:[/] {(result.HasPendingApprovals ? "yes" : "no")}");
console.Write(new Text($"Plan Hash: {result.PlanHash}{Environment.NewLine}"));
console.Write(new Text($"Pending Approvals: {(result.HasPendingApprovals ? "yes" : "no")}{Environment.NewLine}"));
}
private static IEnumerable<(TaskRunnerSimulationStep Step, int Depth)> FlattenTaskRunnerSimulationSteps(
IReadOnlyList steps,
int depth = 0)
{
for (var i = 0; i < steps.Count; i++)
{
var step = steps[i];
yield return (step, depth);
foreach (var child in FlattenTaskRunnerSimulationSteps(step.Children, depth + 1))
{
yield return child;
}
}
}
private static PolicySimulationOutputFormat DeterminePolicySimulationFormat(string? value, string? outputPath)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant() switch
{
"table" => PolicySimulationOutputFormat.Table,
"json" => PolicySimulationOutputFormat.Json,
"markdown" or "md" => PolicySimulationOutputFormat.Markdown,
_ => throw new ArgumentException("Invalid format. Use 'table', 'json', or 'markdown'.")
};
}
// CLI-POLICY-27-003: Infer format from output file extension
if (!string.IsNullOrWhiteSpace(outputPath))
{
var extension = Path.GetExtension(outputPath).ToLowerInvariant();
return extension switch
{
".md" or ".markdown" => PolicySimulationOutputFormat.Markdown,
".json" => PolicySimulationOutputFormat.Json,
_ => PolicySimulationOutputFormat.Json
};
}
if (Console.IsOutputRedirected)
{
return PolicySimulationOutputFormat.Json;
}
return PolicySimulationOutputFormat.Table;
}
private static object BuildPolicySimulationPayload(
string policyId,
int? baseVersion,
int? candidateVersion,
IReadOnlyList sbomSet,
IReadOnlyDictionary environment,
PolicySimulationResult result)
=> new
{
policyId,
baseVersion,
candidateVersion,
sbomSet = sbomSet.Count == 0 ? Array.Empty() : sbomSet,
environment = environment.Count == 0 ? null : environment,
diff = result.Diff,
explainUri = result.ExplainUri
};
private static void RenderPolicySimulationResult(
ILogger logger,
object payload,
PolicySimulationResult result,
PolicySimulationOutputFormat format)
{
if (format == PolicySimulationOutputFormat.Json)
{
var json = JsonSerializer.Serialize(payload, SimulationJsonOptions);
Console.WriteLine(json);
return;
}
// CLI-POLICY-27-003: Handle markdown console output
if (format == PolicySimulationOutputFormat.Markdown)
{
RenderPolicySimulationMarkdown(result);
return;
}
logger.LogInformation(
"Policy diff summary — Added: {Added}, Removed: {Removed}, Unchanged: {Unchanged}.",
result.Diff.Added,
result.Diff.Removed,
result.Diff.Unchanged);
// CLI-POLICY-27-003: Render heatmap summary if present
if (result.Heatmap is not null)
{
RenderPolicySimulationHeatmap(result.Heatmap);
}
if (result.Diff.BySeverity.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().AddColumns("Severity", "Up", "Down");
foreach (var entry in result.Diff.BySeverity.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
table.AddRow(
entry.Key,
FormatDelta(entry.Value.Up),
FormatDelta(entry.Value.Down));
}
AnsiConsole.Write(table);
}
else
{
foreach (var entry in result.Diff.BySeverity.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
logger.LogInformation("Severity {Severity}: up={Up}, down={Down}", entry.Key, entry.Value.Up ?? 0, entry.Value.Down ?? 0);
}
}
}
if (result.Diff.RuleHits.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().AddColumns("Rule", "Up", "Down");
foreach (var hit in result.Diff.RuleHits)
{
table.AddRow(
string.IsNullOrWhiteSpace(hit.RuleName) ? hit.RuleId : $"{hit.RuleName} ({hit.RuleId})",
FormatDelta(hit.Up),
FormatDelta(hit.Down));
}
AnsiConsole.Write(table);
}
else
{
foreach (var hit in result.Diff.RuleHits)
{
logger.LogInformation("Rule {RuleId}: up={Up}, down={Down}", hit.RuleId, hit.Up ?? 0, hit.Down ?? 0);
}
}
}
if (!string.IsNullOrWhiteSpace(result.ExplainUri))
{
logger.LogInformation("Explain trace available at {ExplainUri}.", result.ExplainUri);
}
}
// CLI-POLICY-27-003: Render heatmap severity visualization
private static void RenderPolicySimulationHeatmap(PolicySimulationHeatmap heatmap)
{
if (!AnsiConsole.Profile.Capabilities.Interactive)
{
Console.WriteLine($"Heatmap: Critical={heatmap.Critical}, High={heatmap.High}, Medium={heatmap.Medium}, Low={heatmap.Low}, Info={heatmap.Info}");
return;
}
var grid = new Grid();
grid.AddColumn(new GridColumn().NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
grid.AddRow(
new Markup($"[bold red]Critical: {heatmap.Critical}[/]"),
new Markup($"[bold orange1]High: {heatmap.High}[/]"),
new Markup($"[bold yellow]Medium: {heatmap.Medium}[/]"),
new Markup($"[bold blue]Low: {heatmap.Low}[/]"),
new Markup($"[bold grey]Info: {heatmap.Info}[/]"));
var panel = new Panel(grid)
.Header("[bold]Severity Heatmap[/]")
.Border(BoxBorder.Rounded);
AnsiConsole.Write(panel);
}
// CLI-POLICY-27-003: Render markdown output to console
private static void RenderPolicySimulationMarkdown(PolicySimulationResult result)
{
Console.WriteLine("# Policy Simulation Report");
Console.WriteLine();
Console.WriteLine("## Summary");
Console.WriteLine();
Console.WriteLine("| Metric | Count |");
Console.WriteLine("|--------|-------|");
Console.WriteLine($"| Added | {result.Diff.Added} |");
Console.WriteLine($"| Removed | {result.Diff.Removed} |");
Console.WriteLine($"| Unchanged | {result.Diff.Unchanged} |");
Console.WriteLine();
if (result.Heatmap is not null)
{
Console.WriteLine("## Severity Heatmap");
Console.WriteLine();
Console.WriteLine("| Severity | Count |");
Console.WriteLine("|----------|-------|");
Console.WriteLine($"| Critical | {result.Heatmap.Critical} |");
Console.WriteLine($"| High | {result.Heatmap.High} |");
Console.WriteLine($"| Medium | {result.Heatmap.Medium} |");
Console.WriteLine($"| Low | {result.Heatmap.Low} |");
Console.WriteLine($"| Info | {result.Heatmap.Info} |");
Console.WriteLine();
}
if (result.Diff.BySeverity.Count > 0)
{
Console.WriteLine("## Changes by Severity");
Console.WriteLine();
Console.WriteLine("| Severity | Up | Down |");
Console.WriteLine("|----------|-----|------|");
foreach (var entry in result.Diff.BySeverity.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
Console.WriteLine($"| {entry.Key} | {entry.Value.Up ?? 0} | {entry.Value.Down ?? 0} |");
}
Console.WriteLine();
}
if (result.Diff.RuleHits.Count > 0)
{
Console.WriteLine("## Rule Impacts");
Console.WriteLine();
Console.WriteLine("| Rule | Up | Down |");
Console.WriteLine("|------|-----|------|");
foreach (var hit in result.Diff.RuleHits)
{
var ruleName = string.IsNullOrWhiteSpace(hit.RuleName) ? hit.RuleId : $"{hit.RuleName} ({hit.RuleId})";
Console.WriteLine($"| {ruleName} | {hit.Up ?? 0} | {hit.Down ?? 0} |");
}
Console.WriteLine();
}
if (!string.IsNullOrWhiteSpace(result.ExplainUri))
{
Console.WriteLine("## Resources");
Console.WriteLine();
Console.WriteLine($"- [Explain Trace]({result.ExplainUri})");
}
if (!string.IsNullOrWhiteSpace(result.ManifestDownloadUri))
{
if (string.IsNullOrWhiteSpace(result.ExplainUri))
{
Console.WriteLine("## Resources");
Console.WriteLine();
}
Console.WriteLine($"- [Manifest Download]({result.ManifestDownloadUri})");
}
}
private static IReadOnlyList NormalizePolicySbomSet(IReadOnlyList arguments)
{
if (arguments is null || arguments.Count == 0)
{
return EmptyPolicySbomSet;
}
var set = new SortedSet(StringComparer.Ordinal);
foreach (var raw in arguments)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var trimmed = raw.Trim();
if (trimmed.Length > 0)
{
set.Add(trimmed);
}
}
if (set.Count == 0)
{
return EmptyPolicySbomSet;
}
var list = set.ToList();
return new ReadOnlyCollection(list);
}
private static IReadOnlyDictionary ParsePolicyEnvironment(IReadOnlyList arguments)
{
if (arguments is null || arguments.Count == 0)
{
return EmptyPolicyEnvironment;
}
var env = new SortedDictionary(StringComparer.Ordinal);
foreach (var raw in arguments)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var trimmed = raw.Trim();
var separator = trimmed.IndexOf('=');
if (separator <= 0 || separator == trimmed.Length - 1)
{
throw new ArgumentException($"Invalid environment assignment '{raw}'. Expected key=value.");
}
var key = trimmed[..separator].Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException($"Invalid environment assignment '{raw}'. Expected key=value.");
}
var valueToken = trimmed[(separator + 1)..].Trim();
env[key] = ParsePolicyEnvironmentValue(valueToken);
}
return env.Count == 0 ? EmptyPolicyEnvironment : new ReadOnlyDictionary(env);
}
private static object? ParsePolicyEnvironmentValue(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return string.Empty;
}
var value = token;
if ((value.Length >= 2 && value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal)) ||
(value.Length >= 2 && value.StartsWith("'", StringComparison.Ordinal) && value.EndsWith("'", StringComparison.Ordinal)))
{
value = value[1..^1];
}
if (string.Equals(value, "null", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (bool.TryParse(value, out var boolResult))
{
return boolResult;
}
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longResult))
{
return longResult;
}
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var doubleResult))
{
return doubleResult;
}
return value;
}
// CLI-SIG-26-002: Parse reachability overrides from CLI arguments
private static IReadOnlyList ParseReachabilityOverrides(
IReadOnlyList stateOverrides,
IReadOnlyList scoreOverrides)
{
var overrides = new Dictionary(StringComparer.OrdinalIgnoreCase);
// Parse state overrides (format: "CVE-XXXX:reachable" or "pkg:npm/lodash@4.17.0:unreachable")
foreach (var raw in stateOverrides)
{
if (string.IsNullOrWhiteSpace(raw))
continue;
var (target, value) = ParseOverrideValue(raw, "state");
if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(value))
continue;
var state = value.ToLowerInvariant() switch
{
"reachable" => "reachable",
"unreachable" => "unreachable",
"unknown" => "unknown",
"indeterminate" => "indeterminate",
_ => throw new ArgumentException($"Invalid reachability state '{value}'. Use 'reachable', 'unreachable', 'unknown', or 'indeterminate'.")
};
if (!overrides.TryGetValue(target, out var existing))
{
existing = CreateReachabilityOverride(target);
}
overrides[target] = existing with { State = state };
}
// Parse score overrides (format: "CVE-XXXX:0.85" or "pkg:npm/lodash@4.17.0:0.5")
foreach (var raw in scoreOverrides)
{
if (string.IsNullOrWhiteSpace(raw))
continue;
var (target, value) = ParseOverrideValue(raw, "score");
if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(value))
continue;
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var score))
{
throw new ArgumentException($"Invalid reachability score '{value}'. Expected a decimal number between 0 and 1.");
}
if (score < 0 || score > 1)
{
throw new ArgumentException($"Reachability score '{score}' out of range. Expected a value between 0 and 1.");
}
if (!overrides.TryGetValue(target, out var existing))
{
existing = CreateReachabilityOverride(target);
}
overrides[target] = existing with { Score = score };
}
return overrides.Values.ToList();
}
private static (string Target, string Value) ParseOverrideValue(string raw, string overrideType)
{
var trimmed = raw.Trim();
// Handle PURL format which contains colons (pkg:type/name@version)
int separatorIndex;
if (trimmed.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
// Find the last colon which separates the value
separatorIndex = trimmed.LastIndexOf(':');
if (separatorIndex <= 4) // "pkg:" is 4 chars
{
throw new ArgumentException($"Invalid {overrideType} override format '{raw}'. Expected 'pkg:type/name@version:{overrideType}'.");
}
}
else
{
// CVE or other identifier format
separatorIndex = trimmed.LastIndexOf(':');
}
if (separatorIndex < 0 || separatorIndex == trimmed.Length - 1)
{
throw new ArgumentException($"Invalid {overrideType} override format '{raw}'. Expected 'identifier:{overrideType}'.");
}
var target = trimmed[..separatorIndex].Trim();
var value = trimmed[(separatorIndex + 1)..].Trim();
return (target, value);
}
private static ReachabilityOverride CreateReachabilityOverride(string target)
{
// Determine if target is a vulnerability ID or package PURL
if (target.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return new ReachabilityOverride { PackagePurl = target, State = string.Empty };
}
else
{
return new ReachabilityOverride { VulnerabilityId = target, State = string.Empty };
}
}
private static Task WriteSimulationOutputAsync(string outputPath, object payload, CancellationToken cancellationToken)
=> WriteJsonPayloadAsync(outputPath, payload, cancellationToken);
// CLI-POLICY-27-003: Write markdown report for CI integration
private static async Task WriteMarkdownSimulationOutputAsync(
string outputPath,
string policyId,
PolicySimulationResult result,
CancellationToken cancellationToken)
{
var fullPath = Path.GetFullPath(outputPath);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var sb = new StringBuilder();
sb.AppendLine("# Policy Simulation Report");
sb.AppendLine();
sb.AppendLine($"**Policy:** `{policyId}` ");
sb.AppendLine($"**Generated:** {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine();
sb.AppendLine("## Summary");
sb.AppendLine();
sb.AppendLine("| Metric | Count |");
sb.AppendLine("|--------|-------|");
sb.AppendLine($"| Added | {result.Diff.Added} |");
sb.AppendLine($"| Removed | {result.Diff.Removed} |");
sb.AppendLine($"| Unchanged | {result.Diff.Unchanged} |");
sb.AppendLine();
if (result.Heatmap is not null)
{
sb.AppendLine("## Severity Heatmap");
sb.AppendLine();
sb.AppendLine("| Severity | Count |");
sb.AppendLine("|----------|-------|");
sb.AppendLine($"| Critical | {result.Heatmap.Critical} |");
sb.AppendLine($"| High | {result.Heatmap.High} |");
sb.AppendLine($"| Medium | {result.Heatmap.Medium} |");
sb.AppendLine($"| Low | {result.Heatmap.Low} |");
sb.AppendLine($"| Info | {result.Heatmap.Info} |");
sb.AppendLine();
}
if (result.Diff.BySeverity.Count > 0)
{
sb.AppendLine("## Changes by Severity");
sb.AppendLine();
sb.AppendLine("| Severity | Up | Down |");
sb.AppendLine("|----------|-----|------|");
foreach (var entry in result.Diff.BySeverity.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
sb.AppendLine($"| {entry.Key} | {entry.Value.Up ?? 0} | {entry.Value.Down ?? 0} |");
}
sb.AppendLine();
}
if (result.Diff.RuleHits.Count > 0)
{
sb.AppendLine("## Rule Impacts");
sb.AppendLine();
sb.AppendLine("| Rule | Up | Down |");
sb.AppendLine("|------|-----|------|");
foreach (var hit in result.Diff.RuleHits)
{
var ruleName = string.IsNullOrWhiteSpace(hit.RuleName) ? hit.RuleId : $"{hit.RuleName} ({hit.RuleId})";
sb.AppendLine($"| {ruleName} | {hit.Up ?? 0} | {hit.Down ?? 0} |");
}
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(result.ExplainUri))
{
sb.AppendLine("## Additional Resources");
sb.AppendLine();
sb.AppendLine($"- [Explain Trace]({result.ExplainUri})");
}
if (!string.IsNullOrWhiteSpace(result.ManifestDownloadUri))
{
if (string.IsNullOrWhiteSpace(result.ExplainUri))
{
sb.AppendLine("## Additional Resources");
sb.AppendLine();
}
sb.AppendLine($"- [Manifest Download]({result.ManifestDownloadUri})");
if (!string.IsNullOrWhiteSpace(result.ManifestDigest))
{
sb.AppendLine($" - Digest: `{result.ManifestDigest}`");
}
}
await File.WriteAllTextAsync(fullPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
}
private static async Task WriteJsonPayloadAsync(string outputPath, object payload, CancellationToken cancellationToken)
{
var fullPath = Path.GetFullPath(outputPath);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(payload, SimulationJsonOptions);
await File.WriteAllTextAsync(fullPath, json + Environment.NewLine, cancellationToken).ConfigureAwait(false);
}
private static int DetermineSimulationExitCode(PolicySimulationResult result, bool failOnDiff)
{
if (!failOnDiff)
{
return 0;
}
return (result.Diff.Added + result.Diff.Removed) > 0 ? 20 : 0;
}
private static void HandlePolicySimulationFailure(PolicyApiException exception, ILogger logger)
{
var exitCode = exception.ErrorCode switch
{
"ERR_POL_001" => 10,
"ERR_POL_002" or "ERR_POL_005" => 12,
"ERR_POL_003" => 21,
"ERR_POL_004" => 22,
"ERR_POL_006" => 23,
_ when exception.StatusCode == HttpStatusCode.Forbidden || exception.StatusCode == HttpStatusCode.Unauthorized => 12,
_ => 1
};
if (string.IsNullOrWhiteSpace(exception.ErrorCode))
{
logger.LogError("Policy simulation failed ({StatusCode}): {Message}", (int)exception.StatusCode, exception.Message);
}
else
{
logger.LogError("Policy simulation failed ({StatusCode} {Code}): {Message}", (int)exception.StatusCode, exception.ErrorCode, exception.Message);
}
CliMetrics.RecordPolicySimulation("error");
Environment.ExitCode = exitCode;
}
private static void HandlePolicyActivationFailure(PolicyApiException exception, ILogger logger)
{
var exitCode = exception.ErrorCode switch
{
"ERR_POL_002" => 70,
"ERR_POL_003" => 71,
"ERR_POL_004" => 72,
_ when exception.StatusCode == HttpStatusCode.Forbidden || exception.StatusCode == HttpStatusCode.Unauthorized => 12,
_ => 1
};
if (string.IsNullOrWhiteSpace(exception.ErrorCode))
{
logger.LogError("Policy activation failed ({StatusCode}): {Message}", (int)exception.StatusCode, exception.Message);
}
else
{
logger.LogError("Policy activation failed ({StatusCode} {Code}): {Message}", (int)exception.StatusCode, exception.ErrorCode, exception.Message);
}
CliMetrics.RecordPolicyActivation("error");
Environment.ExitCode = exitCode;
}
private static IReadOnlyList NormalizePolicyFilterValues(string[] values, bool toLower = false)
{
if (values is null || values.Length == 0)
{
return Array.Empty();
}
var set = new HashSet(StringComparer.OrdinalIgnoreCase);
var list = new List();
foreach (var raw in values)
{
var candidate = raw?.Trim();
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
var normalized = toLower ? candidate.ToLowerInvariant() : candidate;
if (set.Add(normalized))
{
list.Add(normalized);
}
}
return list.Count == 0 ? Array.Empty() : list;
}
private static string? NormalizePolicyPriority(string? priority)
{
if (string.IsNullOrWhiteSpace(priority))
{
return null;
}
var normalized = priority.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized.ToLowerInvariant();
}
private static string NormalizePolicyActivationOutcome(string status)
{
if (string.IsNullOrWhiteSpace(status))
{
return "unknown";
}
return status.Trim().ToLowerInvariant();
}
private static int DeterminePolicyActivationExitCode(string outcome)
=> string.Equals(outcome, "pending_second_approval", StringComparison.Ordinal) ? 75 : 0;
private static void RenderPolicyActivationResult(PolicyActivationResult result, PolicyActivationRequest request)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var summary = new Table().Expand();
summary.Border(TableBorder.Rounded);
summary.AddColumn(new TableColumn("[grey]Field[/]").LeftAligned());
summary.AddColumn(new TableColumn("[grey]Value[/]").LeftAligned());
summary.AddRow("Policy", Markup.Escape($"{result.Revision.PolicyId} v{result.Revision.Version}"));
summary.AddRow("Status", FormatActivationStatus(result.Status));
summary.AddRow("Requires 2 approvals", result.Revision.RequiresTwoPersonApproval ? "[yellow]yes[/]" : "[green]no[/]");
summary.AddRow("Created (UTC)", Markup.Escape(FormatUpdatedAt(result.Revision.CreatedAt)));
summary.AddRow("Activated (UTC)", result.Revision.ActivatedAt.HasValue
? Markup.Escape(FormatUpdatedAt(result.Revision.ActivatedAt.Value))
: "[grey](not yet active)[/]");
if (request.RunNow)
{
summary.AddRow("Run", "[green]immediate[/]");
}
else if (request.ScheduledAt.HasValue)
{
summary.AddRow("Scheduled at", Markup.Escape(FormatUpdatedAt(request.ScheduledAt.Value)));
}
if (!string.IsNullOrWhiteSpace(request.Priority))
{
summary.AddRow("Priority", Markup.Escape(request.Priority!));
}
if (request.Rollback)
{
summary.AddRow("Rollback", "[yellow]yes[/]");
}
if (!string.IsNullOrWhiteSpace(request.IncidentId))
{
summary.AddRow("Incident", Markup.Escape(request.IncidentId!));
}
if (!string.IsNullOrWhiteSpace(request.Comment))
{
summary.AddRow("Note", Markup.Escape(request.Comment!));
}
AnsiConsole.Write(summary);
if (result.Revision.Approvals.Count > 0)
{
var approvalTable = new Table().Title("[grey]Approvals[/]");
approvalTable.Border(TableBorder.Minimal);
approvalTable.AddColumn(new TableColumn("Actor").LeftAligned());
approvalTable.AddColumn(new TableColumn("Approved (UTC)").LeftAligned());
approvalTable.AddColumn(new TableColumn("Comment").LeftAligned());
foreach (var approval in result.Revision.Approvals)
{
var comment = string.IsNullOrWhiteSpace(approval.Comment) ? "-" : approval.Comment!;
approvalTable.AddRow(
Markup.Escape(approval.ActorId),
Markup.Escape(FormatUpdatedAt(approval.ApprovedAt)),
Markup.Escape(comment));
}
AnsiConsole.Write(approvalTable);
}
else
{
AnsiConsole.MarkupLine("[grey]No activation approvals recorded yet.[/]");
}
}
else
{
Console.WriteLine(FormattableString.Invariant($"Policy: {result.Revision.PolicyId} v{result.Revision.Version}"));
Console.WriteLine(FormattableString.Invariant($"Status: {NormalizePolicyActivationOutcome(result.Status)}"));
Console.WriteLine(FormattableString.Invariant($"Requires 2 approvals: {(result.Revision.RequiresTwoPersonApproval ? "yes" : "no")}"));
Console.WriteLine(FormattableString.Invariant($"Created (UTC): {FormatUpdatedAt(result.Revision.CreatedAt)}"));
Console.WriteLine(FormattableString.Invariant($"Activated (UTC): {(result.Revision.ActivatedAt.HasValue ? FormatUpdatedAt(result.Revision.ActivatedAt.Value) : "(not yet active)")}"));
if (request.RunNow)
{
Console.WriteLine("Run: immediate");
}
else if (request.ScheduledAt.HasValue)
{
Console.WriteLine(FormattableString.Invariant($"Scheduled at: {FormatUpdatedAt(request.ScheduledAt.Value)}"));
}
if (!string.IsNullOrWhiteSpace(request.Priority))
{
Console.WriteLine(FormattableString.Invariant($"Priority: {request.Priority}"));
}
if (request.Rollback)
{
Console.WriteLine("Rollback: yes");
}
if (!string.IsNullOrWhiteSpace(request.IncidentId))
{
Console.WriteLine(FormattableString.Invariant($"Incident: {request.IncidentId}"));
}
if (!string.IsNullOrWhiteSpace(request.Comment))
{
Console.WriteLine(FormattableString.Invariant($"Note: {request.Comment}"));
}
if (result.Revision.Approvals.Count == 0)
{
Console.WriteLine("Approvals: none");
}
else
{
foreach (var approval in result.Revision.Approvals)
{
var comment = string.IsNullOrWhiteSpace(approval.Comment) ? "-" : approval.Comment;
Console.WriteLine(FormattableString.Invariant($"Approval: {approval.ActorId} at {FormatUpdatedAt(approval.ApprovedAt)} ({comment})"));
}
}
}
}
private static string FormatActivationStatus(string status)
{
var normalized = NormalizePolicyActivationOutcome(status);
return normalized switch
{
"activated" => "[green]activated[/]",
"already_active" => "[yellow]already_active[/]",
"pending_second_approval" => "[yellow]pending_second_approval[/]",
_ => "[red]" + Markup.Escape(string.IsNullOrWhiteSpace(status) ? "unknown" : status) + "[/]"
};
}
private static DateTimeOffset? ParsePolicySince(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (DateTimeOffset.TryParse(
value.Trim(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed.ToUniversalTime();
}
throw new ArgumentException("Invalid --since value. Use an ISO-8601 timestamp.");
}
private static string? NormalizeExplainMode(string? mode)
=> string.IsNullOrWhiteSpace(mode) ? null : mode.Trim().ToLowerInvariant();
private static PolicyFindingsOutputFormat DeterminePolicyFindingsFormat(string? value, string? outputPath)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant() switch
{
"table" => PolicyFindingsOutputFormat.Table,
"json" => PolicyFindingsOutputFormat.Json,
_ => throw new ArgumentException("Invalid format. Use 'table' or 'json'.")
};
}
if (!string.IsNullOrWhiteSpace(outputPath) || Console.IsOutputRedirected)
{
return PolicyFindingsOutputFormat.Json;
}
return PolicyFindingsOutputFormat.Table;
}
private static object BuildPolicyFindingsPayload(
string policyId,
PolicyFindingsQuery query,
PolicyFindingsPage page)
=> new
{
policyId,
filters = new
{
sbom = query.SbomIds,
status = query.Statuses,
severity = query.Severities,
cursor = query.Cursor,
page = query.Page,
pageSize = query.PageSize,
since = query.Since?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)
},
items = page.Items.Select(item => new
{
findingId = item.FindingId,
status = item.Status,
severity = new
{
normalized = item.Severity.Normalized,
score = item.Severity.Score
},
sbomId = item.SbomId,
advisoryIds = item.AdvisoryIds,
vex = item.Vex is null ? null : new
{
winningStatementId = item.Vex.WinningStatementId,
source = item.Vex.Source,
status = item.Vex.Status
},
policyVersion = item.PolicyVersion,
updatedAt = item.UpdatedAt == DateTimeOffset.MinValue ? null : item.UpdatedAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
runId = item.RunId
}),
nextCursor = page.NextCursor,
totalCount = page.TotalCount
};
private static object BuildPolicyFindingPayload(string policyId, PolicyFindingDocument finding)
=> new
{
policyId,
finding = new
{
findingId = finding.FindingId,
status = finding.Status,
severity = new
{
normalized = finding.Severity.Normalized,
score = finding.Severity.Score
},
sbomId = finding.SbomId,
advisoryIds = finding.AdvisoryIds,
vex = finding.Vex is null ? null : new
{
winningStatementId = finding.Vex.WinningStatementId,
source = finding.Vex.Source,
status = finding.Vex.Status
},
policyVersion = finding.PolicyVersion,
updatedAt = finding.UpdatedAt == DateTimeOffset.MinValue ? null : finding.UpdatedAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
runId = finding.RunId
}
};
private static object BuildPolicyFindingExplainPayload(
string policyId,
string findingId,
string? mode,
PolicyFindingExplainResult explain)
=> new
{
policyId,
findingId,
mode,
explain = new
{
policyVersion = explain.PolicyVersion,
steps = explain.Steps.Select(step => new
{
rule = step.Rule,
status = step.Status,
action = step.Action,
score = step.Score,
inputs = step.Inputs,
evidence = step.Evidence
}),
sealedHints = explain.SealedHints.Select(hint => hint.Message)
}
};
private static void RenderPolicyFindingsTable(ILogger logger, PolicyFindingsPage page)
{
var items = page.Items;
if (items.Count == 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
AnsiConsole.MarkupLine("[yellow]No findings matched the provided filters.[/]");
}
else
{
logger.LogWarning("No findings matched the provided filters.");
}
return;
}
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table()
.Border(TableBorder.Rounded)
.Centered();
table.AddColumn("Finding");
table.AddColumn("Status");
table.AddColumn("Severity");
table.AddColumn("Score");
table.AddColumn("SBOM");
table.AddColumn("Advisories");
table.AddColumn("Updated (UTC)");
foreach (var item in items)
{
table.AddRow(
Markup.Escape(item.FindingId),
Markup.Escape(item.Status),
Markup.Escape(item.Severity.Normalized),
Markup.Escape(FormatScore(item.Severity.Score)),
Markup.Escape(item.SbomId),
Markup.Escape(FormatListPreview(item.AdvisoryIds)),
Markup.Escape(FormatUpdatedAt(item.UpdatedAt)));
}
AnsiConsole.Write(table);
}
else
{
foreach (var item in items)
{
logger.LogInformation(
"{Finding} — Status {Status}, Severity {Severity} ({Score}), SBOM {Sbom}, Updated {Updated}",
item.FindingId,
item.Status,
item.Severity.Normalized,
item.Severity.Score?.ToString("0.00", CultureInfo.InvariantCulture) ?? "n/a",
item.SbomId,
FormatUpdatedAt(item.UpdatedAt));
}
}
logger.LogInformation("{Count} finding(s).", items.Count);
if (page.TotalCount.HasValue)
{
logger.LogInformation("Total available: {Total}", page.TotalCount.Value);
}
if (!string.IsNullOrWhiteSpace(page.NextCursor))
{
logger.LogInformation("Next cursor: {Cursor}", page.NextCursor);
}
}
private static void RenderPolicyFindingDetails(ILogger logger, PolicyFindingDocument finding)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn("Field")
.AddColumn("Value");
table.AddRow("Finding", Markup.Escape(finding.FindingId));
table.AddRow("Status", Markup.Escape(finding.Status));
table.AddRow("Severity", Markup.Escape(FormatSeverity(finding.Severity)));
table.AddRow("SBOM", Markup.Escape(finding.SbomId));
table.AddRow("Policy Version", Markup.Escape(finding.PolicyVersion.ToString(CultureInfo.InvariantCulture)));
table.AddRow("Updated (UTC)", Markup.Escape(FormatUpdatedAt(finding.UpdatedAt)));
table.AddRow("Run Id", Markup.Escape(string.IsNullOrWhiteSpace(finding.RunId) ? "(none)" : finding.RunId));
table.AddRow("Advisories", Markup.Escape(FormatListPreview(finding.AdvisoryIds)));
table.AddRow("VEX", Markup.Escape(FormatVexMetadata(finding.Vex)));
AnsiConsole.Write(table);
}
else
{
logger.LogInformation("Finding {Finding}", finding.FindingId);
logger.LogInformation(" Status: {Status}", finding.Status);
logger.LogInformation(" Severity: {Severity}", FormatSeverity(finding.Severity));
logger.LogInformation(" SBOM: {Sbom}", finding.SbomId);
logger.LogInformation(" Policy version: {Version}", finding.PolicyVersion);
logger.LogInformation(" Updated (UTC): {Updated}", FormatUpdatedAt(finding.UpdatedAt));
if (!string.IsNullOrWhiteSpace(finding.RunId))
{
logger.LogInformation(" Run Id: {Run}", finding.RunId);
}
if (finding.AdvisoryIds.Count > 0)
{
logger.LogInformation(" Advisories: {Advisories}", string.Join(", ", finding.AdvisoryIds));
}
if (!string.IsNullOrWhiteSpace(FormatVexMetadata(finding.Vex)))
{
logger.LogInformation(" VEX: {Vex}", FormatVexMetadata(finding.Vex));
}
}
}
private static void RenderPolicyFindingExplain(ILogger logger, PolicyFindingExplainResult explain)
{
if (explain.Steps.Count == 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
AnsiConsole.MarkupLine("[yellow]No explain steps were returned.[/]");
}
else
{
logger.LogWarning("No explain steps were returned.");
}
}
else if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn("Rule")
.AddColumn("Status")
.AddColumn("Action")
.AddColumn("Score")
.AddColumn("Inputs")
.AddColumn("Evidence");
foreach (var step in explain.Steps)
{
table.AddRow(
Markup.Escape(step.Rule),
Markup.Escape(step.Status ?? "(n/a)"),
Markup.Escape(step.Action ?? "(n/a)"),
Markup.Escape(step.Score.HasValue ? step.Score.Value.ToString("0.00", CultureInfo.InvariantCulture) : "-"),
Markup.Escape(FormatKeyValuePairs(step.Inputs)),
Markup.Escape(FormatKeyValuePairs(step.Evidence)));
}
AnsiConsole.Write(table);
}
else
{
logger.LogInformation("{Count} explain step(s).", explain.Steps.Count);
foreach (var step in explain.Steps)
{
logger.LogInformation(
"Rule {Rule} — Status {Status}, Action {Action}, Score {Score}, Inputs {Inputs}",
step.Rule,
step.Status ?? "n/a",
step.Action ?? "n/a",
step.Score?.ToString("0.00", CultureInfo.InvariantCulture) ?? "n/a",
FormatKeyValuePairs(step.Inputs));
if (step.Evidence is not null && step.Evidence.Count > 0)
{
logger.LogInformation(" Evidence: {Evidence}", FormatKeyValuePairs(step.Evidence));
}
}
}
if (explain.SealedHints.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
AnsiConsole.MarkupLine("[grey]Hints:[/]");
foreach (var hint in explain.SealedHints)
{
AnsiConsole.MarkupLine($" • {Markup.Escape(hint.Message)}");
}
}
else
{
foreach (var hint in explain.SealedHints)
{
logger.LogInformation("Hint: {Hint}", hint.Message);
}
}
}
}
private static string FormatSeverity(PolicyFindingSeverity severity)
{
if (severity.Score.HasValue)
{
return FormattableString.Invariant($"{severity.Normalized} ({severity.Score.Value:0.00})");
}
return severity.Normalized;
}
private static string FormatListPreview(IReadOnlyList values)
{
if (values is null || values.Count == 0)
{
return "(none)";
}
const int MaxItems = 3;
if (values.Count <= MaxItems)
{
return string.Join(", ", values);
}
var preview = string.Join(", ", values.Take(MaxItems));
return FormattableString.Invariant($"{preview} (+{values.Count - MaxItems})");
}
private static string FormatUpdatedAt(DateTimeOffset timestamp)
{
if (timestamp == DateTimeOffset.MinValue)
{
return "(unknown)";
}
return timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
}
private static string FormatScore(double? score)
=> score.HasValue ? score.Value.ToString("0.00", CultureInfo.InvariantCulture) : "-";
private static string FormatKeyValuePairs(IReadOnlyDictionary? values)
{
if (values is null || values.Count == 0)
{
return "(none)";
}
return string.Join(", ", values.Select(pair => $"{pair.Key}={pair.Value}"));
}
private static string FormatVexMetadata(PolicyFindingVexMetadata? value)
{
if (value is null)
{
return "(none)";
}
var parts = new List(3);
if (!string.IsNullOrWhiteSpace(value.WinningStatementId))
{
parts.Add($"winning={value.WinningStatementId}");
}
if (!string.IsNullOrWhiteSpace(value.Source))
{
parts.Add($"source={value.Source}");
}
if (!string.IsNullOrWhiteSpace(value.Status))
{
parts.Add($"status={value.Status}");
}
return parts.Count == 0 ? "(none)" : string.Join(", ", parts);
}
private static void HandlePolicyFindingsFailure(PolicyApiException exception, ILogger logger, Action recordMetric)
{
var exitCode = exception.StatusCode switch
{
HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => 12,
HttpStatusCode.NotFound => 1,
_ => 1
};
if (string.IsNullOrWhiteSpace(exception.ErrorCode))
{
logger.LogError("Policy API request failed ({StatusCode}): {Message}", (int)exception.StatusCode, exception.Message);
}
else
{
logger.LogError("Policy API request failed ({StatusCode} {Code}): {Message}", (int)exception.StatusCode, exception.ErrorCode, exception.Message);
}
recordMetric("error");
Environment.ExitCode = exitCode;
}
private static string FormatDelta(int? value)
=> value.HasValue ? value.Value.ToString("N0", CultureInfo.InvariantCulture) : "-";
private static readonly JsonSerializerOptions SimulationJsonOptions =
new(JsonSerializerDefaults.Web) { WriteIndented = true };
private static readonly IReadOnlyDictionary EmptyPolicyEnvironment =
new ReadOnlyDictionary(new Dictionary(0, StringComparer.Ordinal));
private static readonly IReadOnlyList EmptyPolicySbomSet =
new ReadOnlyCollection(Array.Empty());
private static readonly IReadOnlyDictionary EmptyLabelSelectors =
new ReadOnlyDictionary(new Dictionary(0, StringComparer.OrdinalIgnoreCase));
private enum TaskRunnerSimulationOutputFormat
{
Table,
Json
}
private enum PolicySimulationOutputFormat
{
Table,
Json,
Markdown
}
private enum PolicyFindingsOutputFormat
{
Table,
Json
}
private static string FormatAdditionalValue(object? value)
{
return value switch
{
null => "null",
bool b => b ? "true" : "false",
double d => d.ToString("G17", CultureInfo.InvariantCulture),
float f => f.ToString("G9", CultureInfo.InvariantCulture),
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString() ?? string.Empty
};
}
private static IReadOnlyList NormalizeProviders(IReadOnlyList providers)
{
if (providers is null || providers.Count == 0)
{
return Array.Empty