Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
30260 lines
1.2 MiB
30260 lines
1.2 MiB
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// Standard JSON serializer options for CLI output.
|
|
/// </summary>
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// JSON serializer options for output (alias for JsonOptions).
|
|
/// </summary>
|
|
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
|
|
|
|
private static readonly JsonSerializerOptions CompactJson = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
|
|
/// <summary>
|
|
/// Sets the verbosity level for logging.
|
|
/// </summary>
|
|
private static void SetVerbosity(IServiceProvider services, bool verbose)
|
|
{
|
|
// Configure logging level based on verbose flag
|
|
var loggerFactory = services.GetService<ILoggerFactory>();
|
|
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<ILoggerFactory>().CreateLogger("cvss-score");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<ICvssV4Engine>();
|
|
var parsed = engine.ParseVector(vector);
|
|
|
|
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
|
|
|
|
var request = new CreateCvssReceipt(
|
|
vulnerabilityId,
|
|
policy,
|
|
parsed.BaseMetrics,
|
|
parsed.ThreatMetrics,
|
|
parsed.EnvironmentalMetrics,
|
|
parsed.SupplementalMetrics,
|
|
Array.Empty<CvssEvidenceItem>(),
|
|
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<ILoggerFactory>().CreateLogger("cvss-show");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
try
|
|
{
|
|
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
|
|
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<ILoggerFactory>().CreateLogger("cvss-history");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
try
|
|
{
|
|
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
|
|
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<ILoggerFactory>().CreateLogger("cvss-export");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
try
|
|
{
|
|
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IScannerInstaller>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("task-runner-simulate");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string> arguments,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.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<StellaOpsCliOptions>();
|
|
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<IBackendOperationsClient>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scan-entrytrace");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.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<string> preferredSections,
|
|
bool forceRefresh,
|
|
int timeoutSeconds,
|
|
AdvisoryOutputFormat outputFormat,
|
|
string? outputPath,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advise-run");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string> advisoryKeys,
|
|
string? artifactId,
|
|
string? artifactPurl,
|
|
string? policyVersion,
|
|
string profile,
|
|
IReadOnlyList<string> 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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("sources-ingest");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("aoc-verify");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string>()
|
|
: sources.Select(item => item.ToLowerInvariant()).ToArray();
|
|
|
|
var normalizedCodes = codes.Count == 0
|
|
? Array.Empty<string>()
|
|
: 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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string, object?>(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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string, object?>(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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string, object?>(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<string> providers,
|
|
bool resume,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var normalizedProviders = NormalizeProviders(providers);
|
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
if (normalizedProviders.Count > 0)
|
|
{
|
|
payload["providers"] = normalizedProviders;
|
|
}
|
|
if (resume)
|
|
{
|
|
payload["resume"] = true;
|
|
}
|
|
|
|
return ExecuteExcititorCommandAsync(
|
|
services,
|
|
commandName: "excititor init",
|
|
verbose,
|
|
new Dictionary<string, object?>
|
|
{
|
|
["providers"] = normalizedProviders.Count,
|
|
["resume"] = resume
|
|
},
|
|
client => client.ExecuteExcititorOperationAsync("init", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
|
|
cancellationToken);
|
|
}
|
|
|
|
public static Task HandleExcititorPullAsync(
|
|
IServiceProvider services,
|
|
IReadOnlyList<string> providers,
|
|
DateTimeOffset? since,
|
|
TimeSpan? window,
|
|
bool force,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var normalizedProviders = NormalizeProviders(providers);
|
|
var payload = new Dictionary<string, object?>(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<string, object?>
|
|
{
|
|
["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<string> providers,
|
|
string? checkpoint,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var normalizedProviders = NormalizeProviders(providers);
|
|
var payload = new Dictionary<string, object?>(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<string, object?>
|
|
{
|
|
["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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-list-providers");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scopeHandle.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-export");
|
|
var options = scopeHandle.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
|
var verbosity = scopeHandle.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string, object?>(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<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["force"] = force,
|
|
["batchSize"] = batchSize,
|
|
["maxDocuments"] = maxDocuments
|
|
};
|
|
|
|
if (retrievedSince.HasValue)
|
|
{
|
|
payload["retrievedSince"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
var activityTags = new Dictionary<string, object?>(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<ILoggerFactory>().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<string, object?>(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<ILoggerFactory>().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<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["fileName"] = Path.GetFileName(fullPath),
|
|
["base64"] = Convert.ToBase64String(bytes)
|
|
};
|
|
}
|
|
|
|
return ExecuteExcititorCommandAsync(
|
|
services,
|
|
commandName: "excititor verify",
|
|
verbose,
|
|
new Dictionary<string, object?>
|
|
{
|
|
["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<string> providers,
|
|
TimeSpan? maxAge,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var normalizedProviders = NormalizeProviders(providers);
|
|
var payload = new Dictionary<string, object?>(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<string, object?>
|
|
{
|
|
["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<string> imageArguments,
|
|
string? filePath,
|
|
IReadOnlyList<string> labelArguments,
|
|
bool outputJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("runtime-policy-test");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string> 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<string, string> 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<ILoggerFactory>().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<IStellaOpsTokenClient>();
|
|
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<ILoggerFactory>().CreateLogger("auth-logout");
|
|
Environment.ExitCode = 0;
|
|
|
|
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
|
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<ILoggerFactory>().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<IStellaOpsTokenClient>();
|
|
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<ILoggerFactory>().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<IStellaOpsTokenClient>();
|
|
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<ILoggerFactory>().CreateLogger("auth-revoke-export");
|
|
Environment.ExitCode = 0;
|
|
|
|
try
|
|
{
|
|
var client = scope.ServiceProvider.GetRequiredService<IAuthorityRevocationClient>();
|
|
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) ? "<unknown>" : 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<ICryptoProvider>
|
|
{
|
|
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<string>();
|
|
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<byte>.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<byte>(buffer, 0, signingInputLength),
|
|
signatureBytes,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!verified)
|
|
{
|
|
logger.LogError("Signature verification failed.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.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<ILoggerFactory>().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<IAuthorityConsoleClient>();
|
|
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 <tenant-id>'.");
|
|
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<ILoggerFactory>().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<IAuthorityConsoleClient>();
|
|
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 <tenant-id>' 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<ILoggerFactory>().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<IAuthorityConsoleClient>();
|
|
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<ILoggerFactory>().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<IAuthorityConsoleClient>();
|
|
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<string>(),
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<string> observationIds,
|
|
IReadOnlyList<string> aliases,
|
|
IReadOnlyList<string> purls,
|
|
IReadOnlyList<string> cpes,
|
|
int? limit,
|
|
string? cursor,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IConcelierObservationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-observations");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string> NormalizeSet(IReadOnlyList<string> values, bool toLower)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var set = new HashSet<string>(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<string>() : set.ToArray();
|
|
}
|
|
|
|
static void RenderObservationTable(AdvisoryObservationsResponse response)
|
|
{
|
|
var observations = response.Observations ?? Array.Empty<AdvisoryObservationDocument>();
|
|
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<string>? 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<IBackendOperationsClient>();
|
|
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-pull");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-findings-ls");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-findings-get");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-findings-explain");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-activate");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<string> sbomArguments,
|
|
IReadOnlyList<string> environmentArguments,
|
|
string? format,
|
|
string? outputPath,
|
|
bool explain,
|
|
bool failOnDiff,
|
|
IReadOnlyList<string> withExceptions,
|
|
IReadOnlyList<string> withoutExceptions,
|
|
string? mode,
|
|
IReadOnlyList<string> sbomSelectors,
|
|
bool includeHeatmap,
|
|
bool manifestDownload,
|
|
IReadOnlyList<string> reachabilityStates,
|
|
IReadOnlyList<string> reachabilityScores,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-simulate");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IBackendOperationsClient>();
|
|
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-import");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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) ? "<pending>" : 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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-status");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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 ?? "<n/a>",
|
|
status.BundleSize.HasValue ? status.BundleSize.Value.ToString("N0", CultureInfo.InvariantCulture) : "<n/a>");
|
|
}
|
|
|
|
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<OfflineKitMetadataDocument?> 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<OfflineKitMetadataDocument>(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<string> 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<string, string?>(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<string, string> claims,
|
|
out DateTimeOffset? issuedAt,
|
|
out DateTimeOffset? notBefore)
|
|
{
|
|
claims = new Dictionary<string, string>(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<byte>();
|
|
|
|
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<string>();
|
|
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<string> CollectAdditionalClaims(Dictionary<string, string> claims)
|
|
{
|
|
var result = new List<string>();
|
|
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<string> 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<string, object?>? activityTags,
|
|
Func<IBackendOperationsClient, Task<ExcititorOperationResult>> operation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger(commandName.Replace(' ', '-'));
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
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<IReadOnlyList<string>> GatherImageDigestsAsync(
|
|
IReadOnlyList<string> inline,
|
|
string? filePath,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var results = new List<string>();
|
|
var seen = new HashSet<string>(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<string>(results);
|
|
}
|
|
|
|
private static IEnumerable<string> 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<string, string> ParseLabelSelectors(IReadOnlyList<string> labelArguments)
|
|
{
|
|
if (labelArguments is null || labelArguments.Count == 0)
|
|
{
|
|
return EmptyLabelSelectors;
|
|
}
|
|
|
|
var labels = new Dictionary<string, string>(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<string, string>(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<string> requestedImages)
|
|
{
|
|
var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys);
|
|
var results = new Dictionary<string, object?>(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<string, object?>(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<string, object?> BuildDecisionMap(RuntimePolicyImageDecision decision)
|
|
{
|
|
var map = new Dictionary<string, object?>(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<string, object?>(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<string> requestedImages)
|
|
{
|
|
var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys);
|
|
var summary = new Dictionary<string, int>(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, "<missing>", "-", "-", "-", "-", "-", "-");
|
|
}
|
|
}
|
|
|
|
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<string> BuildImageOrder(IReadOnlyList<string> requestedImages, IEnumerable<string> actual)
|
|
{
|
|
var order = new List<string>();
|
|
var seen = new HashSet<string>(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<string>(order);
|
|
}
|
|
|
|
private static string FormatBoolean(bool? value)
|
|
=> value is null ? "unknown" : value.Value ? "yes" : "no";
|
|
|
|
private static string FormatQuietedDisplay(IReadOnlyDictionary<string, object?> 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<string, object?> 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<string, object?> 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<string, object?> 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<string, object?> 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<TaskRunnerSimulationStep> 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<string> sbomSet,
|
|
IReadOnlyDictionary<string, object?> environment,
|
|
PolicySimulationResult result)
|
|
=> new
|
|
{
|
|
policyId,
|
|
baseVersion,
|
|
candidateVersion,
|
|
sbomSet = sbomSet.Count == 0 ? Array.Empty<string>() : 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<string> NormalizePolicySbomSet(IReadOnlyList<string> arguments)
|
|
{
|
|
if (arguments is null || arguments.Count == 0)
|
|
{
|
|
return EmptyPolicySbomSet;
|
|
}
|
|
|
|
var set = new SortedSet<string>(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<string>(list);
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, object?> ParsePolicyEnvironment(IReadOnlyList<string> arguments)
|
|
{
|
|
if (arguments is null || arguments.Count == 0)
|
|
{
|
|
return EmptyPolicyEnvironment;
|
|
}
|
|
|
|
var env = new SortedDictionary<string, object?>(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<string, object?>(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<ReachabilityOverride> ParseReachabilityOverrides(
|
|
IReadOnlyList<string> stateOverrides,
|
|
IReadOnlyList<string> scoreOverrides)
|
|
{
|
|
var overrides = new Dictionary<string, ReachabilityOverride>(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<string> NormalizePolicyFilterValues(string[] values, bool toLower = false)
|
|
{
|
|
if (values is null || values.Length == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var list = new List<string>();
|
|
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<string>() : 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<string> 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<string, string>? 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<string>(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<string> 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<string, object?> EmptyPolicyEnvironment =
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.Ordinal));
|
|
|
|
private static readonly IReadOnlyList<string> EmptyPolicySbomSet =
|
|
new ReadOnlyCollection<string>(Array.Empty<string>());
|
|
|
|
private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
|
|
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(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<string> NormalizeProviders(IReadOnlyList<string> providers)
|
|
{
|
|
if (providers is null || providers.Count == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var list = new List<string>();
|
|
foreach (var provider in providers)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(provider))
|
|
{
|
|
list.Add(provider.Trim());
|
|
}
|
|
}
|
|
|
|
return list.Count == 0 ? Array.Empty<string>() : list;
|
|
}
|
|
|
|
private static string ResolveTenant(string? tenantOption)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(tenantOption))
|
|
{
|
|
return tenantOption.Trim();
|
|
}
|
|
|
|
var fromEnvironment = Environment.GetEnvironmentVariable("STELLA_TENANT");
|
|
return string.IsNullOrWhiteSpace(fromEnvironment) ? string.Empty : fromEnvironment.Trim();
|
|
}
|
|
|
|
private static async Task<IngestInputPayload> LoadIngestInputAsync(IServiceProvider services, string input, CancellationToken cancellationToken)
|
|
{
|
|
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) &&
|
|
(uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
|
|
uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return await LoadIngestInputFromHttpAsync(services, uri, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return await LoadIngestInputFromFileAsync(input, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<IngestInputPayload> LoadIngestInputFromHttpAsync(IServiceProvider services, Uri uri, CancellationToken cancellationToken)
|
|
{
|
|
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
|
var httpClient = httpClientFactory.CreateClient("stellaops-cli.ingest-download");
|
|
using var response = await httpClient.GetAsync(uri, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new InvalidOperationException($"Failed to download document from {uri} (HTTP {(int)response.StatusCode}).");
|
|
}
|
|
|
|
var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/json";
|
|
var contentEncoding = response.Content.Headers.ContentEncoding is { Count: > 0 }
|
|
? string.Join(",", response.Content.Headers.ContentEncoding)
|
|
: null;
|
|
|
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
|
var normalized = NormalizeDocument(bytes, contentType, contentEncoding);
|
|
|
|
return new IngestInputPayload(
|
|
"uri",
|
|
uri.ToString(),
|
|
normalized.Content,
|
|
normalized.ContentType,
|
|
normalized.ContentEncoding);
|
|
}
|
|
|
|
private static async Task<IngestInputPayload> LoadIngestInputFromFileAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
var fullPath = Path.GetFullPath(path);
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
throw new FileNotFoundException("Input document not found.", fullPath);
|
|
}
|
|
|
|
var bytes = await File.ReadAllBytesAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
|
var normalized = NormalizeDocument(bytes, GuessContentTypeFromExtension(fullPath), null);
|
|
|
|
return new IngestInputPayload(
|
|
"file",
|
|
Path.GetFileName(fullPath),
|
|
normalized.Content,
|
|
normalized.ContentType,
|
|
normalized.ContentEncoding);
|
|
}
|
|
|
|
private static DocumentNormalizationResult NormalizeDocument(byte[] bytes, string? contentType, string? encodingHint)
|
|
{
|
|
if (bytes is null || bytes.Length == 0)
|
|
{
|
|
throw new InvalidOperationException("Input document is empty.");
|
|
}
|
|
|
|
var working = bytes;
|
|
var encodings = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(encodingHint))
|
|
{
|
|
encodings.Add(encodingHint);
|
|
}
|
|
|
|
if (IsGzip(working))
|
|
{
|
|
working = DecompressGzip(working);
|
|
encodings.Add("gzip");
|
|
}
|
|
|
|
var text = DecodeText(working);
|
|
var trimmed = text.TrimStart();
|
|
|
|
if (!string.IsNullOrWhiteSpace(trimmed) && trimmed[0] != '{' && trimmed[0] != '[')
|
|
{
|
|
if (TryDecodeBase64(text, out var decodedBytes))
|
|
{
|
|
working = decodedBytes;
|
|
encodings.Add("base64");
|
|
|
|
if (IsGzip(working))
|
|
{
|
|
working = DecompressGzip(working);
|
|
encodings.Add("gzip");
|
|
}
|
|
|
|
text = DecodeText(working);
|
|
}
|
|
}
|
|
|
|
text = text.Trim();
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
{
|
|
throw new InvalidOperationException("Input document contained no data after decoding.");
|
|
}
|
|
|
|
var encodingLabel = encodings.Count == 0 ? null : string.Join("+", encodings);
|
|
var finalContentType = string.IsNullOrWhiteSpace(contentType) ? "application/json" : contentType;
|
|
|
|
return new DocumentNormalizationResult(text, finalContentType, encodingLabel);
|
|
}
|
|
|
|
private static string GuessContentTypeFromExtension(string path)
|
|
{
|
|
var extension = Path.GetExtension(path);
|
|
if (string.IsNullOrWhiteSpace(extension))
|
|
{
|
|
return "application/json";
|
|
}
|
|
|
|
return extension.ToLowerInvariant() switch
|
|
{
|
|
".json" or ".csaf" => "application/json",
|
|
".xml" => "application/xml",
|
|
_ => "application/json"
|
|
};
|
|
}
|
|
|
|
private static DateTimeOffset DetermineVerificationSince(string? sinceOption)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sinceOption))
|
|
{
|
|
return DateTimeOffset.UtcNow.AddHours(-24);
|
|
}
|
|
|
|
var trimmed = sinceOption.Trim();
|
|
|
|
if (DateTimeOffset.TryParse(
|
|
trimmed,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
|
out var parsedTimestamp))
|
|
{
|
|
return parsedTimestamp.ToUniversalTime();
|
|
}
|
|
|
|
if (TryParseRelativeDuration(trimmed, out var duration))
|
|
{
|
|
return DateTimeOffset.UtcNow.Subtract(duration);
|
|
}
|
|
|
|
throw new InvalidOperationException("Invalid --since value. Use ISO-8601 timestamp or duration (e.g. 24h, 7d).");
|
|
}
|
|
|
|
private static bool TryParseRelativeDuration(string value, out TimeSpan duration)
|
|
{
|
|
duration = TimeSpan.Zero;
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var normalized = value.Trim().ToLowerInvariant();
|
|
if (normalized.Length < 2)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var suffix = normalized[^1];
|
|
var magnitudeText = normalized[..^1];
|
|
|
|
double multiplier = suffix switch
|
|
{
|
|
's' => 1,
|
|
'm' => 60,
|
|
'h' => 3600,
|
|
'd' => 86400,
|
|
'w' => 604800,
|
|
_ => 0
|
|
};
|
|
|
|
if (multiplier == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!double.TryParse(magnitudeText, NumberStyles.Float, CultureInfo.InvariantCulture, out var magnitude))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (double.IsNaN(magnitude) || double.IsInfinity(magnitude) || magnitude <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var seconds = magnitude * multiplier;
|
|
if (double.IsNaN(seconds) || double.IsInfinity(seconds) || seconds <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
duration = TimeSpan.FromSeconds(seconds);
|
|
return true;
|
|
}
|
|
|
|
private static int NormalizeLimit(int? limitOption)
|
|
{
|
|
if (!limitOption.HasValue)
|
|
{
|
|
return 20;
|
|
}
|
|
|
|
if (limitOption.Value < 0)
|
|
{
|
|
throw new InvalidOperationException("Limit cannot be negative.");
|
|
}
|
|
|
|
return limitOption.Value;
|
|
}
|
|
|
|
private static IReadOnlyList<string> ParseCommaSeparatedList(string? raw)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var tokens = raw
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(token => token.Trim())
|
|
.Where(token => token.Length > 0)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
return tokens.Length == 0 ? Array.Empty<string>() : tokens;
|
|
}
|
|
|
|
private static string FormatWindowRange(AocVerifyWindow? window)
|
|
{
|
|
if (window is null)
|
|
{
|
|
return "(unspecified)";
|
|
}
|
|
|
|
var fromText = window.From?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) ?? "(unknown)";
|
|
var toText = window.To?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) ?? "(unknown)";
|
|
return $"{fromText} -> {toText}";
|
|
}
|
|
|
|
private static string FormatCheckedCounts(AocVerifyChecked? checkedCounts)
|
|
{
|
|
if (checkedCounts is null)
|
|
{
|
|
return "(unspecified)";
|
|
}
|
|
|
|
return $"advisories: {checkedCounts.Advisories.ToString("N0", CultureInfo.InvariantCulture)}, vex: {checkedCounts.Vex.ToString("N0", CultureInfo.InvariantCulture)}";
|
|
}
|
|
|
|
private static string DetermineVerifyStatus(AocVerifyResponse? response)
|
|
{
|
|
if (response is null)
|
|
{
|
|
return "unknown";
|
|
}
|
|
|
|
if (response.Truncated == true && (response.Violations is null || response.Violations.Count == 0))
|
|
{
|
|
return "truncated";
|
|
}
|
|
|
|
var total = response.Violations?.Sum(violation => Math.Max(0, violation?.Count ?? 0)) ?? 0;
|
|
return total > 0 ? "violations" : "ok";
|
|
}
|
|
|
|
private static string FormatBoolean(bool value, bool useColor)
|
|
{
|
|
var text = value ? "yes" : "no";
|
|
if (!useColor)
|
|
{
|
|
return text;
|
|
}
|
|
|
|
return value
|
|
? $"[yellow]{text}[/]"
|
|
: $"[green]{text}[/]";
|
|
}
|
|
|
|
private static string FormatVerifyStatus(string? status, bool useColor)
|
|
{
|
|
var normalized = string.IsNullOrWhiteSpace(status) ? "unknown" : status.Trim();
|
|
var escaped = Markup.Escape(normalized);
|
|
if (!useColor)
|
|
{
|
|
return escaped;
|
|
}
|
|
|
|
return normalized switch
|
|
{
|
|
"ok" => $"[green]{escaped}[/]",
|
|
"violations" => $"[red]{escaped}[/]",
|
|
"truncated" => $"[yellow]{escaped}[/]",
|
|
_ => $"[grey]{escaped}[/]"
|
|
};
|
|
}
|
|
|
|
private static string FormatViolationExample(AocVerifyViolationExample? example)
|
|
{
|
|
if (example is null)
|
|
{
|
|
return "(n/a)";
|
|
}
|
|
|
|
var parts = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(example.Source))
|
|
{
|
|
parts.Add(example.Source.Trim());
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(example.DocumentId))
|
|
{
|
|
parts.Add(example.DocumentId.Trim());
|
|
}
|
|
|
|
var label = parts.Count == 0 ? "(n/a)" : string.Join(" | ", parts);
|
|
if (!string.IsNullOrWhiteSpace(example.ContentHash))
|
|
{
|
|
label = $"{label} [{example.ContentHash.Trim()}]";
|
|
}
|
|
|
|
return label;
|
|
}
|
|
|
|
private static void RenderAocVerifyTable(AocVerifyResponse response, bool useColor, int limit)
|
|
{
|
|
var summary = new Table().Border(TableBorder.Rounded);
|
|
summary.AddColumn("Field");
|
|
summary.AddColumn("Value");
|
|
|
|
summary.AddRow("Tenant", Markup.Escape(string.IsNullOrWhiteSpace(response?.Tenant) ? "(unknown)" : response.Tenant!));
|
|
summary.AddRow("Window", Markup.Escape(FormatWindowRange(response?.Window)));
|
|
summary.AddRow("Checked", Markup.Escape(FormatCheckedCounts(response?.Checked)));
|
|
|
|
summary.AddRow("Limit", Markup.Escape(limit <= 0 ? "unbounded" : limit.ToString(CultureInfo.InvariantCulture)));
|
|
summary.AddRow("Status", FormatVerifyStatus(DetermineVerifyStatus(response), useColor));
|
|
|
|
if (response?.Metrics?.IngestionWriteTotal is int writes)
|
|
{
|
|
summary.AddRow("Ingestion Writes", Markup.Escape(writes.ToString("N0", CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
if (response?.Metrics?.AocViolationTotal is int totalViolations)
|
|
{
|
|
summary.AddRow("Violations (total)", Markup.Escape(totalViolations.ToString("N0", CultureInfo.InvariantCulture)));
|
|
}
|
|
else
|
|
{
|
|
var computedViolations = response?.Violations?.Sum(violation => Math.Max(0, violation?.Count ?? 0)) ?? 0;
|
|
summary.AddRow("Violations (total)", Markup.Escape(computedViolations.ToString("N0", CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
summary.AddRow("Truncated", FormatBoolean(response?.Truncated == true, useColor));
|
|
|
|
AnsiConsole.Write(summary);
|
|
|
|
if (response?.Violations is null || response.Violations.Count == 0)
|
|
{
|
|
var message = response?.Truncated == true
|
|
? "No violations reported, but results were truncated. Increase --limit to review full output."
|
|
: "No AOC violations detected in the requested window.";
|
|
|
|
if (useColor)
|
|
{
|
|
var color = response?.Truncated == true ? "yellow" : "green";
|
|
AnsiConsole.MarkupLine($"[{color}]{Markup.Escape(message)}[/]");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(message);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var violationTable = new Table().Border(TableBorder.Rounded);
|
|
violationTable.AddColumn("Code");
|
|
violationTable.AddColumn("Count");
|
|
violationTable.AddColumn("Sample Document");
|
|
violationTable.AddColumn("Path");
|
|
|
|
foreach (var violation in response.Violations)
|
|
{
|
|
var codeDisplay = FormatViolationCode(violation.Code, useColor);
|
|
var countDisplay = violation.Count.ToString("N0", CultureInfo.InvariantCulture);
|
|
var example = violation.Examples?.FirstOrDefault();
|
|
var documentDisplay = Markup.Escape(FormatViolationExample(example));
|
|
var pathDisplay = example is null || string.IsNullOrWhiteSpace(example.Path)
|
|
? "(none)"
|
|
: example.Path!;
|
|
|
|
violationTable.AddRow(codeDisplay, countDisplay, documentDisplay, Markup.Escape(pathDisplay));
|
|
}
|
|
|
|
AnsiConsole.Write(violationTable);
|
|
}
|
|
|
|
private static int DetermineVerifyExitCode(AocVerifyResponse response)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(response);
|
|
|
|
if (response.Violations is not null && response.Violations.Count > 0)
|
|
{
|
|
var exitCodes = new List<int>();
|
|
foreach (var violation in response.Violations)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(violation.Code))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (AocViolationExitCodeMap.TryGetValue(violation.Code, out var mapped))
|
|
{
|
|
exitCodes.Add(mapped);
|
|
}
|
|
}
|
|
|
|
if (exitCodes.Count > 0)
|
|
{
|
|
return exitCodes.Min();
|
|
}
|
|
|
|
return response.Truncated == true ? 18 : 17;
|
|
}
|
|
|
|
if (response.Truncated == true)
|
|
{
|
|
return 18;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static async Task<string> WriteJsonReportAsync<T>(T payload, string destination, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(payload);
|
|
|
|
if (string.IsNullOrWhiteSpace(destination))
|
|
{
|
|
throw new InvalidOperationException("Output path must be provided.");
|
|
}
|
|
|
|
var outputPath = Path.GetFullPath(destination);
|
|
var directory = Path.GetDirectoryName(outputPath);
|
|
if (!string.IsNullOrWhiteSpace(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
|
|
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
|
|
return outputPath;
|
|
}
|
|
|
|
private static void RenderDryRunTable(AocIngestDryRunResponse response, bool useColor)
|
|
{
|
|
var summary = new Table().Border(TableBorder.Rounded);
|
|
summary.AddColumn("Field");
|
|
summary.AddColumn("Value");
|
|
|
|
summary.AddRow("Source", Markup.Escape(response?.Source ?? "(unknown)"));
|
|
summary.AddRow("Tenant", Markup.Escape(response?.Tenant ?? "(unknown)"));
|
|
summary.AddRow("Guard Version", Markup.Escape(response?.GuardVersion ?? "(unknown)"));
|
|
summary.AddRow("Status", FormatStatusMarkup(response?.Status, useColor));
|
|
|
|
var violationCount = response?.Violations?.Count ?? 0;
|
|
summary.AddRow("Violations", violationCount.ToString(CultureInfo.InvariantCulture));
|
|
|
|
if (!string.IsNullOrWhiteSpace(response?.Document?.ContentHash))
|
|
{
|
|
summary.AddRow("Content Hash", Markup.Escape(response.Document.ContentHash!));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(response?.Document?.Supersedes))
|
|
{
|
|
summary.AddRow("Supersedes", Markup.Escape(response.Document.Supersedes!));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(response?.Document?.Provenance?.Signature?.Format))
|
|
{
|
|
var signature = response.Document.Provenance.Signature;
|
|
var summaryText = signature!.Present
|
|
? signature.Format ?? "present"
|
|
: "missing";
|
|
summary.AddRow("Signature", Markup.Escape(summaryText));
|
|
}
|
|
|
|
AnsiConsole.Write(summary);
|
|
|
|
if (violationCount == 0)
|
|
{
|
|
if (useColor)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]No AOC violations detected.[/]");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("No AOC violations detected.");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var violationTable = new Table().Border(TableBorder.Rounded);
|
|
violationTable.AddColumn("Code");
|
|
violationTable.AddColumn("Path");
|
|
violationTable.AddColumn("Message");
|
|
|
|
foreach (var violation in response!.Violations!)
|
|
{
|
|
var codeDisplay = FormatViolationCode(violation.Code, useColor);
|
|
var pathDisplay = string.IsNullOrWhiteSpace(violation.Path) ? "(root)" : violation.Path!;
|
|
var messageDisplay = string.IsNullOrWhiteSpace(violation.Message) ? "(unspecified)" : violation.Message!;
|
|
violationTable.AddRow(codeDisplay, Markup.Escape(pathDisplay), Markup.Escape(messageDisplay));
|
|
}
|
|
|
|
AnsiConsole.Write(violationTable);
|
|
}
|
|
|
|
private static int DetermineDryRunExitCode(AocIngestDryRunResponse response)
|
|
{
|
|
if (response?.Violations is null || response.Violations.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var exitCodes = new List<int>();
|
|
foreach (var violation in response.Violations)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(violation.Code))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (AocViolationExitCodeMap.TryGetValue(violation.Code, out var mapped))
|
|
{
|
|
exitCodes.Add(mapped);
|
|
}
|
|
}
|
|
|
|
if (exitCodes.Count == 0)
|
|
{
|
|
return 17;
|
|
}
|
|
|
|
return exitCodes.Min();
|
|
}
|
|
|
|
private static string FormatStatusMarkup(string? status, bool useColor)
|
|
{
|
|
var normalized = string.IsNullOrWhiteSpace(status) ? "unknown" : status.Trim();
|
|
if (!useColor)
|
|
{
|
|
return Markup.Escape(normalized);
|
|
}
|
|
|
|
return normalized.Equals("ok", StringComparison.OrdinalIgnoreCase)
|
|
? $"[green]{Markup.Escape(normalized)}[/]"
|
|
: $"[red]{Markup.Escape(normalized)}[/]";
|
|
}
|
|
|
|
private static string FormatViolationCode(string code, bool useColor)
|
|
{
|
|
var sanitized = string.IsNullOrWhiteSpace(code) ? "(unknown)" : code.Trim();
|
|
if (!useColor)
|
|
{
|
|
return Markup.Escape(sanitized);
|
|
}
|
|
|
|
return $"[red]{Markup.Escape(sanitized)}[/]";
|
|
}
|
|
|
|
private static bool IsGzip(ReadOnlySpan<byte> data)
|
|
{
|
|
return data.Length >= 2 && data[0] == 0x1F && data[1] == 0x8B;
|
|
}
|
|
|
|
private static byte[] DecompressGzip(byte[] payload)
|
|
{
|
|
using var input = new MemoryStream(payload);
|
|
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
|
using var output = new MemoryStream();
|
|
gzip.CopyTo(output);
|
|
return output.ToArray();
|
|
}
|
|
|
|
private static string DecodeText(byte[] payload)
|
|
{
|
|
var encoding = DetectEncoding(payload);
|
|
return encoding.GetString(payload);
|
|
}
|
|
|
|
private static Encoding DetectEncoding(ReadOnlySpan<byte> data)
|
|
{
|
|
if (data.Length >= 4)
|
|
{
|
|
if (data[0] == 0x00 && data[1] == 0x00 && data[2] == 0xFE && data[3] == 0xFF)
|
|
{
|
|
return new UTF32Encoding(bigEndian: true, byteOrderMark: true);
|
|
}
|
|
|
|
if (data[0] == 0xFF && data[1] == 0xFE && data[2] == 0x00 && data[3] == 0x00)
|
|
{
|
|
return new UTF32Encoding(bigEndian: false, byteOrderMark: true);
|
|
}
|
|
}
|
|
|
|
if (data.Length >= 2)
|
|
{
|
|
if (data[0] == 0xFE && data[1] == 0xFF)
|
|
{
|
|
return Encoding.BigEndianUnicode;
|
|
}
|
|
|
|
if (data[0] == 0xFF && data[1] == 0xFE)
|
|
{
|
|
return Encoding.Unicode;
|
|
}
|
|
}
|
|
|
|
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
|
|
{
|
|
return Encoding.UTF8;
|
|
}
|
|
|
|
return Encoding.UTF8;
|
|
}
|
|
|
|
public static async Task HandleKmsExportAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string keyId,
|
|
string? versionId,
|
|
string outputPath,
|
|
bool overwrite,
|
|
string? passphrase,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("kms-export");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
try
|
|
{
|
|
var resolvedPassphrase = ResolvePassphrase(passphrase, "Enter file KMS passphrase:");
|
|
if (string.IsNullOrEmpty(resolvedPassphrase))
|
|
{
|
|
logger.LogError("KMS passphrase must be supplied via --passphrase, {EnvironmentVariable}, or interactive prompt.", KmsPassphraseEnvironmentVariable);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var resolvedRoot = ResolveRootDirectory(rootPath);
|
|
if (!Directory.Exists(resolvedRoot))
|
|
{
|
|
logger.LogError("KMS root directory '{Root}' does not exist.", resolvedRoot);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var outputFullPath = Path.GetFullPath(string.IsNullOrWhiteSpace(outputPath) ? "kms-export.json" : outputPath);
|
|
if (Directory.Exists(outputFullPath))
|
|
{
|
|
logger.LogError("Output path '{Output}' is a directory. Provide a file path.", outputFullPath);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (!overwrite && File.Exists(outputFullPath))
|
|
{
|
|
logger.LogError("Output file '{Output}' already exists. Use --force to overwrite.", outputFullPath);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var outputDirectory = Path.GetDirectoryName(outputFullPath);
|
|
if (!string.IsNullOrEmpty(outputDirectory))
|
|
{
|
|
Directory.CreateDirectory(outputDirectory);
|
|
}
|
|
|
|
using var client = new FileKmsClient(new FileKmsOptions
|
|
{
|
|
RootPath = resolvedRoot,
|
|
Password = resolvedPassphrase!
|
|
});
|
|
|
|
var material = await client.ExportAsync(keyId, versionId, cancellationToken).ConfigureAwait(false);
|
|
var json = JsonSerializer.Serialize(material, KmsJsonOptions);
|
|
await File.WriteAllTextAsync(outputFullPath, json, cancellationToken).ConfigureAwait(false);
|
|
|
|
logger.LogInformation("Exported key {KeyId} version {VersionId} to {Output}.", material.KeyId, material.VersionId, outputFullPath);
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to export key material.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleKmsImportAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string keyId,
|
|
string inputPath,
|
|
string? versionOverride,
|
|
string? passphrase,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("kms-import");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
try
|
|
{
|
|
var resolvedPassphrase = ResolvePassphrase(passphrase, "Enter file KMS passphrase:");
|
|
if (string.IsNullOrEmpty(resolvedPassphrase))
|
|
{
|
|
logger.LogError("KMS passphrase must be supplied via --passphrase, {EnvironmentVariable}, or interactive prompt.", KmsPassphraseEnvironmentVariable);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var resolvedRoot = ResolveRootDirectory(rootPath);
|
|
Directory.CreateDirectory(resolvedRoot);
|
|
|
|
var inputFullPath = Path.GetFullPath(inputPath ?? string.Empty);
|
|
if (!File.Exists(inputFullPath))
|
|
{
|
|
logger.LogError("Input file '{Input}' does not exist.", inputFullPath);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var json = await File.ReadAllTextAsync(inputFullPath, cancellationToken).ConfigureAwait(false);
|
|
var material = JsonSerializer.Deserialize<KmsKeyMaterial>(json, KmsJsonOptions)
|
|
?? throw new InvalidOperationException("Key material payload is empty.");
|
|
|
|
if (!string.IsNullOrWhiteSpace(versionOverride))
|
|
{
|
|
material = material with { VersionId = versionOverride };
|
|
}
|
|
|
|
var sourceKeyId = material.KeyId;
|
|
material = material with { KeyId = keyId };
|
|
|
|
using var client = new FileKmsClient(new FileKmsOptions
|
|
{
|
|
RootPath = resolvedRoot,
|
|
Password = resolvedPassphrase!
|
|
});
|
|
|
|
var metadata = await client.ImportAsync(keyId, material, cancellationToken).ConfigureAwait(false);
|
|
if (!string.IsNullOrWhiteSpace(sourceKeyId) && !string.Equals(sourceKeyId, keyId, StringComparison.Ordinal))
|
|
{
|
|
logger.LogWarning("Imported key material originally identified as '{SourceKeyId}' into '{TargetKeyId}'.", sourceKeyId, keyId);
|
|
}
|
|
|
|
var activeVersion = metadata.Versions.Length > 0 ? metadata.Versions[^1].VersionId : material.VersionId;
|
|
logger.LogInformation("Imported key {KeyId} version {VersionId} into {Root}.", metadata.KeyId, activeVersion, resolvedRoot);
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
logger.LogError(ex, "Failed to parse key material JSON from {Input}.", inputPath);
|
|
Environment.ExitCode = 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to import key material.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static string ResolveRootDirectory(string? rootPath)
|
|
=> Path.GetFullPath(string.IsNullOrWhiteSpace(rootPath) ? "kms" : rootPath);
|
|
|
|
private static string? ResolvePassphrase(string? passphrase, string promptMessage)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(passphrase))
|
|
{
|
|
return passphrase;
|
|
}
|
|
|
|
var fromEnvironment = Environment.GetEnvironmentVariable(KmsPassphraseEnvironmentVariable);
|
|
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
|
{
|
|
return fromEnvironment;
|
|
}
|
|
|
|
return KmsPassphrasePrompt.Prompt(promptMessage);
|
|
}
|
|
|
|
private static bool TryDecodeBase64(string text, out byte[] decoded)
|
|
{
|
|
decoded = Array.Empty<byte>();
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var builder = new StringBuilder(text.Length);
|
|
foreach (var ch in text)
|
|
{
|
|
if (!char.IsWhiteSpace(ch))
|
|
{
|
|
builder.Append(ch);
|
|
}
|
|
}
|
|
|
|
var candidate = builder.ToString();
|
|
if (candidate.Length < 8 || candidate.Length % 4 != 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (var i = 0; i < candidate.Length; i++)
|
|
{
|
|
var c = candidate[i];
|
|
if (!(char.IsLetterOrDigit(c) || c is '+' or '/' or '='))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
decoded = Convert.FromBase64String(candidate);
|
|
return true;
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private sealed record IngestInputPayload(string Kind, string Name, string Content, string ContentType, string? ContentEncoding);
|
|
|
|
private sealed record DocumentNormalizationResult(string Content, string ContentType, string? ContentEncoding);
|
|
|
|
private static readonly IReadOnlyDictionary<string, int> AocViolationExitCodeMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["ERR_AOC_001"] = 11,
|
|
["ERR_AOC_002"] = 12,
|
|
["ERR_AOC_003"] = 13,
|
|
["ERR_AOC_004"] = 14,
|
|
["ERR_AOC_005"] = 15,
|
|
["ERR_AOC_006"] = 16,
|
|
["ERR_AOC_007"] = 17
|
|
};
|
|
|
|
private static string[] NormalizeSections(IReadOnlyList<string> sections)
|
|
{
|
|
if (sections is null || sections.Count == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
return sections
|
|
.Where(section => !string.IsNullOrWhiteSpace(section))
|
|
.Select(section => section.Trim())
|
|
.Where(section => section.Length > 0)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
}
|
|
|
|
private static void RenderAdvisoryPlan(AdvisoryPipelinePlanResponseModel plan)
|
|
{
|
|
var console = AnsiConsole.Console;
|
|
|
|
var summary = new Table()
|
|
.Border(TableBorder.Rounded)
|
|
.Title("[bold]Advisory Plan[/]");
|
|
summary.AddColumn("Field");
|
|
summary.AddColumn("Value");
|
|
summary.AddRow("Task", Markup.Escape(plan.TaskType));
|
|
summary.AddRow("Cache Key", Markup.Escape(plan.CacheKey));
|
|
summary.AddRow("Prompt Template", Markup.Escape(plan.PromptTemplate));
|
|
summary.AddRow("Chunks", plan.Chunks.Count.ToString(CultureInfo.InvariantCulture));
|
|
summary.AddRow("Vectors", plan.Vectors.Count.ToString(CultureInfo.InvariantCulture));
|
|
summary.AddRow("Prompt Tokens", plan.Budget.PromptTokens.ToString(CultureInfo.InvariantCulture));
|
|
summary.AddRow("Completion Tokens", plan.Budget.CompletionTokens.ToString(CultureInfo.InvariantCulture));
|
|
|
|
console.Write(summary);
|
|
|
|
if (plan.Metadata.Count > 0)
|
|
{
|
|
console.Write(CreateKeyValueTable("Plan Metadata", plan.Metadata));
|
|
}
|
|
}
|
|
|
|
private static string? RenderAdvisoryOutput(AdvisoryPipelineOutputModel output, AdvisoryOutputFormat format)
|
|
{
|
|
return format switch
|
|
{
|
|
AdvisoryOutputFormat.Json => RenderAdvisoryOutputJson(output),
|
|
AdvisoryOutputFormat.Markdown => RenderAdvisoryOutputMarkdown(output),
|
|
_ => RenderAdvisoryOutputTable(output)
|
|
};
|
|
}
|
|
|
|
private static string RenderAdvisoryOutputJson(AdvisoryPipelineOutputModel output)
|
|
{
|
|
return JsonSerializer.Serialize(output, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
}
|
|
|
|
private static string RenderAdvisoryOutputMarkdown(AdvisoryPipelineOutputModel output)
|
|
{
|
|
var builder = new StringBuilder();
|
|
builder.AppendLine($"# Advisory {output.TaskType} ({output.Profile})");
|
|
builder.AppendLine();
|
|
builder.AppendLine($"- Cache Key: `{output.CacheKey}`");
|
|
builder.AppendLine($"- Generated: {output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture)}");
|
|
builder.AppendLine($"- Plan From Cache: {(output.PlanFromCache ? "yes" : "no")}");
|
|
builder.AppendLine($"- Guardrail Blocked: {(output.Guardrail.Blocked ? "yes" : "no")}");
|
|
builder.AppendLine();
|
|
|
|
if (!string.IsNullOrWhiteSpace(output.Response))
|
|
{
|
|
builder.AppendLine("## Response");
|
|
builder.AppendLine(output.Response.Trim());
|
|
builder.AppendLine();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(output.Prompt))
|
|
{
|
|
builder.AppendLine("## Prompt (sanitized)");
|
|
builder.AppendLine(output.Prompt.Trim());
|
|
builder.AppendLine();
|
|
}
|
|
|
|
if (output.Citations.Count > 0)
|
|
{
|
|
builder.AppendLine("## Citations");
|
|
foreach (var citation in output.Citations.OrderBy(c => c.Index))
|
|
{
|
|
builder.AppendLine($"- [{citation.Index}] {citation.DocumentId} :: {citation.ChunkId}");
|
|
}
|
|
|
|
builder.AppendLine();
|
|
}
|
|
|
|
if (output.Metadata.Count > 0)
|
|
{
|
|
builder.AppendLine("## Output Metadata");
|
|
foreach (var entry in output.Metadata.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
builder.AppendLine($"- **{entry.Key}**: {entry.Value}");
|
|
}
|
|
|
|
builder.AppendLine();
|
|
}
|
|
|
|
if (output.Guardrail.Metadata.Count > 0)
|
|
{
|
|
builder.AppendLine("## Guardrail Metadata");
|
|
foreach (var entry in output.Guardrail.Metadata.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
builder.AppendLine($"- **{entry.Key}**: {entry.Value}");
|
|
}
|
|
|
|
builder.AppendLine();
|
|
}
|
|
|
|
if (output.Guardrail.Violations.Count > 0)
|
|
{
|
|
builder.AppendLine("## Guardrail Violations");
|
|
foreach (var violation in output.Guardrail.Violations)
|
|
{
|
|
builder.AppendLine($"- `{violation.Code}`: {violation.Message}");
|
|
}
|
|
|
|
builder.AppendLine();
|
|
}
|
|
|
|
builder.AppendLine("## Provenance");
|
|
builder.AppendLine($"- Input Digest: `{output.Provenance.InputDigest}`");
|
|
builder.AppendLine($"- Output Hash: `{output.Provenance.OutputHash}`");
|
|
|
|
if (output.Provenance.Signatures.Count > 0)
|
|
{
|
|
foreach (var signature in output.Provenance.Signatures)
|
|
{
|
|
builder.AppendLine($"- Signature: `{signature}`");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
builder.AppendLine("- Signature: none");
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static string? RenderAdvisoryOutputTable(AdvisoryPipelineOutputModel output)
|
|
{
|
|
var console = AnsiConsole.Console;
|
|
|
|
var summary = new Table()
|
|
.Border(TableBorder.Rounded)
|
|
.Title("[bold]Advisory Output[/]");
|
|
summary.AddColumn("Field");
|
|
summary.AddColumn("Value");
|
|
summary.AddRow("Cache Key", Markup.Escape(output.CacheKey));
|
|
summary.AddRow("Task", Markup.Escape(output.TaskType));
|
|
summary.AddRow("Profile", Markup.Escape(output.Profile));
|
|
summary.AddRow("Generated", output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
|
summary.AddRow("Plan From Cache", output.PlanFromCache ? "yes" : "no");
|
|
summary.AddRow("Citations", output.Citations.Count.ToString(CultureInfo.InvariantCulture));
|
|
summary.AddRow("Guardrail Blocked", output.Guardrail.Blocked ? "[red]yes[/]" : "no");
|
|
|
|
console.Write(summary);
|
|
|
|
if (!string.IsNullOrWhiteSpace(output.Response))
|
|
{
|
|
var responsePanel = new Panel(new Markup(Markup.Escape(output.Response)))
|
|
{
|
|
Header = new PanelHeader("Response"),
|
|
Border = BoxBorder.Rounded,
|
|
Expand = true
|
|
};
|
|
console.Write(responsePanel);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(output.Prompt))
|
|
{
|
|
var promptPanel = new Panel(new Markup(Markup.Escape(output.Prompt)))
|
|
{
|
|
Header = new PanelHeader("Prompt (sanitized)"),
|
|
Border = BoxBorder.Rounded,
|
|
Expand = true
|
|
};
|
|
console.Write(promptPanel);
|
|
}
|
|
|
|
if (output.Citations.Count > 0)
|
|
{
|
|
var citations = new Table()
|
|
.Border(TableBorder.Minimal)
|
|
.Title("[grey]Citations[/]");
|
|
citations.AddColumn("Index");
|
|
citations.AddColumn("Document");
|
|
citations.AddColumn("Chunk");
|
|
|
|
foreach (var citation in output.Citations.OrderBy(c => c.Index))
|
|
{
|
|
citations.AddRow(
|
|
citation.Index.ToString(CultureInfo.InvariantCulture),
|
|
Markup.Escape(citation.DocumentId),
|
|
Markup.Escape(citation.ChunkId));
|
|
}
|
|
|
|
console.Write(citations);
|
|
}
|
|
|
|
if (output.Metadata.Count > 0)
|
|
{
|
|
console.Write(CreateKeyValueTable("Output Metadata", output.Metadata));
|
|
}
|
|
|
|
if (output.Guardrail.Metadata.Count > 0)
|
|
{
|
|
console.Write(CreateKeyValueTable("Guardrail Metadata", output.Guardrail.Metadata));
|
|
}
|
|
|
|
if (output.Guardrail.Violations.Count > 0)
|
|
{
|
|
var violations = new Table()
|
|
.Border(TableBorder.Minimal)
|
|
.Title("[red]Guardrail Violations[/]");
|
|
violations.AddColumn("Code");
|
|
violations.AddColumn("Message");
|
|
|
|
foreach (var violation in output.Guardrail.Violations)
|
|
{
|
|
violations.AddRow(Markup.Escape(violation.Code), Markup.Escape(violation.Message));
|
|
}
|
|
|
|
console.Write(violations);
|
|
}
|
|
|
|
var provenance = new Table()
|
|
.Border(TableBorder.Minimal)
|
|
.Title("[grey]Provenance[/]");
|
|
provenance.AddColumn("Field");
|
|
provenance.AddColumn("Value");
|
|
|
|
provenance.AddRow("Input Digest", Markup.Escape(output.Provenance.InputDigest));
|
|
provenance.AddRow("Output Hash", Markup.Escape(output.Provenance.OutputHash));
|
|
|
|
var signatures = output.Provenance.Signatures.Count == 0
|
|
? "none"
|
|
: string.Join(Environment.NewLine, output.Provenance.Signatures.Select(Markup.Escape));
|
|
provenance.AddRow("Signatures", signatures);
|
|
|
|
console.Write(provenance);
|
|
|
|
return null;
|
|
}
|
|
|
|
private static Table CreateKeyValueTable(string title, IReadOnlyDictionary<string, string> entries)
|
|
{
|
|
var table = new Table()
|
|
.Border(TableBorder.Minimal)
|
|
.Title($"[grey]{Markup.Escape(title)}[/]");
|
|
table.AddColumn("Key");
|
|
table.AddColumn("Value");
|
|
|
|
foreach (var kvp in entries.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
table.AddRow(Markup.Escape(kvp.Key), Markup.Escape(kvp.Value));
|
|
}
|
|
|
|
return table;
|
|
}
|
|
|
|
private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source)
|
|
{
|
|
foreach (var key in source.Where(kvp => kvp.Value is null).Select(kvp => kvp.Key).ToList())
|
|
{
|
|
source.Remove(key);
|
|
}
|
|
|
|
return source;
|
|
}
|
|
|
|
private static async Task TriggerJobAsync(
|
|
IBackendOperationsClient client,
|
|
ILogger logger,
|
|
string jobKind,
|
|
IDictionary<string, object?> parameters,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
|
if (result.Success)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(result.Location))
|
|
{
|
|
logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
|
|
}
|
|
else if (result.Run is not null)
|
|
{
|
|
logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("Job accepted.");
|
|
}
|
|
|
|
Environment.ExitCode = 0;
|
|
}
|
|
else
|
|
{
|
|
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
|
|
public static Task HandleCryptoProvidersAsync(
|
|
IServiceProvider services,
|
|
bool verbose,
|
|
bool jsonOutput,
|
|
string? profileOverride,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var scope = services.CreateScope();
|
|
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("crypto-providers");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.crypto.providers", ActivityKind.Internal);
|
|
using var duration = CliMetrics.MeasureCommandDuration("crypto providers");
|
|
|
|
try
|
|
{
|
|
var registry = scope.ServiceProvider.GetService<ICryptoProviderRegistry>();
|
|
if (registry is null)
|
|
{
|
|
logger.LogWarning("Crypto provider registry not available in this environment.");
|
|
AnsiConsole.MarkupLine("[yellow]Crypto subsystem is not configured in this environment.[/]");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var optionsMonitor = scope.ServiceProvider.GetService<IOptionsMonitor<CryptoProviderRegistryOptions>>();
|
|
var registryOptions = optionsMonitor?.CurrentValue ?? new CryptoProviderRegistryOptions();
|
|
var preferredOrder = DeterminePreferredOrder(registryOptions, profileOverride);
|
|
var providers = registry.Providers
|
|
.Select(provider => new ProviderInfo(
|
|
provider.Name,
|
|
provider.GetType().FullName ?? provider.GetType().Name,
|
|
DescribeProviderKeys(provider).ToList()))
|
|
.ToList();
|
|
|
|
if (jsonOutput)
|
|
{
|
|
var payload = new
|
|
{
|
|
activeProfile = registryOptions.ActiveProfile,
|
|
preferredOrder,
|
|
providers = providers.Select(info => new
|
|
{
|
|
info.Name,
|
|
info.Type,
|
|
keys = info.Keys.Select(k => new
|
|
{
|
|
k.KeyId,
|
|
k.AlgorithmId,
|
|
Metadata = k.Metadata
|
|
})
|
|
})
|
|
};
|
|
|
|
Console.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
}));
|
|
Environment.ExitCode = 0;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
RenderCryptoProviders(preferredOrder, providers);
|
|
Environment.ExitCode = 0;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public static Task HandleNodeLockValidateAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
=> HandleLanguageLockValidateAsync(
|
|
services,
|
|
loggerCategory: "node-lock-validate",
|
|
activityName: "cli.node.lock_validate",
|
|
rootTag: "stellaops.cli.node.root",
|
|
declaredTag: "stellaops.cli.node.declared_only",
|
|
missingTag: "stellaops.cli.node.lock_missing",
|
|
commandName: "node lock-validate",
|
|
analyzer: new NodeLanguageAnalyzer(),
|
|
rootPath: rootPath,
|
|
format: format,
|
|
verbose: verbose,
|
|
cancellationToken: cancellationToken,
|
|
telemetryRecorder: CliMetrics.RecordNodeLockValidate);
|
|
|
|
public static Task HandlePythonLockValidateAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
=> HandleLanguageLockValidateAsync(
|
|
services,
|
|
loggerCategory: "python-lock-validate",
|
|
activityName: "cli.python.lock_validate",
|
|
rootTag: "stellaops.cli.python.root",
|
|
declaredTag: "stellaops.cli.python.declared_only",
|
|
missingTag: "stellaops.cli.python.lock_missing",
|
|
commandName: "python lock-validate",
|
|
analyzer: new PythonLanguageAnalyzer(),
|
|
rootPath: rootPath,
|
|
format: format,
|
|
verbose: verbose,
|
|
cancellationToken: cancellationToken,
|
|
telemetryRecorder: CliMetrics.RecordPythonLockValidate);
|
|
|
|
public static Task HandleJavaLockValidateAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
=> HandleLanguageLockValidateAsync(
|
|
services,
|
|
loggerCategory: "java-lock-validate",
|
|
activityName: "cli.java.lock_validate",
|
|
rootTag: "stellaops.cli.java.root",
|
|
declaredTag: "stellaops.cli.java.declared_only",
|
|
missingTag: "stellaops.cli.java.lock_missing",
|
|
commandName: "java lock-validate",
|
|
analyzer: new JavaLanguageAnalyzer(),
|
|
rootPath: rootPath,
|
|
format: format,
|
|
verbose: verbose,
|
|
cancellationToken: cancellationToken,
|
|
telemetryRecorder: CliMetrics.RecordJavaLockValidate);
|
|
|
|
private static async Task HandleLanguageLockValidateAsync(
|
|
IServiceProvider services,
|
|
string loggerCategory,
|
|
string activityName,
|
|
string rootTag,
|
|
string declaredTag,
|
|
string missingTag,
|
|
string commandName,
|
|
ILanguageAnalyzer analyzer,
|
|
string? rootPath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken,
|
|
Action<string> telemetryRecorder)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger(loggerCategory);
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity(activityName, ActivityKind.Internal);
|
|
using var duration = CliMetrics.MeasureCommandDuration(commandName);
|
|
var outcome = "unknown";
|
|
|
|
try
|
|
{
|
|
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 targetRoot = string.IsNullOrWhiteSpace(rootPath)
|
|
? Directory.GetCurrentDirectory()
|
|
: Path.GetFullPath(rootPath);
|
|
|
|
if (!Directory.Exists(targetRoot))
|
|
{
|
|
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
|
|
}
|
|
|
|
logger.LogInformation("Validating lockfiles in {Root}.", targetRoot);
|
|
activity?.SetTag(rootTag, targetRoot);
|
|
|
|
var engine = new LanguageAnalyzerEngine(new[] { analyzer });
|
|
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
|
|
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
|
|
var report = LockValidationReport.Create(result.ToSnapshots());
|
|
|
|
activity?.SetTag(declaredTag, report.DeclaredOnly.Count);
|
|
activity?.SetTag(missingTag, report.MissingLockMetadata.Count);
|
|
|
|
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
|
{
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
|
}
|
|
else
|
|
{
|
|
RenderLockValidationReport(report);
|
|
}
|
|
|
|
outcome = report.HasIssues ? "violations" : "ok";
|
|
Environment.ExitCode = report.HasIssues ? 1 : 0;
|
|
}
|
|
catch (DirectoryNotFoundException ex)
|
|
{
|
|
outcome = "not_found";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 71;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
outcome = "error";
|
|
logger.LogError(ex, "Lock validation failed.");
|
|
Environment.ExitCode = 70;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
telemetryRecorder(outcome);
|
|
}
|
|
}
|
|
|
|
private static void RenderLockValidationReport(LockValidationReport report)
|
|
{
|
|
if (!report.HasIssues)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Lockfiles match installed packages.[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Declared components: {report.TotalDeclared}, Installed: {report.TotalInstalled}[/]");
|
|
return;
|
|
}
|
|
|
|
var table = new Table().Border(TableBorder.Rounded);
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("Source");
|
|
table.AddColumn("Locator");
|
|
table.AddColumn("Path");
|
|
|
|
foreach (var entry in report.DeclaredOnly)
|
|
{
|
|
table.AddRow(
|
|
"[red]Declared Only[/]",
|
|
Markup.Escape(entry.Name),
|
|
Markup.Escape(entry.Version ?? "-"),
|
|
Markup.Escape(entry.LockSource ?? "-"),
|
|
Markup.Escape(entry.LockLocator ?? "-"),
|
|
Markup.Escape(entry.Path));
|
|
}
|
|
|
|
foreach (var entry in report.MissingLockMetadata)
|
|
{
|
|
table.AddRow(
|
|
"[yellow]Missing Lock[/]",
|
|
Markup.Escape(entry.Name),
|
|
Markup.Escape(entry.Version ?? "-"),
|
|
"-",
|
|
"-",
|
|
Markup.Escape(entry.Path));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine($"[grey]Declared components: {report.TotalDeclared}, Installed: {report.TotalInstalled}[/]");
|
|
}
|
|
|
|
public static async Task HandleRubyInspectAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("ruby-inspect");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.ruby.inspect", ActivityKind.Internal);
|
|
activity?.SetTag("stellaops.cli.command", "ruby inspect");
|
|
using var duration = CliMetrics.MeasureCommandDuration("ruby inspect");
|
|
|
|
var outcome = "unknown";
|
|
try
|
|
{
|
|
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 targetRoot = string.IsNullOrWhiteSpace(rootPath)
|
|
? Directory.GetCurrentDirectory()
|
|
: Path.GetFullPath(rootPath);
|
|
if (!Directory.Exists(targetRoot))
|
|
{
|
|
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
|
|
}
|
|
|
|
logger.LogInformation("Inspecting Ruby workspace in {Root}.", targetRoot);
|
|
activity?.SetTag("stellaops.cli.ruby.root", targetRoot);
|
|
|
|
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() });
|
|
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
|
|
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
|
|
var report = RubyInspectReport.Create(result.ToSnapshots());
|
|
|
|
activity?.SetTag("stellaops.cli.ruby.package_count", report.Packages.Count);
|
|
|
|
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
|
{
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
|
}
|
|
else
|
|
{
|
|
RenderRubyInspectReport(report);
|
|
}
|
|
|
|
outcome = report.Packages.Count == 0 ? "empty" : "ok";
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (DirectoryNotFoundException ex)
|
|
{
|
|
outcome = "not_found";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 71;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
outcome = "invalid";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 64;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
outcome = "error";
|
|
logger.LogError(ex, "Ruby inspect failed.");
|
|
Environment.ExitCode = 70;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
CliMetrics.RecordRubyInspect(outcome);
|
|
}
|
|
}
|
|
|
|
public static async Task HandleRubyResolveAsync(
|
|
IServiceProvider services,
|
|
string? imageReference,
|
|
string? scanId,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("ruby-resolve");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.ruby.resolve", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "ruby resolve");
|
|
using var duration = CliMetrics.MeasureCommandDuration("ruby resolve");
|
|
|
|
var outcome = "unknown";
|
|
try
|
|
{
|
|
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 identifier = !string.IsNullOrWhiteSpace(scanId)
|
|
? scanId!.Trim()
|
|
: imageReference?.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(identifier))
|
|
{
|
|
throw new InvalidOperationException("An --image or --scan-id value is required.");
|
|
}
|
|
|
|
logger.LogInformation("Resolving Ruby packages for scan {ScanId}.", identifier);
|
|
activity?.SetTag("stellaops.cli.scan_id", identifier);
|
|
|
|
var inventory = await client.GetRubyPackagesAsync(identifier, cancellationToken).ConfigureAwait(false);
|
|
if (inventory is null)
|
|
{
|
|
outcome = "empty";
|
|
Environment.ExitCode = 0;
|
|
AnsiConsole.MarkupLine("[yellow]Ruby package inventory is not available for scan {0}.[/]", Markup.Escape(identifier));
|
|
return;
|
|
}
|
|
|
|
var report = RubyResolveReport.Create(inventory);
|
|
|
|
if (!report.HasPackages)
|
|
{
|
|
outcome = "empty";
|
|
Environment.ExitCode = 0;
|
|
var displayScanId = string.IsNullOrWhiteSpace(report.ScanId) ? identifier : report.ScanId;
|
|
AnsiConsole.MarkupLine("[yellow]No Ruby packages found for scan {0}.[/]", Markup.Escape(displayScanId));
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
|
{
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
|
}
|
|
else
|
|
{
|
|
RenderRubyResolveReport(report);
|
|
}
|
|
|
|
outcome = "ok";
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
outcome = "invalid";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 64;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
outcome = "error";
|
|
logger.LogError(ex, "Ruby resolve failed.");
|
|
Environment.ExitCode = 70;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
CliMetrics.RecordRubyResolve(outcome);
|
|
}
|
|
}
|
|
|
|
public static async Task HandlePhpInspectAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("php-inspect");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.php.inspect", ActivityKind.Internal);
|
|
activity?.SetTag("stellaops.cli.command", "php inspect");
|
|
using var duration = CliMetrics.MeasureCommandDuration("php inspect");
|
|
|
|
var outcome = "unknown";
|
|
try
|
|
{
|
|
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 targetRoot = string.IsNullOrWhiteSpace(rootPath)
|
|
? Directory.GetCurrentDirectory()
|
|
: Path.GetFullPath(rootPath);
|
|
if (!Directory.Exists(targetRoot))
|
|
{
|
|
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
|
|
}
|
|
|
|
logger.LogInformation("Inspecting PHP workspace in {Root}.", targetRoot);
|
|
activity?.SetTag("stellaops.cli.php.root", targetRoot);
|
|
|
|
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new PhpLanguageAnalyzer() });
|
|
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
|
|
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
|
|
var report = PhpInspectReport.Create(result.ToSnapshots());
|
|
|
|
activity?.SetTag("stellaops.cli.php.package_count", report.Packages.Count);
|
|
|
|
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
|
{
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
|
}
|
|
else
|
|
{
|
|
RenderPhpInspectReport(report);
|
|
}
|
|
|
|
outcome = report.Packages.Count == 0 ? "empty" : "ok";
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (DirectoryNotFoundException ex)
|
|
{
|
|
outcome = "not_found";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 71;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
outcome = "invalid";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 64;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
outcome = "error";
|
|
logger.LogError(ex, "PHP inspect failed.");
|
|
Environment.ExitCode = 70;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
CliMetrics.RecordPhpInspect(outcome);
|
|
}
|
|
}
|
|
|
|
public static async Task HandlePythonInspectAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string format,
|
|
string[]? sitePackages,
|
|
bool includeFrameworks,
|
|
bool includeCapabilities,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("python-inspect");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.python.inspect", ActivityKind.Internal);
|
|
activity?.SetTag("stellaops.cli.command", "python inspect");
|
|
using var duration = CliMetrics.MeasureCommandDuration("python inspect");
|
|
|
|
var outcome = "unknown";
|
|
try
|
|
{
|
|
var normalizedFormat = string.IsNullOrWhiteSpace(format)
|
|
? "table"
|
|
: format.Trim().ToLowerInvariant();
|
|
if (normalizedFormat is not ("table" or "json" or "aoc"))
|
|
{
|
|
throw new InvalidOperationException("Format must be 'table', 'json', or 'aoc'.");
|
|
}
|
|
|
|
var targetRoot = string.IsNullOrWhiteSpace(rootPath)
|
|
? Directory.GetCurrentDirectory()
|
|
: Path.GetFullPath(rootPath);
|
|
if (!Directory.Exists(targetRoot))
|
|
{
|
|
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
|
|
}
|
|
|
|
logger.LogInformation("Inspecting Python workspace in {Root}.", targetRoot);
|
|
activity?.SetTag("stellaops.cli.python.root", targetRoot);
|
|
|
|
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new PythonLanguageAnalyzer() });
|
|
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
|
|
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
|
|
var snapshots = result.ToSnapshots();
|
|
|
|
activity?.SetTag("stellaops.cli.python.package_count", snapshots.Count);
|
|
|
|
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
|
{
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
Console.WriteLine(JsonSerializer.Serialize(snapshots, options));
|
|
}
|
|
else if (string.Equals(normalizedFormat, "aoc", StringComparison.Ordinal))
|
|
{
|
|
// AOC format output
|
|
var aocResult = new
|
|
{
|
|
Schema = "python-aoc-v1",
|
|
Packages = snapshots.Select(s => new
|
|
{
|
|
s.Name,
|
|
s.Version,
|
|
s.Type,
|
|
Purl = s.Purl,
|
|
s.Metadata
|
|
})
|
|
};
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
Console.WriteLine(JsonSerializer.Serialize(aocResult, options));
|
|
}
|
|
else
|
|
{
|
|
RenderPythonInspectReport(snapshots);
|
|
}
|
|
|
|
outcome = snapshots.Count == 0 ? "empty" : "ok";
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (DirectoryNotFoundException ex)
|
|
{
|
|
outcome = "not_found";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 71;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
outcome = "invalid";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 64;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
outcome = "error";
|
|
logger.LogError(ex, "Python inspect failed.");
|
|
Environment.ExitCode = 70;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
CliMetrics.RecordPythonInspect(outcome);
|
|
}
|
|
}
|
|
|
|
public static async Task HandleBunInspectAsync(
|
|
IServiceProvider services,
|
|
string? rootPath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("bun-inspect");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.bun.inspect", ActivityKind.Internal);
|
|
activity?.SetTag("stellaops.cli.command", "bun inspect");
|
|
using var duration = CliMetrics.MeasureCommandDuration("bun inspect");
|
|
|
|
var outcome = "unknown";
|
|
try
|
|
{
|
|
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 targetRoot = string.IsNullOrWhiteSpace(rootPath)
|
|
? Directory.GetCurrentDirectory()
|
|
: Path.GetFullPath(rootPath);
|
|
if (!Directory.Exists(targetRoot))
|
|
{
|
|
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
|
|
}
|
|
|
|
logger.LogInformation("Inspecting Bun workspace in {Root}.", targetRoot);
|
|
activity?.SetTag("stellaops.cli.bun.root", targetRoot);
|
|
|
|
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new BunLanguageAnalyzer() });
|
|
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
|
|
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
|
|
var report = BunInspectReport.Create(result.ToSnapshots());
|
|
|
|
activity?.SetTag("stellaops.cli.bun.package_count", report.Packages.Count);
|
|
|
|
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
|
{
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
|
}
|
|
else
|
|
{
|
|
RenderBunInspectReport(report);
|
|
}
|
|
|
|
outcome = report.Packages.Count == 0 ? "empty" : "ok";
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (DirectoryNotFoundException ex)
|
|
{
|
|
outcome = "not_found";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 71;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
outcome = "invalid";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 64;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
outcome = "error";
|
|
logger.LogError(ex, "Bun inspect failed.");
|
|
Environment.ExitCode = 70;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
CliMetrics.RecordBunInspect(outcome);
|
|
}
|
|
}
|
|
|
|
public static async Task HandleBunResolveAsync(
|
|
IServiceProvider services,
|
|
string? imageReference,
|
|
string? scanId,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("bun-resolve");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.bun.resolve", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "bun resolve");
|
|
using var duration = CliMetrics.MeasureCommandDuration("bun resolve");
|
|
|
|
var outcome = "unknown";
|
|
try
|
|
{
|
|
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 identifier = !string.IsNullOrWhiteSpace(scanId)
|
|
? scanId!.Trim()
|
|
: imageReference?.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(identifier))
|
|
{
|
|
throw new InvalidOperationException("An --image or --scan-id value is required.");
|
|
}
|
|
|
|
logger.LogInformation("Resolving Bun packages for scan {ScanId}.", identifier);
|
|
activity?.SetTag("stellaops.cli.scan_id", identifier);
|
|
|
|
var inventory = await client.GetBunPackagesAsync(identifier, cancellationToken).ConfigureAwait(false);
|
|
if (inventory is null)
|
|
{
|
|
outcome = "empty";
|
|
Environment.ExitCode = 0;
|
|
AnsiConsole.MarkupLine("[yellow]Bun package inventory is not available for scan {0}.[/]", Markup.Escape(identifier));
|
|
return;
|
|
}
|
|
|
|
var report = BunResolveReport.Create(inventory);
|
|
|
|
if (!report.HasPackages)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No Bun packages found for scan {0}.[/]", Markup.Escape(identifier));
|
|
}
|
|
else if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
|
{
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
|
}
|
|
else
|
|
{
|
|
RenderBunResolveReport(report);
|
|
}
|
|
|
|
outcome = report.HasPackages ? "ok" : "empty";
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
outcome = "invalid";
|
|
logger.LogError(ex.Message);
|
|
Environment.ExitCode = 64;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
outcome = "network_error";
|
|
logger.LogError(ex, "Failed to resolve Bun packages.");
|
|
Environment.ExitCode = 69;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
outcome = "error";
|
|
logger.LogError(ex, "Bun resolve failed.");
|
|
Environment.ExitCode = 70;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
CliMetrics.RecordBunResolve(outcome);
|
|
}
|
|
}
|
|
|
|
private static void RenderPythonInspectReport(IReadOnlyList<LanguageComponentSnapshot> snapshots)
|
|
{
|
|
if (snapshots.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No Python packages detected.[/]");
|
|
return;
|
|
}
|
|
|
|
var table = new Table().Border(TableBorder.Rounded);
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("Installer");
|
|
table.AddColumn("Source");
|
|
|
|
foreach (var entry in snapshots)
|
|
{
|
|
var installer = entry.Metadata.TryGetValue("installer", out var inst) ? inst : "-";
|
|
var source = entry.Metadata.TryGetValue("provenance", out var src) ? src : "-";
|
|
table.AddRow(
|
|
Markup.Escape(entry.Name ?? "-"),
|
|
Markup.Escape(entry.Version ?? "-"),
|
|
Markup.Escape(installer ?? "-"),
|
|
Markup.Escape(source ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
private static void RenderPhpInspectReport(PhpInspectReport report)
|
|
{
|
|
if (!report.Packages.Any())
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No PHP packages detected.[/]");
|
|
return;
|
|
}
|
|
|
|
var table = new Table().Border(TableBorder.Rounded);
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("Type");
|
|
table.AddColumn(new TableColumn("Lockfile").NoWrap());
|
|
table.AddColumn("Dev");
|
|
|
|
foreach (var entry in report.Packages)
|
|
{
|
|
var dev = entry.IsDev ? "[grey]yes[/]" : "-";
|
|
table.AddRow(
|
|
Markup.Escape(entry.Name),
|
|
Markup.Escape(entry.Version ?? "-"),
|
|
Markup.Escape(entry.Type ?? "-"),
|
|
Markup.Escape(entry.Lockfile ?? "-"),
|
|
dev);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
private static void RenderBunInspectReport(BunInspectReport report)
|
|
{
|
|
if (!report.Packages.Any())
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No Bun packages detected.[/]");
|
|
return;
|
|
}
|
|
|
|
var table = new Table().Border(TableBorder.Rounded);
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("Source");
|
|
table.AddColumn("Dev");
|
|
table.AddColumn("Direct");
|
|
|
|
foreach (var entry in report.Packages)
|
|
{
|
|
var dev = entry.IsDev ? "[grey]yes[/]" : "-";
|
|
var direct = entry.IsDirect ? "[blue]yes[/]" : "-";
|
|
table.AddRow(
|
|
Markup.Escape(entry.Name),
|
|
Markup.Escape(entry.Version ?? "-"),
|
|
Markup.Escape(entry.Source ?? "-"),
|
|
dev,
|
|
direct);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine($"[grey]Total packages: {report.Packages.Count}[/]");
|
|
}
|
|
|
|
private static void RenderBunResolveReport(BunResolveReport report)
|
|
{
|
|
if (!report.HasPackages)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No Bun packages found.[/]");
|
|
return;
|
|
}
|
|
|
|
var table = new Table().Border(TableBorder.Rounded);
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("Source");
|
|
table.AddColumn("Integrity");
|
|
|
|
foreach (var entry in report.Packages)
|
|
{
|
|
table.AddRow(
|
|
Markup.Escape(entry.Name),
|
|
Markup.Escape(entry.Version ?? "-"),
|
|
Markup.Escape(entry.Source ?? "-"),
|
|
Markup.Escape(entry.Integrity ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine($"[grey]Scan: {Markup.Escape(report.ScanId ?? "-")} • Total: {report.Packages.Count}[/]");
|
|
}
|
|
|
|
private static void RenderRubyInspectReport(RubyInspectReport report)
|
|
{
|
|
if (!report.Packages.Any())
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No Ruby packages detected.[/]");
|
|
return;
|
|
}
|
|
|
|
if (report.Observation is { } observation)
|
|
{
|
|
var bundler = string.IsNullOrWhiteSpace(observation.BundlerVersion)
|
|
? "n/a"
|
|
: observation.BundlerVersion;
|
|
|
|
AnsiConsole.MarkupLine(
|
|
"[grey]Observation[/] bundler={0} • packages={1} • runtimeEdges={2}",
|
|
Markup.Escape(bundler),
|
|
observation.PackageCount,
|
|
observation.RuntimeEdgeCount);
|
|
|
|
AnsiConsole.MarkupLine(
|
|
"[grey]Capabilities[/] exec={0} net={1} serialization={2}",
|
|
observation.UsesExec ? "[green]on[/]" : "[red]off[/]",
|
|
observation.UsesNetwork ? "[green]on[/]" : "[red]off[/]",
|
|
observation.UsesSerialization ? "[green]on[/]" : "[red]off[/]");
|
|
|
|
if (observation.SchedulerCount > 0)
|
|
{
|
|
var schedulerLabel = observation.Schedulers.Count > 0
|
|
? string.Join(", ", observation.Schedulers)
|
|
: observation.SchedulerCount.ToString(CultureInfo.InvariantCulture);
|
|
AnsiConsole.MarkupLine("[grey]Schedulers[/] {0}", Markup.Escape(schedulerLabel));
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
var table = new Table().Border(TableBorder.Rounded);
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("Groups");
|
|
table.AddColumn("Platform");
|
|
table.AddColumn(new TableColumn("Source").NoWrap());
|
|
table.AddColumn(new TableColumn("Lockfile").NoWrap());
|
|
table.AddColumn(new TableColumn("Runtime").NoWrap());
|
|
|
|
foreach (var entry in report.Packages)
|
|
{
|
|
var groups = entry.Groups.Count == 0 ? "-" : string.Join(", ", entry.Groups);
|
|
var runtime = entry.UsedByEntrypoint
|
|
? "[green]Entrypoint[/]"
|
|
: entry.RuntimeEntrypoints.Count > 0
|
|
? Markup.Escape(string.Join(", ", entry.RuntimeEntrypoints))
|
|
: "[grey]-[/]";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(entry.Name),
|
|
Markup.Escape(entry.Version ?? "-"),
|
|
Markup.Escape(groups),
|
|
Markup.Escape(entry.Platform ?? "-"),
|
|
Markup.Escape(entry.Source ?? "-"),
|
|
Markup.Escape(entry.Lockfile ?? "-"),
|
|
runtime);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
private static void RenderRubyResolveReport(RubyResolveReport report)
|
|
{
|
|
var table = new Table().Border(TableBorder.Rounded);
|
|
table.AddColumn("Group");
|
|
table.AddColumn("Platform");
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Version");
|
|
table.AddColumn(new TableColumn("Source").NoWrap());
|
|
table.AddColumn(new TableColumn("Lockfile").NoWrap());
|
|
table.AddColumn(new TableColumn("Runtime").NoWrap());
|
|
|
|
foreach (var group in report.Groups)
|
|
{
|
|
foreach (var package in group.Packages)
|
|
{
|
|
var runtime = package.RuntimeEntrypoints.Count > 0
|
|
? Markup.Escape(string.Join(", ", package.RuntimeEntrypoints))
|
|
: package.RuntimeUsed ? "[green]Entrypoint[/]" : "[grey]-[/]";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(group.Group),
|
|
Markup.Escape(group.Platform ?? "-"),
|
|
Markup.Escape(package.Name),
|
|
Markup.Escape(package.Version ?? "-"),
|
|
Markup.Escape(package.Source ?? "-"),
|
|
Markup.Escape(package.Lockfile ?? "-"),
|
|
runtime);
|
|
}
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine("[grey]Scan {0} • Total packages: {1}[/]", Markup.Escape(report.ScanId), report.TotalPackages);
|
|
}
|
|
|
|
private static void RenderCryptoProviders(
|
|
IReadOnlyList<string> preferredOrder,
|
|
IReadOnlyCollection<ProviderInfo> providers)
|
|
{
|
|
if (preferredOrder.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[cyan]Preferred order:[/] {0}", Markup.Escape(string.Join(", ", preferredOrder)));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Preferred order is not configured; using registration order.[/]");
|
|
}
|
|
|
|
var table = new Table().Border(TableBorder.Rounded);
|
|
table.AddColumn("Provider");
|
|
table.AddColumn("Type");
|
|
table.AddColumn("Keys");
|
|
|
|
foreach (var provider in providers)
|
|
{
|
|
var keySummary = provider.Keys.Count == 0
|
|
? "[grey]No signing keys exposed (managed externally).[/]"
|
|
: string.Join(Environment.NewLine, provider.Keys.Select(FormatDescriptor));
|
|
|
|
table.AddRow(
|
|
Markup.Escape(provider.Name),
|
|
Markup.Escape(provider.Type),
|
|
keySummary);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
private static IReadOnlyList<CryptoProviderKeyDescriptor> DescribeProviderKeys(ICryptoProvider provider)
|
|
{
|
|
if (provider is ICryptoProviderDiagnostics diagnostics)
|
|
{
|
|
return diagnostics.DescribeKeys().ToList();
|
|
}
|
|
|
|
var signingKeys = provider.GetSigningKeys();
|
|
if (signingKeys.Count == 0)
|
|
{
|
|
return Array.Empty<CryptoProviderKeyDescriptor>();
|
|
}
|
|
|
|
var descriptors = new List<CryptoProviderKeyDescriptor>(signingKeys.Count);
|
|
foreach (var signingKey in signingKeys)
|
|
{
|
|
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["kind"] = signingKey.Kind.ToString(),
|
|
["createdAt"] = signingKey.CreatedAt.UtcDateTime.ToString("O"),
|
|
["providerHint"] = signingKey.Reference.ProviderHint
|
|
};
|
|
|
|
if (signingKey.ExpiresAt.HasValue)
|
|
{
|
|
metadata["expiresAt"] = signingKey.ExpiresAt.Value.UtcDateTime.ToString("O");
|
|
}
|
|
|
|
foreach (var pair in signingKey.Metadata)
|
|
{
|
|
metadata[$"meta.{pair.Key}"] = pair.Value;
|
|
}
|
|
|
|
descriptors.Add(new CryptoProviderKeyDescriptor(
|
|
provider.Name,
|
|
signingKey.Reference.KeyId,
|
|
signingKey.AlgorithmId,
|
|
metadata));
|
|
}
|
|
|
|
return descriptors;
|
|
}
|
|
|
|
private sealed class RubyInspectReport
|
|
{
|
|
[JsonPropertyName("packages")]
|
|
public IReadOnlyList<RubyInspectEntry> Packages { get; }
|
|
|
|
[JsonPropertyName("observation")]
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public RubyObservationSummary? Observation { get; }
|
|
|
|
private RubyInspectReport(IReadOnlyList<RubyInspectEntry> packages, RubyObservationSummary? observation)
|
|
{
|
|
Packages = packages;
|
|
Observation = observation;
|
|
}
|
|
|
|
public static RubyInspectReport Create(IEnumerable<LanguageComponentSnapshot>? snapshots)
|
|
{
|
|
var source = snapshots?.ToArray() ?? Array.Empty<LanguageComponentSnapshot>();
|
|
|
|
var entries = source
|
|
.Where(static snapshot => string.Equals(snapshot.Type, "gem", StringComparison.OrdinalIgnoreCase))
|
|
.Select(RubyInspectEntry.FromSnapshot)
|
|
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
var observation = RubyObservationSummary.TryCreate(source);
|
|
|
|
return new RubyInspectReport(entries, observation);
|
|
}
|
|
}
|
|
|
|
private sealed record RubyInspectEntry(
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("version")] string? Version,
|
|
[property: JsonPropertyName("source")] string? Source,
|
|
[property: JsonPropertyName("lockfile")] string? Lockfile,
|
|
[property: JsonPropertyName("groups")] IReadOnlyList<string> Groups,
|
|
[property: JsonPropertyName("platform")] string? Platform,
|
|
[property: JsonPropertyName("declaredOnly")] bool DeclaredOnly,
|
|
[property: JsonPropertyName("runtimeEntrypoints")] IReadOnlyList<string> RuntimeEntrypoints,
|
|
[property: JsonPropertyName("runtimeFiles")] IReadOnlyList<string> RuntimeFiles,
|
|
[property: JsonPropertyName("runtimeReasons")] IReadOnlyList<string> RuntimeReasons,
|
|
[property: JsonPropertyName("usedByEntrypoint")] bool UsedByEntrypoint)
|
|
{
|
|
public static RubyInspectEntry FromSnapshot(LanguageComponentSnapshot snapshot)
|
|
{
|
|
var metadata = RubyMetadataHelpers.Clone(snapshot.Metadata);
|
|
var groups = RubyMetadataHelpers.GetList(metadata, "groups");
|
|
var platform = RubyMetadataHelpers.GetString(metadata, "platform");
|
|
var source = RubyMetadataHelpers.GetString(metadata, "source");
|
|
var lockfile = RubyMetadataHelpers.GetString(metadata, "lockfile");
|
|
var declaredOnly = RubyMetadataHelpers.GetBool(metadata, "declaredOnly") ?? false;
|
|
var runtimeEntrypoints = RubyMetadataHelpers.GetList(metadata, "runtime.entrypoints");
|
|
var runtimeFiles = RubyMetadataHelpers.GetList(metadata, "runtime.files");
|
|
var runtimeReasons = RubyMetadataHelpers.GetList(metadata, "runtime.reasons");
|
|
var usedByEntrypoint = RubyMetadataHelpers.GetBool(metadata, "runtime.used") ?? snapshot.UsedByEntrypoint;
|
|
|
|
return new RubyInspectEntry(
|
|
snapshot.Name,
|
|
snapshot.Version,
|
|
source,
|
|
lockfile,
|
|
groups,
|
|
platform,
|
|
declaredOnly,
|
|
runtimeEntrypoints,
|
|
runtimeFiles,
|
|
runtimeReasons,
|
|
usedByEntrypoint);
|
|
}
|
|
}
|
|
|
|
private sealed record RubyObservationSummary(
|
|
[property: JsonPropertyName("packageCount")] int PackageCount,
|
|
[property: JsonPropertyName("runtimeEdgeCount")] int RuntimeEdgeCount,
|
|
[property: JsonPropertyName("bundlerVersion")] string? BundlerVersion,
|
|
[property: JsonPropertyName("usesExec")] bool UsesExec,
|
|
[property: JsonPropertyName("usesNetwork")] bool UsesNetwork,
|
|
[property: JsonPropertyName("usesSerialization")] bool UsesSerialization,
|
|
[property: JsonPropertyName("schedulerCount")] int SchedulerCount,
|
|
[property: JsonPropertyName("schedulers")] IReadOnlyList<string> Schedulers)
|
|
{
|
|
public static RubyObservationSummary? TryCreate(IEnumerable<LanguageComponentSnapshot> snapshots)
|
|
{
|
|
var observation = snapshots.FirstOrDefault(static snapshot =>
|
|
string.Equals(snapshot.Type, "ruby-observation", StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (observation is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var metadata = RubyMetadataHelpers.Clone(observation.Metadata);
|
|
var schedulers = RubyMetadataHelpers.GetList(metadata, "ruby.observation.capability.scheduler_list");
|
|
|
|
return new RubyObservationSummary(
|
|
RubyMetadataHelpers.GetInt(metadata, "ruby.observation.packages") ?? 0,
|
|
RubyMetadataHelpers.GetInt(metadata, "ruby.observation.runtime_edges") ?? 0,
|
|
RubyMetadataHelpers.GetString(metadata, "ruby.observation.bundler_version"),
|
|
RubyMetadataHelpers.GetBool(metadata, "ruby.observation.capability.exec") ?? false,
|
|
RubyMetadataHelpers.GetBool(metadata, "ruby.observation.capability.net") ?? false,
|
|
RubyMetadataHelpers.GetBool(metadata, "ruby.observation.capability.serialization") ?? false,
|
|
RubyMetadataHelpers.GetInt(metadata, "ruby.observation.capability.schedulers") ?? schedulers.Count,
|
|
schedulers);
|
|
}
|
|
}
|
|
|
|
private sealed class RubyResolveReport
|
|
{
|
|
[JsonPropertyName("scanId")]
|
|
public string ScanId { get; }
|
|
|
|
[JsonPropertyName("imageDigest")]
|
|
public string ImageDigest { get; }
|
|
|
|
[JsonPropertyName("generatedAt")]
|
|
public DateTimeOffset GeneratedAt { get; }
|
|
|
|
[JsonPropertyName("groups")]
|
|
public IReadOnlyList<RubyResolveGroup> Groups { get; }
|
|
|
|
[JsonIgnore]
|
|
public bool HasPackages => TotalPackages > 0;
|
|
|
|
[JsonIgnore]
|
|
public int TotalPackages => Groups.Sum(static group => group.Packages.Count);
|
|
|
|
private RubyResolveReport(string scanId, string imageDigest, DateTimeOffset generatedAt, IReadOnlyList<RubyResolveGroup> groups)
|
|
{
|
|
ScanId = scanId;
|
|
ImageDigest = imageDigest;
|
|
GeneratedAt = generatedAt;
|
|
Groups = groups;
|
|
}
|
|
|
|
public static RubyResolveReport Create(RubyPackageInventoryModel inventory)
|
|
{
|
|
var resolved = (inventory.Packages ?? Array.Empty<RubyPackageArtifactModel>())
|
|
.Select(RubyResolvePackage.FromModel)
|
|
.ToArray();
|
|
|
|
var rows = new List<(string Group, string Platform, RubyResolvePackage Package)>();
|
|
foreach (var package in resolved)
|
|
{
|
|
var groups = package.Groups.Count == 0
|
|
? new[] { "(default)" }
|
|
: package.Groups;
|
|
|
|
foreach (var group in groups)
|
|
{
|
|
rows.Add((group, package.Platform ?? "-", package));
|
|
}
|
|
}
|
|
|
|
var grouped = rows
|
|
.GroupBy(static row => (row.Group, row.Platform))
|
|
.OrderBy(static g => g.Key.Group, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(static g => g.Key.Platform, StringComparer.OrdinalIgnoreCase)
|
|
.Select(group => new RubyResolveGroup(
|
|
group.Key.Group,
|
|
group.Key.Platform,
|
|
group.Select(row => row.Package)
|
|
.OrderBy(static pkg => pkg.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(static pkg => pkg.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray()))
|
|
.ToArray();
|
|
|
|
var normalizedScanId = inventory.ScanId ?? string.Empty;
|
|
var normalizedDigest = inventory.ImageDigest ?? string.Empty;
|
|
return new RubyResolveReport(normalizedScanId, normalizedDigest, inventory.GeneratedAt, grouped);
|
|
}
|
|
}
|
|
|
|
private sealed record RubyResolveGroup(
|
|
[property: JsonPropertyName("group")] string Group,
|
|
[property: JsonPropertyName("platform")] string Platform,
|
|
[property: JsonPropertyName("packages")] IReadOnlyList<RubyResolvePackage> Packages);
|
|
|
|
private sealed record RubyResolvePackage(
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("version")] string? Version,
|
|
[property: JsonPropertyName("source")] string? Source,
|
|
[property: JsonPropertyName("lockfile")] string? Lockfile,
|
|
[property: JsonPropertyName("groups")] IReadOnlyList<string> Groups,
|
|
[property: JsonPropertyName("platform")] string? Platform,
|
|
[property: JsonPropertyName("declaredOnly")] bool DeclaredOnly,
|
|
[property: JsonPropertyName("runtimeEntrypoints")] IReadOnlyList<string> RuntimeEntrypoints,
|
|
[property: JsonPropertyName("runtimeFiles")] IReadOnlyList<string> RuntimeFiles,
|
|
[property: JsonPropertyName("runtimeReasons")] IReadOnlyList<string> RuntimeReasons,
|
|
[property: JsonPropertyName("runtimeUsed")] bool RuntimeUsed)
|
|
{
|
|
public static RubyResolvePackage FromModel(RubyPackageArtifactModel model)
|
|
{
|
|
var metadata = RubyMetadataHelpers.Clone(model.Metadata);
|
|
|
|
IReadOnlyList<string> groups = model.Groups is { Count: > 0 }
|
|
? model.Groups
|
|
.Where(static group => !string.IsNullOrWhiteSpace(group))
|
|
.Select(static group => group.Trim())
|
|
.ToArray()
|
|
: RubyMetadataHelpers.GetList(metadata, "groups");
|
|
|
|
IReadOnlyList<string>? runtimeEntrypoints = model.Runtime?.Entrypoints?.Where(static e => !string.IsNullOrWhiteSpace(e)).Select(static e => e.Trim()).ToArray();
|
|
if (runtimeEntrypoints is null || runtimeEntrypoints.Count == 0)
|
|
{
|
|
runtimeEntrypoints = RubyMetadataHelpers.GetList(metadata, "runtime.entrypoints");
|
|
}
|
|
|
|
IReadOnlyList<string>? runtimeFiles = model.Runtime?.Files?.Where(static e => !string.IsNullOrWhiteSpace(e)).Select(static e => e.Trim()).ToArray();
|
|
if (runtimeFiles is null || runtimeFiles.Count == 0)
|
|
{
|
|
runtimeFiles = RubyMetadataHelpers.GetList(metadata, "runtime.files");
|
|
}
|
|
|
|
IReadOnlyList<string>? runtimeReasons = model.Runtime?.Reasons?.Where(static e => !string.IsNullOrWhiteSpace(e)).Select(static e => e.Trim()).ToArray();
|
|
if (runtimeReasons is null || runtimeReasons.Count == 0)
|
|
{
|
|
runtimeReasons = RubyMetadataHelpers.GetList(metadata, "runtime.reasons");
|
|
}
|
|
|
|
runtimeEntrypoints ??= Array.Empty<string>();
|
|
runtimeFiles ??= Array.Empty<string>();
|
|
runtimeReasons ??= Array.Empty<string>();
|
|
|
|
var source = model.Provenance?.Source
|
|
?? model.Source
|
|
?? RubyMetadataHelpers.GetString(metadata, "source");
|
|
var lockfile = model.Provenance?.Lockfile ?? RubyMetadataHelpers.GetString(metadata, "lockfile");
|
|
var platform = model.Platform ?? RubyMetadataHelpers.GetString(metadata, "platform");
|
|
var declaredOnly = model.DeclaredOnly ?? RubyMetadataHelpers.GetBool(metadata, "declaredOnly") ?? false;
|
|
var runtimeUsed = model.RuntimeUsed ?? RubyMetadataHelpers.GetBool(metadata, "runtime.used") ?? false;
|
|
|
|
return new RubyResolvePackage(
|
|
model.Name,
|
|
model.Version,
|
|
source,
|
|
lockfile,
|
|
groups,
|
|
platform,
|
|
declaredOnly,
|
|
runtimeEntrypoints,
|
|
runtimeFiles,
|
|
runtimeReasons,
|
|
runtimeUsed);
|
|
}
|
|
}
|
|
|
|
private static class RubyMetadataHelpers
|
|
{
|
|
public static IDictionary<string, string?> Clone(IDictionary<string, string?>? metadata)
|
|
{
|
|
if (metadata is null || metadata.Count == 0)
|
|
{
|
|
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
var clone = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var pair in metadata)
|
|
{
|
|
clone[pair.Key] = pair.Value;
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
|
|
public static string? GetString(IDictionary<string, string?> metadata, string key)
|
|
{
|
|
if (metadata.TryGetValue(key, out var value))
|
|
{
|
|
return value;
|
|
}
|
|
|
|
foreach (var pair in metadata)
|
|
{
|
|
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return pair.Value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static IReadOnlyList<string> GetList(IDictionary<string, string?> metadata, string key)
|
|
{
|
|
var value = GetString(metadata, key);
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
return value
|
|
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.ToArray();
|
|
}
|
|
|
|
public static bool? GetBool(IDictionary<string, string?> metadata, string key)
|
|
{
|
|
var value = GetString(metadata, key);
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (bool.TryParse(value, out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static int? GetInt(IDictionary<string, string?> metadata, string key)
|
|
{
|
|
var value = GetString(metadata, key);
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private sealed class PhpInspectReport
|
|
{
|
|
[JsonPropertyName("packages")]
|
|
public IReadOnlyList<PhpInspectEntry> Packages { get; }
|
|
|
|
private PhpInspectReport(IReadOnlyList<PhpInspectEntry> packages)
|
|
{
|
|
Packages = packages;
|
|
}
|
|
|
|
public static PhpInspectReport Create(IEnumerable<LanguageComponentSnapshot>? snapshots)
|
|
{
|
|
var source = snapshots?.ToArray() ?? Array.Empty<LanguageComponentSnapshot>();
|
|
|
|
var entries = source
|
|
.Where(static snapshot => string.Equals(snapshot.Type, "composer", StringComparison.OrdinalIgnoreCase))
|
|
.Select(PhpInspectEntry.FromSnapshot)
|
|
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
return new PhpInspectReport(entries);
|
|
}
|
|
}
|
|
|
|
private sealed record PhpInspectEntry(
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("version")] string? Version,
|
|
[property: JsonPropertyName("type")] string? Type,
|
|
[property: JsonPropertyName("lockfile")] string? Lockfile,
|
|
[property: JsonPropertyName("isDev")] bool IsDev,
|
|
[property: JsonPropertyName("source")] string? Source,
|
|
[property: JsonPropertyName("distSha")] string? DistSha)
|
|
{
|
|
public static PhpInspectEntry FromSnapshot(LanguageComponentSnapshot snapshot)
|
|
{
|
|
var metadata = PhpMetadataHelpers.Clone(snapshot.Metadata);
|
|
var type = PhpMetadataHelpers.GetString(metadata, "type");
|
|
var lockfile = PhpMetadataHelpers.GetString(metadata, "lockfile");
|
|
var isDev = PhpMetadataHelpers.GetBool(metadata, "isDev") ?? false;
|
|
var source = PhpMetadataHelpers.GetString(metadata, "source");
|
|
var distSha = PhpMetadataHelpers.GetString(metadata, "distSha");
|
|
|
|
return new PhpInspectEntry(
|
|
snapshot.Name,
|
|
snapshot.Version,
|
|
type,
|
|
lockfile,
|
|
isDev,
|
|
source,
|
|
distSha);
|
|
}
|
|
}
|
|
|
|
private static class PhpMetadataHelpers
|
|
{
|
|
public static IDictionary<string, string?> Clone(IDictionary<string, string?>? metadata)
|
|
{
|
|
if (metadata is null || metadata.Count == 0)
|
|
{
|
|
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
var clone = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var pair in metadata)
|
|
{
|
|
clone[pair.Key] = pair.Value;
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
|
|
public static string? GetString(IDictionary<string, string?> metadata, string key)
|
|
{
|
|
if (metadata.TryGetValue(key, out var value))
|
|
{
|
|
return value;
|
|
}
|
|
|
|
foreach (var pair in metadata)
|
|
{
|
|
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return pair.Value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static bool? GetBool(IDictionary<string, string?> metadata, string key)
|
|
{
|
|
var value = GetString(metadata, key);
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (bool.TryParse(value, out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private sealed class BunInspectReport
|
|
{
|
|
[JsonPropertyName("packages")]
|
|
public IReadOnlyList<BunInspectEntry> Packages { get; }
|
|
|
|
private BunInspectReport(IReadOnlyList<BunInspectEntry> packages)
|
|
{
|
|
Packages = packages;
|
|
}
|
|
|
|
public static BunInspectReport Create(IEnumerable<LanguageComponentSnapshot>? snapshots)
|
|
{
|
|
var source = snapshots?.ToArray() ?? Array.Empty<LanguageComponentSnapshot>();
|
|
|
|
var entries = source
|
|
.Where(static snapshot => string.Equals(snapshot.Type, "npm", StringComparison.OrdinalIgnoreCase))
|
|
.Select(BunInspectEntry.FromSnapshot)
|
|
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
return new BunInspectReport(entries);
|
|
}
|
|
}
|
|
|
|
private sealed record BunInspectEntry(
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("version")] string? Version,
|
|
[property: JsonPropertyName("source")] string? Source,
|
|
[property: JsonPropertyName("isDev")] bool IsDev,
|
|
[property: JsonPropertyName("isDirect")] bool IsDirect,
|
|
[property: JsonPropertyName("resolved")] string? Resolved,
|
|
[property: JsonPropertyName("integrity")] string? Integrity)
|
|
{
|
|
public static BunInspectEntry FromSnapshot(LanguageComponentSnapshot snapshot)
|
|
{
|
|
var metadata = BunMetadataHelpers.Clone(snapshot.Metadata);
|
|
var source = BunMetadataHelpers.GetString(metadata, "source");
|
|
var isDev = BunMetadataHelpers.GetBool(metadata, "dev") ?? false;
|
|
var isDirect = BunMetadataHelpers.GetBool(metadata, "direct") ?? false;
|
|
var resolved = BunMetadataHelpers.GetString(metadata, "resolved");
|
|
var integrity = BunMetadataHelpers.GetString(metadata, "integrity");
|
|
|
|
return new BunInspectEntry(
|
|
snapshot.Name ?? "-",
|
|
snapshot.Version,
|
|
source,
|
|
isDev,
|
|
isDirect,
|
|
resolved,
|
|
integrity);
|
|
}
|
|
}
|
|
|
|
private static class BunMetadataHelpers
|
|
{
|
|
public static IDictionary<string, string?> Clone(IDictionary<string, string?>? metadata)
|
|
{
|
|
if (metadata is null || metadata.Count == 0)
|
|
{
|
|
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
var clone = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var pair in metadata)
|
|
{
|
|
clone[pair.Key] = pair.Value;
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
|
|
public static string? GetString(IDictionary<string, string?> metadata, string key)
|
|
{
|
|
if (metadata.TryGetValue(key, out var value))
|
|
{
|
|
return value;
|
|
}
|
|
|
|
foreach (var pair in metadata)
|
|
{
|
|
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return pair.Value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static bool? GetBool(IDictionary<string, string?> metadata, string key)
|
|
{
|
|
var value = GetString(metadata, key);
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (bool.TryParse(value, out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private sealed class BunResolveReport
|
|
{
|
|
[JsonPropertyName("scanId")]
|
|
public string? ScanId { get; }
|
|
|
|
[JsonPropertyName("packages")]
|
|
public IReadOnlyList<BunResolveEntry> Packages { get; }
|
|
|
|
[JsonIgnore]
|
|
public bool HasPackages => Packages.Count > 0;
|
|
|
|
private BunResolveReport(string? scanId, IReadOnlyList<BunResolveEntry> packages)
|
|
{
|
|
ScanId = scanId;
|
|
Packages = packages;
|
|
}
|
|
|
|
public static BunResolveReport Create(BunPackageInventory? inventory)
|
|
{
|
|
if (inventory is null)
|
|
{
|
|
return new BunResolveReport(null, Array.Empty<BunResolveEntry>());
|
|
}
|
|
|
|
var entries = inventory.Packages
|
|
.Select(BunResolveEntry.FromPackage)
|
|
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
return new BunResolveReport(inventory.ScanId, entries);
|
|
}
|
|
}
|
|
|
|
private sealed record BunResolveEntry(
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("version")] string? Version,
|
|
[property: JsonPropertyName("source")] string? Source,
|
|
[property: JsonPropertyName("integrity")] string? Integrity)
|
|
{
|
|
public static BunResolveEntry FromPackage(BunPackageItem package)
|
|
{
|
|
return new BunResolveEntry(
|
|
package.Name,
|
|
package.Version,
|
|
package.Source,
|
|
package.Integrity);
|
|
}
|
|
}
|
|
|
|
private sealed record LockValidationEntry(
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("version")] string? Version,
|
|
[property: JsonPropertyName("path")] string Path,
|
|
[property: JsonPropertyName("lockSource")] string? LockSource,
|
|
[property: JsonPropertyName("lockLocator")] string? LockLocator,
|
|
[property: JsonPropertyName("resolved")] string? Resolved,
|
|
[property: JsonPropertyName("integrity")] string? Integrity);
|
|
|
|
private sealed class LockValidationReport
|
|
{
|
|
public LockValidationReport(
|
|
IReadOnlyList<LockValidationEntry> declaredOnly,
|
|
IReadOnlyList<LockValidationEntry> missingLockMetadata,
|
|
int totalDeclared,
|
|
int totalInstalled)
|
|
{
|
|
DeclaredOnly = declaredOnly;
|
|
MissingLockMetadata = missingLockMetadata;
|
|
TotalDeclared = totalDeclared;
|
|
TotalInstalled = totalInstalled;
|
|
}
|
|
|
|
[JsonPropertyName("declaredOnly")]
|
|
public IReadOnlyList<LockValidationEntry> DeclaredOnly { get; }
|
|
|
|
[JsonPropertyName("missingLockMetadata")]
|
|
public IReadOnlyList<LockValidationEntry> MissingLockMetadata { get; }
|
|
|
|
[JsonPropertyName("totalDeclared")]
|
|
public int TotalDeclared { get; }
|
|
|
|
[JsonPropertyName("totalInstalled")]
|
|
public int TotalInstalled { get; }
|
|
|
|
[JsonIgnore]
|
|
public bool HasIssues => DeclaredOnly.Count > 0 || MissingLockMetadata.Count > 0;
|
|
|
|
public static LockValidationReport Create(IEnumerable<LanguageComponentSnapshot> snapshots)
|
|
{
|
|
var declaredOnly = new List<LockValidationEntry>();
|
|
var missingLock = new List<LockValidationEntry>();
|
|
var declaredCount = 0;
|
|
var installedCount = 0;
|
|
|
|
foreach (var component in snapshots ?? Array.Empty<LanguageComponentSnapshot>())
|
|
{
|
|
var metadata = component.Metadata ?? new Dictionary<string, string?>(StringComparer.Ordinal);
|
|
var entry = CreateEntry(component, metadata);
|
|
|
|
if (IsDeclaredOnly(metadata))
|
|
{
|
|
declaredOnly.Add(entry);
|
|
declaredCount++;
|
|
continue;
|
|
}
|
|
|
|
installedCount++;
|
|
|
|
if (!metadata.TryGetValue("lockSource", out var lockSource) || string.IsNullOrWhiteSpace(lockSource))
|
|
{
|
|
missingLock.Add(entry);
|
|
}
|
|
}
|
|
|
|
declaredOnly.Sort(CompareEntries);
|
|
missingLock.Sort(CompareEntries);
|
|
|
|
return new LockValidationReport(declaredOnly, missingLock, declaredCount, installedCount);
|
|
}
|
|
|
|
private static LockValidationEntry CreateEntry(
|
|
LanguageComponentSnapshot component,
|
|
IDictionary<string, string?> metadata)
|
|
{
|
|
metadata.TryGetValue("path", out var path);
|
|
metadata.TryGetValue("lockSource", out var lockSource);
|
|
metadata.TryGetValue("lockLocator", out var lockLocator);
|
|
metadata.TryGetValue("resolved", out var resolved);
|
|
metadata.TryGetValue("integrity", out var integrity);
|
|
|
|
return new LockValidationEntry(
|
|
component.Name,
|
|
component.Version,
|
|
string.IsNullOrWhiteSpace(path) ? "." : path!,
|
|
lockSource,
|
|
lockLocator,
|
|
resolved,
|
|
integrity);
|
|
}
|
|
|
|
private static bool IsDeclaredOnly(IDictionary<string, string?> metadata)
|
|
{
|
|
if (metadata.TryGetValue("declaredOnly", out var value))
|
|
{
|
|
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static int CompareEntries(LockValidationEntry left, LockValidationEntry right)
|
|
{
|
|
var nameComparison = string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase);
|
|
if (nameComparison != 0)
|
|
{
|
|
return nameComparison;
|
|
}
|
|
|
|
return string.Compare(left.Version, right.Version, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<string> DeterminePreferredOrder(
|
|
CryptoProviderRegistryOptions? options,
|
|
string? overrideProfile)
|
|
{
|
|
if (options is null)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(overrideProfile) &&
|
|
options.Profiles.TryGetValue(overrideProfile, out var profile) &&
|
|
profile.PreferredProviders.Count > 0)
|
|
{
|
|
return profile.PreferredProviders
|
|
.Where(static provider => !string.IsNullOrWhiteSpace(provider))
|
|
.Select(static provider => provider.Trim())
|
|
.ToArray();
|
|
}
|
|
|
|
return options.ResolvePreferredProviders();
|
|
}
|
|
|
|
private static string FormatDescriptor(CryptoProviderKeyDescriptor descriptor)
|
|
{
|
|
if (descriptor.Metadata.Count == 0)
|
|
{
|
|
return $"{Markup.Escape(descriptor.KeyId)} ({Markup.Escape(descriptor.AlgorithmId)})";
|
|
}
|
|
|
|
var metadataText = string.Join(
|
|
", ",
|
|
descriptor.Metadata.Select(pair => $"{pair.Key}={pair.Value}"));
|
|
|
|
return $"{Markup.Escape(descriptor.KeyId)} ({Markup.Escape(descriptor.AlgorithmId)}){Environment.NewLine}[grey]{Markup.Escape(metadataText)}[/]";
|
|
}
|
|
|
|
private sealed record ProviderInfo(string Name, string Type, IReadOnlyList<CryptoProviderKeyDescriptor> Keys);
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// ATTEST HANDLERS (DSSE-CLI-401-021)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Handle 'stella attest verify' command (CLI-ATTEST-73-002).
|
|
/// Verifies a DSSE envelope with policy selection, explainability output, and JSON/table formatting.
|
|
/// </summary>
|
|
public static async Task<int> HandleAttestVerifyAsync(
|
|
IServiceProvider services,
|
|
string envelopePath,
|
|
string? policyPath,
|
|
string? rootPath,
|
|
string? checkpointPath,
|
|
string? outputPath,
|
|
string format,
|
|
bool explain,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// format: "table" or "json"
|
|
// explain: include detailed explanations in output
|
|
// Exit codes per docs: 0 success, 2 verification failed, 4 input error
|
|
const int ExitSuccess = 0;
|
|
const int ExitVerificationFailed = 2;
|
|
const int ExitInputError = 4;
|
|
|
|
using var duration = CliMetrics.MeasureCommandDuration("attest verify");
|
|
|
|
if (!File.Exists(envelopePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Envelope file not found: {Markup.Escape(envelopePath)}");
|
|
CliMetrics.RecordAttestVerify("input_error");
|
|
return ExitInputError;
|
|
}
|
|
|
|
try
|
|
{
|
|
var envelopeJson = await File.ReadAllTextAsync(envelopePath, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Parse the envelope
|
|
var envelope = JsonSerializer.Deserialize<JsonElement>(envelopeJson);
|
|
|
|
// Extract envelope components
|
|
string payloadType = "";
|
|
string payload = "";
|
|
var signatures = new List<(string KeyId, string Sig)>();
|
|
string? predicateType = null;
|
|
var subjects = new List<(string Name, string Algorithm, string Digest)>();
|
|
|
|
if (envelope.TryGetProperty("payloadType", out var pt))
|
|
payloadType = pt.GetString() ?? "";
|
|
|
|
if (envelope.TryGetProperty("payload", out var pl))
|
|
payload = pl.GetString() ?? "";
|
|
|
|
if (envelope.TryGetProperty("signatures", out var sigs) && sigs.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var sig in sigs.EnumerateArray())
|
|
{
|
|
var keyId = sig.TryGetProperty("keyid", out var kid) ? kid.GetString() ?? "(none)" : "(none)";
|
|
var sigValue = sig.TryGetProperty("sig", out var sv) ? sv.GetString() ?? "" : "";
|
|
signatures.Add((keyId, sigValue));
|
|
}
|
|
}
|
|
|
|
// Decode and parse payload (in-toto statement)
|
|
if (!string.IsNullOrWhiteSpace(payload))
|
|
{
|
|
try
|
|
{
|
|
var payloadBytes = Convert.FromBase64String(payload);
|
|
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
|
var statement = JsonSerializer.Deserialize<JsonElement>(payloadJson);
|
|
|
|
if (statement.TryGetProperty("predicateType", out var predType))
|
|
predicateType = predType.GetString();
|
|
|
|
if (statement.TryGetProperty("subject", out var subjs) && subjs.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var subj in subjs.EnumerateArray())
|
|
{
|
|
var name = subj.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
|
if (subj.TryGetProperty("digest", out var digest))
|
|
{
|
|
foreach (var d in digest.EnumerateObject())
|
|
{
|
|
subjects.Add((name, d.Name, d.Value.GetString() ?? ""));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
// Invalid base64
|
|
}
|
|
}
|
|
|
|
// Verification checks
|
|
var checks = new List<(string Check, bool Passed, string Reason)>();
|
|
|
|
// Check 1: Valid envelope structure
|
|
var hasValidStructure = !string.IsNullOrWhiteSpace(payloadType) &&
|
|
!string.IsNullOrWhiteSpace(payload) &&
|
|
signatures.Count > 0;
|
|
checks.Add(("Envelope Structure", hasValidStructure,
|
|
hasValidStructure ? "Valid DSSE envelope with payload and signature(s)" : "Missing required envelope fields (payloadType, payload, or signatures)"));
|
|
|
|
// Check 2: Payload type
|
|
var validPayloadType = payloadType == "application/vnd.in-toto+json";
|
|
checks.Add(("Payload Type", validPayloadType,
|
|
validPayloadType ? "Correct in-toto payload type" : $"Unexpected payload type: {payloadType}"));
|
|
|
|
// Check 3: Has subjects
|
|
var hasSubjects = subjects.Count > 0;
|
|
checks.Add(("Subject Presence", hasSubjects,
|
|
hasSubjects ? $"Found {subjects.Count} subject(s)" : "No subjects found in statement"));
|
|
|
|
// Check 4: Trust root verification (if provided)
|
|
var hasRoot = !string.IsNullOrWhiteSpace(rootPath) && File.Exists(rootPath);
|
|
if (hasRoot)
|
|
{
|
|
// In full implementation, would verify signature against root certificate
|
|
// For now, mark as passed if root is provided (placeholder)
|
|
checks.Add(("Signature Verification", true,
|
|
$"Trust root provided: {Path.GetFileName(rootPath)}"));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Signature Verification", false,
|
|
"No trust root provided (use --root to specify trusted certificate)"));
|
|
}
|
|
|
|
// Check 5: Transparency log (if checkpoint provided)
|
|
var hasCheckpoint = !string.IsNullOrWhiteSpace(checkpointPath) && File.Exists(checkpointPath);
|
|
if (hasCheckpoint)
|
|
{
|
|
checks.Add(("Transparency Log", true,
|
|
$"Checkpoint file provided: {Path.GetFileName(checkpointPath)}"));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Transparency Log", false,
|
|
"No transparency checkpoint provided (use --transparency-checkpoint)"));
|
|
}
|
|
|
|
// Check 6: Policy compliance (if policy provided)
|
|
var policyCompliant = true;
|
|
var policyReasons = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(policyPath))
|
|
{
|
|
if (!File.Exists(policyPath))
|
|
{
|
|
policyCompliant = false;
|
|
policyReasons.Add($"Policy file not found: {policyPath}");
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
|
|
var policy = JsonSerializer.Deserialize<JsonElement>(policyJson);
|
|
|
|
// Check required predicate types
|
|
if (policy.TryGetProperty("requiredPredicateTypes", out var requiredTypes) &&
|
|
requiredTypes.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var required = requiredTypes.EnumerateArray()
|
|
.Select(t => t.GetString())
|
|
.Where(t => t != null)
|
|
.ToList();
|
|
|
|
if (required.Count > 0 && !required.Contains(predicateType))
|
|
{
|
|
policyCompliant = false;
|
|
policyReasons.Add($"Predicate type '{predicateType}' not in required list: [{string.Join(", ", required)}]");
|
|
}
|
|
else if (required.Count > 0)
|
|
{
|
|
policyReasons.Add($"Predicate type '{predicateType}' is allowed");
|
|
}
|
|
}
|
|
|
|
// Check minimum signatures
|
|
if (policy.TryGetProperty("minimumSignatures", out var minSigs) &&
|
|
minSigs.TryGetInt32(out var minCount))
|
|
{
|
|
if (signatures.Count < minCount)
|
|
{
|
|
policyCompliant = false;
|
|
policyReasons.Add($"Insufficient signatures: {signatures.Count} < {minCount} required");
|
|
}
|
|
else
|
|
{
|
|
policyReasons.Add($"Signature count ({signatures.Count}) meets minimum ({minCount})");
|
|
}
|
|
}
|
|
|
|
// Check required signers
|
|
if (policy.TryGetProperty("requiredSigners", out var requiredSigners) &&
|
|
requiredSigners.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var required = requiredSigners.EnumerateArray()
|
|
.Select(s => s.GetString())
|
|
.Where(s => s != null)
|
|
.ToList();
|
|
|
|
var actualSigners = signatures.Select(s => s.KeyId).ToHashSet();
|
|
var missing = required.Where(r => !actualSigners.Contains(r)).ToList();
|
|
|
|
if (missing.Count > 0)
|
|
{
|
|
policyCompliant = false;
|
|
policyReasons.Add($"Missing required signers: [{string.Join(", ", missing!)}]");
|
|
}
|
|
}
|
|
|
|
if (policyReasons.Count == 0)
|
|
{
|
|
policyReasons.Add("Policy file loaded, no constraints defined");
|
|
}
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
policyCompliant = false;
|
|
policyReasons.Add($"Invalid policy JSON: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
checks.Add(("Policy Compliance", policyCompliant,
|
|
string.Join("; ", policyReasons)));
|
|
}
|
|
|
|
// Overall result
|
|
var requiredPassed = checks.Where(c => c.Check is "Envelope Structure" or "Payload Type" or "Subject Presence")
|
|
.All(c => c.Passed);
|
|
var signatureVerified = checks.FirstOrDefault(c => c.Check == "Signature Verification").Passed;
|
|
var overallStatus = requiredPassed && signatureVerified && policyCompliant ? "PASSED" : "FAILED";
|
|
|
|
// Build result object
|
|
var result = new
|
|
{
|
|
envelopePath,
|
|
verifiedAt = DateTimeOffset.UtcNow.ToString("o"),
|
|
status = overallStatus,
|
|
envelope = new
|
|
{
|
|
payloadType,
|
|
signatureCount = signatures.Count,
|
|
signers = signatures.Select(s => s.KeyId).ToList()
|
|
},
|
|
statement = new
|
|
{
|
|
predicateType = predicateType ?? "(unknown)",
|
|
subjectCount = subjects.Count,
|
|
subjects = subjects.Select(s => new
|
|
{
|
|
name = s.Name,
|
|
algorithm = s.Algorithm,
|
|
digest = s.Digest.Length > 16 ? s.Digest[..16] + "..." : s.Digest
|
|
}).ToList()
|
|
},
|
|
checks = checks.Select(c => new
|
|
{
|
|
check = c.Check,
|
|
passed = c.Passed,
|
|
reason = c.Reason
|
|
}).ToList(),
|
|
inputs = new
|
|
{
|
|
policyPath,
|
|
rootPath,
|
|
checkpointPath
|
|
}
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
|
|
// Output to file if specified
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine($"[green]Verification report written to:[/] {Markup.Escape(outputPath)}");
|
|
}
|
|
|
|
// Output to console based on format
|
|
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// JSON output to console
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
// Table output for console
|
|
var statusColor = overallStatus == "PASSED" ? "green" : "red";
|
|
AnsiConsole.MarkupLine($"[bold]Attestation Verification:[/] [{statusColor}]{overallStatus}[/{statusColor}]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Envelope: {Markup.Escape(envelopePath)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Predicate Type: {Markup.Escape(predicateType ?? "(unknown)")}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Subjects: {subjects.Count}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Signatures: {signatures.Count}[/]");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Subjects table
|
|
if (subjects.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Subjects:[/]");
|
|
var subjectTable = new Table { Border = TableBorder.Rounded };
|
|
subjectTable.AddColumn("Name");
|
|
subjectTable.AddColumn("Algorithm");
|
|
subjectTable.AddColumn("Digest");
|
|
|
|
foreach (var (name, algorithm, digest) in subjects)
|
|
{
|
|
var displayDigest = digest.Length > 20 ? digest[..20] + "..." : digest;
|
|
subjectTable.AddRow(Markup.Escape(name), Markup.Escape(algorithm), Markup.Escape(displayDigest));
|
|
}
|
|
|
|
AnsiConsole.Write(subjectTable);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Verification checks table
|
|
AnsiConsole.MarkupLine("[bold]Verification Checks:[/]");
|
|
var checksTable = new Table { Border = TableBorder.Rounded };
|
|
checksTable.AddColumn("Check");
|
|
checksTable.AddColumn("Result");
|
|
if (explain)
|
|
{
|
|
checksTable.AddColumn("Explanation");
|
|
}
|
|
|
|
foreach (var (check, passed, reason) in checks)
|
|
{
|
|
var resultText = passed ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
if (explain)
|
|
{
|
|
checksTable.AddRow(Markup.Escape(check), resultText, Markup.Escape(reason));
|
|
}
|
|
else
|
|
{
|
|
checksTable.AddRow(Markup.Escape(check), resultText);
|
|
}
|
|
}
|
|
|
|
AnsiConsole.Write(checksTable);
|
|
}
|
|
|
|
var outcome = overallStatus == "PASSED" ? "passed" : "failed";
|
|
CliMetrics.RecordAttestVerify(outcome);
|
|
|
|
return overallStatus == "PASSED" ? ExitSuccess : ExitVerificationFailed;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error parsing envelope:[/] {Markup.Escape(ex.Message)}");
|
|
CliMetrics.RecordAttestVerify("parse_error");
|
|
return ExitInputError;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error during verification:[/] {Markup.Escape(ex.Message)}");
|
|
CliMetrics.RecordAttestVerify("error");
|
|
return ExitInputError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella attest list' command (CLI-ATTEST-74-001).
|
|
/// Lists attestations with filters (subject, type, issuer, scope) and pagination.
|
|
/// </summary>
|
|
public static async Task<int> HandleAttestListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? issuer,
|
|
string? subject,
|
|
string? predicateType,
|
|
string scope,
|
|
string format,
|
|
int? limit,
|
|
int? offset,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var duration = CliMetrics.MeasureCommandDuration("attest list");
|
|
|
|
var effectiveLimit = limit ?? 50;
|
|
var effectiveOffset = offset ?? 0;
|
|
var includeLocal = scope.Equals("local", StringComparison.OrdinalIgnoreCase) ||
|
|
scope.Equals("all", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Attestation record for listing
|
|
var attestations = new List<AttestationListItem>();
|
|
|
|
// Load from local storage if scope includes local
|
|
if (includeLocal)
|
|
{
|
|
var configDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".stellaops", "attestations");
|
|
|
|
if (Directory.Exists(configDir))
|
|
{
|
|
foreach (var file in Directory.GetFiles(configDir, "*.json"))
|
|
{
|
|
try
|
|
{
|
|
var content = await File.ReadAllTextAsync(file, cancellationToken);
|
|
var envelope = JsonSerializer.Deserialize<JsonElement>(content);
|
|
|
|
// Extract attestation info
|
|
var item = new AttestationListItem
|
|
{
|
|
Id = Path.GetFileNameWithoutExtension(file),
|
|
Source = "local",
|
|
FilePath = file
|
|
};
|
|
|
|
// Parse payload to get predicate type and subjects
|
|
if (envelope.TryGetProperty("payload", out var payloadProp))
|
|
{
|
|
try
|
|
{
|
|
var payloadBytes = Convert.FromBase64String(payloadProp.GetString() ?? "");
|
|
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
|
var statement = JsonSerializer.Deserialize<JsonElement>(payloadJson);
|
|
|
|
if (statement.TryGetProperty("predicateType", out var pt))
|
|
item.PredicateType = pt.GetString();
|
|
|
|
if (statement.TryGetProperty("subject", out var subjs) &&
|
|
subjs.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var subjects = new List<string>();
|
|
foreach (var subj in subjs.EnumerateArray())
|
|
{
|
|
if (subj.TryGetProperty("name", out var name))
|
|
subjects.Add(name.GetString() ?? "");
|
|
}
|
|
item.Subjects = subjects;
|
|
}
|
|
}
|
|
catch { /* Ignore parsing errors */ }
|
|
}
|
|
|
|
// Extract signatures/issuer
|
|
if (envelope.TryGetProperty("signatures", out var sigs) &&
|
|
sigs.ValueKind == JsonValueKind.Array &&
|
|
sigs.GetArrayLength() > 0)
|
|
{
|
|
var firstSig = sigs.EnumerateArray().First();
|
|
if (firstSig.TryGetProperty("keyid", out var keyId))
|
|
item.Issuer = keyId.GetString();
|
|
item.SignatureCount = sigs.GetArrayLength();
|
|
}
|
|
|
|
// Get file timestamp
|
|
var fileInfo = new FileInfo(file);
|
|
item.CreatedAt = fileInfo.CreationTimeUtc;
|
|
|
|
attestations.Add(item);
|
|
}
|
|
catch
|
|
{
|
|
// Skip files that can't be parsed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply filters
|
|
var filtered = attestations.AsEnumerable();
|
|
|
|
if (!string.IsNullOrEmpty(tenant))
|
|
{
|
|
// Tenant filter would apply to metadata - for local files, skip
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(issuer))
|
|
{
|
|
filtered = filtered.Where(a =>
|
|
a.Issuer != null &&
|
|
a.Issuer.Contains(issuer, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(subject))
|
|
{
|
|
filtered = filtered.Where(a =>
|
|
a.Subjects?.Any(s => s.Contains(subject, StringComparison.OrdinalIgnoreCase)) == true);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(predicateType))
|
|
{
|
|
filtered = filtered.Where(a =>
|
|
a.PredicateType != null &&
|
|
a.PredicateType.Contains(predicateType, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
// Sort by creation time descending
|
|
var sorted = filtered.OrderByDescending(a => a.CreatedAt).ToList();
|
|
var total = sorted.Count;
|
|
|
|
// Apply pagination
|
|
var paginated = sorted.Skip(effectiveOffset).Take(effectiveLimit).ToList();
|
|
|
|
// Output
|
|
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var result = new
|
|
{
|
|
attestations = paginated.Select(a => new
|
|
{
|
|
id = a.Id,
|
|
source = a.Source,
|
|
predicateType = a.PredicateType,
|
|
issuer = a.Issuer,
|
|
subjects = a.Subjects,
|
|
signatureCount = a.SignatureCount,
|
|
createdAt = a.CreatedAt?.ToString("o")
|
|
}).ToList(),
|
|
pagination = new
|
|
{
|
|
total,
|
|
limit = effectiveLimit,
|
|
offset = effectiveOffset,
|
|
returned = paginated.Count
|
|
},
|
|
filters = new
|
|
{
|
|
tenant,
|
|
issuer,
|
|
subject,
|
|
predicateType,
|
|
scope
|
|
}
|
|
};
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
if (paginated.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No attestations found matching criteria.[/]");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Searched scope: {scope}[/]");
|
|
if (!string.IsNullOrEmpty(subject))
|
|
AnsiConsole.MarkupLine($"[grey]Subject filter: {Markup.Escape(subject)}[/]");
|
|
if (!string.IsNullOrEmpty(predicateType))
|
|
AnsiConsole.MarkupLine($"[grey]Type filter: {Markup.Escape(predicateType)}[/]");
|
|
if (!string.IsNullOrEmpty(issuer))
|
|
AnsiConsole.MarkupLine($"[grey]Issuer filter: {Markup.Escape(issuer)}[/]");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("ID");
|
|
table.AddColumn("Predicate Type");
|
|
table.AddColumn("Subjects");
|
|
table.AddColumn("Issuer");
|
|
table.AddColumn("Sigs");
|
|
table.AddColumn("Created (UTC)");
|
|
|
|
foreach (var a in paginated)
|
|
{
|
|
var subjectDisplay = a.Subjects?.Count > 0
|
|
? (a.Subjects.Count == 1 ? a.Subjects[0] : $"{a.Subjects[0]} (+{a.Subjects.Count - 1})")
|
|
: "-";
|
|
if (subjectDisplay.Length > 30)
|
|
subjectDisplay = subjectDisplay[..27] + "...";
|
|
|
|
var typeDisplay = a.PredicateType ?? "-";
|
|
if (typeDisplay.Length > 35)
|
|
typeDisplay = "..." + typeDisplay[^32..];
|
|
|
|
var issuerDisplay = a.Issuer ?? "-";
|
|
if (issuerDisplay.Length > 20)
|
|
issuerDisplay = issuerDisplay[..17] + "...";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(a.Id ?? "-"),
|
|
Markup.Escape(typeDisplay),
|
|
Markup.Escape(subjectDisplay),
|
|
Markup.Escape(issuerDisplay),
|
|
a.SignatureCount.ToString(),
|
|
a.CreatedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Showing {paginated.Count} of {total} attestations[/]");
|
|
|
|
if (total > effectiveOffset + effectiveLimit)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Use --offset {effectiveOffset + effectiveLimit} to see more[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attestation list item for display.
|
|
/// </summary>
|
|
private sealed class AttestationListItem
|
|
{
|
|
public string? Id { get; set; }
|
|
public string? Source { get; set; }
|
|
public string? FilePath { get; set; }
|
|
public string? PredicateType { get; set; }
|
|
public string? Issuer { get; set; }
|
|
public List<string>? Subjects { get; set; }
|
|
public int SignatureCount { get; set; }
|
|
public DateTime? CreatedAt { get; set; }
|
|
}
|
|
|
|
public static Task<int> HandleAttestShowAsync(
|
|
IServiceProvider services,
|
|
string id,
|
|
string outputFormat,
|
|
bool includeProof,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Placeholder: would fetch specific attestation from backend
|
|
var result = new Dictionary<string, object?>
|
|
{
|
|
["id"] = id,
|
|
["found"] = false,
|
|
["message"] = "Attestation lookup requires backend connectivity.",
|
|
["include_proof"] = includeProof
|
|
};
|
|
|
|
if (outputFormat.Equals("json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn("Property");
|
|
table.AddColumn("Value");
|
|
|
|
foreach (var (key, value) in result)
|
|
{
|
|
table.AddRow(Markup.Escape(key), Markup.Escape(value?.ToString() ?? "(null)"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
return Task.FromResult(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella attest fetch' command (CLI-ATTEST-74-002).
|
|
/// Downloads attestation envelopes and payloads to disk.
|
|
/// </summary>
|
|
public static async Task<int> HandleAttestFetchAsync(
|
|
IServiceProvider services,
|
|
string? id,
|
|
string? subject,
|
|
string? predicateType,
|
|
string outputDir,
|
|
string include,
|
|
string scope,
|
|
string format,
|
|
bool overwrite,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitInputError = 1;
|
|
const int ExitNotFound = 2;
|
|
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("StellaOps.Cli.AttestFetch");
|
|
|
|
using var durationScope = CliMetrics.MeasureCommandDuration("attest fetch");
|
|
|
|
// Validate at least one filter is provided
|
|
if (string.IsNullOrEmpty(id) && string.IsNullOrEmpty(subject) && string.IsNullOrEmpty(predicateType))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] At least one filter (--id, --subject, or --type) is required.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Ensure output directory exists
|
|
try
|
|
{
|
|
Directory.CreateDirectory(outputDir);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to create output directory: {Markup.Escape(ex.Message)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var effectiveScope = string.IsNullOrWhiteSpace(scope) ? "all" : scope.ToLowerInvariant();
|
|
var includeEnvelope = include.Equals("envelope", StringComparison.OrdinalIgnoreCase) ||
|
|
include.Equals("both", StringComparison.OrdinalIgnoreCase);
|
|
var includePayload = include.Equals("payload", StringComparison.OrdinalIgnoreCase) ||
|
|
include.Equals("both", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Local attestation directory
|
|
var configDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".stellaops", "attestations");
|
|
|
|
var fetchedCount = 0;
|
|
var skippedCount = 0;
|
|
var errorCount = 0;
|
|
var results = new List<(string Id, bool Success, string Details)>();
|
|
|
|
// Fetch from local storage if scope includes local
|
|
if (effectiveScope == "all" || effectiveScope == "local")
|
|
{
|
|
if (Directory.Exists(configDir))
|
|
{
|
|
foreach (var file in Directory.GetFiles(configDir, "*.json"))
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
break;
|
|
|
|
try
|
|
{
|
|
var content = await File.ReadAllTextAsync(file, cancellationToken);
|
|
var envelope = JsonDocument.Parse(content);
|
|
var root = envelope.RootElement;
|
|
|
|
var attestId = Path.GetFileNameWithoutExtension(file);
|
|
|
|
// Apply ID filter
|
|
if (!string.IsNullOrEmpty(id) &&
|
|
!attestId.Equals(id, StringComparison.OrdinalIgnoreCase) &&
|
|
!attestId.Contains(id, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Extract and check predicate type / subject from payload
|
|
string? payloadPredicateType = null;
|
|
string? payloadSubject = null;
|
|
byte[]? payloadBytes = null;
|
|
|
|
if (root.TryGetProperty("payload", out var payloadProp))
|
|
{
|
|
var payloadBase64 = payloadProp.GetString();
|
|
if (!string.IsNullOrEmpty(payloadBase64))
|
|
{
|
|
try
|
|
{
|
|
payloadBytes = Convert.FromBase64String(payloadBase64);
|
|
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
|
var payloadDoc = JsonDocument.Parse(payloadJson);
|
|
var payloadRoot = payloadDoc.RootElement;
|
|
|
|
if (payloadRoot.TryGetProperty("predicateType", out var pt))
|
|
{
|
|
payloadPredicateType = pt.GetString();
|
|
}
|
|
|
|
if (payloadRoot.TryGetProperty("subject", out var subjects) &&
|
|
subjects.ValueKind == JsonValueKind.Array &&
|
|
subjects.GetArrayLength() > 0)
|
|
{
|
|
var firstSubj = subjects[0];
|
|
if (firstSubj.TryGetProperty("name", out var name))
|
|
{
|
|
payloadSubject = name.GetString();
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Payload decode failed
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply type filter
|
|
if (!string.IsNullOrEmpty(predicateType) &&
|
|
(payloadPredicateType == null ||
|
|
!payloadPredicateType.Contains(predicateType, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Apply subject filter
|
|
if (!string.IsNullOrEmpty(subject) &&
|
|
(payloadSubject == null ||
|
|
!payloadSubject.Contains(subject, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Write envelope
|
|
if (includeEnvelope)
|
|
{
|
|
var envelopePath = Path.Combine(outputDir, $"{attestId}.envelope.json");
|
|
if (!overwrite && File.Exists(envelopePath))
|
|
{
|
|
skippedCount++;
|
|
results.Add((attestId, true, "Envelope exists, skipped"));
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Skipped:[/] {Markup.Escape(attestId)} (envelope exists)");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await File.WriteAllTextAsync(envelopePath, content, cancellationToken);
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Wrote:[/] {Markup.Escape(Path.GetFileName(envelopePath))}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write payload
|
|
if (includePayload && payloadBytes != null)
|
|
{
|
|
var extension = format.Equals("raw", StringComparison.OrdinalIgnoreCase) ? "bin" : "json";
|
|
var payloadPath = Path.Combine(outputDir, $"{attestId}.payload.{extension}");
|
|
|
|
if (!overwrite && File.Exists(payloadPath))
|
|
{
|
|
skippedCount++;
|
|
results.Add((attestId, true, "Payload exists, skipped"));
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Skipped:[/] {Markup.Escape(attestId)} (payload exists)");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Pretty-print JSON
|
|
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
|
var payloadDoc = JsonDocument.Parse(payloadJson);
|
|
var prettyJson = JsonSerializer.Serialize(payloadDoc, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(payloadPath, prettyJson, cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
await File.WriteAllBytesAsync(payloadPath, payloadBytes, cancellationToken);
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Wrote:[/] {Markup.Escape(Path.GetFileName(payloadPath))}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fetchedCount++;
|
|
results.Add((attestId, true, "Fetched successfully"));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errorCount++;
|
|
var errId = Path.GetFileNameWithoutExtension(file);
|
|
results.Add((errId, false, ex.Message));
|
|
logger.LogDebug(ex, "Failed to fetch attestation: {File}", file);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Summary output
|
|
if (fetchedCount == 0 && errorCount == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No attestations found matching the specified criteria.[/]");
|
|
return ExitNotFound;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Fetched:[/] {fetchedCount} attestation(s) to {Markup.Escape(outputDir)}");
|
|
if (skippedCount > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Skipped:[/] {skippedCount} file(s) (already exist, use --overwrite)");
|
|
}
|
|
if (errorCount > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Errors:[/] {errorCount} attestation(s) failed");
|
|
}
|
|
|
|
return ExitSuccess;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella attest key create' command (CLI-ATTEST-75-001).
|
|
/// Creates a new signing key for attestations using FileKmsClient.
|
|
/// </summary>
|
|
public static async Task<int> HandleAttestKeyCreateAsync(
|
|
IServiceProvider services,
|
|
string name,
|
|
string algorithm,
|
|
string? password,
|
|
string? outputPath,
|
|
string format,
|
|
bool exportPublic,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitInputError = 1;
|
|
const int ExitKeyError = 2;
|
|
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("StellaOps.Cli.AttestKeyCreate");
|
|
|
|
using var durationScope = CliMetrics.MeasureCommandDuration("attest key create");
|
|
|
|
// Validate algorithm
|
|
var normalizedAlgorithm = algorithm.ToUpperInvariant() switch
|
|
{
|
|
"ECDSA-P256" or "P256" or "ES256" => "ECDSA-P256",
|
|
"ECDSA-P384" or "P384" or "ES384" => "ECDSA-P384",
|
|
_ => null
|
|
};
|
|
|
|
if (normalizedAlgorithm == null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Unsupported algorithm '{Markup.Escape(algorithm)}'. Supported: ECDSA-P256, ECDSA-P384.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Determine key directory
|
|
var keysDir = outputPath ?? Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".stellaops", "keys");
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(keysDir);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to create keys directory: {Markup.Escape(ex.Message)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Get or prompt for password
|
|
var effectivePassword = password;
|
|
if (string.IsNullOrEmpty(effectivePassword))
|
|
{
|
|
effectivePassword = AnsiConsole.Prompt(
|
|
new TextPrompt<string>("Enter password for key protection:")
|
|
.Secret());
|
|
|
|
var confirm = AnsiConsole.Prompt(
|
|
new TextPrompt<string>("Confirm password:")
|
|
.Secret());
|
|
|
|
if (effectivePassword != confirm)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Passwords do not match.");
|
|
return ExitInputError;
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(effectivePassword))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Password is required to protect the key.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Use FileKmsClient to create the key
|
|
var kmsOptions = new StellaOps.Cryptography.Kms.FileKmsOptions
|
|
{
|
|
RootPath = keysDir,
|
|
Password = effectivePassword,
|
|
Algorithm = normalizedAlgorithm,
|
|
KeyDerivationIterations = 600_000
|
|
};
|
|
|
|
using var kmsClient = new StellaOps.Cryptography.Kms.FileKmsClient(kmsOptions);
|
|
|
|
// RotateAsync creates a key if it doesn't exist
|
|
var metadata = await kmsClient.RotateAsync(name, cancellationToken);
|
|
|
|
// Get the current (active) version from the versions list
|
|
var currentVersion = metadata.Versions.FirstOrDefault(v => v.State == StellaOps.Cryptography.Kms.KmsKeyState.Active);
|
|
var versionId = currentVersion?.VersionId ?? "1";
|
|
var publicKeyString = currentVersion?.PublicKey;
|
|
|
|
// Export public key if requested
|
|
string? publicKeyPath = null;
|
|
if (exportPublic && !string.IsNullOrEmpty(publicKeyString))
|
|
{
|
|
// PublicKey is already base64 encoded from the metadata
|
|
publicKeyPath = Path.Combine(keysDir, $"{name}.pub.pem");
|
|
var publicKeyPem = $"-----BEGIN PUBLIC KEY-----\n{FormatBase64ForPem(publicKeyString)}\n-----END PUBLIC KEY-----\n";
|
|
await File.WriteAllTextAsync(publicKeyPath, publicKeyPem, cancellationToken);
|
|
}
|
|
|
|
// Output result
|
|
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var result = new
|
|
{
|
|
keyId = name,
|
|
algorithm = normalizedAlgorithm,
|
|
version = versionId,
|
|
state = metadata.State.ToString(),
|
|
createdAt = metadata.CreatedAt.ToString("o"),
|
|
keyPath = Path.Combine(keysDir, $"{name}.json"),
|
|
publicKeyPath = publicKeyPath
|
|
};
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Success:[/] Key '{Markup.Escape(name)}' created.");
|
|
AnsiConsole.MarkupLine($"[grey]Algorithm:[/] {normalizedAlgorithm}");
|
|
AnsiConsole.MarkupLine($"[grey]Version:[/] {Markup.Escape(versionId)}");
|
|
AnsiConsole.MarkupLine($"[grey]State:[/] {metadata.State}");
|
|
AnsiConsole.MarkupLine($"[grey]Key path:[/] {Markup.Escape(Path.Combine(keysDir, $"{name}.json"))}");
|
|
|
|
if (publicKeyPath != null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Public key:[/] {Markup.Escape(publicKeyPath)}");
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[dim]Use --key option with 'stella attest sign' to sign attestations with this key.[/]");
|
|
}
|
|
|
|
return ExitSuccess;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to create key: {Name}", name);
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to create key: {Markup.Escape(ex.Message)}");
|
|
return ExitKeyError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Formats Base64 string for PEM output (64 chars per line).
|
|
/// </summary>
|
|
private static string FormatBase64ForPem(string base64)
|
|
{
|
|
const int lineLength = 64;
|
|
var sb = new StringBuilder();
|
|
for (int i = 0; i < base64.Length; i += lineLength)
|
|
{
|
|
var length = Math.Min(lineLength, base64.Length - i);
|
|
sb.AppendLine(base64.Substring(i, length));
|
|
}
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle the 'stella attest bundle build' command (CLI-ATTEST-75-002).
|
|
/// Builds an audit bundle from artifacts conforming to audit-bundle-index.schema.json.
|
|
/// </summary>
|
|
public static async Task<int> HandleAttestBundleBuildAsync(
|
|
IServiceProvider services,
|
|
string subjectName,
|
|
string subjectDigest,
|
|
string subjectType,
|
|
string inputDir,
|
|
string outputPath,
|
|
string? fromDate,
|
|
string? toDate,
|
|
string include,
|
|
bool compress,
|
|
string? creatorId,
|
|
string? creatorName,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitBuildFailed = 2;
|
|
const int ExitInputError = 4;
|
|
|
|
var loggerFactory = services.GetService<ILoggerFactory>();
|
|
var logger = loggerFactory?.CreateLogger("attest-bundle-build");
|
|
|
|
// Validate input directory
|
|
if (!Directory.Exists(inputDir))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Input directory not found: {Markup.Escape(inputDir)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Parse subject digest
|
|
var digestParts = subjectDigest.Split(':', 2);
|
|
if (digestParts.Length != 2 || string.IsNullOrWhiteSpace(digestParts[0]) || string.IsNullOrWhiteSpace(digestParts[1]))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Invalid digest format. Expected algorithm:hex (e.g., sha256:abc123...)");
|
|
return ExitInputError;
|
|
}
|
|
var digestAlgorithm = digestParts[0].ToLowerInvariant();
|
|
var digestValue = digestParts[1].ToLowerInvariant();
|
|
|
|
// Parse time window
|
|
DateTimeOffset? timeFrom = null;
|
|
DateTimeOffset? timeTo = null;
|
|
if (!string.IsNullOrWhiteSpace(fromDate))
|
|
{
|
|
if (!DateTimeOffset.TryParse(fromDate, out var parsed))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid --from date format: {Markup.Escape(fromDate)}");
|
|
return ExitInputError;
|
|
}
|
|
timeFrom = parsed;
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(toDate))
|
|
{
|
|
if (!DateTimeOffset.TryParse(toDate, out var parsed))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid --to date format: {Markup.Escape(toDate)}");
|
|
return ExitInputError;
|
|
}
|
|
timeTo = parsed;
|
|
}
|
|
|
|
// Validate subject type
|
|
var normalizedSubjectType = subjectType.ToUpperInvariant() switch
|
|
{
|
|
"IMAGE" => "IMAGE",
|
|
"REPO" => "REPO",
|
|
"SBOM" => "SBOM",
|
|
"OTHER" => "OTHER",
|
|
_ => null
|
|
};
|
|
if (normalizedSubjectType == null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid subject type '{Markup.Escape(subjectType)}'. Must be: IMAGE, REPO, SBOM, or OTHER.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Parse include filter
|
|
var includeTypes = include.ToLowerInvariant().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
|
|
var includeAll = includeTypes.Contains("all");
|
|
var includeAttestations = includeAll || includeTypes.Contains("attestations");
|
|
var includeSboms = includeAll || includeTypes.Contains("sboms");
|
|
var includeVex = includeAll || includeTypes.Contains("vex");
|
|
var includeScans = includeAll || includeTypes.Contains("scans");
|
|
var includePolicy = includeAll || includeTypes.Contains("policy");
|
|
|
|
try
|
|
{
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Building bundle from: {Markup.Escape(inputDir)}[/]");
|
|
}
|
|
|
|
// Generate bundle ID
|
|
var bundleId = $"bndl-{Guid.NewGuid():D}";
|
|
var createdAt = DateTimeOffset.UtcNow;
|
|
|
|
// Set creator info
|
|
var actualCreatorId = creatorId ?? Environment.UserName ?? "unknown";
|
|
var actualCreatorName = creatorName ?? Environment.UserName ?? "Unknown User";
|
|
|
|
// Collect artifacts
|
|
var artifacts = new List<object>();
|
|
var checksums = new List<string>();
|
|
var artifactCount = 0;
|
|
|
|
// Create output directory structure
|
|
var outputDir = compress ? Path.Combine(Path.GetTempPath(), $"bundle-{bundleId}") : outputPath;
|
|
Directory.CreateDirectory(outputDir);
|
|
|
|
// Subdirectories
|
|
var attestationsDir = Path.Combine(outputDir, "attestations");
|
|
var sbomsDir = Path.Combine(outputDir, "sbom");
|
|
var vexDir = Path.Combine(outputDir, "vex");
|
|
var scansDir = Path.Combine(outputDir, "reports");
|
|
var policyDir = Path.Combine(outputDir, "policy-evals");
|
|
|
|
// Process attestations
|
|
if (includeAttestations)
|
|
{
|
|
var inputAttestDir = Path.Combine(inputDir, "attestations");
|
|
if (Directory.Exists(inputAttestDir))
|
|
{
|
|
Directory.CreateDirectory(attestationsDir);
|
|
foreach (var file in Directory.GetFiles(inputAttestDir, "*.json"))
|
|
{
|
|
var info = new FileInfo(file);
|
|
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
|
|
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
|
|
|
|
var destPath = Path.Combine(attestationsDir, Path.GetFileName(file));
|
|
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
|
|
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
|
|
var relativePath = $"attestations/{Path.GetFileName(file)}";
|
|
|
|
artifacts.Add(new
|
|
{
|
|
id = $"attest-{artifactCount++}",
|
|
type = "OTHER",
|
|
source = "StellaOps",
|
|
path = relativePath,
|
|
mediaType = "application/vnd.dsse+json",
|
|
digest = new Dictionary<string, string> { ["sha256"] = hash }
|
|
});
|
|
checksums.Add($"{hash} {relativePath}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process SBOMs
|
|
if (includeSboms)
|
|
{
|
|
var inputSbomDir = Path.Combine(inputDir, "sboms");
|
|
if (!Directory.Exists(inputSbomDir)) inputSbomDir = Path.Combine(inputDir, "sbom");
|
|
if (Directory.Exists(inputSbomDir))
|
|
{
|
|
Directory.CreateDirectory(sbomsDir);
|
|
foreach (var file in Directory.GetFiles(inputSbomDir, "*.json"))
|
|
{
|
|
var info = new FileInfo(file);
|
|
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
|
|
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
|
|
|
|
var destPath = Path.Combine(sbomsDir, Path.GetFileName(file));
|
|
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
|
|
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
|
|
var relativePath = $"sbom/{Path.GetFileName(file)}";
|
|
|
|
// Detect SBOM type
|
|
var content = await File.ReadAllTextAsync(destPath, cancellationToken).ConfigureAwait(false);
|
|
var mediaType = content.Contains("CycloneDX") || content.Contains("cyclonedx") ?
|
|
"application/vnd.cyclonedx+json" :
|
|
content.Contains("spdxVersion") ? "application/spdx+json" : "application/json";
|
|
|
|
artifacts.Add(new
|
|
{
|
|
id = $"sbom-{artifactCount++}",
|
|
type = "SBOM",
|
|
source = "StellaOps",
|
|
path = relativePath,
|
|
mediaType = mediaType,
|
|
digest = new Dictionary<string, string> { ["sha256"] = hash }
|
|
});
|
|
checksums.Add($"{hash} {relativePath}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process VEX
|
|
if (includeVex)
|
|
{
|
|
var inputVexDir = Path.Combine(inputDir, "vex");
|
|
if (Directory.Exists(inputVexDir))
|
|
{
|
|
Directory.CreateDirectory(vexDir);
|
|
foreach (var file in Directory.GetFiles(inputVexDir, "*.json"))
|
|
{
|
|
var info = new FileInfo(file);
|
|
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
|
|
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
|
|
|
|
var destPath = Path.Combine(vexDir, Path.GetFileName(file));
|
|
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
|
|
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
|
|
var relativePath = $"vex/{Path.GetFileName(file)}";
|
|
|
|
artifacts.Add(new
|
|
{
|
|
id = $"vex-{artifactCount++}",
|
|
type = "VEX",
|
|
source = "StellaOps",
|
|
path = relativePath,
|
|
mediaType = "application/json",
|
|
digest = new Dictionary<string, string> { ["sha256"] = hash }
|
|
});
|
|
checksums.Add($"{hash} {relativePath}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process scans
|
|
if (includeScans)
|
|
{
|
|
var inputScansDir = Path.Combine(inputDir, "scans");
|
|
if (!Directory.Exists(inputScansDir)) inputScansDir = Path.Combine(inputDir, "reports");
|
|
if (Directory.Exists(inputScansDir))
|
|
{
|
|
Directory.CreateDirectory(scansDir);
|
|
foreach (var file in Directory.GetFiles(inputScansDir, "*.json"))
|
|
{
|
|
var info = new FileInfo(file);
|
|
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
|
|
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
|
|
|
|
var destPath = Path.Combine(scansDir, Path.GetFileName(file));
|
|
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
|
|
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
|
|
var relativePath = $"reports/{Path.GetFileName(file)}";
|
|
|
|
artifacts.Add(new
|
|
{
|
|
id = $"scan-{artifactCount++}",
|
|
type = "VULN_REPORT",
|
|
source = "StellaOps",
|
|
path = relativePath,
|
|
mediaType = "application/json",
|
|
digest = new Dictionary<string, string> { ["sha256"] = hash }
|
|
});
|
|
checksums.Add($"{hash} {relativePath}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process policy evaluations
|
|
if (includePolicy)
|
|
{
|
|
var inputPolicyDir = Path.Combine(inputDir, "policy-evals");
|
|
if (!Directory.Exists(inputPolicyDir)) inputPolicyDir = Path.Combine(inputDir, "policy");
|
|
if (Directory.Exists(inputPolicyDir))
|
|
{
|
|
Directory.CreateDirectory(policyDir);
|
|
foreach (var file in Directory.GetFiles(inputPolicyDir, "*.json"))
|
|
{
|
|
var info = new FileInfo(file);
|
|
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
|
|
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
|
|
|
|
var destPath = Path.Combine(policyDir, Path.GetFileName(file));
|
|
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
|
|
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
|
|
var relativePath = $"policy-evals/{Path.GetFileName(file)}";
|
|
|
|
artifacts.Add(new
|
|
{
|
|
id = $"policy-{artifactCount++}",
|
|
type = "POLICY_EVAL",
|
|
source = "StellaPolicyEngine",
|
|
path = relativePath,
|
|
mediaType = "application/json",
|
|
digest = new Dictionary<string, string> { ["sha256"] = hash }
|
|
});
|
|
checksums.Add($"{hash} {relativePath}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute root hash (hash of all checksums)
|
|
var checksumContent = string.Join("\n", checksums.OrderBy(c => c));
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
var checksumBytes = System.Text.Encoding.UTF8.GetBytes(checksumContent);
|
|
var rootHashBytes = sha256.ComputeHash(checksumBytes);
|
|
var rootHash = Convert.ToHexString(rootHashBytes).ToLowerInvariant();
|
|
|
|
// Build index
|
|
var index = new
|
|
{
|
|
apiVersion = "stella.ops/v1",
|
|
kind = "AuditBundleIndex",
|
|
bundleId = bundleId,
|
|
createdAt = createdAt.ToString("o"),
|
|
createdBy = new
|
|
{
|
|
id = actualCreatorId,
|
|
displayName = actualCreatorName
|
|
},
|
|
subject = new
|
|
{
|
|
type = normalizedSubjectType,
|
|
name = subjectName,
|
|
digest = new Dictionary<string, string> { [digestAlgorithm] = digestValue }
|
|
},
|
|
timeWindow = (timeFrom.HasValue || timeTo.HasValue) ? new
|
|
{
|
|
from = timeFrom?.ToString("o"),
|
|
to = timeTo?.ToString("o")
|
|
} : null,
|
|
artifacts = artifacts,
|
|
integrity = new
|
|
{
|
|
rootHash = rootHash,
|
|
hashAlgorithm = "sha256"
|
|
}
|
|
};
|
|
|
|
// Write index
|
|
var indexPath = Path.Combine(outputDir, "index.json");
|
|
var indexJson = JsonSerializer.Serialize(index, new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
|
await File.WriteAllTextAsync(indexPath, indexJson, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Write SHA256SUMS
|
|
var sumsPath = Path.Combine(outputDir, "SHA256SUMS");
|
|
await File.WriteAllTextAsync(sumsPath, checksumContent + "\n", cancellationToken).ConfigureAwait(false);
|
|
|
|
// Compress if requested
|
|
var finalPath = outputDir;
|
|
if (compress)
|
|
{
|
|
var tarPath = outputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) ? outputPath : $"{outputPath}.tar.gz";
|
|
await CreateTarGzAsync(outputDir, tarPath, cancellationToken).ConfigureAwait(false);
|
|
finalPath = tarPath;
|
|
|
|
// Cleanup temp directory
|
|
try { Directory.Delete(outputDir, true); } catch { }
|
|
}
|
|
|
|
// Record metric
|
|
CliMetrics.RecordAttestVerify("bundle-build-success");
|
|
|
|
// Output result
|
|
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var result = new
|
|
{
|
|
bundleId = bundleId,
|
|
output = finalPath,
|
|
artifactCount = artifacts.Count,
|
|
rootHash = rootHash,
|
|
compressed = compress,
|
|
createdAt = createdAt.ToString("o")
|
|
};
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Success:[/] Bundle created.");
|
|
AnsiConsole.MarkupLine($"[grey]Bundle ID:[/] {Markup.Escape(bundleId)}");
|
|
AnsiConsole.MarkupLine($"[grey]Output:[/] {Markup.Escape(finalPath)}");
|
|
AnsiConsole.MarkupLine($"[grey]Artifacts:[/] {artifacts.Count}");
|
|
AnsiConsole.MarkupLine($"[grey]Root hash:[/] {rootHash}");
|
|
if (compress)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Compressed:[/] Yes (tar.gz)");
|
|
}
|
|
}
|
|
|
|
return ExitSuccess;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Failed to build bundle");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to build bundle: {Markup.Escape(ex.Message)}");
|
|
return ExitBuildFailed;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle the 'stella attest bundle verify' command (CLI-ATTEST-75-002).
|
|
/// Verifies an audit bundle's integrity and attestation signatures.
|
|
/// </summary>
|
|
public static async Task<int> HandleAttestBundleVerifyAsync(
|
|
IServiceProvider services,
|
|
string inputPath,
|
|
string? policyPath,
|
|
string? rootPath,
|
|
string? outputPath,
|
|
string format,
|
|
bool strict,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitVerifyFailed = 2;
|
|
const int ExitInputError = 4;
|
|
|
|
var loggerFactory = services.GetService<ILoggerFactory>();
|
|
var logger = loggerFactory?.CreateLogger("attest-bundle-verify");
|
|
|
|
// Determine if input is compressed
|
|
var isCompressed = inputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) ||
|
|
inputPath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase);
|
|
var bundleDir = inputPath;
|
|
var tempDir = (string?)null;
|
|
|
|
try
|
|
{
|
|
// Extract if compressed
|
|
if (isCompressed)
|
|
{
|
|
if (!File.Exists(inputPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle file not found: {Markup.Escape(inputPath)}");
|
|
return ExitInputError;
|
|
}
|
|
tempDir = Path.Combine(Path.GetTempPath(), $"bundle-verify-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(tempDir);
|
|
await ExtractTarGzAsync(inputPath, tempDir, cancellationToken).ConfigureAwait(false);
|
|
bundleDir = tempDir;
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Extracted bundle to: {Markup.Escape(tempDir)}[/]");
|
|
}
|
|
}
|
|
else if (!Directory.Exists(inputPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle directory not found: {Markup.Escape(inputPath)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Read index
|
|
var indexPath = Path.Combine(bundleDir, "index.json");
|
|
if (!File.Exists(indexPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Bundle index.json not found.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var indexJson = await File.ReadAllTextAsync(indexPath, cancellationToken).ConfigureAwait(false);
|
|
var index = JsonSerializer.Deserialize<JsonElement>(indexJson);
|
|
|
|
// Verification checks
|
|
var checks = new List<(string Check, string Status, string Reason)>();
|
|
var hasWarnings = false;
|
|
var hasFailed = false;
|
|
|
|
// Check 1: Index structure
|
|
if (index.TryGetProperty("apiVersion", out var apiVersion) && apiVersion.GetString() == "stella.ops/v1")
|
|
{
|
|
checks.Add(("Index Structure", "PASS", "Valid apiVersion stella.ops/v1"));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Index Structure", "FAIL", "Missing or invalid apiVersion"));
|
|
hasFailed = true;
|
|
}
|
|
|
|
// Check 2: Required fields
|
|
var hasRequiredFields = index.TryGetProperty("bundleId", out _) &&
|
|
index.TryGetProperty("createdAt", out _) &&
|
|
index.TryGetProperty("subject", out _) &&
|
|
index.TryGetProperty("artifacts", out _);
|
|
if (hasRequiredFields)
|
|
{
|
|
checks.Add(("Required Fields", "PASS", "All required fields present"));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Required Fields", "FAIL", "Missing required fields in index"));
|
|
hasFailed = true;
|
|
}
|
|
|
|
// Check 3: Integrity verification (root hash)
|
|
var integrityOk = false;
|
|
if (index.TryGetProperty("integrity", out var integrity) &&
|
|
integrity.TryGetProperty("rootHash", out var rootHashElem))
|
|
{
|
|
var expectedRootHash = rootHashElem.GetString() ?? "";
|
|
|
|
// Read SHA256SUMS and compute root hash
|
|
var sumsPath = Path.Combine(bundleDir, "SHA256SUMS");
|
|
if (File.Exists(sumsPath))
|
|
{
|
|
var checksumContent = await File.ReadAllTextAsync(sumsPath, cancellationToken).ConfigureAwait(false);
|
|
checksumContent = checksumContent.TrimEnd('\n', '\r');
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
var checksumBytes = System.Text.Encoding.UTF8.GetBytes(checksumContent);
|
|
var rootHashBytes = sha256.ComputeHash(checksumBytes);
|
|
var computedRootHash = Convert.ToHexString(rootHashBytes).ToLowerInvariant();
|
|
|
|
if (computedRootHash == expectedRootHash.ToLowerInvariant())
|
|
{
|
|
checks.Add(("Root Hash Integrity", "PASS", $"Root hash matches: {expectedRootHash[..16]}..."));
|
|
integrityOk = true;
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Root Hash Integrity", "FAIL", $"Root hash mismatch. Expected: {expectedRootHash[..16]}..., Got: {computedRootHash[..16]}..."));
|
|
hasFailed = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Root Hash Integrity", "WARN", "SHA256SUMS file not found"));
|
|
hasWarnings = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Root Hash Integrity", "WARN", "No integrity data in index"));
|
|
hasWarnings = true;
|
|
}
|
|
|
|
// Check 4: Artifact checksums
|
|
var artifactsFailed = 0;
|
|
var artifactsPassed = 0;
|
|
if (index.TryGetProperty("artifacts", out var artifactsElem) && artifactsElem.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var artifact in artifactsElem.EnumerateArray())
|
|
{
|
|
if (!artifact.TryGetProperty("path", out var pathElem) ||
|
|
!artifact.TryGetProperty("digest", out var digestElem))
|
|
{
|
|
artifactsFailed++;
|
|
continue;
|
|
}
|
|
|
|
var artifactPath = pathElem.GetString() ?? "";
|
|
var fullPath = Path.Combine(bundleDir, artifactPath);
|
|
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
artifactsFailed++;
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Artifact not found: {Markup.Escape(artifactPath)}");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Check SHA256 digest
|
|
if (digestElem.TryGetProperty("sha256", out var sha256Elem))
|
|
{
|
|
var expectedHash = sha256Elem.GetString() ?? "";
|
|
var actualHash = await ComputeSha256Async(fullPath, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (actualHash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
artifactsPassed++;
|
|
}
|
|
else
|
|
{
|
|
artifactsFailed++;
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Checksum mismatch: {Markup.Escape(artifactPath)}");
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
artifactsPassed++; // No SHA256 to verify
|
|
}
|
|
}
|
|
|
|
if (artifactsFailed > 0)
|
|
{
|
|
checks.Add(("Artifact Checksums", "FAIL", $"{artifactsFailed} artifact(s) failed verification, {artifactsPassed} passed"));
|
|
hasFailed = true;
|
|
}
|
|
else if (artifactsPassed > 0)
|
|
{
|
|
checks.Add(("Artifact Checksums", "PASS", $"All {artifactsPassed} artifact(s) verified"));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Artifact Checksums", "WARN", "No artifacts to verify"));
|
|
hasWarnings = true;
|
|
}
|
|
}
|
|
|
|
// Check 5: Policy compliance (if policy provided)
|
|
if (!string.IsNullOrWhiteSpace(policyPath))
|
|
{
|
|
if (!File.Exists(policyPath))
|
|
{
|
|
checks.Add(("Policy Compliance", "FAIL", $"Policy file not found: {policyPath}"));
|
|
hasFailed = true;
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
|
|
var policy = JsonSerializer.Deserialize<JsonElement>(policyJson);
|
|
|
|
// Check required predicate types
|
|
var policyMet = true;
|
|
var policyReasons = new List<string>();
|
|
|
|
if (policy.TryGetProperty("requiredArtifactTypes", out var requiredTypes) &&
|
|
requiredTypes.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var presentTypes = new HashSet<string>();
|
|
if (index.TryGetProperty("artifacts", out var arts) && arts.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var art in arts.EnumerateArray())
|
|
{
|
|
if (art.TryGetProperty("type", out var t))
|
|
{
|
|
presentTypes.Add(t.GetString() ?? "");
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var required in requiredTypes.EnumerateArray())
|
|
{
|
|
var reqType = required.GetString() ?? "";
|
|
if (!presentTypes.Contains(reqType))
|
|
{
|
|
policyMet = false;
|
|
policyReasons.Add($"Missing required type: {reqType}");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (policy.TryGetProperty("minimumArtifacts", out var minArtifacts))
|
|
{
|
|
var count = index.TryGetProperty("artifacts", out var arts) && arts.ValueKind == JsonValueKind.Array ?
|
|
arts.GetArrayLength() : 0;
|
|
if (count < minArtifacts.GetInt32())
|
|
{
|
|
policyMet = false;
|
|
policyReasons.Add($"Minimum artifacts not met: {count} < {minArtifacts.GetInt32()}");
|
|
}
|
|
}
|
|
|
|
if (policyMet)
|
|
{
|
|
checks.Add(("Policy Compliance", "PASS", "All policy requirements satisfied"));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(("Policy Compliance", "FAIL", string.Join("; ", policyReasons)));
|
|
hasFailed = true;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
checks.Add(("Policy Compliance", "FAIL", $"Failed to parse policy: {ex.Message}"));
|
|
hasFailed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 6: Attestation signatures (if root provided)
|
|
if (!string.IsNullOrWhiteSpace(rootPath))
|
|
{
|
|
checks.Add(("Signature Verification", "WARN", "Signature verification not yet implemented; trust root provided but skipped"));
|
|
hasWarnings = true;
|
|
}
|
|
|
|
// Record metric
|
|
var outcome = hasFailed ? "bundle-verify-failed" : (hasWarnings ? "bundle-verify-warning" : "bundle-verify-success");
|
|
CliMetrics.RecordAttestVerify(outcome);
|
|
|
|
// Determine final status
|
|
var overallStatus = hasFailed ? "FAIL" : (strict && hasWarnings ? "FAIL" : (hasWarnings ? "WARN" : "PASS"));
|
|
|
|
// Write output if requested
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
var report = new
|
|
{
|
|
bundleId = index.TryGetProperty("bundleId", out var bid) ? bid.GetString() : null,
|
|
verifiedAt = DateTimeOffset.UtcNow.ToString("o"),
|
|
status = overallStatus,
|
|
checks = checks.Select(c => new { check = c.Check, status = c.Status, reason = c.Reason }).ToArray()
|
|
};
|
|
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(outputPath, reportJson, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
// Output result
|
|
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var result = new
|
|
{
|
|
bundleId = index.TryGetProperty("bundleId", out var bid) ? bid.GetString() : null,
|
|
status = overallStatus,
|
|
checks = checks.Select(c => new { check = c.Check, status = c.Status, reason = c.Reason }).ToArray()
|
|
};
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
|
}
|
|
else
|
|
{
|
|
var bundleId = index.TryGetProperty("bundleId", out var bid) ? bid.GetString() ?? "unknown" : "unknown";
|
|
AnsiConsole.MarkupLine($"[grey]Bundle ID:[/] {Markup.Escape(bundleId)}");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Check");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Reason");
|
|
|
|
foreach (var (check, status, reason) in checks)
|
|
{
|
|
var statusMarkup = status switch
|
|
{
|
|
"PASS" => "[green]PASS[/]",
|
|
"FAIL" => "[red]FAIL[/]",
|
|
"WARN" => "[yellow]WARN[/]",
|
|
_ => status
|
|
};
|
|
table.AddRow(Markup.Escape(check), statusMarkup, Markup.Escape(reason));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.WriteLine();
|
|
|
|
if (overallStatus == "PASS")
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Verification passed.[/]");
|
|
}
|
|
else if (overallStatus == "WARN")
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Verification completed with warnings.[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Verification failed.[/]");
|
|
}
|
|
}
|
|
|
|
return (hasFailed || (strict && hasWarnings)) ? ExitVerifyFailed : ExitSuccess;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Failed to verify bundle");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to verify bundle: {Markup.Escape(ex.Message)}");
|
|
return ExitInputError;
|
|
}
|
|
finally
|
|
{
|
|
// Cleanup temp directory
|
|
if (tempDir != null)
|
|
{
|
|
try { Directory.Delete(tempDir, true); } catch { }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copy file asynchronously.
|
|
/// </summary>
|
|
private static async Task CopyFileAsync(string source, string dest, CancellationToken cancellationToken)
|
|
{
|
|
using var sourceStream = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true);
|
|
using var destStream = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
|
|
await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a tar.gz archive from a directory.
|
|
/// </summary>
|
|
private static async Task CreateTarGzAsync(string sourceDir, string destPath, CancellationToken cancellationToken)
|
|
{
|
|
using var destStream = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
|
|
using var gzipStream = new System.IO.Compression.GZipStream(destStream, System.IO.Compression.CompressionLevel.Optimal);
|
|
await System.Formats.Tar.TarFile.CreateFromDirectoryAsync(sourceDir, gzipStream, false, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract a tar.gz archive to a directory.
|
|
/// </summary>
|
|
private static async Task ExtractTarGzAsync(string sourcePath, string destDir, CancellationToken cancellationToken)
|
|
{
|
|
using var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true);
|
|
using var gzipStream = new System.IO.Compression.GZipStream(sourceStream, System.IO.Compression.CompressionMode.Decompress);
|
|
await System.Formats.Tar.TarFile.ExtractToDirectoryAsync(gzipStream, destDir, true, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle the 'stella attest sign' command (CLI-ATTEST-73-001).
|
|
/// Creates and signs a DSSE attestation envelope conforming to the attestor-transport schema.
|
|
/// </summary>
|
|
public static async Task<int> HandleAttestSignAsync(
|
|
IServiceProvider services,
|
|
string predicatePath,
|
|
string predicateType,
|
|
string subjectName,
|
|
string subjectDigest,
|
|
string? keyId,
|
|
bool keyless,
|
|
bool useRekor,
|
|
string? outputPath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Exit codes per CLI spec: 0 success, 2 signing failed, 4 input error
|
|
const int ExitSuccess = 0;
|
|
const int ExitSigningFailed = 2;
|
|
const int ExitInputError = 4;
|
|
|
|
// Validate predicate file exists
|
|
if (!File.Exists(predicatePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Predicate file not found: {Markup.Escape(predicatePath)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Parse subject digest (format: algorithm:hex)
|
|
var digestParts = subjectDigest.Split(':', 2);
|
|
if (digestParts.Length != 2 || string.IsNullOrWhiteSpace(digestParts[0]) || string.IsNullOrWhiteSpace(digestParts[1]))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Invalid digest format. Expected algorithm:hex (e.g., sha256:abc123...)");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var digestAlgorithm = digestParts[0].ToLowerInvariant();
|
|
var digestValue = digestParts[1].ToLowerInvariant();
|
|
|
|
// Validate predicate type URI
|
|
if (!predicateType.StartsWith("https://", StringComparison.OrdinalIgnoreCase) &&
|
|
!predicateType.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Predicate type '{Markup.Escape(predicateType)}' is not a valid URI.");
|
|
}
|
|
|
|
try
|
|
{
|
|
// Read predicate JSON
|
|
var predicateJson = await File.ReadAllTextAsync(predicatePath, cancellationToken).ConfigureAwait(false);
|
|
var predicate = JsonSerializer.Deserialize<JsonElement>(predicateJson);
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Subject: {Markup.Escape(subjectName)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Digest: {Markup.Escape(subjectDigest)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Predicate Type: {Markup.Escape(predicateType)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Key ID: {Markup.Escape(keyId ?? "(default)")}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Keyless: {keyless}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Rekor: {useRekor}[/]");
|
|
}
|
|
|
|
// Build the in-toto statement
|
|
var statement = new Dictionary<string, object>
|
|
{
|
|
["_type"] = "https://in-toto.io/Statement/v1",
|
|
["subject"] = new[]
|
|
{
|
|
new Dictionary<string, object>
|
|
{
|
|
["name"] = subjectName,
|
|
["digest"] = new Dictionary<string, string>
|
|
{
|
|
[digestAlgorithm] = digestValue
|
|
}
|
|
}
|
|
},
|
|
["predicateType"] = predicateType,
|
|
["predicate"] = predicate
|
|
};
|
|
|
|
var statementJson = JsonSerializer.Serialize(statement, new JsonSerializerOptions { WriteIndented = false });
|
|
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
|
|
|
// Build signing options based on parameters
|
|
var signingOptions = new Dictionary<string, object?>
|
|
{
|
|
["keyId"] = keyId,
|
|
["keyless"] = keyless,
|
|
["transparencyLog"] = useRekor,
|
|
["provider"] = keyless ? "sigstore" : "default"
|
|
};
|
|
|
|
// Create the attestation request (per attestor-transport.schema.json)
|
|
var requestId = Guid.NewGuid();
|
|
var request = new Dictionary<string, object?>
|
|
{
|
|
["requestType"] = "CREATE_ATTESTATION",
|
|
["requestId"] = requestId.ToString(),
|
|
["predicateType"] = predicateType,
|
|
["subject"] = new[]
|
|
{
|
|
new Dictionary<string, object>
|
|
{
|
|
["name"] = subjectName,
|
|
["digest"] = new Dictionary<string, string>
|
|
{
|
|
[digestAlgorithm] = digestValue
|
|
}
|
|
}
|
|
},
|
|
["predicate"] = predicate,
|
|
["signingOptions"] = signingOptions
|
|
};
|
|
|
|
// For now, generate a placeholder envelope structure
|
|
// Full implementation would call into StellaOps.Attestor signing service
|
|
var signatureKeyId = keyId ?? (keyless ? "keyless:oidc" : "local:default");
|
|
var signaturePlaceholder = Convert.ToBase64String(
|
|
SHA256.HashData(Encoding.UTF8.GetBytes(payloadBase64 + signatureKeyId)));
|
|
|
|
var envelope = new Dictionary<string, object>
|
|
{
|
|
["payloadType"] = "application/vnd.in-toto+json",
|
|
["payload"] = payloadBase64,
|
|
["signatures"] = new[]
|
|
{
|
|
new Dictionary<string, string>
|
|
{
|
|
["keyid"] = signatureKeyId,
|
|
["sig"] = signaturePlaceholder
|
|
}
|
|
}
|
|
};
|
|
|
|
// Calculate envelope digest
|
|
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = false });
|
|
var envelopeDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson))).ToLowerInvariant();
|
|
envelope["envelopeDigest"] = envelopeDigest;
|
|
|
|
// Build response per attestor-transport schema
|
|
var response = new Dictionary<string, object?>
|
|
{
|
|
["responseType"] = "ATTESTATION_CREATED",
|
|
["requestId"] = requestId.ToString(),
|
|
["status"] = "SUCCESS",
|
|
["attestation"] = envelope,
|
|
["metadata"] = new Dictionary<string, object?>
|
|
{
|
|
["createdAt"] = DateTime.UtcNow.ToString("o"),
|
|
["predicateType"] = predicateType,
|
|
["subjectDigest"] = subjectDigest,
|
|
["rekorSubmitted"] = useRekor,
|
|
["signingMode"] = keyless ? "keyless" : "keyed"
|
|
}
|
|
};
|
|
|
|
// Format output
|
|
object outputObject = format.Equals("sigstore-bundle", StringComparison.OrdinalIgnoreCase)
|
|
? new Dictionary<string, object>
|
|
{
|
|
["mediaType"] = "application/vnd.dev.sigstore.bundle+json;version=0.2",
|
|
["dsseEnvelope"] = envelope,
|
|
["verificationMaterial"] = new Dictionary<string, object?>
|
|
{
|
|
["certificate"] = keyless ? "[placeholder:oidc-cert]" : null,
|
|
["tlogEntries"] = useRekor ? new[] { new Dictionary<string, object>
|
|
{
|
|
["logIndex"] = 0,
|
|
["logId"] = "[pending]",
|
|
["integratedTime"] = DateTime.UtcNow.ToString("o")
|
|
}} : Array.Empty<object>()
|
|
}
|
|
}
|
|
: envelope;
|
|
|
|
var outputJson = JsonSerializer.Serialize(outputObject, new JsonSerializerOptions { WriteIndented = true });
|
|
|
|
// Write output
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
await File.WriteAllTextAsync(outputPath, outputJson, cancellationToken).ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine($"[green]Attestation envelope written to:[/] {Markup.Escape(outputPath)}");
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Envelope digest: {envelopeDigest}[/]");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.WriteLine(outputJson);
|
|
}
|
|
|
|
// Emit metrics
|
|
CliMetrics.AttestSignCompleted(predicateType, keyless ? "keyless" : "keyed", useRekor);
|
|
|
|
return ExitSuccess;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error parsing predicate JSON:[/] {Markup.Escape(ex.Message)}");
|
|
return ExitInputError;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error during attestation signing:[/] {Markup.Escape(ex.Message)}");
|
|
return ExitSigningFailed;
|
|
}
|
|
}
|
|
|
|
private static string SanitizeFileName(string value)
|
|
{
|
|
var safe = value.Trim();
|
|
foreach (var invalid in Path.GetInvalidFileNameChars())
|
|
{
|
|
safe = safe.Replace(invalid, '_');
|
|
}
|
|
|
|
return safe;
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyLintAsync(
|
|
string filePath,
|
|
string? format,
|
|
string? outputPath,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitValidationError = 1;
|
|
const int ExitInputError = 4;
|
|
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Policy file path is required.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(filePath);
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Policy file not found: {Markup.Escape(fullPath)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
try
|
|
{
|
|
var source = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
|
var compiler = new PolicyDsl.PolicyCompiler();
|
|
var result = compiler.Compile(source);
|
|
|
|
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
|
|
|
|
var diagnosticsList = new List<Dictionary<string, object?>>();
|
|
foreach (var d in result.Diagnostics)
|
|
{
|
|
diagnosticsList.Add(new Dictionary<string, object?>
|
|
{
|
|
["severity"] = d.Severity.ToString(),
|
|
["code"] = d.Code,
|
|
["message"] = d.Message,
|
|
["path"] = d.Path
|
|
});
|
|
}
|
|
|
|
var output = new Dictionary<string, object?>
|
|
{
|
|
["file"] = fullPath,
|
|
["success"] = result.Success,
|
|
["checksum"] = result.Checksum,
|
|
["policy_name"] = result.Document?.Name,
|
|
["syntax"] = result.Document?.Syntax,
|
|
["rule_count"] = result.Document?.Rules.Length ?? 0,
|
|
["profile_count"] = result.Document?.Profiles.Length ?? 0,
|
|
["diagnostics"] = diagnosticsList
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
var json = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Output written to {Markup.Escape(outputPath)}[/]");
|
|
}
|
|
}
|
|
|
|
if (outputFormat == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
// Table format output
|
|
if (result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]✓[/] Policy [bold]{Markup.Escape(result.Document?.Name ?? "unknown")}[/] is valid.");
|
|
AnsiConsole.MarkupLine($" Syntax: {Markup.Escape(result.Document?.Syntax ?? "unknown")}");
|
|
AnsiConsole.MarkupLine($" Rules: {result.Document?.Rules.Length ?? 0}");
|
|
AnsiConsole.MarkupLine($" Profiles: {result.Document?.Profiles.Length ?? 0}");
|
|
AnsiConsole.MarkupLine($" Checksum: {Markup.Escape(result.Checksum ?? "N/A")}");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]✗[/] Policy validation failed with {result.Diagnostics.Length} diagnostic(s):");
|
|
}
|
|
|
|
if (result.Diagnostics.Length > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
var table = new Table();
|
|
table.AddColumn("Severity");
|
|
table.AddColumn("Code");
|
|
table.AddColumn("Path");
|
|
table.AddColumn("Message");
|
|
|
|
foreach (var diag in result.Diagnostics)
|
|
{
|
|
var severityColor = diag.Severity switch
|
|
{
|
|
PolicyIssueSeverity.Error => "red",
|
|
PolicyIssueSeverity.Warning => "yellow",
|
|
_ => "grey"
|
|
};
|
|
|
|
table.AddRow(
|
|
$"[{severityColor}]{diag.Severity}[/]",
|
|
diag.Code ?? "-",
|
|
diag.Path ?? "-",
|
|
Markup.Escape(diag.Message));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
}
|
|
|
|
return result.Success ? ExitSuccess : ExitValidationError;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
|
|
return ExitInputError;
|
|
}
|
|
}
|
|
|
|
#region Risk Profile Commands
|
|
|
|
public static async Task HandleRiskProfileValidateAsync(
|
|
string inputPath,
|
|
string format,
|
|
string? outputPath,
|
|
bool strict,
|
|
bool verbose)
|
|
{
|
|
_ = verbose;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.riskprofile.validate", ActivityKind.Client);
|
|
using var duration = CliMetrics.MeasureCommandDuration("risk-profile validate");
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(inputPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Input file not found: {0}", Markup.Escape(inputPath));
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var profileJson = await File.ReadAllTextAsync(inputPath).ConfigureAwait(false);
|
|
var schema = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchema();
|
|
var schemaVersion = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaVersion();
|
|
|
|
JsonNode? profileNode;
|
|
try
|
|
{
|
|
profileNode = JsonNode.Parse(profileJson);
|
|
if (profileNode is null)
|
|
{
|
|
throw new InvalidOperationException("Parsed JSON is null.");
|
|
}
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Invalid JSON: {0}", Markup.Escape(ex.Message));
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var result = schema.Evaluate(profileNode);
|
|
var issues = new List<RiskProfileValidationIssue>();
|
|
|
|
if (!result.IsValid)
|
|
{
|
|
CollectValidationIssues(result, issues);
|
|
}
|
|
|
|
var report = new RiskProfileValidationReport(
|
|
FilePath: inputPath,
|
|
IsValid: result.IsValid,
|
|
SchemaVersion: schemaVersion,
|
|
Issues: issues);
|
|
|
|
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
|
});
|
|
|
|
if (!string.IsNullOrEmpty(outputPath))
|
|
{
|
|
await File.WriteAllTextAsync(outputPath, reportJson).ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine("Validation report written to [cyan]{0}[/]", Markup.Escape(outputPath));
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(reportJson);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (result.IsValid)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]✓[/] Profile is valid (schema v{0})", schemaVersion);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]✗[/] Profile is invalid (schema v{0})", schemaVersion);
|
|
AnsiConsole.WriteLine();
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Path");
|
|
table.AddColumn("Error");
|
|
table.AddColumn("Message");
|
|
|
|
foreach (var issue in issues)
|
|
{
|
|
table.AddRow(
|
|
Markup.Escape(issue.Path),
|
|
Markup.Escape(issue.Error),
|
|
Markup.Escape(issue.Message));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
}
|
|
|
|
Environment.ExitCode = result.IsValid ? 0 : 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyEditAsync(
|
|
string filePath,
|
|
bool commit,
|
|
string? version,
|
|
string? message,
|
|
bool noValidate,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitValidationError = 1;
|
|
const int ExitInputError = 4;
|
|
const int ExitEditorError = 5;
|
|
const int ExitGitError = 6;
|
|
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Policy file path is required.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(filePath);
|
|
var fileExists = File.Exists(fullPath);
|
|
|
|
// Determine editor from environment
|
|
var editor = Environment.GetEnvironmentVariable("EDITOR")
|
|
?? Environment.GetEnvironmentVariable("VISUAL")
|
|
?? (OperatingSystem.IsWindows() ? "notepad" : "vi");
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Using editor: {Markup.Escape(editor)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]File path: {Markup.Escape(fullPath)}[/]");
|
|
}
|
|
|
|
// Read original content for change detection
|
|
string? originalContent = null;
|
|
if (fileExists)
|
|
{
|
|
originalContent = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
// Launch editor
|
|
try
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = editor,
|
|
Arguments = $"\"{fullPath}\"",
|
|
UseShellExecute = true,
|
|
CreateNoWindow = false
|
|
};
|
|
|
|
using var process = Process.Start(startInfo);
|
|
if (process == null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to start editor '{Markup.Escape(editor)}'.");
|
|
return ExitEditorError;
|
|
}
|
|
|
|
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
if (process.ExitCode != 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Editor exited with code {process.ExitCode}.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to launch editor: {Markup.Escape(ex.Message)}");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.WriteException(ex);
|
|
}
|
|
return ExitEditorError;
|
|
}
|
|
|
|
// Check if file was created/modified
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No file created. Exiting.[/]");
|
|
return ExitSuccess;
|
|
}
|
|
|
|
var newContent = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
|
if (originalContent != null && originalContent == newContent)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No changes detected.[/]");
|
|
return ExitSuccess;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[green]File modified.[/]");
|
|
|
|
// Validate unless skipped
|
|
if (!noValidate)
|
|
{
|
|
var compiler = new PolicyDsl.PolicyCompiler();
|
|
var result = compiler.Compile(newContent);
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]✗[/] Validation failed with {result.Diagnostics.Length} diagnostic(s):");
|
|
var table = new Table();
|
|
table.AddColumn("Severity");
|
|
table.AddColumn("Code");
|
|
table.AddColumn("Message");
|
|
|
|
foreach (var diag in result.Diagnostics)
|
|
{
|
|
var color = diag.Severity == PolicyIssueSeverity.Error ? "red" : "yellow";
|
|
table.AddRow($"[{color}]{diag.Severity}[/]", diag.Code ?? "-", Markup.Escape(diag.Message));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine("[yellow]Changes saved but not committed due to validation errors.[/]");
|
|
return ExitValidationError;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]✓[/] Policy [bold]{Markup.Escape(result.Document?.Name ?? "unknown")}[/] is valid.");
|
|
AnsiConsole.MarkupLine($" Checksum: {Markup.Escape(result.Checksum ?? "N/A")}");
|
|
}
|
|
|
|
// Commit if requested
|
|
if (commit)
|
|
{
|
|
var gitDir = FindGitDirectory(fullPath);
|
|
if (gitDir == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Not inside a git repository. Cannot commit.");
|
|
return ExitGitError;
|
|
}
|
|
|
|
var relativePath = Path.GetRelativePath(gitDir, fullPath);
|
|
var commitMessage = message ?? GeneratePolicyCommitMessage(relativePath, version);
|
|
|
|
try
|
|
{
|
|
// Stage the file
|
|
var addResult = await RunGitCommandAsync(gitDir, $"add \"{relativePath}\"", cancellationToken).ConfigureAwait(false);
|
|
if (addResult.ExitCode != 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] git add failed: {Markup.Escape(addResult.Output)}");
|
|
return ExitGitError;
|
|
}
|
|
|
|
// Commit with SemVer metadata in trailer
|
|
var trailers = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(version))
|
|
{
|
|
trailers.Add($"Policy-Version: {version}");
|
|
}
|
|
|
|
var trailerArgs = trailers.Count > 0
|
|
? string.Join(" ", trailers.Select(t => $"--trailer \"{t}\""))
|
|
: string.Empty;
|
|
|
|
var commitResult = await RunGitCommandAsync(gitDir, $"commit -m \"{commitMessage}\" {trailerArgs}", cancellationToken).ConfigureAwait(false);
|
|
if (commitResult.ExitCode != 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] git commit failed: {Markup.Escape(commitResult.Output)}");
|
|
return ExitGitError;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]✓[/] Committed: {Markup.Escape(commitMessage)}");
|
|
if (!string.IsNullOrWhiteSpace(version))
|
|
{
|
|
AnsiConsole.MarkupLine($" Policy-Version: {Markup.Escape(version)}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Git operation failed: {Markup.Escape(ex.Message)}");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.WriteException(ex);
|
|
}
|
|
return ExitGitError;
|
|
}
|
|
}
|
|
|
|
return ExitSuccess;
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyTestAsync(
|
|
string filePath,
|
|
string? fixturesPath,
|
|
string? filter,
|
|
string? format,
|
|
string? outputPath,
|
|
bool failFast,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitTestFailure = 1;
|
|
const int ExitInputError = 4;
|
|
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Policy file path is required.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(filePath);
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Policy file not found: {Markup.Escape(fullPath)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Compile the policy first
|
|
var source = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
|
var compiler = new PolicyDsl.PolicyCompiler();
|
|
var compileResult = compiler.Compile(source);
|
|
|
|
if (!compileResult.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Policy compilation failed. Run 'stella policy lint' for details.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var policyName = compileResult.Document?.Name ?? Path.GetFileNameWithoutExtension(fullPath);
|
|
|
|
// Determine fixtures directory
|
|
var fixturesDir = fixturesPath;
|
|
if (string.IsNullOrWhiteSpace(fixturesDir))
|
|
{
|
|
var policyDir = Path.GetDirectoryName(fullPath) ?? ".";
|
|
fixturesDir = Path.Combine(policyDir, "..", "..", "tests", "policy", policyName, "cases");
|
|
if (!Directory.Exists(fixturesDir))
|
|
{
|
|
// Try relative to current directory
|
|
fixturesDir = Path.Combine("tests", "policy", policyName, "cases");
|
|
}
|
|
}
|
|
|
|
fixturesDir = Path.GetFullPath(fixturesDir);
|
|
|
|
if (!Directory.Exists(fixturesDir))
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]No fixtures directory found at {Markup.Escape(fixturesDir)}[/]");
|
|
AnsiConsole.MarkupLine("[grey]Create test fixtures as JSON files in this directory.[/]");
|
|
return ExitSuccess;
|
|
}
|
|
|
|
var fixtureFiles = Directory.GetFiles(fixturesDir, "*.json", SearchOption.AllDirectories);
|
|
if (!string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
fixtureFiles = fixtureFiles.Where(f => Path.GetFileName(f).Contains(filter, StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
}
|
|
|
|
if (fixtureFiles.Length == 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]No fixture files found in {Markup.Escape(fixturesDir)}[/]");
|
|
return ExitSuccess;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Found {fixtureFiles.Length} fixture file(s)[/]");
|
|
}
|
|
|
|
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
|
|
var results = new List<Dictionary<string, object?>>();
|
|
var passed = 0;
|
|
var failed = 0;
|
|
var skipped = 0;
|
|
|
|
foreach (var fixtureFile in fixtureFiles)
|
|
{
|
|
var fixtureName = Path.GetRelativePath(fixturesDir, fixtureFile);
|
|
|
|
try
|
|
{
|
|
var fixtureJson = await File.ReadAllTextAsync(fixtureFile, cancellationToken).ConfigureAwait(false);
|
|
var fixture = JsonSerializer.Deserialize<PolicyTestFixture>(fixtureJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
|
|
if (fixture == null)
|
|
{
|
|
results.Add(new Dictionary<string, object?>
|
|
{
|
|
["fixture"] = fixtureName,
|
|
["status"] = "skipped",
|
|
["reason"] = "Invalid fixture format"
|
|
});
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Run the test case (simplified evaluation stub)
|
|
var testPassed = RunPolicyTestCase(compileResult.Document!, fixture, verbose);
|
|
|
|
results.Add(new Dictionary<string, object?>
|
|
{
|
|
["fixture"] = fixtureName,
|
|
["status"] = testPassed ? "passed" : "failed",
|
|
["expected_outcome"] = fixture.ExpectedOutcome,
|
|
["description"] = fixture.Description
|
|
});
|
|
|
|
if (testPassed)
|
|
{
|
|
passed++;
|
|
}
|
|
else
|
|
{
|
|
failed++;
|
|
if (failFast)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]✗[/] {Markup.Escape(fixtureName)} - Stopping on first failure.");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
results.Add(new Dictionary<string, object?>
|
|
{
|
|
["fixture"] = fixtureName,
|
|
["status"] = "error",
|
|
["reason"] = ex.Message
|
|
});
|
|
failed++;
|
|
|
|
if (failFast)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Output results
|
|
var summary = new Dictionary<string, object?>
|
|
{
|
|
["policy"] = policyName,
|
|
["policy_checksum"] = compileResult.Checksum,
|
|
["fixtures_dir"] = fixturesDir,
|
|
["total"] = results.Count,
|
|
["passed"] = passed,
|
|
["failed"] = failed,
|
|
["skipped"] = skipped,
|
|
["results"] = results
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
var json = JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Output written to {Markup.Escape(outputPath)}[/]");
|
|
}
|
|
}
|
|
|
|
if (outputFormat == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true });
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"\n[bold]Test Results for {Markup.Escape(policyName)}[/]\n");
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Fixture");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Description");
|
|
|
|
foreach (var r in results)
|
|
{
|
|
var status = r["status"]?.ToString() ?? "unknown";
|
|
var statusColor = status switch
|
|
{
|
|
"passed" => "green",
|
|
"failed" => "red",
|
|
"skipped" => "yellow",
|
|
_ => "grey"
|
|
};
|
|
var statusIcon = status switch
|
|
{
|
|
"passed" => "✓",
|
|
"failed" => "✗",
|
|
"skipped" => "○",
|
|
_ => "?"
|
|
};
|
|
|
|
table.AddRow(
|
|
Markup.Escape(r["fixture"]?.ToString() ?? "-"),
|
|
$"[{statusColor}]{statusIcon} {status}[/]",
|
|
Markup.Escape(r["description"]?.ToString() ?? r["reason"]?.ToString() ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[bold]Summary:[/] {passed} passed, {failed} failed, {skipped} skipped");
|
|
}
|
|
|
|
return failed > 0 ? ExitTestFailure : ExitSuccess;
|
|
}
|
|
|
|
private static string? FindGitDirectory(string startPath)
|
|
{
|
|
var dir = Path.GetDirectoryName(startPath);
|
|
while (!string.IsNullOrEmpty(dir))
|
|
{
|
|
if (Directory.Exists(Path.Combine(dir, ".git")))
|
|
{
|
|
return dir;
|
|
}
|
|
dir = Path.GetDirectoryName(dir);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static string GeneratePolicyCommitMessage(string relativePath, string? version)
|
|
{
|
|
var fileName = Path.GetFileNameWithoutExtension(relativePath);
|
|
var versionSuffix = !string.IsNullOrWhiteSpace(version) ? $" (v{version})" : "";
|
|
return $"policy: update {fileName}{versionSuffix}";
|
|
}
|
|
|
|
private static async Task<(int ExitCode, string Output)> RunGitCommandAsync(string workingDir, string arguments, CancellationToken cancellationToken)
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "git",
|
|
Arguments = arguments,
|
|
WorkingDirectory = workingDir,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
};
|
|
|
|
using var process = new Process { StartInfo = startInfo };
|
|
var outputBuilder = new StringBuilder();
|
|
var errorBuilder = new StringBuilder();
|
|
|
|
process.OutputDataReceived += (_, e) => { if (e.Data != null) outputBuilder.AppendLine(e.Data); };
|
|
process.ErrorDataReceived += (_, e) => { if (e.Data != null) errorBuilder.AppendLine(e.Data); };
|
|
|
|
process.Start();
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var output = outputBuilder.ToString();
|
|
var error = errorBuilder.ToString();
|
|
return (process.ExitCode, string.IsNullOrWhiteSpace(error) ? output : error);
|
|
}
|
|
|
|
private static bool RunPolicyTestCase(PolicyDsl.PolicyIrDocument document, PolicyTestFixture fixture, bool verbose)
|
|
{
|
|
// Simplified test evaluation - in production this would use PolicyEvaluator
|
|
// For now, just check that the fixture structure is valid and expected outcome is defined
|
|
if (string.IsNullOrWhiteSpace(fixture.ExpectedOutcome))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Basic validation that the policy has rules that could match the fixture's scenario
|
|
if (document.Rules.Length == 0)
|
|
{
|
|
return fixture.ExpectedOutcome.Equals("pass", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// Stub: In full implementation, this would:
|
|
// 1. Build evaluation context from fixture.Input
|
|
// 2. Run PolicyEvaluator.Evaluate(document, context)
|
|
// 3. Compare results to fixture.ExpectedOutcome and fixture.ExpectedFindings
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey] Evaluating fixture against {document.Rules.Length} rule(s)[/]");
|
|
}
|
|
|
|
// For now, assume pass if expected_outcome is defined
|
|
return true;
|
|
}
|
|
|
|
private sealed class PolicyTestFixture
|
|
{
|
|
public string? Description { get; set; }
|
|
public string? ExpectedOutcome { get; set; }
|
|
public JsonElement? Input { get; set; }
|
|
public JsonElement? ExpectedFindings { get; set; }
|
|
}
|
|
|
|
public static async Task HandleRiskProfileSchemaAsync(string? outputPath, bool verbose)
|
|
{
|
|
_ = verbose;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.riskprofile.schema", ActivityKind.Client);
|
|
using var duration = CliMetrics.MeasureCommandDuration("risk-profile schema");
|
|
|
|
try
|
|
{
|
|
var schemaText = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaText();
|
|
var schemaVersion = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaVersion();
|
|
|
|
if (!string.IsNullOrEmpty(outputPath))
|
|
{
|
|
await File.WriteAllTextAsync(outputPath, schemaText).ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine("Risk profile schema v{0} written to [cyan]{1}[/]", schemaVersion, Markup.Escape(outputPath));
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(schemaText);
|
|
}
|
|
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
|
|
private static void CollectValidationIssues(
|
|
Json.Schema.EvaluationResults results,
|
|
List<RiskProfileValidationIssue> issues,
|
|
string path = "")
|
|
{
|
|
if (results.Errors is not null)
|
|
{
|
|
foreach (var (key, message) in results.Errors)
|
|
{
|
|
var instancePath = results.InstanceLocation?.ToString() ?? path;
|
|
issues.Add(new RiskProfileValidationIssue(instancePath, key, message));
|
|
}
|
|
}
|
|
|
|
if (results.Details is not null)
|
|
{
|
|
foreach (var detail in results.Details)
|
|
{
|
|
if (!detail.IsValid)
|
|
{
|
|
CollectValidationIssues(detail, issues, detail.InstanceLocation?.ToString() ?? path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed record RiskProfileValidationReport(
|
|
string FilePath,
|
|
bool IsValid,
|
|
string SchemaVersion,
|
|
IReadOnlyList<RiskProfileValidationIssue> Issues);
|
|
|
|
private sealed record RiskProfileValidationIssue(string Path, string Error, string Message);
|
|
|
|
// CLI-POLICY-20-001: policy new handler
|
|
|
|
public static async Task<int> HandlePolicyNewAsync(
|
|
string name,
|
|
string? templateName,
|
|
string? outputPath,
|
|
string? description,
|
|
string[] tags,
|
|
bool shadowMode,
|
|
bool createFixtures,
|
|
bool gitInit,
|
|
string? format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitInputError = 4;
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Policy name is required.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Sanitize name for file
|
|
var safeName = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
|
var template = ParsePolicyTemplate(templateName);
|
|
var finalPath = outputPath ?? $"./{safeName}.stella";
|
|
finalPath = Path.GetFullPath(finalPath);
|
|
|
|
if (File.Exists(finalPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] File already exists: {Markup.Escape(finalPath)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
// Generate policy content
|
|
var policyContent = GeneratePolicyFromTemplate(name, template, description, tags, shadowMode);
|
|
|
|
// Write the policy file
|
|
var directory = Path.GetDirectoryName(finalPath);
|
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
await File.WriteAllTextAsync(finalPath, policyContent, cancellationToken).ConfigureAwait(false);
|
|
|
|
string? fixturesDir = null;
|
|
if (createFixtures)
|
|
{
|
|
fixturesDir = Path.Combine(directory ?? ".", "tests", "policy", safeName, "cases");
|
|
Directory.CreateDirectory(fixturesDir);
|
|
|
|
// Create a sample fixture
|
|
var sampleFixture = GenerateSampleFixture(safeName);
|
|
var fixturePath = Path.Combine(fixturesDir, "sample_test.json");
|
|
await File.WriteAllTextAsync(fixturePath, sampleFixture, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
if (gitInit && !string.IsNullOrEmpty(directory))
|
|
{
|
|
var gitDir = Path.Combine(directory, ".git");
|
|
if (!Directory.Exists(gitDir))
|
|
{
|
|
await RunGitCommandAsync(directory, "init", cancellationToken).ConfigureAwait(false);
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]Initialized Git repository[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build result
|
|
var result = new PolicyNewResult
|
|
{
|
|
Success = true,
|
|
PolicyPath = finalPath,
|
|
FixturesPath = fixturesDir,
|
|
Template = template.ToString(),
|
|
SyntaxVersion = "stella-dsl@1"
|
|
};
|
|
|
|
// Output
|
|
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
|
|
|
|
if (outputFormat == "json")
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
}
|
|
else
|
|
{
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy Created[/]", "[green]Success[/]")
|
|
.AddRow("[bold]Path[/]", Markup.Escape(finalPath))
|
|
.AddRow("[bold]Template[/]", Markup.Escape(template.ToString()))
|
|
.AddRow("[bold]Syntax[/]", "stella-dsl@1")
|
|
.AddRow("[bold]Shadow Mode[/]", shadowMode ? "[yellow]Enabled[/]" : "[dim]Disabled[/]");
|
|
|
|
if (!string.IsNullOrEmpty(fixturesDir))
|
|
{
|
|
grid.AddRow("[bold]Fixtures[/]", Markup.Escape(fixturesDir));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("New Policy") });
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[grey]Next steps:[/]");
|
|
AnsiConsole.MarkupLine($" 1. Edit policy: [cyan]stella policy edit {Markup.Escape(finalPath)}[/]");
|
|
AnsiConsole.MarkupLine($" 2. Validate: [cyan]stella policy lint {Markup.Escape(finalPath)}[/]");
|
|
if (createFixtures)
|
|
{
|
|
AnsiConsole.MarkupLine($" 3. Run tests: [cyan]stella policy test {Markup.Escape(finalPath)}[/]");
|
|
}
|
|
}
|
|
|
|
return ExitSuccess;
|
|
}
|
|
|
|
private static PolicyTemplate ParsePolicyTemplate(string? name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return PolicyTemplate.Minimal;
|
|
|
|
return name.ToLowerInvariant() switch
|
|
{
|
|
"minimal" => PolicyTemplate.Minimal,
|
|
"baseline" => PolicyTemplate.Baseline,
|
|
"vex-precedence" or "vex" => PolicyTemplate.VexPrecedence,
|
|
"reachability" => PolicyTemplate.Reachability,
|
|
"secret-leak" or "secrets" => PolicyTemplate.SecretLeak,
|
|
"full" => PolicyTemplate.Full,
|
|
_ => PolicyTemplate.Minimal
|
|
};
|
|
}
|
|
|
|
private static string GeneratePolicyFromTemplate(string name, PolicyTemplate template, string? description, string[] tags, bool shadowMode)
|
|
{
|
|
var sb = new StringBuilder();
|
|
var desc = description ?? $"Policy for {name}";
|
|
var tagList = tags.Length > 0 ? string.Join(", ", tags.Select(t => $"\"{t}\"")) : "\"custom\"";
|
|
|
|
sb.AppendLine($"policy \"{name}\" syntax \"stella-dsl@1\" {{");
|
|
sb.AppendLine(" metadata {");
|
|
sb.AppendLine($" description = \"{desc}\"");
|
|
sb.AppendLine($" tags = [{tagList}]");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
|
|
if (shadowMode)
|
|
{
|
|
sb.AppendLine(" settings {");
|
|
sb.AppendLine(" shadow = true;");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
switch (template)
|
|
{
|
|
case PolicyTemplate.Baseline:
|
|
sb.AppendLine(" profile severity {");
|
|
sb.AppendLine(" map vendor_weight {");
|
|
sb.AppendLine(" source \"GHSA\" => +0.5;");
|
|
sb.AppendLine(" source \"OSV\" => +0.0;");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
sb.AppendLine(" rule advisory_normalization {");
|
|
sb.AppendLine(" when advisory.source in [\"GHSA\", \"OSV\"]");
|
|
sb.AppendLine(" then severity := normalize_cvss(advisory)");
|
|
sb.AppendLine(" because \"Align vendor severity to CVSS baseline\";");
|
|
sb.AppendLine(" }");
|
|
break;
|
|
|
|
case PolicyTemplate.VexPrecedence:
|
|
sb.AppendLine(" rule vex_strong_claim priority 5 {");
|
|
sb.AppendLine(" when vex.any(status == \"not_affected\")");
|
|
sb.AppendLine(" and vex.justification in [\"component_not_present\", \"vulnerable_code_not_present\"]");
|
|
sb.AppendLine(" then status := vex.status");
|
|
sb.AppendLine(" annotate winning_statement := vex.latest().statementId");
|
|
sb.AppendLine(" because \"Strong VEX justification\";");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
sb.AppendLine(" rule vex_fixed priority 10 {");
|
|
sb.AppendLine(" when vex.any(status == \"fixed\")");
|
|
sb.AppendLine(" then status := \"fixed\"");
|
|
sb.AppendLine(" because \"Vendor confirms fix available\";");
|
|
sb.AppendLine(" }");
|
|
break;
|
|
|
|
case PolicyTemplate.Reachability:
|
|
sb.AppendLine(" rule reachability_gate priority 20 {");
|
|
sb.AppendLine(" when exists(telemetry.reachability)");
|
|
sb.AppendLine(" and telemetry.reachability.state == \"reachable\"");
|
|
sb.AppendLine(" and telemetry.reachability.score >= 0.6");
|
|
sb.AppendLine(" then status := \"affected\"");
|
|
sb.AppendLine(" because \"Runtime/graph evidence shows reachable code path\";");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
sb.AppendLine(" rule unreachable_downgrade priority 25 {");
|
|
sb.AppendLine(" when exists(telemetry.reachability)");
|
|
sb.AppendLine(" and telemetry.reachability.state == \"unreachable\"");
|
|
sb.AppendLine(" then severity := severity - 1.0");
|
|
sb.AppendLine(" annotate reason := \"Unreachable code path\"");
|
|
sb.AppendLine(" because \"Reduce severity for unreachable vulnerabilities\";");
|
|
sb.AppendLine(" }");
|
|
break;
|
|
|
|
case PolicyTemplate.SecretLeak:
|
|
sb.AppendLine(" rule secret_detection priority 1 {");
|
|
sb.AppendLine(" when secret.hasFinding()");
|
|
sb.AppendLine(" then status := \"affected\"");
|
|
sb.AppendLine(" escalate to severity_band(\"critical\")");
|
|
sb.AppendLine(" because \"Secret leak detected in codebase\";");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
sb.AppendLine(" rule secret_allowlist priority 2 {");
|
|
sb.AppendLine(" when secret.hasFinding()");
|
|
sb.AppendLine(" and secret.path.allowlist([\"**/test/**\", \"**/fixtures/**\"])");
|
|
sb.AppendLine(" then ignore until \"2099-12-31T23:59:59Z\"");
|
|
sb.AppendLine(" because \"Test fixtures may contain example secrets\";");
|
|
sb.AppendLine(" }");
|
|
break;
|
|
|
|
case PolicyTemplate.Full:
|
|
sb.AppendLine(" profile severity {");
|
|
sb.AppendLine(" map vendor_weight {");
|
|
sb.AppendLine(" source \"GHSA\" => +0.5;");
|
|
sb.AppendLine(" source \"OSV\" => +0.0;");
|
|
sb.AppendLine(" source \"VendorX\" => -0.2;");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine(" env exposure_adjustments {");
|
|
sb.AppendLine(" if env.runtime == \"serverless\" then -0.5;");
|
|
sb.AppendLine(" if env.exposure == \"internal-only\" then -1.0;");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
sb.AppendLine(" rule vex_precedence priority 10 {");
|
|
sb.AppendLine(" when vex.any(status in [\"not_affected\", \"fixed\"])");
|
|
sb.AppendLine(" and vex.justification in [\"component_not_present\", \"vulnerable_code_not_present\"]");
|
|
sb.AppendLine(" then status := vex.status");
|
|
sb.AppendLine(" because \"Strong vendor justification prevails\";");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
sb.AppendLine(" rule reachability_gate priority 20 {");
|
|
sb.AppendLine(" when exists(telemetry.reachability)");
|
|
sb.AppendLine(" and telemetry.reachability.state == \"reachable\"");
|
|
sb.AppendLine(" and telemetry.reachability.score >= 0.6");
|
|
sb.AppendLine(" then status := \"affected\"");
|
|
sb.AppendLine(" because \"Runtime/graph evidence shows reachable code path\";");
|
|
sb.AppendLine(" }");
|
|
sb.AppendLine();
|
|
sb.AppendLine(" rule trust_penalty priority 30 {");
|
|
sb.AppendLine(" when signals.trust_score < 0.4 or signals.entropy_penalty > 0.2");
|
|
sb.AppendLine(" then severity := severity_band(\"critical\")");
|
|
sb.AppendLine(" because \"Low trust score or high entropy\";");
|
|
sb.AppendLine(" }");
|
|
break;
|
|
|
|
case PolicyTemplate.Minimal:
|
|
default:
|
|
sb.AppendLine(" // Add your rules here");
|
|
sb.AppendLine(" // Example:");
|
|
sb.AppendLine(" // rule example_rule priority 10 {");
|
|
sb.AppendLine(" // when advisory.severity >= \"High\"");
|
|
sb.AppendLine(" // then status := \"affected\"");
|
|
sb.AppendLine(" // because \"High severity findings require attention\";");
|
|
sb.AppendLine(" // }");
|
|
break;
|
|
}
|
|
|
|
sb.AppendLine("}");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string GenerateSampleFixture(string policyName)
|
|
{
|
|
return JsonSerializer.Serialize(new
|
|
{
|
|
description = $"Sample test case for {policyName}",
|
|
expected_outcome = "pass",
|
|
input = new
|
|
{
|
|
sbom = new { purl = "pkg:npm/lodash@4.17.21", name = "lodash", version = "4.17.21" },
|
|
advisory = new { id = "GHSA-test-1234", source = "GHSA", severity = "High" },
|
|
vex = Array.Empty<object>(),
|
|
env = new { runtime = "nodejs", exposure = "internet" }
|
|
},
|
|
expected_findings = new[]
|
|
{
|
|
new { status = "affected", severity = "High" }
|
|
}
|
|
}, new JsonSerializerOptions { WriteIndented = true });
|
|
}
|
|
|
|
// CLI-POLICY-23-006: policy history handler
|
|
|
|
public static async Task<int> HandlePolicyHistoryAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
string? tenant,
|
|
string? from,
|
|
string? to,
|
|
string? status,
|
|
int? limit,
|
|
string? cursor,
|
|
string? format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy history"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
DateTimeOffset? fromDate = null;
|
|
DateTimeOffset? toDate = null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(from) && DateTimeOffset.TryParse(from, out var parsedFrom))
|
|
{
|
|
fromDate = parsedFrom;
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(to) && DateTimeOffset.TryParse(to, out var parsedTo))
|
|
{
|
|
toDate = parsedTo;
|
|
}
|
|
|
|
var request = new PolicyHistoryRequest
|
|
{
|
|
PolicyId = policyId,
|
|
Tenant = tenant,
|
|
From = fromDate,
|
|
To = toDate,
|
|
Status = status,
|
|
Limit = limit,
|
|
Cursor = cursor
|
|
};
|
|
|
|
var response = await client.GetPolicyHistoryAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
|
|
|
|
if (outputFormat == "json")
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Items.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No policy runs found matching the criteria.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Run ID");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Started");
|
|
table.AddColumn("Duration");
|
|
table.AddColumn("SBOMs");
|
|
table.AddColumn("Findings");
|
|
table.AddColumn("Changed");
|
|
|
|
foreach (var run in response.Items)
|
|
{
|
|
var statusColor = run.Status.ToLowerInvariant() switch
|
|
{
|
|
"completed" => "green",
|
|
"failed" => "red",
|
|
"running" => "yellow",
|
|
_ => "dim"
|
|
};
|
|
|
|
var durationStr = run.Duration.HasValue
|
|
? $"{run.Duration.Value.TotalSeconds:F1}s"
|
|
: "-";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(run.RunId.Length > 12 ? run.RunId[..12] + "..." : run.RunId),
|
|
$"v{run.PolicyVersion}",
|
|
$"[{statusColor}]{Markup.Escape(run.Status)}[/]",
|
|
run.StartedAt.ToString("yyyy-MM-dd HH:mm"),
|
|
durationStr,
|
|
run.SbomCount.ToString(),
|
|
run.FindingsGenerated.ToString(),
|
|
run.FindingsChanged > 0 ? $"[yellow]{run.FindingsChanged}[/]" : "0");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(table) { Header = new PanelHeader($"Policy Runs: {policyId}") });
|
|
|
|
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor {Markup.Escape(response.NextCursor)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
// CLI-POLICY-23-006: policy explain tree handler
|
|
|
|
public static async Task<int> HandlePolicyExplainTreeAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
string? runId,
|
|
string? findingId,
|
|
string? sbomId,
|
|
string? purl,
|
|
string? advisory,
|
|
string? tenant,
|
|
int? depth,
|
|
string? format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy explain"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyExplainRequest
|
|
{
|
|
PolicyId = policyId,
|
|
RunId = runId,
|
|
FindingId = findingId,
|
|
SbomId = sbomId,
|
|
ComponentPurl = purl,
|
|
AdvisoryId = advisory,
|
|
Tenant = tenant,
|
|
Depth = depth
|
|
};
|
|
|
|
var result = await client.GetPolicyExplainAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
|
|
|
|
if (outputFormat == "json")
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var err in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(err)}[/]");
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// Header panel
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.PolicyVersion}")
|
|
.AddRow("[bold]Timestamp[/]", result.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.RunId))
|
|
{
|
|
grid.AddRow("[bold]Run ID[/]", Markup.Escape(result.RunId));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Policy Explain") });
|
|
|
|
// Subject
|
|
if (result.Subject != null)
|
|
{
|
|
var subjectGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn();
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.Subject.ComponentPurl))
|
|
subjectGrid.AddRow("[bold]PURL[/]", Markup.Escape(result.Subject.ComponentPurl));
|
|
if (!string.IsNullOrWhiteSpace(result.Subject.ComponentName))
|
|
subjectGrid.AddRow("[bold]Component[/]", $"{Markup.Escape(result.Subject.ComponentName)}@{Markup.Escape(result.Subject.ComponentVersion ?? "?")}");
|
|
if (!string.IsNullOrWhiteSpace(result.Subject.AdvisoryId))
|
|
subjectGrid.AddRow("[bold]Advisory[/]", $"{Markup.Escape(result.Subject.AdvisoryId)} ({Markup.Escape(result.Subject.AdvisorySource ?? "unknown")})");
|
|
|
|
AnsiConsole.Write(new Panel(subjectGrid) { Header = new PanelHeader("Subject") });
|
|
}
|
|
|
|
// Decision
|
|
if (result.Decision != null)
|
|
{
|
|
var decisionColor = result.Decision.Status.ToLowerInvariant() switch
|
|
{
|
|
"affected" => "red",
|
|
"not_affected" or "fixed" => "green",
|
|
"under_investigation" => "yellow",
|
|
_ => "dim"
|
|
};
|
|
|
|
var decisionGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Status[/]", $"[{decisionColor}]{Markup.Escape(result.Decision.Status)}[/]");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.Decision.Severity))
|
|
{
|
|
decisionGrid.AddRow("[bold]Severity[/]", Markup.Escape(result.Decision.Severity));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.Decision.WinningRule))
|
|
{
|
|
decisionGrid.AddRow("[bold]Winning Rule[/]", Markup.Escape(result.Decision.WinningRule));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.Decision.Rationale))
|
|
{
|
|
decisionGrid.AddRow("[bold]Rationale[/]", $"[italic]{Markup.Escape(result.Decision.Rationale)}[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(decisionGrid) { Header = new PanelHeader("Decision") });
|
|
}
|
|
|
|
// Rule trace
|
|
if (result.RuleTrace is { Count: > 0 })
|
|
{
|
|
AnsiConsole.MarkupLine("\n[bold blue]Rule Evaluation Trace[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
foreach (var entry in result.RuleTrace.OrderBy(e => e.Priority))
|
|
{
|
|
var icon = entry.Matched ? "[green]✓[/]" : (entry.Evaluated ? "[red]✗[/]" : "[dim]○[/]");
|
|
var ruleColor = entry.Matched ? "green" : (entry.Evaluated ? "dim" : "grey");
|
|
|
|
AnsiConsole.MarkupLine($"{icon} [{ruleColor}]Rule: {Markup.Escape(entry.RuleName)}[/] (priority {entry.Priority})");
|
|
|
|
if (entry.Predicates is { Count: > 0 } && verbose)
|
|
{
|
|
foreach (var pred in entry.Predicates)
|
|
{
|
|
var predIcon = pred.Result ? "[green]✓[/]" : "[red]✗[/]";
|
|
AnsiConsole.MarkupLine($" {predIcon} {Markup.Escape(pred.Expression)}");
|
|
if (!string.IsNullOrWhiteSpace(pred.LeftValue) || !string.IsNullOrWhiteSpace(pred.RightValue))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]left={Markup.Escape(pred.LeftValue ?? "null")} right={Markup.Escape(pred.RightValue ?? "null")}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (entry.Matched && entry.Actions is { Count: > 0 })
|
|
{
|
|
foreach (var action in entry.Actions.Where(a => a.Executed))
|
|
{
|
|
AnsiConsole.MarkupLine($" [cyan]→ {Markup.Escape(action.Action)}: {Markup.Escape(action.Target ?? "")} = {Markup.Escape(action.Value ?? "")}[/]");
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(entry.Because) && entry.Matched)
|
|
{
|
|
AnsiConsole.MarkupLine($" [italic grey]because: {Markup.Escape(entry.Because)}[/]");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(entry.SkippedReason))
|
|
{
|
|
AnsiConsole.MarkupLine($" [dim]skipped: {Markup.Escape(entry.SkippedReason)}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
// CLI-POLICY-27-001: policy init handler
|
|
|
|
public static async Task<int> HandlePolicyInitAsync(
|
|
string? path,
|
|
string? name,
|
|
string? templateName,
|
|
bool noGit,
|
|
bool noReadme,
|
|
bool noFixtures,
|
|
string? format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitInputError = 4;
|
|
|
|
var workspacePath = Path.GetFullPath(path ?? ".");
|
|
var policyName = name ?? Path.GetFileName(workspacePath);
|
|
|
|
if (string.IsNullOrWhiteSpace(policyName))
|
|
{
|
|
policyName = "my-policy";
|
|
}
|
|
|
|
// Create workspace directory
|
|
Directory.CreateDirectory(workspacePath);
|
|
|
|
var template = ParsePolicyTemplate(templateName);
|
|
var policyPath = Path.Combine(workspacePath, $"{policyName}.stella");
|
|
string? fixturesPath = null;
|
|
var gitInitialized = false;
|
|
var warnings = new List<string>();
|
|
|
|
// Create policy file
|
|
if (!File.Exists(policyPath))
|
|
{
|
|
var policyContent = GeneratePolicyFromTemplate(policyName, template, null, Array.Empty<string>(), true);
|
|
await File.WriteAllTextAsync(policyPath, policyContent, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
warnings.Add($"Policy file already exists: {policyPath}");
|
|
}
|
|
|
|
// Create fixtures directory
|
|
if (!noFixtures)
|
|
{
|
|
fixturesPath = Path.Combine(workspacePath, "tests", "policy", policyName, "cases");
|
|
Directory.CreateDirectory(fixturesPath);
|
|
|
|
var sampleFixturePath = Path.Combine(fixturesPath, "sample_test.json");
|
|
if (!File.Exists(sampleFixturePath))
|
|
{
|
|
var sampleFixture = GenerateSampleFixture(policyName);
|
|
await File.WriteAllTextAsync(sampleFixturePath, sampleFixture, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
// Create README
|
|
if (!noReadme)
|
|
{
|
|
var readmePath = Path.Combine(workspacePath, "README.md");
|
|
if (!File.Exists(readmePath))
|
|
{
|
|
var readme = GeneratePolicyReadme(policyName);
|
|
await File.WriteAllTextAsync(readmePath, readme, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
// Initialize Git
|
|
if (!noGit)
|
|
{
|
|
var gitDir = Path.Combine(workspacePath, ".git");
|
|
if (!Directory.Exists(gitDir))
|
|
{
|
|
var (exitCode, _) = await RunGitCommandAsync(workspacePath, "init", cancellationToken).ConfigureAwait(false);
|
|
gitInitialized = exitCode == 0;
|
|
|
|
if (gitInitialized)
|
|
{
|
|
// Create .gitignore
|
|
var gitignorePath = Path.Combine(workspacePath, ".gitignore");
|
|
if (!File.Exists(gitignorePath))
|
|
{
|
|
await File.WriteAllTextAsync(gitignorePath, "*.ir.json\n.stella-cache/\n", cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
gitInitialized = true;
|
|
}
|
|
}
|
|
|
|
var result = new PolicyWorkspaceInitResult
|
|
{
|
|
Success = true,
|
|
WorkspacePath = workspacePath,
|
|
PolicyPath = policyPath,
|
|
FixturesPath = fixturesPath,
|
|
GitInitialized = gitInitialized,
|
|
Warnings = warnings.Count > 0 ? warnings : null
|
|
};
|
|
|
|
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
|
|
|
|
if (outputFormat == "json")
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
}
|
|
else
|
|
{
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Workspace[/]", Markup.Escape(workspacePath))
|
|
.AddRow("[bold]Policy[/]", Markup.Escape(policyPath))
|
|
.AddRow("[bold]Template[/]", Markup.Escape(template.ToString()))
|
|
.AddRow("[bold]Git[/]", gitInitialized ? "[green]Initialized[/]" : "[dim]Skipped[/]");
|
|
|
|
if (!string.IsNullOrEmpty(fixturesPath))
|
|
{
|
|
grid.AddRow("[bold]Fixtures[/]", Markup.Escape(fixturesPath));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Policy Workspace Initialized") });
|
|
|
|
foreach (var warning in warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[grey]Quick start:[/]");
|
|
AnsiConsole.MarkupLine($" cd {Markup.Escape(workspacePath)}");
|
|
AnsiConsole.MarkupLine($" stella policy edit {policyName}.stella");
|
|
AnsiConsole.MarkupLine($" stella policy lint {policyName}.stella");
|
|
AnsiConsole.MarkupLine($" stella policy test {policyName}.stella");
|
|
}
|
|
|
|
return ExitSuccess;
|
|
}
|
|
|
|
private static string GeneratePolicyReadme(string policyName)
|
|
{
|
|
return $@"# {policyName}
|
|
|
|
This is a StellaOps policy workspace.
|
|
|
|
## Files
|
|
|
|
- `{policyName}.stella` - Policy DSL file
|
|
- `tests/policy/{policyName}/cases/` - Test fixtures
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# Edit the policy
|
|
stella policy edit {policyName}.stella
|
|
|
|
# Validate syntax
|
|
stella policy lint {policyName}.stella
|
|
|
|
# Compile to IR
|
|
stella policy compile {policyName}.stella
|
|
|
|
# Run tests
|
|
stella policy test {policyName}.stella
|
|
```
|
|
|
|
## Workflow
|
|
|
|
1. Edit the policy with shadow mode enabled
|
|
2. Run `stella policy lint` to validate syntax
|
|
3. Add test fixtures in `tests/policy/{policyName}/cases/`
|
|
4. Run `stella policy test` to verify behavior
|
|
5. Disable shadow mode and promote to production
|
|
";
|
|
}
|
|
|
|
// CLI-POLICY-27-001: policy compile handler
|
|
|
|
public static async Task<int> HandlePolicyCompileAsync(
|
|
string filePath,
|
|
string? outputPath,
|
|
bool noIr,
|
|
bool noDigest,
|
|
bool optimize,
|
|
bool strict,
|
|
string? format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitCompileError = 1;
|
|
const int ExitInputError = 4;
|
|
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Policy file path is required.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(filePath);
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Policy file not found: {Markup.Escape(fullPath)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var source = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
|
var compiler = new PolicyDsl.PolicyCompiler();
|
|
var compileResult = compiler.Compile(source);
|
|
|
|
var errors = new List<PolicyDiagnostic>();
|
|
var warnings = new List<PolicyDiagnostic>();
|
|
|
|
foreach (var diag in compileResult.Diagnostics)
|
|
{
|
|
var diagnostic = new PolicyDiagnostic
|
|
{
|
|
Code = diag.Code,
|
|
Message = diag.Message,
|
|
Severity = diag.Severity.ToString().ToLowerInvariant(),
|
|
Path = diag.Path
|
|
};
|
|
|
|
if (diag.Severity == PolicyIssueSeverity.Error)
|
|
{
|
|
errors.Add(diagnostic);
|
|
}
|
|
else
|
|
{
|
|
warnings.Add(diagnostic);
|
|
}
|
|
}
|
|
|
|
// In strict mode, treat warnings as errors
|
|
if (strict && warnings.Count > 0)
|
|
{
|
|
errors.AddRange(warnings);
|
|
warnings.Clear();
|
|
}
|
|
|
|
string? irPath = null;
|
|
string? digest = null;
|
|
|
|
if (compileResult.Success && !noIr)
|
|
{
|
|
irPath = outputPath ?? Path.ChangeExtension(fullPath, ".stella.ir.json");
|
|
var ir = JsonSerializer.Serialize(compileResult.Document, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(irPath, ir, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
if (compileResult.Success && !noDigest)
|
|
{
|
|
digest = compileResult.Checksum;
|
|
}
|
|
|
|
var result = new PolicyCompileResult
|
|
{
|
|
Success = compileResult.Success && errors.Count == 0,
|
|
InputPath = fullPath,
|
|
IrPath = irPath,
|
|
Digest = digest,
|
|
SyntaxVersion = compileResult.Document?.Syntax,
|
|
PolicyName = compileResult.Document?.Name,
|
|
RuleCount = compileResult.Document?.Rules.Length ?? 0,
|
|
ProfileCount = compileResult.Document?.Profiles.Length ?? 0,
|
|
Errors = errors.Count > 0 ? errors : null,
|
|
Warnings = warnings.Count > 0 ? warnings : null
|
|
};
|
|
|
|
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
|
|
|
|
if (outputFormat == "json")
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
}
|
|
else
|
|
{
|
|
if (result.Success)
|
|
{
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Status[/]", "[green]Compiled[/]")
|
|
.AddRow("[bold]Policy[/]", Markup.Escape(result.PolicyName ?? "-"))
|
|
.AddRow("[bold]Syntax[/]", Markup.Escape(result.SyntaxVersion ?? "-"))
|
|
.AddRow("[bold]Rules[/]", result.RuleCount.ToString())
|
|
.AddRow("[bold]Profiles[/]", result.ProfileCount.ToString());
|
|
|
|
if (!string.IsNullOrEmpty(digest))
|
|
{
|
|
grid.AddRow("[bold]Digest[/]", Markup.Escape(digest.Length > 32 ? digest[..32] + "..." : digest));
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(irPath))
|
|
{
|
|
grid.AddRow("[bold]IR Output[/]", Markup.Escape(irPath));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Compilation Result") });
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Compilation Failed[/]\n");
|
|
}
|
|
|
|
foreach (var err in errors)
|
|
{
|
|
var location = !string.IsNullOrWhiteSpace(err.Path) ? $" at {err.Path}" : "";
|
|
AnsiConsole.MarkupLine($"[red]error[{Markup.Escape(err.Code)}]{Markup.Escape(location)}: {Markup.Escape(err.Message)}[/]");
|
|
}
|
|
|
|
foreach (var warn in warnings)
|
|
{
|
|
var location = !string.IsNullOrWhiteSpace(warn.Path) ? $" at {warn.Path}" : "";
|
|
AnsiConsole.MarkupLine($"[yellow]warning[{Markup.Escape(warn.Code)}]{Markup.Escape(location)}: {Markup.Escape(warn.Message)}[/]");
|
|
}
|
|
}
|
|
|
|
return result.Success ? ExitSuccess : ExitCompileError;
|
|
}
|
|
|
|
// CLI-POLICY-27-002: Policy workflow handlers
|
|
|
|
public static async Task<int> HandlePolicyVersionBumpAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
string? bumpType,
|
|
string? changelog,
|
|
string? filePath,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy version bump"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var bump = bumpType?.ToLowerInvariant() switch
|
|
{
|
|
"major" => PolicyBumpType.Major,
|
|
"minor" => PolicyBumpType.Minor,
|
|
_ => PolicyBumpType.Patch
|
|
};
|
|
|
|
var request = new PolicyVersionBumpRequest
|
|
{
|
|
PolicyId = policyId,
|
|
BumpType = bump,
|
|
Changelog = changelog,
|
|
FilePath = filePath,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.BumpPolicyVersionAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", Markup.Escape(result.PolicyId))
|
|
.AddRow("[bold]Previous Version[/]", $"v{result.PreviousVersion}")
|
|
.AddRow("[bold]New Version[/]", $"[green]v{result.NewVersion}[/]");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.Changelog))
|
|
{
|
|
grid.AddRow("[bold]Changelog[/]", Markup.Escape(result.Changelog));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.Digest))
|
|
{
|
|
var digestShort = result.Digest.Length > 16 ? result.Digest[..16] + "..." : result.Digest;
|
|
grid.AddRow("[bold]Digest[/]", Markup.Escape(digestShort));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Version Bumped") });
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicySubmitAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
int? version,
|
|
string[] reviewers,
|
|
string? message,
|
|
bool urgent,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy submit"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicySubmitRequest
|
|
{
|
|
PolicyId = policyId,
|
|
Version = version,
|
|
Reviewers = reviewers.Length > 0 ? reviewers : null,
|
|
Message = message,
|
|
Urgent = urgent,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.SubmitPolicyForReviewAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", Markup.Escape(result.PolicyId))
|
|
.AddRow("[bold]Version[/]", $"v{result.Version}")
|
|
.AddRow("[bold]Review ID[/]", Markup.Escape(result.ReviewId))
|
|
.AddRow("[bold]State[/]", $"[yellow]{Markup.Escape(result.State)}[/]")
|
|
.AddRow("[bold]Submitted At[/]", result.SubmittedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Submitted By[/]", Markup.Escape(result.SubmittedBy ?? "unknown"));
|
|
|
|
if (result.Reviewers is { Count: > 0 })
|
|
{
|
|
grid.AddRow("[bold]Reviewers[/]", Markup.Escape(string.Join(", ", result.Reviewers)));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Policy Submitted for Review") });
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyReviewStatusAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
string? reviewId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy review status"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyReviewStatusRequest
|
|
{
|
|
PolicyId = policyId,
|
|
ReviewId = reviewId,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.GetPolicyReviewStatusAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (result == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No review found for this policy.[/]");
|
|
return 0;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
var stateColor = result.State.ToLowerInvariant() switch
|
|
{
|
|
"approved" => "green",
|
|
"rejected" => "red",
|
|
"submitted" or "inreview" => "yellow",
|
|
_ => "dim"
|
|
};
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Review ID[/]", Markup.Escape(result.ReviewId))
|
|
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
|
|
.AddRow("[bold]State[/]", $"[{stateColor}]{Markup.Escape(result.State)}[/]")
|
|
.AddRow("[bold]Submitted At[/]", result.SubmittedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Submitted By[/]", Markup.Escape(result.SubmittedBy ?? "unknown"));
|
|
|
|
if (result.Reviewers is { Count: > 0 })
|
|
{
|
|
grid.AddRow("[bold]Reviewers[/]", Markup.Escape(string.Join(", ", result.Reviewers)));
|
|
}
|
|
|
|
grid.AddRow("[bold]Blocking Comments[/]", result.BlockingComments > 0 ? $"[red]{result.BlockingComments}[/]" : "0");
|
|
grid.AddRow("[bold]Resolved Comments[/]", result.ResolvedComments.ToString());
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Review Status") });
|
|
|
|
// Show approvals
|
|
if (result.Approvals is { Count: > 0 })
|
|
{
|
|
AnsiConsole.MarkupLine("\n[bold green]Approvals[/]");
|
|
foreach (var approval in result.Approvals)
|
|
{
|
|
AnsiConsole.MarkupLine($" [green]✓[/] {Markup.Escape(approval.Approver)} at {approval.ApprovedAt:yyyy-MM-dd HH:mm}");
|
|
if (!string.IsNullOrWhiteSpace(approval.Comment))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(approval.Comment)}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show comments (verbose mode)
|
|
if (verbose && result.Comments is { Count: > 0 })
|
|
{
|
|
AnsiConsole.MarkupLine("\n[bold]Comments[/]");
|
|
foreach (var comment in result.Comments)
|
|
{
|
|
var icon = comment.Blocking ? "[red]![/]" : "[dim]○[/]";
|
|
var resolved = comment.Resolved ? " [green](resolved)[/]" : "";
|
|
AnsiConsole.MarkupLine($" {icon} {Markup.Escape(comment.Author)} at {comment.CreatedAt:yyyy-MM-dd HH:mm}{resolved}");
|
|
if (comment.Line.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [dim]Line {comment.Line}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine($" {Markup.Escape(comment.Comment)}");
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyReviewCommentAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
string reviewId,
|
|
string comment,
|
|
int? line,
|
|
string? ruleName,
|
|
bool blocking,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy review comment"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyReviewCommentRequest
|
|
{
|
|
PolicyId = policyId,
|
|
ReviewId = reviewId,
|
|
Comment = comment,
|
|
Line = line,
|
|
RuleName = ruleName,
|
|
Blocking = blocking,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.AddPolicyReviewCommentAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var blockingStr = blocking ? "[red]blocking[/]" : "[dim]non-blocking[/]";
|
|
AnsiConsole.MarkupLine($"[green]Comment added[/] ({blockingStr})");
|
|
AnsiConsole.MarkupLine($" Comment ID: {Markup.Escape(result.CommentId)}");
|
|
AnsiConsole.MarkupLine($" Author: {Markup.Escape(result.Author ?? "unknown")}");
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyReviewApproveAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
string reviewId,
|
|
string? comment,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy review approve"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyApproveRequest
|
|
{
|
|
PolicyId = policyId,
|
|
ReviewId = reviewId,
|
|
Comment = comment,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.ApprovePolicyReviewAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
|
|
.AddRow("[bold]Review ID[/]", Markup.Escape(result.ReviewId))
|
|
.AddRow("[bold]State[/]", $"[green]{Markup.Escape(result.State)}[/]")
|
|
.AddRow("[bold]Approved At[/]", result.ApprovedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Approved By[/]", Markup.Escape(result.ApprovedBy ?? "unknown"))
|
|
.AddRow("[bold]Approvals[/]", $"{result.CurrentApprovals}/{result.RequiredApprovals}");
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[green]Policy Approved[/]") });
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyReviewRejectAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
string reviewId,
|
|
string reason,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy review reject"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyRejectRequest
|
|
{
|
|
PolicyId = policyId,
|
|
ReviewId = reviewId,
|
|
Reason = reason,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.RejectPolicyReviewAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
|
|
.AddRow("[bold]Review ID[/]", Markup.Escape(result.ReviewId))
|
|
.AddRow("[bold]State[/]", $"[red]{Markup.Escape(result.State)}[/]")
|
|
.AddRow("[bold]Rejected At[/]", result.RejectedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Rejected By[/]", Markup.Escape(result.RejectedBy ?? "unknown"))
|
|
.AddRow("[bold]Reason[/]", Markup.Escape(result.Reason ?? reason));
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[red]Policy Rejected[/]") });
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
// CLI-POLICY-27-004: Policy lifecycle handlers
|
|
|
|
public static async Task<int> HandlePolicyPublishAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
int version,
|
|
bool sign,
|
|
string? algorithm,
|
|
string? keyId,
|
|
string? note,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy publish"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyPublishRequest
|
|
{
|
|
PolicyId = policyId,
|
|
Version = version,
|
|
Sign = sign,
|
|
SignatureAlgorithm = algorithm,
|
|
KeyId = keyId,
|
|
Note = note,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.PublishPolicyAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
|
|
.AddRow("[bold]Published At[/]", result.PublishedAt.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.SignatureId))
|
|
{
|
|
grid.AddRow("[bold]Signature ID[/]", Markup.Escape(result.SignatureId));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.RekorLogIndex))
|
|
{
|
|
grid.AddRow("[bold]Rekor Log Index[/]", Markup.Escape(result.RekorLogIndex));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[green]Policy Published[/]") });
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyPromoteAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
int version,
|
|
string targetEnvironment,
|
|
bool canary,
|
|
int? canaryPercent,
|
|
string? note,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy promote"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyPromoteRequest
|
|
{
|
|
PolicyId = policyId,
|
|
Version = version,
|
|
TargetEnvironment = targetEnvironment,
|
|
Canary = canary,
|
|
CanaryPercentage = canaryPercent,
|
|
Note = note,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.PromotePolicyAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
|
|
.AddRow("[bold]Target Environment[/]", Markup.Escape(result.TargetEnvironment))
|
|
.AddRow("[bold]Promoted At[/]", result.PromotedAt.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
|
|
if (result.PreviousVersion.HasValue)
|
|
{
|
|
grid.AddRow("[bold]Previous Version[/]", $"v{result.PreviousVersion}");
|
|
}
|
|
|
|
if (result.CanaryActive)
|
|
{
|
|
grid.AddRow("[bold]Canary[/]", $"[yellow]Active ({result.CanaryPercentage}%)[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[green]Policy Promoted[/]") });
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyRollbackAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
int? targetVersion,
|
|
string? environment,
|
|
string? reason,
|
|
string? incidentId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy rollback"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyRollbackRequest
|
|
{
|
|
PolicyId = policyId,
|
|
TargetVersion = targetVersion,
|
|
Environment = environment,
|
|
Reason = reason,
|
|
IncidentId = incidentId,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.RollbackPolicyAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", Markup.Escape(result.PolicyId))
|
|
.AddRow("[bold]Rolled Back From[/]", $"v{result.PreviousVersion}")
|
|
.AddRow("[bold]Rolled Back To[/]", $"v{result.RolledBackToVersion}")
|
|
.AddRow("[bold]Rolled Back At[/]", result.RolledBackAt.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.Environment))
|
|
{
|
|
grid.AddRow("[bold]Environment[/]", Markup.Escape(result.Environment));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.IncidentId))
|
|
{
|
|
grid.AddRow("[bold]Incident ID[/]", Markup.Escape(result.IncidentId));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[yellow]Policy Rolled Back[/]") });
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicySignAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
int version,
|
|
string? keyId,
|
|
string? algorithm,
|
|
bool rekorUpload,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy sign"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicySignRequest
|
|
{
|
|
PolicyId = policyId,
|
|
Version = version,
|
|
KeyId = keyId,
|
|
SignatureAlgorithm = algorithm,
|
|
RekorUpload = rekorUpload,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.SignPolicyAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
|
|
.AddRow("[bold]Signature ID[/]", Markup.Escape(result.SignatureId))
|
|
.AddRow("[bold]Algorithm[/]", Markup.Escape(result.SignatureAlgorithm))
|
|
.AddRow("[bold]Signed At[/]", result.SignedAt.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.KeyId))
|
|
{
|
|
grid.AddRow("[bold]Key ID[/]", Markup.Escape(result.KeyId));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.RekorLogIndex))
|
|
{
|
|
grid.AddRow("[bold]Rekor Log Index[/]", Markup.Escape(result.RekorLogIndex));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.RekorEntryUuid))
|
|
{
|
|
grid.AddRow("[bold]Rekor Entry UUID[/]", Markup.Escape(result.RekorEntryUuid));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[green]Policy Signed[/]") });
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> HandlePolicyVerifySignatureAsync(
|
|
IServiceProvider services,
|
|
string policyId,
|
|
int version,
|
|
string? signatureId,
|
|
bool checkRekor,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy verify-signature"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<IBackendOperationsClient>();
|
|
|
|
try
|
|
{
|
|
var request = new PolicyVerifySignatureRequest
|
|
{
|
|
PolicyId = policyId,
|
|
Version = version,
|
|
SignatureId = signatureId,
|
|
CheckRekor = checkRekor,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.VerifyPolicySignatureAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Valid ? 0 : 1;
|
|
}
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
|
|
.AddRow("[bold]Signature ID[/]", Markup.Escape(result.SignatureId))
|
|
.AddRow("[bold]Algorithm[/]", Markup.Escape(result.SignatureAlgorithm))
|
|
.AddRow("[bold]Signed At[/]", result.SignedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Valid[/]", result.Valid ? "[green]Yes[/]" : "[red]No[/]");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.SignedBy))
|
|
{
|
|
grid.AddRow("[bold]Signed By[/]", Markup.Escape(result.SignedBy));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.KeyId))
|
|
{
|
|
grid.AddRow("[bold]Key ID[/]", Markup.Escape(result.KeyId));
|
|
}
|
|
|
|
if (result.RekorVerified.HasValue)
|
|
{
|
|
grid.AddRow("[bold]Rekor Verified[/]", result.RekorVerified.Value ? "[green]Yes[/]" : "[red]No[/]");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.RekorLogIndex))
|
|
{
|
|
grid.AddRow("[bold]Rekor Log Index[/]", Markup.Escape(result.RekorLogIndex));
|
|
}
|
|
|
|
if (result.Warnings is { Count: > 0 })
|
|
{
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
}
|
|
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
|
|
var headerColor = result.Valid ? "green" : "red";
|
|
var headerText = result.Valid ? "Signature Valid" : "Signature Invalid";
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader($"[{headerColor}]{headerText}[/]") });
|
|
|
|
return result.Valid ? 0 : 1;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region VEX Consensus (CLI-VEX-30-001)
|
|
|
|
public static async Task HandleVexConsensusListAsync(
|
|
IServiceProvider services,
|
|
string? vulnerabilityId,
|
|
string? productKey,
|
|
string? purl,
|
|
string? status,
|
|
string? policyVersion,
|
|
int? limit,
|
|
int? offset,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool emitCsv,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-consensus");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.consensus.list", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vex consensus list");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vex consensus list");
|
|
|
|
try
|
|
{
|
|
// Resolve effective tenant (CLI arg > env var > profile)
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
var request = new VexConsensusListRequest(
|
|
VulnerabilityId: vulnerabilityId?.Trim(),
|
|
ProductKey: productKey?.Trim(),
|
|
Purl: purl?.Trim(),
|
|
Status: status?.Trim().ToLowerInvariant(),
|
|
PolicyVersion: policyVersion?.Trim(),
|
|
Limit: limit ?? 50,
|
|
Offset: offset ?? 0);
|
|
|
|
logger.LogDebug("Fetching VEX consensus: vuln={VulnId}, product={ProductKey}, purl={Purl}, status={Status}, policy={PolicyVersion}, limit={Limit}, offset={Offset}",
|
|
request.VulnerabilityId, request.ProductKey, request.Purl, request.Status, request.PolicyVersion, request.Limit, request.Offset);
|
|
|
|
var response = await client.ListVexConsensusAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
if (emitCsv)
|
|
{
|
|
RenderVexConsensusCsv(response);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderVexConsensusTable(response);
|
|
if (response.HasMore)
|
|
{
|
|
var nextOffset = response.Offset + response.Limit;
|
|
AnsiConsole.MarkupLine($"[yellow]More results available. Continue with[/] [cyan]--offset {nextOffset}[/]");
|
|
}
|
|
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 VEX consensus data.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderVexConsensusTable(VexConsensusListResponse response)
|
|
{
|
|
if (response.Items.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No VEX consensus entries found matching the criteria.[/]");
|
|
return;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.Border(TableBorder.Rounded);
|
|
table.AddColumn(new TableColumn("[bold]Vulnerability[/]").NoWrap());
|
|
table.AddColumn(new TableColumn("[bold]Product[/]"));
|
|
table.AddColumn(new TableColumn("[bold]Status[/]"));
|
|
table.AddColumn(new TableColumn("[bold]Sources[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Conflicts[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Calculated[/]"));
|
|
|
|
foreach (var item in response.Items)
|
|
{
|
|
var statusColor = item.Status.ToLowerInvariant() switch
|
|
{
|
|
"not_affected" => "green",
|
|
"fixed" => "blue",
|
|
"affected" => "red",
|
|
"under_investigation" => "yellow",
|
|
_ => "grey"
|
|
};
|
|
|
|
var productDisplay = item.Product.Name ?? item.Product.Key;
|
|
if (!string.IsNullOrWhiteSpace(item.Product.Version))
|
|
{
|
|
productDisplay += $" ({item.Product.Version})";
|
|
}
|
|
|
|
var conflictCount = item.Conflicts?.Count ?? 0;
|
|
var conflictDisplay = conflictCount > 0 ? $"[red]{conflictCount}[/]" : "[grey]0[/]";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(item.VulnerabilityId),
|
|
Markup.Escape(productDisplay),
|
|
$"[{statusColor}]{Markup.Escape(item.Status)}[/]",
|
|
item.Sources.Count.ToString(),
|
|
conflictDisplay,
|
|
item.CalculatedAt.ToString("yyyy-MM-dd HH:mm"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine($"[grey]Showing {response.Items.Count} of {response.Total} total entries (offset {response.Offset})[/]");
|
|
}
|
|
|
|
private static void RenderVexConsensusCsv(VexConsensusListResponse response)
|
|
{
|
|
Console.WriteLine("vulnerability_id,product_key,product_name,product_version,purl,status,source_count,conflict_count,calculated_at,policy_version");
|
|
foreach (var item in response.Items)
|
|
{
|
|
var sourceCount = item.Sources.Count;
|
|
var conflictCount = item.Conflicts?.Count ?? 0;
|
|
Console.WriteLine(string.Join(",",
|
|
CsvEscape(item.VulnerabilityId),
|
|
CsvEscape(item.Product.Key),
|
|
CsvEscape(item.Product.Name ?? string.Empty),
|
|
CsvEscape(item.Product.Version ?? string.Empty),
|
|
CsvEscape(item.Product.Purl ?? string.Empty),
|
|
CsvEscape(item.Status),
|
|
sourceCount.ToString(),
|
|
conflictCount.ToString(),
|
|
item.CalculatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
|
CsvEscape(item.PolicyVersion ?? string.Empty)));
|
|
}
|
|
}
|
|
|
|
private static string CsvEscape(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
return string.Empty;
|
|
|
|
if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r'))
|
|
{
|
|
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
// CLI-VEX-30-002: VEX consensus show
|
|
public static async Task HandleVexConsensusShowAsync(
|
|
IServiceProvider services,
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-consensus");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.consensus.show", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vex consensus show");
|
|
activity?.SetTag("stellaops.cli.vulnerability_id", vulnerabilityId);
|
|
activity?.SetTag("stellaops.cli.product_key", productKey);
|
|
using var duration = CliMetrics.MeasureCommandDuration("vex consensus show");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Fetching VEX consensus detail: vuln={VulnId}, product={ProductKey}", vulnerabilityId, productKey);
|
|
|
|
var response = await client.GetVexConsensusAsync(vulnerabilityId, productKey, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (response is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]No VEX consensus found for vulnerability[/] [cyan]{Markup.Escape(vulnerabilityId)}[/] [yellow]and product[/] [cyan]{Markup.Escape(productKey)}[/]");
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderVexConsensusDetail(response);
|
|
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 VEX consensus detail.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderVexConsensusDetail(VexConsensusDetailResponse response)
|
|
{
|
|
// Header panel
|
|
var statusColor = response.Status.ToLowerInvariant() switch
|
|
{
|
|
"not_affected" => "green",
|
|
"fixed" => "blue",
|
|
"affected" => "red",
|
|
"under_investigation" => "yellow",
|
|
_ => "grey"
|
|
};
|
|
|
|
var headerPanel = new Panel(new Markup($"[bold]{Markup.Escape(response.VulnerabilityId)}[/] → [{statusColor}]{Markup.Escape(response.Status.ToUpperInvariant())}[/]"))
|
|
{
|
|
Header = new PanelHeader("[bold]VEX Consensus[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(headerPanel);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Product information
|
|
var productGrid = new Grid();
|
|
productGrid.AddColumn();
|
|
productGrid.AddColumn();
|
|
productGrid.AddRow("[grey]Product Key:[/]", Markup.Escape(response.Product.Key));
|
|
if (!string.IsNullOrWhiteSpace(response.Product.Name))
|
|
productGrid.AddRow("[grey]Name:[/]", Markup.Escape(response.Product.Name));
|
|
if (!string.IsNullOrWhiteSpace(response.Product.Version))
|
|
productGrid.AddRow("[grey]Version:[/]", Markup.Escape(response.Product.Version));
|
|
if (!string.IsNullOrWhiteSpace(response.Product.Purl))
|
|
productGrid.AddRow("[grey]PURL:[/]", Markup.Escape(response.Product.Purl));
|
|
if (!string.IsNullOrWhiteSpace(response.Product.Cpe))
|
|
productGrid.AddRow("[grey]CPE:[/]", Markup.Escape(response.Product.Cpe));
|
|
productGrid.AddRow("[grey]Calculated:[/]", response.CalculatedAt.ToString("yyyy-MM-dd HH:mm:ss UTC"));
|
|
if (!string.IsNullOrWhiteSpace(response.PolicyVersion))
|
|
productGrid.AddRow("[grey]Policy Version:[/]", Markup.Escape(response.PolicyVersion));
|
|
|
|
var productPanel = new Panel(productGrid)
|
|
{
|
|
Header = new PanelHeader("[cyan]Product Information[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(productPanel);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Quorum information
|
|
if (response.Quorum is not null)
|
|
{
|
|
var quorum = response.Quorum;
|
|
var quorumMet = quorum.Achieved >= quorum.Required;
|
|
var quorumStatus = quorumMet ? "[green]MET[/]" : "[red]NOT MET[/]";
|
|
|
|
var quorumGrid = new Grid();
|
|
quorumGrid.AddColumn();
|
|
quorumGrid.AddColumn();
|
|
quorumGrid.AddRow("[grey]Status:[/]", quorumStatus);
|
|
quorumGrid.AddRow("[grey]Required:[/]", quorum.Required.ToString());
|
|
quorumGrid.AddRow("[grey]Achieved:[/]", quorum.Achieved.ToString());
|
|
quorumGrid.AddRow("[grey]Threshold:[/]", $"{quorum.Threshold:P0}");
|
|
quorumGrid.AddRow("[grey]Total Weight:[/]", $"{quorum.TotalWeight:F2}");
|
|
quorumGrid.AddRow("[grey]Weight Achieved:[/]", $"{quorum.WeightAchieved:F2}");
|
|
if (quorum.ParticipatingProviders is { Count: > 0 })
|
|
{
|
|
quorumGrid.AddRow("[grey]Providers:[/]", string.Join(", ", quorum.ParticipatingProviders.Select(Markup.Escape)));
|
|
}
|
|
|
|
var quorumPanel = new Panel(quorumGrid)
|
|
{
|
|
Header = new PanelHeader("[cyan]Quorum[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(quorumPanel);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Sources (accepted claims)
|
|
if (response.Sources.Count > 0)
|
|
{
|
|
var sourcesTable = new Table();
|
|
sourcesTable.Border(TableBorder.Rounded);
|
|
sourcesTable.AddColumn("[bold]Provider[/]");
|
|
sourcesTable.AddColumn("[bold]Status[/]");
|
|
sourcesTable.AddColumn("[bold]Weight[/]");
|
|
sourcesTable.AddColumn("[bold]Justification[/]");
|
|
|
|
foreach (var source in response.Sources)
|
|
{
|
|
var sourceStatus = source.Status.ToLowerInvariant() switch
|
|
{
|
|
"not_affected" => "[green]not_affected[/]",
|
|
"fixed" => "[blue]fixed[/]",
|
|
"affected" => "[red]affected[/]",
|
|
_ => Markup.Escape(source.Status)
|
|
};
|
|
|
|
sourcesTable.AddRow(
|
|
Markup.Escape(source.ProviderId),
|
|
sourceStatus,
|
|
$"{source.Weight:F2}",
|
|
Markup.Escape(source.Justification ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[cyan]Sources (Accepted Claims)[/]");
|
|
AnsiConsole.Write(sourcesTable);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Conflicts (rejected claims)
|
|
if (response.Conflicts is { Count: > 0 })
|
|
{
|
|
var conflictsTable = new Table();
|
|
conflictsTable.Border(TableBorder.Rounded);
|
|
conflictsTable.AddColumn("[bold]Provider[/]");
|
|
conflictsTable.AddColumn("[bold]Status[/]");
|
|
conflictsTable.AddColumn("[bold]Reason[/]");
|
|
|
|
foreach (var conflict in response.Conflicts)
|
|
{
|
|
conflictsTable.AddRow(
|
|
Markup.Escape(conflict.ProviderId),
|
|
Markup.Escape(conflict.Status),
|
|
Markup.Escape(conflict.Reason ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[red]Conflicts (Rejected Claims)[/]");
|
|
AnsiConsole.Write(conflictsTable);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Rationale
|
|
if (response.Rationale is not null)
|
|
{
|
|
var rationale = response.Rationale;
|
|
var rationaleGrid = new Grid();
|
|
rationaleGrid.AddColumn();
|
|
|
|
if (!string.IsNullOrWhiteSpace(rationale.Text))
|
|
{
|
|
rationaleGrid.AddRow(Markup.Escape(rationale.Text));
|
|
}
|
|
|
|
if (rationale.Justifications is { Count: > 0 })
|
|
{
|
|
rationaleGrid.AddRow("");
|
|
rationaleGrid.AddRow("[grey]Justifications:[/]");
|
|
foreach (var j in rationale.Justifications)
|
|
{
|
|
rationaleGrid.AddRow($" • {Markup.Escape(j)}");
|
|
}
|
|
}
|
|
|
|
if (rationale.PolicyRules is { Count: > 0 })
|
|
{
|
|
rationaleGrid.AddRow("");
|
|
rationaleGrid.AddRow("[grey]Policy Rules:[/]");
|
|
foreach (var rule in rationale.PolicyRules)
|
|
{
|
|
rationaleGrid.AddRow($" • {Markup.Escape(rule)}");
|
|
}
|
|
}
|
|
|
|
var rationalePanel = new Panel(rationaleGrid)
|
|
{
|
|
Header = new PanelHeader("[cyan]Rationale[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(rationalePanel);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Signature status
|
|
if (response.Signature is not null)
|
|
{
|
|
var sig = response.Signature;
|
|
var sigStatus = sig.Signed ? "[green]SIGNED[/]" : "[yellow]UNSIGNED[/]";
|
|
var verifyStatus = sig.VerificationStatus?.ToLowerInvariant() switch
|
|
{
|
|
"valid" => "[green]VALID[/]",
|
|
"invalid" => "[red]INVALID[/]",
|
|
"unknown" => "[yellow]UNKNOWN[/]",
|
|
_ => sig.VerificationStatus is not null ? Markup.Escape(sig.VerificationStatus) : "[grey]N/A[/]"
|
|
};
|
|
|
|
var sigGrid = new Grid();
|
|
sigGrid.AddColumn();
|
|
sigGrid.AddColumn();
|
|
sigGrid.AddRow("[grey]Status:[/]", sigStatus);
|
|
if (sig.Signed)
|
|
{
|
|
sigGrid.AddRow("[grey]Verification:[/]", verifyStatus);
|
|
if (!string.IsNullOrWhiteSpace(sig.Algorithm))
|
|
sigGrid.AddRow("[grey]Algorithm:[/]", Markup.Escape(sig.Algorithm));
|
|
if (!string.IsNullOrWhiteSpace(sig.KeyId))
|
|
sigGrid.AddRow("[grey]Key ID:[/]", Markup.Escape(sig.KeyId));
|
|
if (sig.SignedAt.HasValue)
|
|
sigGrid.AddRow("[grey]Signed At:[/]", sig.SignedAt.Value.ToString("yyyy-MM-dd HH:mm:ss UTC"));
|
|
}
|
|
|
|
var sigPanel = new Panel(sigGrid)
|
|
{
|
|
Header = new PanelHeader("[cyan]Signature[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(sigPanel);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Evidence
|
|
if (response.Evidence is { Count: > 0 })
|
|
{
|
|
var evidenceTable = new Table();
|
|
evidenceTable.Border(TableBorder.Rounded);
|
|
evidenceTable.AddColumn("[bold]Type[/]");
|
|
evidenceTable.AddColumn("[bold]Provider[/]");
|
|
evidenceTable.AddColumn("[bold]Document[/]");
|
|
evidenceTable.AddColumn("[bold]Timestamp[/]");
|
|
|
|
foreach (var ev in response.Evidence)
|
|
{
|
|
var docRef = !string.IsNullOrWhiteSpace(ev.DocumentDigest)
|
|
? (ev.DocumentDigest.Length > 16 ? ev.DocumentDigest[..16] + "..." : ev.DocumentDigest)
|
|
: ev.DocumentId ?? "-";
|
|
|
|
evidenceTable.AddRow(
|
|
Markup.Escape(ev.Type),
|
|
Markup.Escape(ev.ProviderId),
|
|
Markup.Escape(docRef),
|
|
ev.Timestamp?.ToString("yyyy-MM-dd HH:mm") ?? "-");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[cyan]Evidence[/]");
|
|
AnsiConsole.Write(evidenceTable);
|
|
}
|
|
|
|
// Summary
|
|
if (!string.IsNullOrWhiteSpace(response.Summary))
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Summary:[/] {Markup.Escape(response.Summary)}");
|
|
}
|
|
}
|
|
|
|
// CLI-VEX-30-003: VEX simulate
|
|
public static async Task HandleVexSimulateAsync(
|
|
IServiceProvider services,
|
|
string? vulnerabilityId,
|
|
string? productKey,
|
|
string? purl,
|
|
double? threshold,
|
|
int? quorum,
|
|
IReadOnlyList<string> trustOverrides,
|
|
IReadOnlyList<string> excludeProviders,
|
|
IReadOnlyList<string> includeOnly,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool changedOnly,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-simulate");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.simulate", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vex simulate");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vex simulate");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
// Parse trust overrides (format: provider=weight)
|
|
Dictionary<string, double>? parsedTrustOverrides = null;
|
|
if (trustOverrides.Count > 0)
|
|
{
|
|
parsedTrustOverrides = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var entry in trustOverrides)
|
|
{
|
|
var parts = entry.Split('=', 2);
|
|
if (parts.Length != 2)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Invalid trust override format:[/] {Markup.Escape(entry)}. Expected provider=weight");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var weight))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Invalid weight value:[/] {Markup.Escape(parts[1])}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
parsedTrustOverrides[parts[0].Trim()] = weight;
|
|
}
|
|
}
|
|
|
|
var request = new VexSimulationRequest(
|
|
VulnerabilityId: vulnerabilityId?.Trim(),
|
|
ProductKey: productKey?.Trim(),
|
|
Purl: purl?.Trim(),
|
|
TrustOverrides: parsedTrustOverrides,
|
|
ThresholdOverride: threshold,
|
|
QuorumOverride: quorum,
|
|
ExcludeProviders: excludeProviders.Count > 0 ? excludeProviders.ToList() : null,
|
|
IncludeOnly: includeOnly.Count > 0 ? includeOnly.ToList() : null);
|
|
|
|
logger.LogDebug("Running VEX simulation: vuln={VulnId}, product={ProductKey}, threshold={Threshold}, quorum={Quorum}",
|
|
request.VulnerabilityId, request.ProductKey, request.ThresholdOverride, request.QuorumOverride);
|
|
|
|
var response = await client.SimulateVexConsensusAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderVexSimulationResults(response, changedOnly);
|
|
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 run VEX simulation.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderVexSimulationResults(VexSimulationResponse response, bool changedOnly)
|
|
{
|
|
// Summary panel
|
|
var summary = response.Summary;
|
|
var summaryGrid = new Grid();
|
|
summaryGrid.AddColumn();
|
|
summaryGrid.AddColumn();
|
|
summaryGrid.AddRow("[grey]Total Evaluated:[/]", summary.TotalEvaluated.ToString());
|
|
summaryGrid.AddRow("[grey]Changed:[/]", summary.TotalChanged > 0 ? $"[yellow]{summary.TotalChanged}[/]" : "[green]0[/]");
|
|
summaryGrid.AddRow("[grey]Status Upgrades:[/]", summary.StatusUpgrades > 0 ? $"[green]{summary.StatusUpgrades}[/]" : "0");
|
|
summaryGrid.AddRow("[grey]Status Downgrades:[/]", summary.StatusDowngrades > 0 ? $"[red]{summary.StatusDowngrades}[/]" : "0");
|
|
summaryGrid.AddRow("[grey]No Change:[/]", summary.NoChange.ToString());
|
|
|
|
var summaryPanel = new Panel(summaryGrid)
|
|
{
|
|
Header = new PanelHeader("[bold]Simulation Summary[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(summaryPanel);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Parameters panel
|
|
var parameters = response.Parameters;
|
|
var paramsGrid = new Grid();
|
|
paramsGrid.AddColumn();
|
|
paramsGrid.AddColumn();
|
|
paramsGrid.AddRow("[grey]Threshold:[/]", $"{parameters.Threshold:P0}");
|
|
paramsGrid.AddRow("[grey]Quorum:[/]", parameters.Quorum.ToString());
|
|
if (parameters.TrustWeights is { Count: > 0 })
|
|
{
|
|
var weights = string.Join(", ", parameters.TrustWeights.Select(kv => $"{kv.Key}={kv.Value:F2}"));
|
|
paramsGrid.AddRow("[grey]Trust Weights:[/]", Markup.Escape(weights));
|
|
}
|
|
if (parameters.ExcludedProviders is { Count: > 0 })
|
|
{
|
|
paramsGrid.AddRow("[grey]Excluded:[/]", string.Join(", ", parameters.ExcludedProviders.Select(Markup.Escape)));
|
|
}
|
|
|
|
var paramsPanel = new Panel(paramsGrid)
|
|
{
|
|
Header = new PanelHeader("[cyan]Simulation Parameters[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(paramsPanel);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Results table
|
|
var itemsToShow = changedOnly ? response.Items.Where(i => i.Changed).ToList() : response.Items;
|
|
|
|
if (itemsToShow.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine(changedOnly
|
|
? "[green]No status changes detected with the given parameters.[/]"
|
|
: "[yellow]No items to display.[/]");
|
|
return;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.Border(TableBorder.Rounded);
|
|
table.AddColumn(new TableColumn("[bold]Vulnerability[/]").NoWrap());
|
|
table.AddColumn(new TableColumn("[bold]Product[/]"));
|
|
table.AddColumn(new TableColumn("[bold]Before[/]"));
|
|
table.AddColumn(new TableColumn("[bold]After[/]"));
|
|
table.AddColumn(new TableColumn("[bold]Change[/]"));
|
|
|
|
foreach (var item in itemsToShow)
|
|
{
|
|
var beforeStatus = GetStatusMarkup(item.Before.Status);
|
|
var afterStatus = GetStatusMarkup(item.After.Status);
|
|
|
|
var changeIndicator = item.Changed
|
|
? item.ChangeType?.ToLowerInvariant() switch
|
|
{
|
|
"upgrade" => "[green]UPGRADE[/]",
|
|
"downgrade" => "[red]DOWNGRADE[/]",
|
|
_ => "[yellow]CHANGED[/]"
|
|
}
|
|
: "[grey]-[/]";
|
|
|
|
var productDisplay = item.Product.Name ?? item.Product.Key;
|
|
if (!string.IsNullOrWhiteSpace(item.Product.Version))
|
|
{
|
|
productDisplay += $" ({item.Product.Version})";
|
|
}
|
|
|
|
table.AddRow(
|
|
Markup.Escape(item.VulnerabilityId),
|
|
Markup.Escape(productDisplay),
|
|
beforeStatus,
|
|
afterStatus,
|
|
changeIndicator);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (changedOnly && response.Items.Count > itemsToShow.Count)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Showing {itemsToShow.Count} changed items. {response.Items.Count - itemsToShow.Count} unchanged items hidden.[/]");
|
|
}
|
|
|
|
static string GetStatusMarkup(string status) => status.ToLowerInvariant() switch
|
|
{
|
|
"not_affected" => "[green]not_affected[/]",
|
|
"fixed" => "[blue]fixed[/]",
|
|
"affected" => "[red]affected[/]",
|
|
"under_investigation" => "[yellow]under_investigation[/]",
|
|
_ => Markup.Escape(status)
|
|
};
|
|
}
|
|
|
|
// CLI-VEX-30-004: VEX export
|
|
public static async Task HandleVexExportAsync(
|
|
IServiceProvider services,
|
|
IReadOnlyList<string> vulnIds,
|
|
IReadOnlyList<string> productKeys,
|
|
IReadOnlyList<string> purls,
|
|
IReadOnlyList<string> statuses,
|
|
string? policyVersion,
|
|
string outputPath,
|
|
bool signed,
|
|
string? tenant,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-export");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.export", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vex export");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vex export");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Output path is required.[/]");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var outputDir = Path.GetDirectoryName(outputPath);
|
|
if (!string.IsNullOrWhiteSpace(outputDir) && !Directory.Exists(outputDir))
|
|
{
|
|
Directory.CreateDirectory(outputDir);
|
|
}
|
|
|
|
var request = new VexExportRequest(
|
|
VulnerabilityIds: vulnIds.Count > 0 ? vulnIds.ToList() : null,
|
|
ProductKeys: productKeys.Count > 0 ? productKeys.ToList() : null,
|
|
Purls: purls.Count > 0 ? purls.ToList() : null,
|
|
Statuses: statuses.Count > 0 ? statuses.ToList() : null,
|
|
PolicyVersion: policyVersion?.Trim(),
|
|
Signed: signed,
|
|
Format: "ndjson");
|
|
|
|
logger.LogDebug("Requesting VEX export: signed={Signed}, vulnIds={VulnCount}, productKeys={ProductCount}",
|
|
signed, vulnIds.Count, productKeys.Count);
|
|
|
|
await AnsiConsole.Status()
|
|
.Spinner(Spinner.Known.Dots)
|
|
.StartAsync("Preparing export...", async ctx =>
|
|
{
|
|
var exportResponse = await client.ExportVexConsensusAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
ctx.Status("Downloading export bundle...");
|
|
|
|
await using var downloadStream = await client.DownloadVexExportAsync(exportResponse.ExportId, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
await using var fileStream = File.Create(outputPath);
|
|
await downloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
|
|
|
AnsiConsole.MarkupLine($"[green]Export complete![/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var resultGrid = new Grid();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddRow("[grey]Output File:[/]", Markup.Escape(outputPath));
|
|
resultGrid.AddRow("[grey]Items Exported:[/]", exportResponse.ItemCount.ToString());
|
|
resultGrid.AddRow("[grey]Format:[/]", Markup.Escape(exportResponse.Format));
|
|
resultGrid.AddRow("[grey]Signed:[/]", exportResponse.Signed ? "[green]Yes[/]" : "[yellow]No[/]");
|
|
if (exportResponse.Signed)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(exportResponse.SignatureAlgorithm))
|
|
resultGrid.AddRow("[grey]Signature Algorithm:[/]", Markup.Escape(exportResponse.SignatureAlgorithm));
|
|
if (!string.IsNullOrWhiteSpace(exportResponse.SignatureKeyId))
|
|
resultGrid.AddRow("[grey]Key ID:[/]", Markup.Escape(exportResponse.SignatureKeyId));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(exportResponse.Digest))
|
|
{
|
|
var digestDisplay = exportResponse.Digest.Length > 32
|
|
? exportResponse.Digest[..32] + "..."
|
|
: exportResponse.Digest;
|
|
resultGrid.AddRow("[grey]Digest:[/]", $"{exportResponse.DigestAlgorithm ?? "sha256"}:{Markup.Escape(digestDisplay)}");
|
|
}
|
|
|
|
AnsiConsole.Write(resultGrid);
|
|
});
|
|
|
|
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 export VEX consensus data.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleVexVerifyAsync(
|
|
IServiceProvider services,
|
|
string filePath,
|
|
string? expectedDigest,
|
|
string? publicKeyPath,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-verify");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.export.verify", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vex export verify");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vex export verify");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]File path is required.[/]");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (!File.Exists(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]File not found:[/] {Markup.Escape(filePath)}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
logger.LogDebug("Verifying VEX export: file={FilePath}, expectedDigest={Digest}", filePath, expectedDigest ?? "(none)");
|
|
|
|
// Calculate SHA-256 digest
|
|
string actualDigest;
|
|
await using (var fileStream = File.OpenRead(filePath))
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var hashBytes = await sha256.ComputeHashAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
|
actualDigest = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
|
}
|
|
|
|
var resultGrid = new Grid();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddRow("[grey]File:[/]", Markup.Escape(filePath));
|
|
resultGrid.AddRow("[grey]Actual Digest:[/]", $"sha256:{Markup.Escape(actualDigest)}");
|
|
|
|
var digestValid = true;
|
|
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
|
{
|
|
var normalizedExpected = expectedDigest.Trim().ToLowerInvariant();
|
|
if (normalizedExpected.StartsWith("sha256:"))
|
|
{
|
|
normalizedExpected = normalizedExpected[7..];
|
|
}
|
|
|
|
digestValid = string.Equals(actualDigest, normalizedExpected, StringComparison.OrdinalIgnoreCase);
|
|
resultGrid.AddRow("[grey]Expected Digest:[/]", $"sha256:{Markup.Escape(normalizedExpected)}");
|
|
resultGrid.AddRow("[grey]Digest Match:[/]", digestValid ? "[green]YES[/]" : "[red]NO[/]");
|
|
}
|
|
|
|
var sigStatus = "not_verified";
|
|
|
|
if (!string.IsNullOrWhiteSpace(publicKeyPath))
|
|
{
|
|
if (!File.Exists(publicKeyPath))
|
|
{
|
|
resultGrid.AddRow("[grey]Signature:[/]", $"[red]Public key not found:[/] {Markup.Escape(publicKeyPath)}");
|
|
}
|
|
else
|
|
{
|
|
// Look for .sig file
|
|
var sigPath = filePath + ".sig";
|
|
if (File.Exists(sigPath))
|
|
{
|
|
// Note: Actual signature verification would require cryptographic operations
|
|
// This is a placeholder that shows the structure
|
|
resultGrid.AddRow("[grey]Signature File:[/]", Markup.Escape(sigPath));
|
|
resultGrid.AddRow("[grey]Public Key:[/]", Markup.Escape(publicKeyPath));
|
|
resultGrid.AddRow("[grey]Signature Status:[/]", "[yellow]Verification requires runtime crypto support[/]");
|
|
sigStatus = "requires_verification";
|
|
}
|
|
else
|
|
{
|
|
resultGrid.AddRow("[grey]Signature:[/]", "[yellow]No .sig file found[/]");
|
|
sigStatus = "no_signature";
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
resultGrid.AddRow("[grey]Signature:[/]", "[grey]Skipped (no --public-key provided)[/]");
|
|
sigStatus = "skipped";
|
|
}
|
|
|
|
var panel = new Panel(resultGrid)
|
|
{
|
|
Header = new PanelHeader("[bold]VEX Export Verification[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (!digestValid)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Verification FAILED: Digest mismatch[/]");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
else if (sigStatus == "no_signature" && !string.IsNullOrWhiteSpace(publicKeyPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Warning: No signature file found for verification[/]");
|
|
Environment.ExitCode = 0;
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Verification completed[/]");
|
|
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 verify VEX export.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
// CLI-LNM-22-002: Handle vex obs get command
|
|
public static async Task HandleVexObsGetAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string[] vulnIds,
|
|
string[] productKeys,
|
|
string[] purls,
|
|
string[] cpes,
|
|
string[] statuses,
|
|
string[] providers,
|
|
int? limit,
|
|
string? cursor,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IVexObservationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-obs");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.obs.get", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vex obs get");
|
|
activity?.SetTag("stellaops.cli.tenant", tenant);
|
|
using var duration = CliMetrics.MeasureCommandDuration("vex obs get");
|
|
|
|
try
|
|
{
|
|
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
throw new InvalidOperationException("Tenant must be provided.");
|
|
}
|
|
|
|
var query = new VexObservationQuery
|
|
{
|
|
Tenant = tenant,
|
|
VulnerabilityIds = vulnIds,
|
|
ProductKeys = productKeys,
|
|
Purls = purls,
|
|
Cpes = cpes,
|
|
Statuses = statuses,
|
|
ProviderIds = providers,
|
|
Limit = limit,
|
|
Cursor = 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;
|
|
}
|
|
|
|
RenderVexObservations(response, verbose);
|
|
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 VEX observations.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderVexObservations(VexObservationResponse response, bool verbose)
|
|
{
|
|
if (response.Observations.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No VEX observations found matching the query.[/]");
|
|
return;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[bold]VEX Observations[/] ({response.Observations.Count} result(s))");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Obs ID");
|
|
table.AddColumn("Vuln ID");
|
|
table.AddColumn("Product");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Provider");
|
|
table.AddColumn("Last Seen");
|
|
|
|
foreach (var obs in response.Observations)
|
|
{
|
|
var obsId = obs.ObservationId.Length > 12 ? obs.ObservationId[..12] + "..." : obs.ObservationId;
|
|
var productName = obs.Product?.Name ?? obs.Product?.Key ?? "(unknown)";
|
|
if (productName.Length > 25) productName = productName[..22] + "...";
|
|
var statusMarkup = GetVexStatusMarkup(obs.Status);
|
|
|
|
table.AddRow(
|
|
Markup.Escape(obsId),
|
|
$"[cyan]{Markup.Escape(obs.VulnerabilityId)}[/]",
|
|
Markup.Escape(productName),
|
|
statusMarkup,
|
|
Markup.Escape(obs.ProviderId),
|
|
obs.LastSeen.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor \"{Markup.Escape(response.NextCursor)}\" to continue.[/]");
|
|
}
|
|
|
|
if (verbose && response.Aggregate is { } agg)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Aggregate:[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Vulnerabilities:[/] {agg.VulnerabilityIds.Count}");
|
|
AnsiConsole.MarkupLine($" [grey]Products:[/] {agg.ProductKeys.Count}");
|
|
AnsiConsole.MarkupLine($" [grey]Providers:[/] {string.Join(", ", agg.ProviderIds)}");
|
|
if (agg.StatusCounts.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Status Counts:[/]");
|
|
foreach (var (status, count) in agg.StatusCounts)
|
|
{
|
|
AnsiConsole.MarkupLine($" {GetVexStatusMarkup(status)}: {count}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-LNM-22-002: Handle vex linkset show command
|
|
public static async Task HandleVexLinksetShowAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string vulnId,
|
|
string[] productKeys,
|
|
string[] purls,
|
|
string[] statuses,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IVexObservationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-linkset");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.linkset.show", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vex linkset show");
|
|
activity?.SetTag("stellaops.cli.tenant", tenant);
|
|
activity?.SetTag("stellaops.cli.vuln_id", vulnId);
|
|
using var duration = CliMetrics.MeasureCommandDuration("vex linkset show");
|
|
|
|
try
|
|
{
|
|
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
throw new InvalidOperationException("Tenant must be provided.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(vulnId))
|
|
{
|
|
throw new InvalidOperationException("Vulnerability ID must be provided.");
|
|
}
|
|
|
|
var query = new VexLinksetQuery
|
|
{
|
|
Tenant = tenant,
|
|
VulnerabilityId = vulnId.Trim(),
|
|
ProductKeys = productKeys,
|
|
Purls = purls,
|
|
Statuses = statuses
|
|
};
|
|
|
|
var response = await client.GetLinksetAsync(query, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = response.Conflicts.Count > 0 ? 9 : 0;
|
|
return;
|
|
}
|
|
|
|
RenderVexLinkset(response, verbose);
|
|
Environment.ExitCode = response.Conflicts.Count > 0 ? 9 : 0;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to fetch VEX linkset.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderVexLinkset(VexLinksetResponse response, bool verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]VEX Linkset for[/] [cyan]{Markup.Escape(response.VulnerabilityId)}[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Summary
|
|
if (response.Summary is { } summary)
|
|
{
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[grey]Total Observations:[/]", summary.TotalObservations.ToString(CultureInfo.InvariantCulture));
|
|
grid.AddRow("[grey]Providers:[/]", string.Join(", ", summary.Providers));
|
|
grid.AddRow("[grey]Products:[/]", summary.Products.Count.ToString(CultureInfo.InvariantCulture));
|
|
grid.AddRow("[grey]Has Conflicts:[/]", summary.HasConflicts ? "[red]YES[/]" : "[green]NO[/]");
|
|
|
|
if (summary.StatusCounts.Count > 0)
|
|
{
|
|
var statusStr = string.Join(", ", summary.StatusCounts.Select(kv => $"{kv.Key}:{kv.Value}"));
|
|
grid.AddRow("[grey]Status Distribution:[/]", statusStr);
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Summary[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Observations
|
|
if (response.Observations.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Linked Observations:[/]");
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Product");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Provider");
|
|
table.AddColumn("Justification");
|
|
table.AddColumn("Last Seen");
|
|
|
|
foreach (var obs in response.Observations)
|
|
{
|
|
var productName = obs.Product?.Name ?? obs.Product?.Key ?? "(unknown)";
|
|
if (productName.Length > 30) productName = productName[..27] + "...";
|
|
var justification = obs.Justification ?? "-";
|
|
if (justification.Length > 20) justification = justification[..17] + "...";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(productName),
|
|
GetVexStatusMarkup(obs.Status),
|
|
Markup.Escape(obs.ProviderId),
|
|
Markup.Escape(justification),
|
|
obs.LastSeen.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No observations found for this vulnerability.[/]");
|
|
return;
|
|
}
|
|
|
|
// Conflicts
|
|
if (response.Conflicts.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold red]Conflicts Detected:[/]");
|
|
|
|
var conflictTable = new Table()
|
|
.Border(TableBorder.Simple);
|
|
|
|
conflictTable.AddColumn("Product");
|
|
conflictTable.AddColumn("Conflicting Statuses");
|
|
conflictTable.AddColumn("Description");
|
|
|
|
foreach (var conflict in response.Conflicts)
|
|
{
|
|
conflictTable.AddRow(
|
|
Markup.Escape(conflict.ProductKey),
|
|
string.Join(", ", conflict.ConflictingStatuses.Select(s => GetVexStatusMarkup(s))),
|
|
Markup.Escape(conflict.Description));
|
|
}
|
|
|
|
AnsiConsole.Write(conflictTable);
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[yellow]Conflicts indicate multiple providers disagree on the status.[/]");
|
|
}
|
|
|
|
// Verbose: show observation details
|
|
if (verbose && response.Observations.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Observation Details:[/]");
|
|
foreach (var obs in response.Observations)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(obs.ObservationId)}[/]");
|
|
if (!string.IsNullOrWhiteSpace(obs.Detail))
|
|
{
|
|
var detail = obs.Detail.Length > 80 ? obs.Detail[..77] + "..." : obs.Detail;
|
|
AnsiConsole.MarkupLine($" [grey]Detail:[/] {Markup.Escape(detail)}");
|
|
}
|
|
if (obs.Document is { } doc)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Format:[/] {Markup.Escape(doc.Format)}");
|
|
AnsiConsole.MarkupLine($" [grey]Digest:[/] {Markup.Escape(doc.Digest[..Math.Min(16, doc.Digest.Length)])}...");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Vulnerability Explorer (CLI-VULN-29-001)
|
|
|
|
// CLI-VULN-29-001: Vulnerability list handler
|
|
public static async Task HandleVulnListAsync(
|
|
IServiceProvider services,
|
|
string? vulnId,
|
|
string? severity,
|
|
string? status,
|
|
string? purl,
|
|
string? cpe,
|
|
string? sbomId,
|
|
string? policyId,
|
|
int? policyVersion,
|
|
string? groupBy,
|
|
int? limit,
|
|
int? offset,
|
|
string? cursor,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool emitCsv,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-list");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.list", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vuln list");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vuln list");
|
|
|
|
try
|
|
{
|
|
// Resolve effective tenant (CLI arg > env var > profile)
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Listing vulnerabilities: vuln={VulnId}, severity={Severity}, status={Status}, purl={Purl}, groupBy={GroupBy}",
|
|
vulnId, severity, status, purl, groupBy);
|
|
|
|
var request = new VulnListRequest(
|
|
VulnerabilityId: vulnId,
|
|
Severity: severity,
|
|
Status: status,
|
|
Purl: purl,
|
|
Cpe: cpe,
|
|
SbomId: sbomId,
|
|
PolicyId: policyId,
|
|
PolicyVersion: policyVersion,
|
|
GroupBy: groupBy,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
Cursor: cursor);
|
|
|
|
var response = await client.ListVulnerabilitiesAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(response, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else if (emitCsv)
|
|
{
|
|
RenderVulnListCsv(response);
|
|
}
|
|
else
|
|
{
|
|
RenderVulnListTable(response, groupBy);
|
|
}
|
|
|
|
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 list vulnerabilities.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderVulnListTable(VulnListResponse response, string? groupBy)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(groupBy) && response.Grouping != null)
|
|
{
|
|
// Render grouped summary
|
|
var groupTable = new Table();
|
|
groupTable.AddColumn(new TableColumn($"[bold]{Markup.Escape(response.Grouping.Field)}[/]").LeftAligned());
|
|
groupTable.AddColumn(new TableColumn("[bold]Count[/]").RightAligned());
|
|
groupTable.AddColumn(new TableColumn("[bold]Critical[/]").RightAligned());
|
|
groupTable.AddColumn(new TableColumn("[bold]High[/]").RightAligned());
|
|
groupTable.AddColumn(new TableColumn("[bold]Medium[/]").RightAligned());
|
|
groupTable.AddColumn(new TableColumn("[bold]Low[/]").RightAligned());
|
|
|
|
foreach (var group in response.Grouping.Groups)
|
|
{
|
|
groupTable.AddRow(
|
|
Markup.Escape(group.Key),
|
|
group.Count.ToString(),
|
|
group.CriticalCount?.ToString() ?? "-",
|
|
group.HighCount?.ToString() ?? "-",
|
|
group.MediumCount?.ToString() ?? "-",
|
|
group.LowCount?.ToString() ?? "-");
|
|
}
|
|
|
|
AnsiConsole.Write(groupTable);
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Grouped by:[/] {Markup.Escape(response.Grouping.Field)} | [grey]Total groups:[/] {response.Grouping.Groups.Count}");
|
|
return;
|
|
}
|
|
|
|
// Render individual vulnerabilities
|
|
var table = new Table();
|
|
table.AddColumn(new TableColumn("[bold]Vulnerability ID[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Severity[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Status[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]VEX[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Packages[/]").RightAligned());
|
|
table.AddColumn(new TableColumn("[bold]Updated[/]").RightAligned());
|
|
|
|
foreach (var item in response.Items)
|
|
{
|
|
var severityColor = GetSeverityColor(item.Severity.Level);
|
|
var statusColor = GetVulnStatusColor(item.Status);
|
|
var vexDisplay = item.VexStatus ?? "-";
|
|
var vexColor = GetVexStatusColor(item.VexStatus);
|
|
var packageCount = item.AffectedPackages.Count.ToString();
|
|
|
|
table.AddRow(
|
|
Markup.Escape(item.VulnerabilityId),
|
|
$"[{severityColor}]{Markup.Escape(item.Severity.Level.ToUpperInvariant())}[/]",
|
|
$"[{statusColor}]{Markup.Escape(item.Status)}[/]",
|
|
$"[{vexColor}]{Markup.Escape(vexDisplay)}[/]",
|
|
packageCount,
|
|
item.UpdatedAt?.ToString("yyyy-MM-dd") ?? "-");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Showing:[/] {response.Items.Count} of {response.Total} | [grey]Offset:[/] {response.Offset}");
|
|
|
|
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Next page:[/] --cursor \"{Markup.Escape(response.NextCursor)}\"");
|
|
}
|
|
}
|
|
|
|
private static void RenderVulnListCsv(VulnListResponse response)
|
|
{
|
|
Console.WriteLine("VulnerabilityId,Severity,Score,Status,VexStatus,PackageCount,Assignee,UpdatedAt");
|
|
foreach (var item in response.Items)
|
|
{
|
|
Console.WriteLine($"{CsvEscape(item.VulnerabilityId)},{CsvEscape(item.Severity.Level)},{item.Severity.Score?.ToString("F1") ?? ""},{CsvEscape(item.Status)},{CsvEscape(item.VexStatus ?? "")},{item.AffectedPackages.Count},{CsvEscape(item.Assignee ?? "")},{item.UpdatedAt?.ToString("O") ?? ""}");
|
|
}
|
|
}
|
|
|
|
private static string GetSeverityColor(string severity)
|
|
{
|
|
return severity.ToLowerInvariant() switch
|
|
{
|
|
"critical" => "red bold",
|
|
"high" => "red",
|
|
"medium" => "yellow",
|
|
"low" => "blue",
|
|
_ => "grey"
|
|
};
|
|
}
|
|
|
|
private static string GetVulnStatusColor(string status)
|
|
{
|
|
return status.ToLowerInvariant() switch
|
|
{
|
|
"open" => "red",
|
|
"triaged" => "yellow",
|
|
"accepted" => "green",
|
|
"fixed" => "green",
|
|
"risk_accepted" => "cyan",
|
|
"false_positive" => "grey",
|
|
_ => "white"
|
|
};
|
|
}
|
|
|
|
private static string GetVexStatusColor(string? vexStatus)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(vexStatus)) return "grey";
|
|
return vexStatus.ToLowerInvariant() switch
|
|
{
|
|
"not_affected" => "green",
|
|
"affected" => "red",
|
|
"fixed" => "green",
|
|
"under_investigation" => "yellow",
|
|
_ => "grey"
|
|
};
|
|
}
|
|
|
|
// CLI-VULN-29-002: Vulnerability show handler
|
|
public static async Task HandleVulnShowAsync(
|
|
IServiceProvider services,
|
|
string vulnerabilityId,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-show");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.show", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vuln show");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vuln show");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Vulnerability ID is required.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Fetching vulnerability details: {VulnId}", vulnerabilityId);
|
|
|
|
var response = await client.GetVulnerabilityAsync(vulnerabilityId, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (response == null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Vulnerability not found:[/] {Markup.Escape(vulnerabilityId)}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderVulnDetail(response);
|
|
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 get vulnerability details.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderVulnDetail(VulnDetailResponse vuln)
|
|
{
|
|
// Header panel with basic info
|
|
var severityColor = GetSeverityColor(vuln.Severity.Level);
|
|
var statusColor = GetVulnStatusColor(vuln.Status);
|
|
var vexColor = GetVexStatusColor(vuln.VexStatus);
|
|
|
|
var headerGrid = new Grid();
|
|
headerGrid.AddColumn();
|
|
headerGrid.AddColumn();
|
|
headerGrid.AddRow("[grey]Vulnerability ID:[/]", $"[bold]{Markup.Escape(vuln.VulnerabilityId)}[/]");
|
|
headerGrid.AddRow("[grey]Status:[/]", $"[{statusColor}]{Markup.Escape(vuln.Status)}[/]");
|
|
headerGrid.AddRow("[grey]Severity:[/]", $"[{severityColor}]{Markup.Escape(vuln.Severity.Level.ToUpperInvariant())}[/]" +
|
|
(vuln.Severity.Score.HasValue ? $" ({vuln.Severity.Score:F1})" : ""));
|
|
if (!string.IsNullOrWhiteSpace(vuln.VexStatus))
|
|
headerGrid.AddRow("[grey]VEX Status:[/]", $"[{vexColor}]{Markup.Escape(vuln.VexStatus)}[/]");
|
|
if (vuln.Aliases?.Count > 0)
|
|
headerGrid.AddRow("[grey]Aliases:[/]", Markup.Escape(string.Join(", ", vuln.Aliases)));
|
|
if (!string.IsNullOrWhiteSpace(vuln.Assignee))
|
|
headerGrid.AddRow("[grey]Assignee:[/]", Markup.Escape(vuln.Assignee));
|
|
if (vuln.DueDate.HasValue)
|
|
headerGrid.AddRow("[grey]Due Date:[/]", vuln.DueDate.Value.ToString("yyyy-MM-dd"));
|
|
if (vuln.PublishedAt.HasValue)
|
|
headerGrid.AddRow("[grey]Published:[/]", vuln.PublishedAt.Value.ToString("yyyy-MM-dd HH:mm UTC"));
|
|
if (vuln.UpdatedAt.HasValue)
|
|
headerGrid.AddRow("[grey]Updated:[/]", vuln.UpdatedAt.Value.ToString("yyyy-MM-dd HH:mm UTC"));
|
|
|
|
var headerPanel = new Panel(headerGrid)
|
|
{
|
|
Header = new PanelHeader("[bold]Vulnerability Details[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(headerPanel);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Summary/Description
|
|
if (!string.IsNullOrWhiteSpace(vuln.Summary) || !string.IsNullOrWhiteSpace(vuln.Description))
|
|
{
|
|
var descPanel = new Panel(Markup.Escape(vuln.Description ?? vuln.Summary ?? ""))
|
|
{
|
|
Header = new PanelHeader("[bold]Description[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(descPanel);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Affected Packages
|
|
if (vuln.AffectedPackages.Count > 0)
|
|
{
|
|
var pkgTable = new Table();
|
|
pkgTable.AddColumn("[bold]Package[/]");
|
|
pkgTable.AddColumn("[bold]Version[/]");
|
|
pkgTable.AddColumn("[bold]Fixed In[/]");
|
|
pkgTable.AddColumn("[bold]SBOM[/]");
|
|
|
|
foreach (var pkg in vuln.AffectedPackages)
|
|
{
|
|
var pkgName = pkg.Purl ?? pkg.Cpe ?? pkg.Name ?? "-";
|
|
pkgTable.AddRow(
|
|
Markup.Escape(pkgName.Length > 60 ? pkgName[..57] + "..." : pkgName),
|
|
Markup.Escape(pkg.Version ?? "-"),
|
|
Markup.Escape(pkg.FixedIn ?? "-"),
|
|
Markup.Escape(pkg.SbomId?.Length > 20 ? pkg.SbomId[..17] + "..." : pkg.SbomId ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[bold]Affected Packages[/]");
|
|
AnsiConsole.Write(pkgTable);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Policy Rationale
|
|
if (vuln.PolicyRationale != null)
|
|
{
|
|
var rationaleGrid = new Grid();
|
|
rationaleGrid.AddColumn();
|
|
rationaleGrid.AddColumn();
|
|
rationaleGrid.AddRow("[grey]Policy:[/]", Markup.Escape($"{vuln.PolicyRationale.PolicyId} v{vuln.PolicyRationale.PolicyVersion}"));
|
|
if (!string.IsNullOrWhiteSpace(vuln.PolicyRationale.Summary))
|
|
rationaleGrid.AddRow("[grey]Summary:[/]", Markup.Escape(vuln.PolicyRationale.Summary));
|
|
|
|
if (vuln.PolicyRationale.Rules?.Count > 0)
|
|
{
|
|
var rulesText = string.Join("\n", vuln.PolicyRationale.Rules.Select(r =>
|
|
$" {r.Rule}: {r.Result}" + (r.Weight.HasValue ? $" (weight: {r.Weight:F2})" : "")));
|
|
rationaleGrid.AddRow("[grey]Rules:[/]", Markup.Escape(rulesText));
|
|
}
|
|
|
|
var rationalePanel = new Panel(rationaleGrid)
|
|
{
|
|
Header = new PanelHeader("[bold]Policy Rationale[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(rationalePanel);
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Evidence
|
|
if (vuln.Evidence?.Count > 0)
|
|
{
|
|
var evidenceTable = new Table();
|
|
evidenceTable.AddColumn("[bold]Type[/]");
|
|
evidenceTable.AddColumn("[bold]Source[/]");
|
|
evidenceTable.AddColumn("[bold]Timestamp[/]");
|
|
|
|
foreach (var ev in vuln.Evidence.Take(10))
|
|
{
|
|
evidenceTable.AddRow(
|
|
Markup.Escape(ev.Type),
|
|
Markup.Escape(ev.Source),
|
|
ev.Timestamp?.ToString("yyyy-MM-dd HH:mm") ?? "-");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[bold]Evidence[/]");
|
|
AnsiConsole.Write(evidenceTable);
|
|
if (vuln.Evidence.Count > 10)
|
|
AnsiConsole.MarkupLine($"[grey]... and {vuln.Evidence.Count - 10} more[/]");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Dependency Paths
|
|
if (vuln.DependencyPaths?.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Dependency Paths[/]");
|
|
foreach (var path in vuln.DependencyPaths.Take(5))
|
|
{
|
|
var pathStr = string.Join(" -> ", path.Path);
|
|
AnsiConsole.MarkupLine($" [grey]>[/] {Markup.Escape(pathStr.Length > 100 ? pathStr[..97] + "..." : pathStr)}");
|
|
}
|
|
if (vuln.DependencyPaths.Count > 5)
|
|
AnsiConsole.MarkupLine($" [grey]... and {vuln.DependencyPaths.Count - 5} more paths[/]");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Ledger (Workflow History)
|
|
if (vuln.Ledger?.Count > 0)
|
|
{
|
|
var ledgerTable = new Table();
|
|
ledgerTable.AddColumn("[bold]Timestamp[/]");
|
|
ledgerTable.AddColumn("[bold]Action[/]");
|
|
ledgerTable.AddColumn("[bold]Actor[/]");
|
|
ledgerTable.AddColumn("[bold]Status Change[/]");
|
|
|
|
foreach (var entry in vuln.Ledger.Take(10))
|
|
{
|
|
var statusChange = !string.IsNullOrWhiteSpace(entry.FromStatus) && !string.IsNullOrWhiteSpace(entry.ToStatus)
|
|
? $"{entry.FromStatus} -> {entry.ToStatus}"
|
|
: "-";
|
|
ledgerTable.AddRow(
|
|
entry.Timestamp.ToString("yyyy-MM-dd HH:mm"),
|
|
Markup.Escape(entry.Action),
|
|
Markup.Escape(entry.Actor ?? "-"),
|
|
Markup.Escape(statusChange));
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[bold]Workflow History[/]");
|
|
AnsiConsole.Write(ledgerTable);
|
|
if (vuln.Ledger.Count > 10)
|
|
AnsiConsole.MarkupLine($"[grey]... and {vuln.Ledger.Count - 10} more entries[/]");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// References
|
|
if (vuln.References?.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]References[/]");
|
|
foreach (var refItem in vuln.References.Take(10))
|
|
{
|
|
var title = !string.IsNullOrWhiteSpace(refItem.Title) ? refItem.Title : refItem.Type;
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(title)}:[/] {Markup.Escape(refItem.Url)}");
|
|
}
|
|
if (vuln.References.Count > 10)
|
|
AnsiConsole.MarkupLine($" [grey]... and {vuln.References.Count - 10} more references[/]");
|
|
}
|
|
}
|
|
|
|
// CLI-VULN-29-003: Vulnerability workflow handler
|
|
public static async Task HandleVulnWorkflowAsync(
|
|
IServiceProvider services,
|
|
string action,
|
|
IReadOnlyList<string> vulnIds,
|
|
string? filterSeverity,
|
|
string? filterStatus,
|
|
string? filterPurl,
|
|
string? filterSbom,
|
|
string? tenant,
|
|
string? idempotencyKey,
|
|
bool emitJson,
|
|
bool verbose,
|
|
string? assignee,
|
|
string? comment,
|
|
string? justification,
|
|
string? dueDate,
|
|
string? fixVersion,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-workflow");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.workflow", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", $"vuln {action.Replace("_", "-")}");
|
|
activity?.SetTag("stellaops.cli.workflow.action", action);
|
|
using var duration = CliMetrics.MeasureCommandDuration($"vuln {action.Replace("_", "-")}");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
// Validate that we have either vulnIds or filter criteria
|
|
var hasVulnIds = vulnIds.Count > 0;
|
|
var hasFilter = !string.IsNullOrWhiteSpace(filterSeverity) ||
|
|
!string.IsNullOrWhiteSpace(filterStatus) ||
|
|
!string.IsNullOrWhiteSpace(filterPurl) ||
|
|
!string.IsNullOrWhiteSpace(filterSbom);
|
|
|
|
if (!hasVulnIds && !hasFilter)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Either --vuln-id or filter options (--filter-severity, --filter-status, --filter-purl, --filter-sbom) are required.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Parse due date if provided
|
|
DateTimeOffset? parsedDueDate = null;
|
|
if (!string.IsNullOrWhiteSpace(dueDate))
|
|
{
|
|
if (DateTimeOffset.TryParse(dueDate, out var parsed))
|
|
{
|
|
parsedDueDate = parsed;
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid due date format: {Markup.Escape(dueDate)}. Use ISO-8601 format (e.g., 2025-12-31).");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Build filter spec if filters provided
|
|
VulnFilterSpec? filterSpec = hasFilter
|
|
? new VulnFilterSpec(
|
|
Severity: filterSeverity,
|
|
Status: filterStatus,
|
|
Purl: filterPurl,
|
|
SbomId: filterSbom)
|
|
: null;
|
|
|
|
// Build request
|
|
var request = new VulnWorkflowRequest(
|
|
Action: action,
|
|
VulnerabilityIds: hasVulnIds ? vulnIds.ToList() : null,
|
|
Filter: filterSpec,
|
|
Assignee: assignee,
|
|
Comment: comment,
|
|
DueDate: parsedDueDate,
|
|
Justification: justification,
|
|
FixVersion: fixVersion,
|
|
IdempotencyKey: idempotencyKey);
|
|
|
|
logger.LogDebug("Executing vulnerability workflow: action={Action}, vulnIds={VulnCount}, hasFilter={HasFilter}",
|
|
action, vulnIds.Count, hasFilter);
|
|
|
|
var response = await client.ExecuteVulnWorkflowAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(response, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
Environment.ExitCode = response.Success ? 0 : 1;
|
|
return;
|
|
}
|
|
|
|
// Render result
|
|
var actionDisplay = action.Replace("_", " ");
|
|
if (response.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Success![/] {Markup.Escape(char.ToUpperInvariant(actionDisplay[0]) + actionDisplay[1..])} completed.");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Operation completed with errors.[/]");
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
|
|
var resultGrid = new Grid();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddRow("[grey]Action:[/]", Markup.Escape(actionDisplay));
|
|
resultGrid.AddRow("[grey]Affected:[/]", response.AffectedCount.ToString());
|
|
if (!string.IsNullOrWhiteSpace(response.IdempotencyKey))
|
|
resultGrid.AddRow("[grey]Idempotency Key:[/]", Markup.Escape(response.IdempotencyKey));
|
|
|
|
AnsiConsole.Write(resultGrid);
|
|
|
|
// Show affected IDs if not too many
|
|
if (response.AffectedIds != null && response.AffectedIds.Count > 0 && response.AffectedIds.Count <= 20)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold]Affected Vulnerabilities:[/]");
|
|
foreach (var id in response.AffectedIds)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]>[/] {Markup.Escape(id)}");
|
|
}
|
|
}
|
|
else if (response.AffectedIds != null && response.AffectedIds.Count > 20)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Affected {response.AffectedIds.Count} vulnerabilities (use --json to see full list)[/]");
|
|
}
|
|
|
|
// Show errors if any
|
|
if (response.Errors != null && response.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
|
|
|
|
var errorTable = new Table();
|
|
errorTable.AddColumn("[bold]Vulnerability ID[/]");
|
|
errorTable.AddColumn("[bold]Code[/]");
|
|
errorTable.AddColumn("[bold]Message[/]");
|
|
|
|
foreach (var error in response.Errors.Take(10))
|
|
{
|
|
errorTable.AddRow(
|
|
Markup.Escape(error.VulnerabilityId),
|
|
Markup.Escape(error.Code),
|
|
Markup.Escape(error.Message));
|
|
}
|
|
|
|
AnsiConsole.Write(errorTable);
|
|
|
|
if (response.Errors.Count > 10)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]... and {response.Errors.Count - 10} more errors[/]");
|
|
}
|
|
}
|
|
|
|
Environment.ExitCode = response.Success ? 0 : 1;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to execute vulnerability workflow action.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
// CLI-VULN-29-004: Vulnerability simulate handler
|
|
public static async Task HandleVulnSimulateAsync(
|
|
IServiceProvider services,
|
|
string? policyId,
|
|
int? policyVersion,
|
|
IReadOnlyList<string> vexOverrides,
|
|
string? severityThreshold,
|
|
IReadOnlyList<string> sbomIds,
|
|
bool outputMarkdown,
|
|
bool changedOnly,
|
|
string? tenant,
|
|
bool emitJson,
|
|
string? outputFile,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-simulate");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.simulate", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vuln simulate");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vuln simulate");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
// Parse VEX overrides
|
|
Dictionary<string, string>? parsedVexOverrides = null;
|
|
if (vexOverrides.Count > 0)
|
|
{
|
|
parsedVexOverrides = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var override_ in vexOverrides)
|
|
{
|
|
var parts = override_.Split('=', 2);
|
|
if (parts.Length != 2)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid VEX override format: {Markup.Escape(override_)}. Use vulnId=status format.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
parsedVexOverrides[parts[0].Trim()] = parts[1].Trim();
|
|
}
|
|
}
|
|
|
|
logger.LogDebug("Running vulnerability simulation: policyId={PolicyId}, policyVersion={PolicyVersion}, vexOverrides={OverrideCount}, sbomIds={SbomCount}",
|
|
policyId, policyVersion, vexOverrides.Count, sbomIds.Count);
|
|
|
|
var request = new VulnSimulationRequest(
|
|
PolicyId: policyId,
|
|
PolicyVersion: policyVersion,
|
|
VexOverrides: parsedVexOverrides,
|
|
SeverityThreshold: severityThreshold,
|
|
SbomIds: sbomIds.Count > 0 ? sbomIds.ToList() : null,
|
|
OutputMarkdown: outputMarkdown || !string.IsNullOrWhiteSpace(outputFile));
|
|
|
|
var response = await client.SimulateVulnerabilitiesAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(response, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
// Write markdown report to file if requested
|
|
if (!string.IsNullOrWhiteSpace(outputFile) && !string.IsNullOrWhiteSpace(response.MarkdownReport))
|
|
{
|
|
var outputDir = Path.GetDirectoryName(outputFile);
|
|
if (!string.IsNullOrWhiteSpace(outputDir) && !Directory.Exists(outputDir))
|
|
{
|
|
Directory.CreateDirectory(outputDir);
|
|
}
|
|
await File.WriteAllTextAsync(outputFile, response.MarkdownReport, cancellationToken).ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine($"[green]Markdown report written to:[/] {Markup.Escape(outputFile)}");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Render summary panel
|
|
var summaryGrid = new Grid();
|
|
summaryGrid.AddColumn();
|
|
summaryGrid.AddColumn();
|
|
summaryGrid.AddRow("[grey]Total Evaluated:[/]", response.Summary.TotalEvaluated.ToString());
|
|
summaryGrid.AddRow("[grey]Total Changed:[/]", response.Summary.TotalChanged > 0
|
|
? $"[yellow]{response.Summary.TotalChanged}[/]"
|
|
: "[green]0[/]");
|
|
summaryGrid.AddRow("[grey]Status Upgrades:[/]", response.Summary.StatusUpgrades > 0
|
|
? $"[green]+{response.Summary.StatusUpgrades}[/]"
|
|
: "0");
|
|
summaryGrid.AddRow("[grey]Status Downgrades:[/]", response.Summary.StatusDowngrades > 0
|
|
? $"[red]-{response.Summary.StatusDowngrades}[/]"
|
|
: "0");
|
|
summaryGrid.AddRow("[grey]No Change:[/]", response.Summary.NoChange.ToString());
|
|
|
|
if (!string.IsNullOrWhiteSpace(policyId))
|
|
summaryGrid.AddRow("[grey]Policy:[/]", $"{Markup.Escape(policyId)}" + (policyVersion.HasValue ? $" v{policyVersion}" : ""));
|
|
if (!string.IsNullOrWhiteSpace(severityThreshold))
|
|
summaryGrid.AddRow("[grey]Severity Threshold:[/]", Markup.Escape(severityThreshold));
|
|
|
|
var summaryPanel = new Panel(summaryGrid)
|
|
{
|
|
Header = new PanelHeader("[bold]Simulation Summary[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(summaryPanel);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Render delta table
|
|
var items = changedOnly
|
|
? response.Items.Where(i => i.Changed).ToList()
|
|
: response.Items;
|
|
|
|
if (items.Count > 0)
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn(new TableColumn("[bold]Vulnerability ID[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Before[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]After[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Change[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Reason[/]").LeftAligned());
|
|
|
|
foreach (var item in items.Take(50))
|
|
{
|
|
var beforeColor = GetVulnStatusColor(item.BeforeStatus);
|
|
var afterColor = GetVulnStatusColor(item.AfterStatus);
|
|
var changeIndicator = item.Changed
|
|
? (IsStatusUpgrade(item.BeforeStatus, item.AfterStatus) ? "[green]UPGRADE[/]" : "[red]DOWNGRADE[/]")
|
|
: "[grey]--[/]";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(item.VulnerabilityId),
|
|
$"[{beforeColor}]{Markup.Escape(item.BeforeStatus)}[/]",
|
|
$"[{afterColor}]{Markup.Escape(item.AfterStatus)}[/]",
|
|
changeIndicator,
|
|
Markup.Escape(item.ChangeReason ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (items.Count > 50)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]... and {items.Count - 50} more items (use --json for full list)[/]");
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
else if (changedOnly)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]No vulnerabilities would change status with the simulated configuration.[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No vulnerabilities in simulation scope.[/]");
|
|
}
|
|
|
|
// Print markdown to console if requested and not written to file
|
|
if (outputMarkdown && string.IsNullOrWhiteSpace(outputFile) && !string.IsNullOrWhiteSpace(response.MarkdownReport))
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold]Markdown Report:[/]");
|
|
AnsiConsole.WriteLine(response.MarkdownReport);
|
|
}
|
|
|
|
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 run vulnerability simulation.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static bool IsStatusUpgrade(string before, string after)
|
|
{
|
|
// Status priority (lower is better): fixed > risk_accepted > false_positive > triaged > open
|
|
static int GetPriority(string status) => status.ToLowerInvariant() switch
|
|
{
|
|
"fixed" => 0,
|
|
"risk_accepted" => 1,
|
|
"false_positive" => 2,
|
|
"accepted" => 3,
|
|
"triaged" => 4,
|
|
"open" => 5,
|
|
_ => 10
|
|
};
|
|
|
|
return GetPriority(after) < GetPriority(before);
|
|
}
|
|
|
|
// CLI-VULN-29-005: Vulnerability export handler
|
|
public static async Task HandleVulnExportAsync(
|
|
IServiceProvider services,
|
|
IReadOnlyList<string> vulnIds,
|
|
IReadOnlyList<string> sbomIds,
|
|
string? policyId,
|
|
string format,
|
|
bool includeEvidence,
|
|
bool includeLedger,
|
|
bool signed,
|
|
string outputPath,
|
|
string? tenant,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-export");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.export", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vuln export");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vuln export");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Output path is required.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var outputDir = Path.GetDirectoryName(outputPath);
|
|
if (!string.IsNullOrWhiteSpace(outputDir) && !Directory.Exists(outputDir))
|
|
{
|
|
Directory.CreateDirectory(outputDir);
|
|
}
|
|
|
|
logger.LogDebug("Exporting vulnerability bundle: vulnIds={VulnCount}, sbomIds={SbomCount}, format={Format}, signed={Signed}",
|
|
vulnIds.Count, sbomIds.Count, format, signed);
|
|
|
|
var request = new VulnExportRequest(
|
|
VulnerabilityIds: vulnIds.Count > 0 ? vulnIds.ToList() : null,
|
|
SbomIds: sbomIds.Count > 0 ? sbomIds.ToList() : null,
|
|
PolicyId: policyId,
|
|
Format: format,
|
|
IncludeEvidence: includeEvidence,
|
|
IncludeLedger: includeLedger,
|
|
Signed: signed);
|
|
|
|
await AnsiConsole.Status()
|
|
.Spinner(Spinner.Known.Dots)
|
|
.StartAsync("Preparing export...", async ctx =>
|
|
{
|
|
var exportResponse = await client.ExportVulnerabilitiesAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
ctx.Status("Downloading export bundle...");
|
|
|
|
await using var downloadStream = await client.DownloadVulnExportAsync(exportResponse.ExportId, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
await using var fileStream = File.Create(outputPath);
|
|
await downloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
|
|
|
AnsiConsole.MarkupLine($"[green]Export complete![/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var resultGrid = new Grid();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddRow("[grey]Output File:[/]", Markup.Escape(outputPath));
|
|
resultGrid.AddRow("[grey]Items Exported:[/]", exportResponse.ItemCount.ToString());
|
|
resultGrid.AddRow("[grey]Format:[/]", Markup.Escape(exportResponse.Format));
|
|
resultGrid.AddRow("[grey]Signed:[/]", exportResponse.Signed ? "[green]Yes[/]" : "[yellow]No[/]");
|
|
if (exportResponse.Signed)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(exportResponse.SignatureAlgorithm))
|
|
resultGrid.AddRow("[grey]Signature Algorithm:[/]", Markup.Escape(exportResponse.SignatureAlgorithm));
|
|
if (!string.IsNullOrWhiteSpace(exportResponse.SignatureKeyId))
|
|
resultGrid.AddRow("[grey]Key ID:[/]", Markup.Escape(exportResponse.SignatureKeyId));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(exportResponse.Digest))
|
|
{
|
|
var digestDisplay = exportResponse.Digest.Length > 32
|
|
? exportResponse.Digest[..32] + "..."
|
|
: exportResponse.Digest;
|
|
resultGrid.AddRow("[grey]Digest:[/]", $"{exportResponse.DigestAlgorithm ?? "sha256"}:{Markup.Escape(digestDisplay)}");
|
|
}
|
|
if (exportResponse.ExpiresAt.HasValue)
|
|
{
|
|
resultGrid.AddRow("[grey]Expires:[/]", exportResponse.ExpiresAt.Value.ToString("yyyy-MM-dd HH:mm UTC"));
|
|
}
|
|
|
|
AnsiConsole.Write(resultGrid);
|
|
});
|
|
|
|
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 export vulnerability bundle.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
// CLI-VULN-29-005: Vulnerability export verify handler
|
|
public static async Task HandleVulnExportVerifyAsync(
|
|
IServiceProvider services,
|
|
string filePath,
|
|
string? expectedDigest,
|
|
string? publicKeyPath,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-export-verify");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.export.verify", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "vuln export verify");
|
|
using var duration = CliMetrics.MeasureCommandDuration("vuln export verify");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] File path is required.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (!File.Exists(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {Markup.Escape(filePath)}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
logger.LogDebug("Verifying vulnerability export: file={FilePath}, expectedDigest={Digest}", filePath, expectedDigest ?? "(none)");
|
|
|
|
// Calculate SHA-256 digest
|
|
string actualDigest;
|
|
await using (var fileStream = File.OpenRead(filePath))
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var hashBytes = await sha256.ComputeHashAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
|
actualDigest = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
|
}
|
|
|
|
var resultGrid = new Grid();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddColumn();
|
|
resultGrid.AddRow("[grey]File:[/]", Markup.Escape(filePath));
|
|
resultGrid.AddRow("[grey]Actual Digest:[/]", $"sha256:{Markup.Escape(actualDigest)}");
|
|
|
|
var digestValid = true;
|
|
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
|
{
|
|
var normalizedExpected = expectedDigest.Trim().ToLowerInvariant();
|
|
if (normalizedExpected.StartsWith("sha256:"))
|
|
{
|
|
normalizedExpected = normalizedExpected[7..];
|
|
}
|
|
|
|
digestValid = string.Equals(actualDigest, normalizedExpected, StringComparison.OrdinalIgnoreCase);
|
|
resultGrid.AddRow("[grey]Expected Digest:[/]", $"sha256:{Markup.Escape(normalizedExpected)}");
|
|
resultGrid.AddRow("[grey]Digest Match:[/]", digestValid ? "[green]YES[/]" : "[red]NO[/]");
|
|
}
|
|
|
|
var sigStatus = "not_verified";
|
|
|
|
if (!string.IsNullOrWhiteSpace(publicKeyPath))
|
|
{
|
|
if (!File.Exists(publicKeyPath))
|
|
{
|
|
resultGrid.AddRow("[grey]Signature:[/]", $"[red]Public key not found:[/] {Markup.Escape(publicKeyPath)}");
|
|
}
|
|
else
|
|
{
|
|
// Look for .sig file
|
|
var sigPath = filePath + ".sig";
|
|
if (File.Exists(sigPath))
|
|
{
|
|
// Note: Actual signature verification would require cryptographic operations
|
|
// This is a placeholder that shows the structure
|
|
resultGrid.AddRow("[grey]Signature File:[/]", Markup.Escape(sigPath));
|
|
resultGrid.AddRow("[grey]Public Key:[/]", Markup.Escape(publicKeyPath));
|
|
resultGrid.AddRow("[grey]Signature Status:[/]", "[yellow]Verification requires runtime crypto support[/]");
|
|
sigStatus = "requires_verification";
|
|
}
|
|
else
|
|
{
|
|
resultGrid.AddRow("[grey]Signature:[/]", "[yellow]No .sig file found[/]");
|
|
sigStatus = "no_signature";
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
resultGrid.AddRow("[grey]Signature:[/]", "[grey]Skipped (no --public-key provided)[/]");
|
|
sigStatus = "skipped";
|
|
}
|
|
|
|
var panel = new Panel(resultGrid)
|
|
{
|
|
Header = new PanelHeader("[bold]Vulnerability Export Verification[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (!digestValid)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Verification FAILED: Digest mismatch[/]");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
else if (sigStatus == "no_signature" && !string.IsNullOrWhiteSpace(publicKeyPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Warning: No signature file found for verification[/]");
|
|
Environment.ExitCode = 0;
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Verification completed[/]");
|
|
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 verify vulnerability export.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region CLI-LNM-22-001: Advisory commands
|
|
|
|
// CLI-LNM-22-001: Handle advisory obs get
|
|
public static async Task HandleAdvisoryObsGetAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
IReadOnlyList<string> observationIds,
|
|
IReadOnlyList<string> aliases,
|
|
IReadOnlyList<string> purls,
|
|
IReadOnlyList<string> cpes,
|
|
IReadOnlyList<string> sources,
|
|
string? severity,
|
|
bool kevOnly,
|
|
bool? hasFix,
|
|
int? limit,
|
|
string? cursor,
|
|
bool emitJson,
|
|
bool emitOsv,
|
|
bool showConflicts,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IConcelierObservationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advisory-obs");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.obs.get", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "advisory obs");
|
|
activity?.SetTag("stellaops.cli.tenant", tenant);
|
|
using var duration = CliMetrics.MeasureCommandDuration("advisory obs");
|
|
|
|
try
|
|
{
|
|
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
throw new InvalidOperationException("Tenant must be provided.");
|
|
}
|
|
|
|
var query = new AdvisoryLinksetQuery(
|
|
tenant,
|
|
NormalizeSet(observationIds, toLower: false),
|
|
NormalizeSet(aliases, toLower: true),
|
|
NormalizeSet(purls, toLower: false),
|
|
NormalizeSet(cpes, toLower: false),
|
|
NormalizeSet(sources, toLower: true),
|
|
severity,
|
|
kevOnly ? true : null,
|
|
hasFix,
|
|
limit,
|
|
cursor);
|
|
|
|
var response = await client.GetLinksetAsync(query, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (response.Observations.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No observations matched the provided filters.[/]");
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
if (emitOsv)
|
|
{
|
|
var osvRecords = ConvertToOsv(response, tenant);
|
|
var osvJson = JsonSerializer.Serialize(osvRecords, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(osvJson);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOutput = showConflicts
|
|
? JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true })
|
|
: JsonSerializer.Serialize(new { response.Observations, response.Linkset, response.NextCursor, response.HasMore }, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(jsonOutput);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderAdvisoryObservationTable(response);
|
|
|
|
if (showConflicts && response.Conflicts.Count > 0)
|
|
{
|
|
RenderConflictsTable(response.Conflicts);
|
|
}
|
|
|
|
if (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 advisory observations.");
|
|
Environment.ExitCode = 9; // ERR_AGG_* exit code
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static IReadOnlyList<string> NormalizeSet(IReadOnlyList<string> values, bool toLower)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var set = new HashSet<string>(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<string>() : set.ToArray();
|
|
}
|
|
|
|
static void RenderAdvisoryObservationTable(AdvisoryLinksetResponse response)
|
|
{
|
|
var observations = response.Observations;
|
|
|
|
var table = new Table()
|
|
.Centered()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Observation");
|
|
table.AddColumn("Source");
|
|
table.AddColumn("Aliases");
|
|
table.AddColumn("Severity");
|
|
table.AddColumn("KEV");
|
|
table.AddColumn("Fix");
|
|
table.AddColumn("Created (UTC)");
|
|
|
|
foreach (var obs in observations)
|
|
{
|
|
var sourceVendor = obs.Source?.Vendor ?? "(unknown)";
|
|
var aliasesText = FormatList(obs.Linkset?.Aliases);
|
|
var severityText = obs.Severity?.Level ?? "(n/a)";
|
|
var kevText = obs.Kev?.Listed == true ? "[red]YES[/]" : "[grey]no[/]";
|
|
var fixText = obs.Fix?.Available == true ? "[green]available[/]" : "[grey]none[/]";
|
|
|
|
table.AddRow(
|
|
new Markup(Markup.Escape(obs.ObservationId)),
|
|
new Markup(Markup.Escape(sourceVendor)),
|
|
new Markup(Markup.Escape(aliasesText)),
|
|
new Markup(Markup.Escape(severityText)),
|
|
new Markup(kevText),
|
|
new Markup(fixText),
|
|
new Markup(Markup.Escape(obs.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
var linkset = response.Linkset;
|
|
var conflictCount = response.Conflicts?.Count ?? 0;
|
|
|
|
var summary = new StringBuilder();
|
|
summary.Append($"[green]{observations.Count}[/] observation(s). ");
|
|
summary.Append($"Aliases: [green]{linkset?.Aliases?.Count ?? 0}[/], ");
|
|
summary.Append($"PURLs: [green]{linkset?.Purls?.Count ?? 0}[/], ");
|
|
summary.Append($"CPEs: [green]{linkset?.Cpes?.Count ?? 0}[/]");
|
|
|
|
if (conflictCount > 0)
|
|
{
|
|
summary.Append($", [yellow]{conflictCount} conflict(s)[/]");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine(summary.ToString());
|
|
}
|
|
|
|
static void RenderConflictsTable(IReadOnlyList<AdvisoryLinksetConflict> conflicts)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold yellow]Conflicts Detected:[/]");
|
|
|
|
var table = new Table()
|
|
.Centered()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Type");
|
|
table.AddColumn("Field");
|
|
table.AddColumn("Sources");
|
|
table.AddColumn("Resolution");
|
|
|
|
foreach (var conflict in conflicts)
|
|
{
|
|
var sourcesSummary = conflict.Sources.Count > 0
|
|
? string.Join(", ", conflict.Sources.Select(s => $"{s.Source}={s.Value}"))
|
|
: "(none)";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(conflict.Type),
|
|
Markup.Escape(conflict.Field),
|
|
Markup.Escape(sourcesSummary.Length > 50 ? sourcesSummary[..47] + "..." : sourcesSummary),
|
|
Markup.Escape(conflict.Resolution ?? "(none)"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
static string FormatList(IReadOnlyList<string>? 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})";
|
|
}
|
|
|
|
static IReadOnlyList<OsvVulnerability> ConvertToOsv(AdvisoryLinksetResponse response, string tenant)
|
|
{
|
|
// Group observations by primary alias (CVE)
|
|
var groupedByAlias = new Dictionary<string, List<AdvisoryLinksetObservation>>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var obs in response.Observations)
|
|
{
|
|
var primaryAlias = obs.Linkset?.Aliases?.FirstOrDefault(a =>
|
|
a.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase)) ?? obs.ObservationId;
|
|
|
|
if (!groupedByAlias.TryGetValue(primaryAlias, out var list))
|
|
{
|
|
list = new List<AdvisoryLinksetObservation>();
|
|
groupedByAlias[primaryAlias] = list;
|
|
}
|
|
list.Add(obs);
|
|
}
|
|
|
|
var results = new List<OsvVulnerability>();
|
|
|
|
foreach (var kvp in groupedByAlias)
|
|
{
|
|
var primaryId = kvp.Key;
|
|
var observations = kvp.Value;
|
|
var representative = observations[0];
|
|
|
|
var allAliases = observations
|
|
.SelectMany(o => o.Linkset?.Aliases ?? Array.Empty<string>())
|
|
.Where(a => !string.Equals(a, primaryId, StringComparison.OrdinalIgnoreCase))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var allPurls = observations
|
|
.SelectMany(o => o.Linkset?.Purls ?? Array.Empty<string>())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var allSources = observations
|
|
.Select(o => o.Source?.Vendor)
|
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var severity = representative.Severity;
|
|
var severities = new List<OsvSeverity>();
|
|
if (severity?.CvssV3 is { } cvss3)
|
|
{
|
|
severities.Add(new OsvSeverity
|
|
{
|
|
Type = "CVSS_V3",
|
|
Score = cvss3.Vector ?? $"{cvss3.Score:F1}"
|
|
});
|
|
}
|
|
|
|
var affected = new List<OsvAffected>();
|
|
foreach (var purl in allPurls)
|
|
{
|
|
var (ecosystem, name) = ParsePurl(purl);
|
|
affected.Add(new OsvAffected
|
|
{
|
|
Package = new OsvPackage
|
|
{
|
|
Ecosystem = ecosystem,
|
|
Name = name,
|
|
Purl = purl
|
|
}
|
|
});
|
|
}
|
|
|
|
var references = observations
|
|
.SelectMany(o => o.Linkset?.References ?? Array.Empty<AdvisoryObservationReference>())
|
|
.Select(r => new OsvReference { Type = r.Type, Url = r.Url })
|
|
.DistinctBy(r => r.Url)
|
|
.ToList();
|
|
|
|
var kevInfo = representative.Kev;
|
|
|
|
var osv = new OsvVulnerability
|
|
{
|
|
Id = primaryId,
|
|
Modified = (representative.UpdatedAt ?? representative.CreatedAt).ToString("O", CultureInfo.InvariantCulture),
|
|
Published = representative.CreatedAt.ToString("O", CultureInfo.InvariantCulture),
|
|
Aliases = allAliases,
|
|
Severity = severities,
|
|
Affected = affected,
|
|
References = references,
|
|
DatabaseSpecific = new OsvDatabaseSpecific
|
|
{
|
|
Source = "stellaops",
|
|
Kev = kevInfo is not null ? new OsvKevInfo
|
|
{
|
|
Listed = kevInfo.Listed,
|
|
AddedDate = kevInfo.AddedDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
|
DueDate = kevInfo.DueDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
|
Ransomware = kevInfo.KnownRansomwareCampaignUse
|
|
} : null,
|
|
StellaOps = new OsvStellaOpsInfo
|
|
{
|
|
ObservationIds = observations.Select(o => o.ObservationId).ToList(),
|
|
Tenant = tenant,
|
|
Sources = allSources!,
|
|
HasConflicts = response.Conflicts?.Count > 0
|
|
}
|
|
}
|
|
};
|
|
|
|
results.Add(osv);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
static (string ecosystem, string name) ParsePurl(string purl)
|
|
{
|
|
// Parse pkg:ecosystem/name@version format
|
|
if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return ("unknown", purl);
|
|
}
|
|
|
|
var withoutPrefix = purl[4..];
|
|
var slashIndex = withoutPrefix.IndexOf('/');
|
|
if (slashIndex < 0)
|
|
{
|
|
return ("unknown", withoutPrefix);
|
|
}
|
|
|
|
var ecosystem = withoutPrefix[..slashIndex];
|
|
var rest = withoutPrefix[(slashIndex + 1)..];
|
|
|
|
var atIndex = rest.IndexOf('@');
|
|
var name = atIndex >= 0 ? rest[..atIndex] : rest;
|
|
|
|
return (ecosystem, name);
|
|
}
|
|
}
|
|
|
|
// CLI-LNM-22-001: Handle advisory linkset show
|
|
public static async Task HandleAdvisoryLinksetShowAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
IReadOnlyList<string> aliases,
|
|
IReadOnlyList<string> purls,
|
|
IReadOnlyList<string> cpes,
|
|
IReadOnlyList<string> sources,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IConcelierObservationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advisory-linkset");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.linkset.show", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "advisory linkset");
|
|
activity?.SetTag("stellaops.cli.tenant", tenant);
|
|
using var duration = CliMetrics.MeasureCommandDuration("advisory linkset");
|
|
|
|
try
|
|
{
|
|
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
throw new InvalidOperationException("Tenant must be provided.");
|
|
}
|
|
|
|
var query = new AdvisoryLinksetQuery(
|
|
tenant,
|
|
Array.Empty<string>(),
|
|
NormalizeSet(aliases, toLower: true),
|
|
NormalizeSet(purls, toLower: false),
|
|
NormalizeSet(cpes, toLower: false),
|
|
NormalizeSet(sources, toLower: true),
|
|
Severity: null,
|
|
KevOnly: null,
|
|
HasFix: null,
|
|
Limit: null,
|
|
Cursor: null);
|
|
|
|
var response = await client.GetLinksetAsync(query, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOutput = JsonSerializer.Serialize(new
|
|
{
|
|
response.Linkset,
|
|
response.Conflicts,
|
|
TotalObservations = response.Observations.Count
|
|
}, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(jsonOutput);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderLinksetSummary(response);
|
|
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 advisory linkset.");
|
|
Environment.ExitCode = 9;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static IReadOnlyList<string> NormalizeSet(IReadOnlyList<string> values, bool toLower)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var set = new HashSet<string>(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<string>() : set.ToArray();
|
|
}
|
|
|
|
static void RenderLinksetSummary(AdvisoryLinksetResponse response)
|
|
{
|
|
var linkset = response.Linkset;
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[bold]Linkset Summary[/]", "");
|
|
grid.AddRow("[grey]Total Observations:[/]", $"[green]{response.Observations.Count}[/]");
|
|
grid.AddRow("[grey]Aliases:[/]", $"{linkset.Aliases.Count}");
|
|
grid.AddRow("[grey]PURLs:[/]", $"{linkset.Purls.Count}");
|
|
grid.AddRow("[grey]CPEs:[/]", $"{linkset.Cpes.Count}");
|
|
grid.AddRow("[grey]References:[/]", $"{linkset.References.Count}");
|
|
|
|
if (linkset.SourceCoverage is { } coverage)
|
|
{
|
|
grid.AddRow("[grey]Source Coverage:[/]", $"{coverage.CoveragePercent:F1}% ({coverage.TotalSources} sources)");
|
|
}
|
|
|
|
if (linkset.ConflictSummary is { } conflicts && conflicts.HasConflicts)
|
|
{
|
|
grid.AddRow("[yellow]Conflicts:[/]", $"[yellow]{conflicts.TotalConflicts} total[/]");
|
|
if (conflicts.SeverityConflicts > 0)
|
|
grid.AddRow("", $" Severity: {conflicts.SeverityConflicts}");
|
|
if (conflicts.KevConflicts > 0)
|
|
grid.AddRow("", $" KEV: {conflicts.KevConflicts}");
|
|
if (conflicts.FixConflicts > 0)
|
|
grid.AddRow("", $" Fix: {conflicts.FixConflicts}");
|
|
}
|
|
else
|
|
{
|
|
grid.AddRow("[grey]Conflicts:[/]", "[green]None[/]");
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Advisory Linkset[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Show aliases
|
|
if (linkset.Aliases.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Aliases:[/]");
|
|
foreach (var alias in linkset.Aliases.Take(20))
|
|
{
|
|
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(alias)}[/]");
|
|
}
|
|
if (linkset.Aliases.Count > 20)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]... and {linkset.Aliases.Count - 20} more[/]");
|
|
}
|
|
}
|
|
|
|
// Show PURLs
|
|
if (linkset.Purls.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Package URLs:[/]");
|
|
foreach (var purl in linkset.Purls.Take(10))
|
|
{
|
|
AnsiConsole.MarkupLine($" [blue]{Markup.Escape(purl)}[/]");
|
|
}
|
|
if (linkset.Purls.Count > 10)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]... and {linkset.Purls.Count - 10} more[/]");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-LNM-22-001: Handle advisory export
|
|
public static async Task HandleAdvisoryExportAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
IReadOnlyList<string> aliases,
|
|
IReadOnlyList<string> purls,
|
|
IReadOnlyList<string> cpes,
|
|
IReadOnlyList<string> sources,
|
|
string? severity,
|
|
bool kevOnly,
|
|
bool? hasFix,
|
|
int? limit,
|
|
string format,
|
|
string? outputPath,
|
|
bool signed,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IConcelierObservationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advisory-export");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.export", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "advisory export");
|
|
activity?.SetTag("stellaops.cli.tenant", tenant);
|
|
activity?.SetTag("stellaops.cli.format", format);
|
|
using var duration = CliMetrics.MeasureCommandDuration("advisory export");
|
|
|
|
try
|
|
{
|
|
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
throw new InvalidOperationException("Tenant must be provided.");
|
|
}
|
|
|
|
var exportFormat = format?.ToLowerInvariant() switch
|
|
{
|
|
"osv" => AdvisoryExportFormat.Osv,
|
|
"ndjson" => AdvisoryExportFormat.Ndjson,
|
|
"csv" => AdvisoryExportFormat.Csv,
|
|
_ => AdvisoryExportFormat.Json
|
|
};
|
|
|
|
var query = new AdvisoryLinksetQuery(
|
|
tenant,
|
|
Array.Empty<string>(),
|
|
NormalizeSet(aliases, toLower: true),
|
|
NormalizeSet(purls, toLower: false),
|
|
NormalizeSet(cpes, toLower: false),
|
|
NormalizeSet(sources, toLower: true),
|
|
severity,
|
|
kevOnly ? true : null,
|
|
hasFix,
|
|
limit ?? 500,
|
|
Cursor: null);
|
|
|
|
var response = await client.GetLinksetAsync(query, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (response.Observations.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No observations matched the provided filters. Nothing to export.[/]");
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
var output = exportFormat switch
|
|
{
|
|
AdvisoryExportFormat.Osv => GenerateOsvExport(response, tenant),
|
|
AdvisoryExportFormat.Ndjson => GenerateNdjsonExport(response),
|
|
AdvisoryExportFormat.Csv => GenerateCsvExport(response),
|
|
_ => GenerateJsonExport(response)
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
var fullPath = Path.GetFullPath(outputPath);
|
|
await File.WriteAllTextAsync(fullPath, output, cancellationToken).ConfigureAwait(false);
|
|
logger.LogInformation("Exported {Count} observations to {Path} ({Format})", response.Observations.Count, fullPath, format);
|
|
AnsiConsole.MarkupLine($"[green]Exported[/] {response.Observations.Count} observations to [cyan]{Markup.Escape(fullPath)}[/]");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(output);
|
|
}
|
|
|
|
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 export advisory observations.");
|
|
Environment.ExitCode = 9;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static IReadOnlyList<string> NormalizeSet(IReadOnlyList<string> values, bool toLower)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var set = new HashSet<string>(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<string>() : set.ToArray();
|
|
}
|
|
|
|
static string GenerateJsonExport(AdvisoryLinksetResponse response)
|
|
{
|
|
return JsonSerializer.Serialize(new
|
|
{
|
|
exportedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
|
count = response.Observations.Count,
|
|
observations = response.Observations,
|
|
linkset = response.Linkset,
|
|
conflicts = response.Conflicts
|
|
}, new JsonSerializerOptions { WriteIndented = true });
|
|
}
|
|
|
|
static string GenerateOsvExport(AdvisoryLinksetResponse response, string tenant)
|
|
{
|
|
var osvRecords = ConvertToOsvInternal(response, tenant);
|
|
return JsonSerializer.Serialize(osvRecords, new JsonSerializerOptions { WriteIndented = true });
|
|
}
|
|
|
|
static string GenerateNdjsonExport(AdvisoryLinksetResponse response)
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var obs in response.Observations)
|
|
{
|
|
sb.AppendLine(JsonSerializer.Serialize(obs));
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
static string GenerateCsvExport(AdvisoryLinksetResponse response)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("observation_id,tenant,source,upstream_id,aliases,severity,kev,has_fix,created_at");
|
|
|
|
foreach (var obs in response.Observations)
|
|
{
|
|
var aliases = obs.Linkset?.Aliases is { Count: > 0 } a ? string.Join(";", a) : "";
|
|
var severity = obs.Severity?.Level ?? "";
|
|
var kev = obs.Kev?.Listed == true ? "true" : "false";
|
|
var hasFix = obs.Fix?.Available == true ? "true" : "false";
|
|
|
|
sb.AppendLine($"\"{obs.ObservationId}\",\"{obs.Tenant}\",\"{obs.Source?.Vendor}\",\"{obs.Upstream?.UpstreamId}\",\"{aliases}\",\"{severity}\",{kev},{hasFix},{obs.CreatedAt:O}");
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
static IReadOnlyList<OsvVulnerability> ConvertToOsvInternal(AdvisoryLinksetResponse response, string tenant)
|
|
{
|
|
var groupedByAlias = new Dictionary<string, List<AdvisoryLinksetObservation>>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var obs in response.Observations)
|
|
{
|
|
var primaryAlias = obs.Linkset?.Aliases?.FirstOrDefault(a =>
|
|
a.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase)) ?? obs.ObservationId;
|
|
|
|
if (!groupedByAlias.TryGetValue(primaryAlias, out var list))
|
|
{
|
|
list = new List<AdvisoryLinksetObservation>();
|
|
groupedByAlias[primaryAlias] = list;
|
|
}
|
|
list.Add(obs);
|
|
}
|
|
|
|
var results = new List<OsvVulnerability>();
|
|
|
|
foreach (var kvp in groupedByAlias)
|
|
{
|
|
var primaryId = kvp.Key;
|
|
var observations = kvp.Value;
|
|
var representative = observations[0];
|
|
|
|
var allAliases = observations
|
|
.SelectMany(o => o.Linkset?.Aliases ?? Array.Empty<string>())
|
|
.Where(a => !string.Equals(a, primaryId, StringComparison.OrdinalIgnoreCase))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var osv = new OsvVulnerability
|
|
{
|
|
Id = primaryId,
|
|
Modified = (representative.UpdatedAt ?? representative.CreatedAt).ToString("O", CultureInfo.InvariantCulture),
|
|
Published = representative.CreatedAt.ToString("O", CultureInfo.InvariantCulture),
|
|
Aliases = allAliases,
|
|
DatabaseSpecific = new OsvDatabaseSpecific
|
|
{
|
|
Source = "stellaops",
|
|
StellaOps = new OsvStellaOpsInfo
|
|
{
|
|
ObservationIds = observations.Select(o => o.ObservationId).ToList(),
|
|
Tenant = tenant,
|
|
HasConflicts = response.Conflicts?.Count > 0
|
|
}
|
|
}
|
|
};
|
|
|
|
results.Add(osv);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region CLI-FORENSICS-53-001: Forensic snapshot commands
|
|
|
|
// CLI-FORENSICS-53-001: Handle forensic snapshot create
|
|
public static async Task HandleForensicSnapshotCreateAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string caseId,
|
|
string? description,
|
|
IReadOnlyList<string> tags,
|
|
IReadOnlyList<string> sbomIds,
|
|
IReadOnlyList<string> scanIds,
|
|
IReadOnlyList<string> policyIds,
|
|
IReadOnlyList<string> vulnIds,
|
|
int? retentionDays,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IForensicSnapshotClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-snapshot");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.snapshot.create", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "forensic snapshot create");
|
|
activity?.SetTag("stellaops.cli.tenant", tenant);
|
|
activity?.SetTag("stellaops.cli.case_id", caseId);
|
|
using var duration = CliMetrics.MeasureCommandDuration("forensic snapshot create");
|
|
|
|
try
|
|
{
|
|
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
throw new InvalidOperationException("Tenant must be provided.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(caseId))
|
|
{
|
|
throw new InvalidOperationException("Case ID must be provided.");
|
|
}
|
|
|
|
var snapshotScope = new ForensicSnapshotScope(
|
|
SbomIds: sbomIds.Count > 0 ? sbomIds : null,
|
|
ScanIds: scanIds.Count > 0 ? scanIds : null,
|
|
PolicyIds: policyIds.Count > 0 ? policyIds : null,
|
|
VulnerabilityIds: vulnIds.Count > 0 ? vulnIds : null);
|
|
|
|
var request = new ForensicSnapshotCreateRequest(
|
|
caseId,
|
|
description,
|
|
tags.Count > 0 ? tags : null,
|
|
snapshotScope,
|
|
retentionDays);
|
|
|
|
logger.LogDebug("Creating forensic snapshot for case {CaseId} in tenant {Tenant}", caseId, tenant);
|
|
|
|
var snapshot = await client.CreateSnapshotAsync(tenant, request, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("stellaops.cli.snapshot_id", snapshot.SnapshotId);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderSnapshotCreated(snapshot);
|
|
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 create forensic snapshot.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderSnapshotCreated(ForensicSnapshotDocument snapshot)
|
|
{
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[bold green]Forensic Snapshot Created[/]", "");
|
|
grid.AddRow("[grey]Snapshot ID:[/]", $"[cyan]{Markup.Escape(snapshot.SnapshotId)}[/]");
|
|
grid.AddRow("[grey]Case ID:[/]", Markup.Escape(snapshot.CaseId));
|
|
grid.AddRow("[grey]Status:[/]", GetStatusMarkup(snapshot.Status));
|
|
grid.AddRow("[grey]Created At:[/]", snapshot.CreatedAt.ToString("u", CultureInfo.InvariantCulture));
|
|
|
|
if (snapshot.Manifest is { } manifest)
|
|
{
|
|
grid.AddRow("[grey]Manifest Digest:[/]", $"[yellow]{manifest.DigestAlgorithm}:{Markup.Escape(manifest.Digest)}[/]");
|
|
}
|
|
|
|
if (snapshot.ExpiresAt.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Expires At:[/]", snapshot.ExpiresAt.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
}
|
|
}
|
|
|
|
// CLI-FORENSICS-53-001: Handle forensic snapshot list
|
|
public static async Task HandleForensicSnapshotListAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string? caseId,
|
|
string? status,
|
|
IReadOnlyList<string> tags,
|
|
int? limit,
|
|
int? offset,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IForensicSnapshotClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-snapshot");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.snapshot.list", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "forensic list");
|
|
activity?.SetTag("stellaops.cli.tenant", tenant);
|
|
using var duration = CliMetrics.MeasureCommandDuration("forensic list");
|
|
|
|
try
|
|
{
|
|
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
throw new InvalidOperationException("Tenant must be provided.");
|
|
}
|
|
|
|
var query = new ForensicSnapshotListQuery(
|
|
tenant,
|
|
caseId,
|
|
status,
|
|
tags.Count > 0 ? tags : null,
|
|
CreatedAfter: null,
|
|
CreatedBefore: null,
|
|
limit,
|
|
offset);
|
|
|
|
var response = await client.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
if (response.Snapshots.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No forensic snapshots found matching the criteria.[/]");
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderSnapshotTable(response);
|
|
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 list forensic snapshots.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderSnapshotTable(ForensicSnapshotListResponse response)
|
|
{
|
|
var table = new Table()
|
|
.Centered()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Snapshot ID");
|
|
table.AddColumn("Case");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Artifacts");
|
|
table.AddColumn("Size");
|
|
table.AddColumn("Created (UTC)");
|
|
|
|
foreach (var snapshot in response.Snapshots)
|
|
{
|
|
var statusMarkup = GetStatusMarkup(snapshot.Status);
|
|
var artifactCount = snapshot.ArtifactCount?.ToString(CultureInfo.InvariantCulture) ?? "-";
|
|
var size = FormatSize(snapshot.SizeBytes);
|
|
|
|
table.AddRow(
|
|
new Markup(Markup.Escape(snapshot.SnapshotId.Length > 20 ? snapshot.SnapshotId[..17] + "..." : snapshot.SnapshotId)),
|
|
new Markup(Markup.Escape(snapshot.CaseId)),
|
|
new Markup(statusMarkup),
|
|
new Markup(Markup.Escape(artifactCount)),
|
|
new Markup(Markup.Escape(size)),
|
|
new Markup(Markup.Escape(snapshot.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine($"[green]{response.Snapshots.Count}[/] of [green]{response.Total}[/] snapshot(s).");
|
|
|
|
if (response.HasMore)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]More snapshots available. Use --limit and --offset to paginate.[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-FORENSICS-53-001: Handle forensic snapshot show
|
|
public static async Task HandleForensicSnapshotShowAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string snapshotId,
|
|
bool emitJson,
|
|
bool includeManifest,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IForensicSnapshotClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-snapshot");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.snapshot.show", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "forensic show");
|
|
activity?.SetTag("stellaops.cli.tenant", tenant);
|
|
activity?.SetTag("stellaops.cli.snapshot_id", snapshotId);
|
|
using var duration = CliMetrics.MeasureCommandDuration("forensic show");
|
|
|
|
try
|
|
{
|
|
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
throw new InvalidOperationException("Tenant must be provided.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(snapshotId))
|
|
{
|
|
throw new InvalidOperationException("Snapshot ID must be provided.");
|
|
}
|
|
|
|
var snapshot = await client.GetSnapshotAsync(tenant, snapshotId, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (snapshot is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Forensic snapshot not found:[/] {Markup.Escape(snapshotId)}");
|
|
Environment.ExitCode = 4; // NOT_FOUND
|
|
return;
|
|
}
|
|
|
|
ForensicSnapshotManifest? manifest = null;
|
|
if (includeManifest)
|
|
{
|
|
manifest = await client.GetSnapshotManifestAsync(tenant, snapshotId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var output = includeManifest
|
|
? new { snapshot, manifest }
|
|
: (object)snapshot;
|
|
var json = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = 0;
|
|
return;
|
|
}
|
|
|
|
RenderSnapshotDetails(snapshot, manifest);
|
|
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 show forensic snapshot.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderSnapshotDetails(ForensicSnapshotDocument snapshot, ForensicSnapshotManifest? manifest)
|
|
{
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[bold]Forensic Snapshot Details[/]", "");
|
|
grid.AddRow("[grey]Snapshot ID:[/]", $"[cyan]{Markup.Escape(snapshot.SnapshotId)}[/]");
|
|
grid.AddRow("[grey]Case ID:[/]", Markup.Escape(snapshot.CaseId));
|
|
grid.AddRow("[grey]Tenant:[/]", Markup.Escape(snapshot.Tenant));
|
|
grid.AddRow("[grey]Status:[/]", GetStatusMarkup(snapshot.Status));
|
|
|
|
if (!string.IsNullOrWhiteSpace(snapshot.Description))
|
|
{
|
|
grid.AddRow("[grey]Description:[/]", Markup.Escape(snapshot.Description));
|
|
}
|
|
|
|
grid.AddRow("[grey]Created At:[/]", snapshot.CreatedAt.ToString("u", CultureInfo.InvariantCulture));
|
|
|
|
if (snapshot.CompletedAt.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Completed At:[/]", snapshot.CompletedAt.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
if (snapshot.ExpiresAt.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Expires At:[/]", snapshot.ExpiresAt.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(snapshot.CreatedBy))
|
|
{
|
|
grid.AddRow("[grey]Created By:[/]", Markup.Escape(snapshot.CreatedBy));
|
|
}
|
|
|
|
if (snapshot.ArtifactCount.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Artifact Count:[/]", snapshot.ArtifactCount.Value.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
if (snapshot.SizeBytes.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Size:[/]", FormatSize(snapshot.SizeBytes));
|
|
}
|
|
|
|
if (snapshot.Tags.Count > 0)
|
|
{
|
|
grid.AddRow("[grey]Tags:[/]", string.Join(", ", snapshot.Tags.Select(t => $"[blue]{Markup.Escape(t)}[/]")));
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Snapshot[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Manifest details
|
|
var snapshotManifest = manifest ?? snapshot.Manifest;
|
|
if (snapshotManifest is not null)
|
|
{
|
|
RenderManifestDetails(snapshotManifest, manifest is not null);
|
|
}
|
|
}
|
|
|
|
static void RenderManifestDetails(ForensicSnapshotManifest manifest, bool includeArtifacts)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[bold]Manifest[/]", "");
|
|
grid.AddRow("[grey]Manifest ID:[/]", Markup.Escape(manifest.ManifestId));
|
|
grid.AddRow("[grey]Version:[/]", Markup.Escape(manifest.Version));
|
|
grid.AddRow("[grey]Digest:[/]", $"[yellow]{manifest.DigestAlgorithm}:{Markup.Escape(manifest.Digest)}[/]");
|
|
|
|
if (manifest.Signature is { } sig)
|
|
{
|
|
grid.AddRow("[grey]Signed:[/]", "[green]YES[/]");
|
|
grid.AddRow("[grey]Algorithm:[/]", Markup.Escape(sig.Algorithm));
|
|
if (!string.IsNullOrWhiteSpace(sig.KeyId))
|
|
{
|
|
grid.AddRow("[grey]Key ID:[/]", Markup.Escape(sig.KeyId));
|
|
}
|
|
if (sig.SignedAt.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Signed At:[/]", sig.SignedAt.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
grid.AddRow("[grey]Signed:[/]", "[grey]NO[/]");
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Artifacts table
|
|
if (includeArtifacts && manifest.Artifacts.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Artifacts:[/]");
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Type");
|
|
table.AddColumn("Path");
|
|
table.AddColumn("Digest");
|
|
table.AddColumn("Size");
|
|
|
|
foreach (var artifact in manifest.Artifacts)
|
|
{
|
|
var path = artifact.Path.Length > 30 ? "..." + artifact.Path[^27..] : artifact.Path;
|
|
var digest = artifact.Digest.Length > 16 ? artifact.Digest[..16] + "..." : artifact.Digest;
|
|
|
|
table.AddRow(
|
|
Markup.Escape(artifact.Type),
|
|
Markup.Escape(path),
|
|
$"[grey]{artifact.DigestAlgorithm}:[/]{Markup.Escape(digest)}",
|
|
FormatSize(artifact.SizeBytes));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
// Chain of custody
|
|
if (manifest.Metadata?.ChainOfCustody is { Count: > 0 } chain)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Chain of Custody:[/]");
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Simple);
|
|
|
|
table.AddColumn("Timestamp");
|
|
table.AddColumn("Action");
|
|
table.AddColumn("Actor");
|
|
table.AddColumn("Notes");
|
|
|
|
foreach (var entry in chain)
|
|
{
|
|
table.AddRow(
|
|
entry.Timestamp.ToString("u", CultureInfo.InvariantCulture),
|
|
Markup.Escape(entry.Action),
|
|
Markup.Escape(entry.Actor),
|
|
Markup.Escape(entry.Notes ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string GetStatusMarkup(string status)
|
|
{
|
|
return status?.ToLowerInvariant() switch
|
|
{
|
|
"ready" => "[green]ready[/]",
|
|
"pending" => "[yellow]pending[/]",
|
|
"creating" => "[blue]creating[/]",
|
|
"failed" => "[red]failed[/]",
|
|
"expired" => "[grey]expired[/]",
|
|
"archived" => "[grey]archived[/]",
|
|
_ => Markup.Escape(status ?? "(unknown)")
|
|
};
|
|
}
|
|
|
|
private static string FormatSize(long? bytes)
|
|
{
|
|
if (!bytes.HasValue || bytes.Value == 0)
|
|
{
|
|
return "-";
|
|
}
|
|
|
|
var size = bytes.Value;
|
|
string[] suffixes = ["B", "KB", "MB", "GB", "TB"];
|
|
var index = 0;
|
|
var value = (double)size;
|
|
|
|
while (value >= 1024 && index < suffixes.Length - 1)
|
|
{
|
|
value /= 1024;
|
|
index++;
|
|
}
|
|
|
|
return $"{value:F1} {suffixes[index]}";
|
|
}
|
|
|
|
// CLI-FORENSICS-54-001: Handle forensic verify command
|
|
public static async Task HandleForensicVerifyAsync(
|
|
IServiceProvider services,
|
|
string bundlePath,
|
|
bool emitJson,
|
|
string? trustRootPath,
|
|
bool verifyChecksums,
|
|
bool verifySignatures,
|
|
bool verifyChain,
|
|
bool strictTimeline,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var verifier = scope.ServiceProvider.GetRequiredService<IForensicVerifier>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-verify");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.verify", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "forensic verify");
|
|
activity?.SetTag("stellaops.cli.bundle_path", bundlePath);
|
|
using var duration = CliMetrics.MeasureCommandDuration("forensic verify");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(bundlePath))
|
|
{
|
|
throw new InvalidOperationException("Bundle path must be provided.");
|
|
}
|
|
|
|
var options = new ForensicVerificationOptions
|
|
{
|
|
VerifyChecksums = verifyChecksums,
|
|
VerifySignatures = verifySignatures,
|
|
VerifyChainOfCustody = verifyChain,
|
|
StrictTimeline = strictTimeline,
|
|
TrustRootPath = trustRootPath
|
|
};
|
|
|
|
var result = await verifier.VerifyBundleAsync(bundlePath, options, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = result.IsValid ? 0 : 12; // Exit code 12 for forensic verification errors
|
|
return;
|
|
}
|
|
|
|
RenderVerificationResult(result, verbose);
|
|
Environment.ExitCode = result.IsValid ? 0 : 12;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to verify forensic bundle.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderVerificationResult(ForensicVerificationResult result, bool verbose)
|
|
{
|
|
var statusIcon = result.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"\n[bold]Forensic Bundle Verification: {statusIcon}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Bundle:[/] {Markup.Escape(result.BundlePath)}");
|
|
AnsiConsole.MarkupLine($"[grey]Verified At:[/] {result.VerifiedAt:u}");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Manifest verification
|
|
if (result.ManifestVerification is { } manifest)
|
|
{
|
|
var manifestStatus = manifest.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Manifest Verification:[/] {manifestStatus}");
|
|
AnsiConsole.MarkupLine($" [grey]ID:[/] {Markup.Escape(manifest.ManifestId)}");
|
|
AnsiConsole.MarkupLine($" [grey]Version:[/] {Markup.Escape(manifest.Version)}");
|
|
AnsiConsole.MarkupLine($" [grey]Digest ({manifest.DigestAlgorithm}):[/] {Markup.Escape(manifest.Digest)}");
|
|
if (!manifest.IsValid)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Computed:[/] [red]{Markup.Escape(manifest.ComputedDigest)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine($" [grey]Artifacts:[/] {manifest.ArtifactCount}");
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Checksum verification
|
|
if (result.ChecksumVerification is { } checksums)
|
|
{
|
|
var checksumStatus = checksums.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Checksum Verification:[/] {checksumStatus}");
|
|
AnsiConsole.MarkupLine($" [grey]Total:[/] {checksums.TotalArtifacts}");
|
|
AnsiConsole.MarkupLine($" [grey]Verified:[/] [green]{checksums.VerifiedArtifacts}[/]");
|
|
if (checksums.FailedArtifacts.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Failed:[/] [red]{checksums.FailedArtifacts.Count}[/]");
|
|
if (verbose)
|
|
{
|
|
foreach (var failure in checksums.FailedArtifacts)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(failure.ArtifactId)}:[/] {Markup.Escape(failure.Reason)}");
|
|
}
|
|
}
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Signature verification
|
|
if (result.SignatureVerification is { } signatures)
|
|
{
|
|
var sigStatus = signatures.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Signature Verification:[/] {sigStatus}");
|
|
AnsiConsole.MarkupLine($" [grey]Signatures:[/] {signatures.SignatureCount}");
|
|
AnsiConsole.MarkupLine($" [grey]Verified:[/] [green]{signatures.VerifiedSignatures}[/]");
|
|
if (verbose && signatures.Signatures.Count > 0)
|
|
{
|
|
foreach (var sig in signatures.Signatures)
|
|
{
|
|
var sigIcon = sig.IsTrusted ? "[green]TRUSTED[/]" :
|
|
sig.IsValid ? "[yellow]VALID (UNTRUSTED)[/]" :
|
|
"[red]INVALID[/]";
|
|
AnsiConsole.MarkupLine($" - Key: {Markup.Escape(sig.KeyId)} {sigIcon}");
|
|
if (!string.IsNullOrWhiteSpace(sig.Reason))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(sig.Reason)}[/]");
|
|
}
|
|
}
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Chain of custody verification
|
|
if (result.ChainOfCustodyVerification is { } chain)
|
|
{
|
|
var chainStatus = chain.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Chain of Custody Verification:[/] {chainStatus}");
|
|
AnsiConsole.MarkupLine($" [grey]Entries:[/] {chain.EntryCount}");
|
|
AnsiConsole.MarkupLine($" [grey]Timeline:[/] {(chain.TimelineValid ? "[green]VALID[/]" : "[red]INVALID[/]")}");
|
|
AnsiConsole.MarkupLine($" [grey]Signatures:[/] {(chain.SignaturesValid ? "[green]VALID[/]" : "[red]INVALID[/]")}");
|
|
if (chain.Gaps.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Gaps:[/] [yellow]{chain.Gaps.Count}[/]");
|
|
if (verbose)
|
|
{
|
|
foreach (var gap in chain.Gaps)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(gap.Description)}[/]");
|
|
}
|
|
}
|
|
}
|
|
if (verbose && chain.Entries.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
var table = new Table()
|
|
.Border(TableBorder.Simple);
|
|
|
|
table.AddColumn("#");
|
|
table.AddColumn("Timestamp");
|
|
table.AddColumn("Action");
|
|
table.AddColumn("Actor");
|
|
table.AddColumn("Sig");
|
|
|
|
foreach (var entry in chain.Entries)
|
|
{
|
|
var sigStatus = entry.SignatureValid switch
|
|
{
|
|
true => "[green]OK[/]",
|
|
false => "[red]BAD[/]",
|
|
null => "[grey]-[/]"
|
|
};
|
|
table.AddRow(
|
|
entry.Index.ToString(CultureInfo.InvariantCulture),
|
|
entry.Timestamp.ToString("u", CultureInfo.InvariantCulture),
|
|
Markup.Escape(entry.Action),
|
|
Markup.Escape(entry.Actor),
|
|
sigStatus);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Errors
|
|
if (result.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]- [{Markup.Escape(error.Code)}][/] {Markup.Escape(error.Message)}");
|
|
if (!string.IsNullOrWhiteSpace(error.Detail))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(error.Detail)}[/]");
|
|
}
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Warnings
|
|
if (result.Warnings.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Summary
|
|
AnsiConsole.MarkupLine(result.IsValid
|
|
? "[bold green]All verification checks passed.[/]"
|
|
: "[bold red]Verification failed. See errors above.[/]");
|
|
}
|
|
}
|
|
|
|
// CLI-FORENSICS-54-002: Handle forensic attest show command
|
|
public static async Task HandleForensicAttestShowAsync(
|
|
IServiceProvider services,
|
|
string artifactPath,
|
|
bool emitJson,
|
|
string? trustRootPath,
|
|
bool verify,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var reader = scope.ServiceProvider.GetRequiredService<IAttestationReader>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-attest");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.attest.show", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "forensic attest show");
|
|
activity?.SetTag("stellaops.cli.artifact_path", artifactPath);
|
|
using var duration = CliMetrics.MeasureCommandDuration("forensic attest show");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(artifactPath))
|
|
{
|
|
throw new InvalidOperationException("Artifact path must be provided.");
|
|
}
|
|
|
|
var options = new AttestationShowOptions
|
|
{
|
|
VerifySignatures = verify,
|
|
TrustRootPath = trustRootPath
|
|
};
|
|
|
|
var result = await reader.ReadAttestationAsync(artifactPath, options, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = (result.VerificationResult?.IsValid ?? true) ? 0 : 12;
|
|
return;
|
|
}
|
|
|
|
RenderAttestationResult(result, verbose);
|
|
Environment.ExitCode = (result.VerificationResult?.IsValid ?? true) ? 0 : 12;
|
|
}
|
|
catch (FileNotFoundException ex)
|
|
{
|
|
logger.LogError("Attestation file not found: {Path}", ex.FileName);
|
|
AnsiConsole.MarkupLine($"[red]Attestation file not found:[/] {Markup.Escape(ex.FileName ?? artifactPath)}");
|
|
Environment.ExitCode = 4;
|
|
}
|
|
catch (InvalidDataException ex)
|
|
{
|
|
logger.LogError(ex, "Invalid attestation format.");
|
|
AnsiConsole.MarkupLine($"[red]Invalid attestation format:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to read attestation.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderAttestationResult(AttestationShowResult result, bool verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"\n[bold]Attestation Details[/]");
|
|
AnsiConsole.MarkupLine($"[grey]File:[/] {Markup.Escape(result.FilePath)}");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Basic info
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[grey]Payload Type:[/]", Markup.Escape(result.PayloadType));
|
|
grid.AddRow("[grey]Statement Type:[/]", Markup.Escape(result.StatementType));
|
|
grid.AddRow("[grey]Predicate Type:[/]", $"[cyan]{Markup.Escape(result.PredicateType)}[/]");
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Statement[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Subjects
|
|
if (result.Subjects.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Subjects:[/]");
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Name");
|
|
table.AddColumn("Algorithm");
|
|
table.AddColumn("Digest");
|
|
|
|
foreach (var subject in result.Subjects)
|
|
{
|
|
var digest = subject.DigestValue.Length > 24
|
|
? subject.DigestValue[..24] + "..."
|
|
: subject.DigestValue;
|
|
table.AddRow(
|
|
Markup.Escape(subject.Name),
|
|
Markup.Escape(subject.DigestAlgorithm),
|
|
$"[grey]{Markup.Escape(digest)}[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Signatures
|
|
if (result.Signatures.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Signatures:[/]");
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Key ID");
|
|
table.AddColumn("Algorithm");
|
|
table.AddColumn("Valid");
|
|
table.AddColumn("Trusted");
|
|
|
|
foreach (var sig in result.Signatures)
|
|
{
|
|
var keyId = sig.KeyId.Length > 20 ? sig.KeyId[..20] + "..." : sig.KeyId;
|
|
var validStatus = sig.IsValid switch
|
|
{
|
|
true => "[green]YES[/]",
|
|
false => "[red]NO[/]",
|
|
null => "[grey]-[/]"
|
|
};
|
|
var trustedStatus = sig.IsTrusted switch
|
|
{
|
|
true => "[green]YES[/]",
|
|
false => "[red]NO[/]",
|
|
null => "[grey]-[/]"
|
|
};
|
|
|
|
table.AddRow(
|
|
Markup.Escape(keyId),
|
|
Markup.Escape(sig.Algorithm),
|
|
validStatus,
|
|
trustedStatus);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (verbose)
|
|
{
|
|
foreach (var sig in result.Signatures.Where(s => !string.IsNullOrWhiteSpace(s.Reason)))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(sig.KeyId)}:[/] {Markup.Escape(sig.Reason!)}");
|
|
}
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Predicate summary
|
|
if (result.PredicateSummary is { } pred)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Predicate Summary:[/]");
|
|
var predGrid = new Grid();
|
|
predGrid.AddColumn();
|
|
predGrid.AddColumn();
|
|
|
|
if (!string.IsNullOrWhiteSpace(pred.BuildType))
|
|
{
|
|
predGrid.AddRow("[grey]Build Type:[/]", Markup.Escape(pred.BuildType));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(pred.Builder))
|
|
{
|
|
predGrid.AddRow("[grey]Builder:[/]", Markup.Escape(pred.Builder));
|
|
}
|
|
|
|
if (pred.Timestamp.HasValue)
|
|
{
|
|
predGrid.AddRow("[grey]Timestamp:[/]", pred.Timestamp.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(pred.InvocationId))
|
|
{
|
|
predGrid.AddRow("[grey]Invocation ID:[/]", Markup.Escape(pred.InvocationId));
|
|
}
|
|
|
|
var predPanel = new Panel(predGrid)
|
|
{
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
|
|
AnsiConsole.Write(predPanel);
|
|
|
|
// Materials
|
|
if (verbose && pred.Materials is { Count: > 0 })
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Materials:[/]");
|
|
foreach (var mat in pred.Materials)
|
|
{
|
|
var uri = mat.Uri.Length > 60 ? "..." + mat.Uri[^57..] : mat.Uri;
|
|
AnsiConsole.MarkupLine($" [grey]- {Markup.Escape(uri)}[/]");
|
|
if (mat.Digest is { Count: > 0 })
|
|
{
|
|
foreach (var (algo, digest) in mat.Digest)
|
|
{
|
|
var d = digest.Length > 16 ? digest[..16] + "..." : digest;
|
|
AnsiConsole.MarkupLine($" [grey]{algo}:[/] {d}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Metadata
|
|
if (verbose && pred.Metadata is { Count: > 0 })
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Metadata:[/]");
|
|
foreach (var (key, value) in pred.Metadata)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(key)}:[/] {Markup.Escape(value)}");
|
|
}
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Verification result
|
|
if (result.VerificationResult is { } vr)
|
|
{
|
|
var vrStatus = vr.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Verification:[/] {vrStatus}");
|
|
AnsiConsole.MarkupLine($" [grey]Signatures:[/] {vr.SignatureCount}");
|
|
AnsiConsole.MarkupLine($" [grey]Valid:[/] [green]{vr.ValidSignatures}[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Trusted:[/] [green]{vr.TrustedSignatures}[/]");
|
|
|
|
if (vr.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
|
|
foreach (var error in vr.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Promotion (CLI-PROMO-70-001)
|
|
|
|
// CLI-PROMO-70-001: Handle promotion assemble command
|
|
public static async Task HandlePromotionAssembleAsync(
|
|
IServiceProvider services,
|
|
string image,
|
|
string? sbomPath,
|
|
string? vexPath,
|
|
string fromEnvironment,
|
|
string toEnvironment,
|
|
string? actor,
|
|
string? pipeline,
|
|
string? ticket,
|
|
string? notes,
|
|
bool skipRekor,
|
|
string? outputPath,
|
|
bool emitJson,
|
|
string? tenant,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var assembler = scope.ServiceProvider.GetRequiredService<IPromotionAssembler>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("promotion-assemble");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.promotion.assemble", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "promotion assemble");
|
|
activity?.SetTag("stellaops.cli.image", image);
|
|
using var duration = CliMetrics.MeasureCommandDuration("promotion assemble");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(image))
|
|
{
|
|
throw new InvalidOperationException("Image reference must be provided.");
|
|
}
|
|
|
|
var request = new PromotionAssembleRequest
|
|
{
|
|
Tenant = tenant ?? string.Empty,
|
|
Image = image,
|
|
SbomPath = sbomPath,
|
|
VexPath = vexPath,
|
|
FromEnvironment = fromEnvironment,
|
|
ToEnvironment = toEnvironment,
|
|
Actor = actor,
|
|
Pipeline = pipeline,
|
|
Ticket = ticket,
|
|
Notes = notes,
|
|
SkipRekor = skipRekor,
|
|
OutputPath = outputPath
|
|
};
|
|
|
|
var result = await assembler.AssembleAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = result.Success ? 0 : 1;
|
|
return;
|
|
}
|
|
|
|
RenderPromotionResult(result, verbose);
|
|
Environment.ExitCode = result.Success ? 0 : 1;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to assemble promotion attestation.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderPromotionResult(PromotionAssembleResult result, bool verbose)
|
|
{
|
|
var statusIcon = result.Success ? "[green]SUCCESS[/]" : "[red]FAILED[/]";
|
|
AnsiConsole.MarkupLine($"\n[bold]Promotion Attestation Assembly: {statusIcon}[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Basic info
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[grey]Image Digest:[/]", $"sha256:{Markup.Escape(result.ImageDigest)}");
|
|
|
|
if (result.Predicate is { } pred)
|
|
{
|
|
grid.AddRow("[grey]From:[/]", Markup.Escape(pred.Promotion.From));
|
|
grid.AddRow("[grey]To:[/]", Markup.Escape(pred.Promotion.To));
|
|
grid.AddRow("[grey]Actor:[/]", Markup.Escape(pred.Promotion.Actor ?? "(not specified)"));
|
|
grid.AddRow("[grey]Timestamp:[/]", pred.Promotion.Timestamp.ToString("u", CultureInfo.InvariantCulture));
|
|
|
|
if (!string.IsNullOrWhiteSpace(pred.Promotion.Pipeline))
|
|
{
|
|
grid.AddRow("[grey]Pipeline:[/]", Markup.Escape(pred.Promotion.Pipeline));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(pred.Promotion.Ticket))
|
|
{
|
|
grid.AddRow("[grey]Ticket:[/]", Markup.Escape(pred.Promotion.Ticket));
|
|
}
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Promotion Details[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Materials
|
|
if (result.Materials.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Materials:[/]");
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Role");
|
|
table.AddColumn("Format");
|
|
table.AddColumn("Digest");
|
|
|
|
foreach (var mat in result.Materials)
|
|
{
|
|
var digest = mat.Digest.Length > 16 ? mat.Digest[..16] + "..." : mat.Digest;
|
|
table.AddRow(
|
|
Markup.Escape(mat.Role),
|
|
Markup.Escape(mat.Format ?? "unknown"),
|
|
$"[grey]{Markup.Escape(digest)}[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Rekor
|
|
if (result.RekorEntry is { } rekor)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Rekor Entry:[/]");
|
|
AnsiConsole.MarkupLine($" [grey]UUID:[/] {Markup.Escape(rekor.Uuid)}");
|
|
AnsiConsole.MarkupLine($" [grey]Log Index:[/] {rekor.LogIndex}");
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Output path
|
|
if (!string.IsNullOrWhiteSpace(result.OutputPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Output:[/] {Markup.Escape(result.OutputPath)}");
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Warnings
|
|
if (result.Warnings.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Errors
|
|
if (result.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Summary
|
|
if (result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Promotion attestation assembled successfully.[/]");
|
|
AnsiConsole.MarkupLine("[grey]Use 'stella promotion attest' to sign and submit to Signer.[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Promotion attestation assembly failed. See errors above.[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-PROMO-70-002: Handle promotion attest command
|
|
public static async Task HandlePromotionAttestAsync(
|
|
IServiceProvider services,
|
|
string predicatePath,
|
|
string? keyId,
|
|
bool useKeyless,
|
|
bool uploadToRekor,
|
|
string? outputPath,
|
|
bool emitJson,
|
|
string? tenant,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var assembler = scope.ServiceProvider.GetRequiredService<IPromotionAssembler>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("promotion-attest");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.promotion.attest", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "promotion attest");
|
|
using var duration = CliMetrics.MeasureCommandDuration("promotion attest");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(predicatePath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Predicate file path is required.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var request = new PromotionAttestRequest
|
|
{
|
|
Tenant = tenant ?? string.Empty,
|
|
PredicatePath = predicatePath,
|
|
KeyId = keyId,
|
|
UseKeyless = useKeyless,
|
|
OutputPath = outputPath,
|
|
UploadToRekor = uploadToRekor
|
|
};
|
|
|
|
var result = await assembler.AttestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = result.Success ? 0 : 1;
|
|
return;
|
|
}
|
|
|
|
RenderAttestResult(result, verbose);
|
|
Environment.ExitCode = result.Success ? 0 : 1;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to attest promotion.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderAttestResult(PromotionAttestResult result, bool verbose)
|
|
{
|
|
var statusIcon = result.Success ? "[green]SUCCESS[/]" : "[red]FAILED[/]";
|
|
AnsiConsole.MarkupLine($"\n[bold]Promotion Attestation: {statusIcon}[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
if (result.Success)
|
|
{
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.SignerKeyId))
|
|
{
|
|
grid.AddRow("[grey]Key ID:[/]", Markup.Escape(result.SignerKeyId));
|
|
}
|
|
|
|
if (result.SignedAt.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Signed At:[/]", result.SignedAt.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditId))
|
|
{
|
|
grid.AddRow("[grey]Audit ID:[/]", Markup.Escape(result.AuditId));
|
|
}
|
|
|
|
if (result.RekorEntry is { } rekor)
|
|
{
|
|
grid.AddRow("[grey]Rekor UUID:[/]", Markup.Escape(rekor.Uuid));
|
|
grid.AddRow("[grey]Rekor Index:[/]", rekor.LogIndex.ToString());
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Attestation Details[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.BundlePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Bundle:[/] {Markup.Escape(result.BundlePath)}");
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[green]Attestation signed successfully.[/]");
|
|
AnsiConsole.MarkupLine("[grey]Use 'stella promotion verify' to verify the bundle.[/]");
|
|
}
|
|
|
|
// Warnings
|
|
if (result.Warnings.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Errors
|
|
if (result.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-PROMO-70-002: Handle promotion verify command
|
|
public static async Task HandlePromotionVerifyAsync(
|
|
IServiceProvider services,
|
|
string bundlePath,
|
|
string? sbomPath,
|
|
string? vexPath,
|
|
string? trustRootPath,
|
|
string? checkpointPath,
|
|
bool skipSignature,
|
|
bool skipRekor,
|
|
bool emitJson,
|
|
string? tenant,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var assembler = scope.ServiceProvider.GetRequiredService<IPromotionAssembler>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("promotion-verify");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.promotion.verify", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "promotion verify");
|
|
using var duration = CliMetrics.MeasureCommandDuration("promotion verify");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(bundlePath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Bundle file path is required.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var request = new PromotionVerifyRequest
|
|
{
|
|
Tenant = tenant ?? string.Empty,
|
|
BundlePath = bundlePath,
|
|
SbomPath = sbomPath,
|
|
VexPath = vexPath,
|
|
TrustRootPath = trustRootPath,
|
|
CheckpointPath = checkpointPath,
|
|
SkipSignatureVerification = skipSignature,
|
|
SkipRekorVerification = skipRekor
|
|
};
|
|
|
|
var result = await assembler.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = result.Verified ? 0 : 1;
|
|
return;
|
|
}
|
|
|
|
RenderVerifyResult(result, verbose);
|
|
Environment.ExitCode = result.Verified ? 0 : 1;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to verify promotion attestation.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderVerifyResult(PromotionVerifyResult result, bool verbose)
|
|
{
|
|
var statusIcon = result.Verified ? "[green]VERIFIED[/]" : "[red]FAILED[/]";
|
|
AnsiConsole.MarkupLine($"\n[bold]Promotion Verification: {statusIcon}[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Signature verification
|
|
if (result.SignatureVerification is { } sigV)
|
|
{
|
|
var sigStatus = sigV.Verified ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Signature:[/] {sigStatus}");
|
|
|
|
if (verbose)
|
|
{
|
|
var sigGrid = new Grid();
|
|
sigGrid.AddColumn();
|
|
sigGrid.AddColumn();
|
|
|
|
if (!string.IsNullOrWhiteSpace(sigV.KeyId))
|
|
{
|
|
sigGrid.AddRow("[grey]Key ID:[/]", Markup.Escape(sigV.KeyId));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(sigV.Algorithm))
|
|
{
|
|
sigGrid.AddRow("[grey]Algorithm:[/]", Markup.Escape(sigV.Algorithm));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(sigV.CertSubject))
|
|
{
|
|
sigGrid.AddRow("[grey]Subject:[/]", Markup.Escape(sigV.CertSubject));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(sigV.CertIssuer))
|
|
{
|
|
sigGrid.AddRow("[grey]Issuer:[/]", Markup.Escape(sigV.CertIssuer));
|
|
}
|
|
if (sigV.ValidFrom.HasValue)
|
|
{
|
|
sigGrid.AddRow("[grey]Valid From:[/]", sigV.ValidFrom.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
if (sigV.ValidTo.HasValue)
|
|
{
|
|
sigGrid.AddRow("[grey]Valid To:[/]", sigV.ValidTo.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
AnsiConsole.Write(sigGrid);
|
|
}
|
|
|
|
if (!sigV.Verified && !string.IsNullOrWhiteSpace(sigV.Error))
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]{Markup.Escape(sigV.Error)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Material verification
|
|
if (result.MaterialVerification is { } matV)
|
|
{
|
|
var matStatus = matV.Verified ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Materials:[/] {matStatus}");
|
|
|
|
if (matV.Materials.Count > 0)
|
|
{
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Role");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Digest");
|
|
|
|
foreach (var mat in matV.Materials)
|
|
{
|
|
var status = mat.Verified ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
var digest = mat.ExpectedDigest.Length > 16 ? mat.ExpectedDigest[..16] + "..." : mat.ExpectedDigest;
|
|
table.AddRow(
|
|
Markup.Escape(mat.Role),
|
|
status,
|
|
$"[grey]{Markup.Escape(digest)}[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Rekor verification
|
|
if (result.RekorVerification is { } rekorV)
|
|
{
|
|
var rekorStatus = rekorV.Verified ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Rekor:[/] {rekorStatus}");
|
|
|
|
if (verbose)
|
|
{
|
|
var rekorGrid = new Grid();
|
|
rekorGrid.AddColumn();
|
|
rekorGrid.AddColumn();
|
|
|
|
if (!string.IsNullOrWhiteSpace(rekorV.Uuid))
|
|
{
|
|
rekorGrid.AddRow("[grey]UUID:[/]", Markup.Escape(rekorV.Uuid));
|
|
}
|
|
if (rekorV.LogIndex.HasValue)
|
|
{
|
|
rekorGrid.AddRow("[grey]Log Index:[/]", rekorV.LogIndex.Value.ToString());
|
|
}
|
|
rekorGrid.AddRow("[grey]Inclusion Proof:[/]", rekorV.InclusionProofVerified ? "[green]PASS[/]" : "[red]FAIL[/]");
|
|
rekorGrid.AddRow("[grey]Checkpoint:[/]", rekorV.CheckpointVerified ? "[green]PASS[/]" : "[red]FAIL[/]");
|
|
|
|
AnsiConsole.Write(rekorGrid);
|
|
}
|
|
|
|
if (!rekorV.Verified && !string.IsNullOrWhiteSpace(rekorV.Error))
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]{Markup.Escape(rekorV.Error)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Predicate summary
|
|
if (verbose && result.Predicate is { } pred)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Predicate Summary:[/]");
|
|
var predGrid = new Grid();
|
|
predGrid.AddColumn();
|
|
predGrid.AddColumn();
|
|
|
|
predGrid.AddRow("[grey]Type:[/]", Markup.Escape(pred.Type));
|
|
predGrid.AddRow("[grey]From:[/]", Markup.Escape(pred.Promotion.From));
|
|
predGrid.AddRow("[grey]To:[/]", Markup.Escape(pred.Promotion.To));
|
|
predGrid.AddRow("[grey]Actor:[/]", Markup.Escape(pred.Promotion.Actor ?? "(not specified)"));
|
|
predGrid.AddRow("[grey]Timestamp:[/]", pred.Promotion.Timestamp.ToString("u", CultureInfo.InvariantCulture));
|
|
|
|
AnsiConsole.Write(predGrid);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Warnings
|
|
if (result.Warnings.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Errors
|
|
if (result.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Summary
|
|
if (result.Verified)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Promotion attestation verified successfully.[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Promotion attestation verification failed. See details above.[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-DETER-70-003: Handle detscore run command
|
|
public static async Task HandleDetscoreRunAsync(
|
|
IServiceProvider services,
|
|
string[] images,
|
|
string scanner,
|
|
string? policyBundle,
|
|
string? feedsBundle,
|
|
int runs,
|
|
DateTimeOffset? fixedClock,
|
|
int rngSeed,
|
|
int maxConcurrency,
|
|
string memoryLimit,
|
|
string cpuSet,
|
|
string platform,
|
|
double imageThreshold,
|
|
double overallThreshold,
|
|
string? outputDir,
|
|
string? release,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var harness = scope.ServiceProvider.GetRequiredService<IDeterminismHarness>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("detscore-run");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.detscore.run", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "detscore run");
|
|
activity?.SetTag("stellaops.cli.images", images.Length);
|
|
activity?.SetTag("stellaops.cli.runs", runs);
|
|
using var duration = CliMetrics.MeasureCommandDuration("detscore run");
|
|
|
|
try
|
|
{
|
|
if (images.Length == 0)
|
|
{
|
|
var error = new CliError(
|
|
CliErrorCodes.DeterminismNoImages,
|
|
"At least one image must be provided for determinism testing.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
|
Environment.ExitCode = error.ExitCode;
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(scanner))
|
|
{
|
|
var error = new CliError(
|
|
CliErrorCodes.DeterminismScannerMissing,
|
|
"Scanner image reference must be provided.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
|
Environment.ExitCode = error.ExitCode;
|
|
return;
|
|
}
|
|
|
|
var request = new DeterminismRunRequest
|
|
{
|
|
Images = images,
|
|
Scanner = scanner,
|
|
PolicyBundle = policyBundle,
|
|
FeedsBundle = feedsBundle,
|
|
Runs = runs,
|
|
FixedClock = fixedClock,
|
|
RngSeed = rngSeed,
|
|
MaxConcurrency = maxConcurrency,
|
|
MemoryLimit = memoryLimit,
|
|
CpuSet = cpuSet,
|
|
Platform = platform,
|
|
ImageThreshold = imageThreshold,
|
|
OverallThreshold = overallThreshold,
|
|
OutputDir = outputDir,
|
|
Release = release
|
|
};
|
|
|
|
if (!emitJson)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Starting Determinism Harness[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Images:[/] {images.Length}");
|
|
AnsiConsole.MarkupLine($" [grey]Scanner:[/] {Markup.Escape(scanner)}");
|
|
AnsiConsole.MarkupLine($" [grey]Runs per image:[/] {runs}");
|
|
AnsiConsole.MarkupLine($" [grey]Thresholds:[/] image >= {imageThreshold:P0}, overall >= {overallThreshold:P0}");
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
var result = await harness.RunAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
Environment.ExitCode = result.PassedThreshold ? 0 : 13;
|
|
return;
|
|
}
|
|
|
|
RenderDetscoreResult(result, verbose);
|
|
|
|
if (!result.PassedThreshold)
|
|
{
|
|
var error = new CliError(
|
|
CliErrorCodes.DeterminismThresholdFailed,
|
|
$"Determinism score {result.Manifest?.OverallScore:P0} below threshold {overallThreshold:P0}");
|
|
Environment.ExitCode = error.ExitCode;
|
|
}
|
|
else
|
|
{
|
|
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 run determinism harness.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
var error = new CliError(CliErrorCodes.DeterminismRunFailed, ex.Message);
|
|
Environment.ExitCode = error.ExitCode;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderDetscoreResult(DeterminismRunResult result, bool verbose)
|
|
{
|
|
var manifest = result.Manifest;
|
|
if (manifest == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]No determinism manifest generated.[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
|
|
}
|
|
return;
|
|
}
|
|
|
|
var statusIcon = result.PassedThreshold ? "[green]PASSED[/]" : "[red]FAILED[/]";
|
|
AnsiConsole.MarkupLine($"\n[bold]Determinism Score: {statusIcon}[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Summary info
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[grey]Release:[/]", Markup.Escape(manifest.Release));
|
|
grid.AddRow("[grey]Platform:[/]", Markup.Escape(manifest.Platform));
|
|
grid.AddRow("[grey]Overall Score:[/]", GetScoreMarkup(manifest.OverallScore, manifest.Thresholds.OverallMin));
|
|
grid.AddRow("[grey]Generated:[/]", manifest.GeneratedAt.ToString("u", CultureInfo.InvariantCulture));
|
|
grid.AddRow("[grey]Duration:[/]", $"{result.DurationMs / 1000.0:F1}s");
|
|
|
|
if (!string.IsNullOrWhiteSpace(manifest.ScannerSha))
|
|
{
|
|
var sha = manifest.ScannerSha.Length > 16 ? manifest.ScannerSha[..16] + "..." : manifest.ScannerSha;
|
|
grid.AddRow("[grey]Scanner SHA:[/]", $"[grey]{Markup.Escape(sha)}[/]");
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Determinism Summary[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Per-image results
|
|
AnsiConsole.MarkupLine("[bold]Per-Image Results:[/]");
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Image");
|
|
table.AddColumn("Runs");
|
|
table.AddColumn("Identical");
|
|
table.AddColumn("Score");
|
|
table.AddColumn("Status");
|
|
|
|
foreach (var img in manifest.Images)
|
|
{
|
|
var digest = img.Digest.Length > 24 ? img.Digest[..24] + "..." : img.Digest;
|
|
var status = img.Score >= manifest.Thresholds.ImageMin
|
|
? "[green]PASS[/]"
|
|
: "[red]FAIL[/]";
|
|
var scoreColor = img.Score >= manifest.Thresholds.ImageMin ? "green" : "red";
|
|
|
|
table.AddRow(
|
|
$"[grey]{Markup.Escape(digest)}[/]",
|
|
img.Runs.ToString(),
|
|
img.Identical.ToString(),
|
|
$"[{scoreColor}]{img.Score:P0}[/{scoreColor}]",
|
|
status);
|
|
|
|
if (verbose && img.NonDeterministic.Count > 0)
|
|
{
|
|
table.AddRow(
|
|
"",
|
|
"[yellow]Non-det:[/]",
|
|
$"[yellow]{string.Join(", ", img.NonDeterministic)}[/]",
|
|
"",
|
|
"");
|
|
}
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Execution details
|
|
if (verbose && manifest.Execution is { } exec)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Execution Parameters:[/]");
|
|
var execGrid = new Grid();
|
|
execGrid.AddColumn();
|
|
execGrid.AddColumn();
|
|
|
|
if (exec.FixedClock.HasValue)
|
|
{
|
|
execGrid.AddRow("[grey]Fixed Clock:[/]", exec.FixedClock.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
execGrid.AddRow("[grey]RNG Seed:[/]", exec.RngSeed.ToString());
|
|
execGrid.AddRow("[grey]Max Concurrency:[/]", exec.MaxConcurrency.ToString());
|
|
execGrid.AddRow("[grey]Memory Limit:[/]", exec.MemoryLimit);
|
|
execGrid.AddRow("[grey]CPU Set:[/]", exec.CpuSet);
|
|
execGrid.AddRow("[grey]Network Mode:[/]", exec.NetworkMode);
|
|
|
|
AnsiConsole.Write(execGrid);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Output path
|
|
if (!string.IsNullOrWhiteSpace(result.OutputPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Output:[/] {Markup.Escape(result.OutputPath)}");
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Warnings
|
|
if (result.Warnings.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Errors
|
|
if (result.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Summary
|
|
if (result.PassedThreshold)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Determinism score {manifest.OverallScore:P0} meets threshold {manifest.Thresholds.OverallMin:P0}[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Determinism score {manifest.OverallScore:P0} below threshold {manifest.Thresholds.OverallMin:P0}[/]");
|
|
if (result.FailedImages.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Failed images: {string.Join(", ", result.FailedImages.Select(i => i.Length > 16 ? i[..16] + "..." : i))}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
static string GetScoreMarkup(double score, double threshold)
|
|
{
|
|
var color = score >= threshold ? "green" : "red";
|
|
return $"[{color}]{score:P0}[/{color}]";
|
|
}
|
|
}
|
|
|
|
// CLI-DETER-70-004: Handle detscore report command
|
|
public static async Task HandleDetscoreReportAsync(
|
|
IServiceProvider services,
|
|
string[] manifestPaths,
|
|
string format,
|
|
string? outputPath,
|
|
bool includeDetails,
|
|
string? title,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var harness = scope.ServiceProvider.GetRequiredService<IDeterminismHarness>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("detscore-report");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.detscore.report", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "detscore report");
|
|
activity?.SetTag("stellaops.cli.manifests", manifestPaths.Length);
|
|
activity?.SetTag("stellaops.cli.format", format);
|
|
using var duration = CliMetrics.MeasureCommandDuration("detscore report");
|
|
|
|
try
|
|
{
|
|
if (manifestPaths.Length == 0)
|
|
{
|
|
var error = new CliError(
|
|
CliErrorCodes.DeterminismManifestInvalid,
|
|
"At least one determinism.json manifest path must be provided.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
|
Environment.ExitCode = error.ExitCode;
|
|
return;
|
|
}
|
|
|
|
// Verify all files exist
|
|
var missingFiles = manifestPaths.Where(p => !File.Exists(p)).ToList();
|
|
if (missingFiles.Count > 0)
|
|
{
|
|
var error = new CliError(
|
|
CliErrorCodes.DeterminismManifestInvalid,
|
|
$"Manifest files not found: {string.Join(", ", missingFiles)}");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
|
Environment.ExitCode = error.ExitCode;
|
|
return;
|
|
}
|
|
|
|
var request = new DeterminismReportRequest
|
|
{
|
|
ManifestPaths = manifestPaths,
|
|
Format = format,
|
|
OutputPath = outputPath,
|
|
IncludeDetails = includeDetails,
|
|
Title = title
|
|
};
|
|
|
|
if (format != "json" && string.IsNullOrEmpty(outputPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Generating Determinism Report[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Manifests:[/] {manifestPaths.Length}");
|
|
AnsiConsole.MarkupLine($" [grey]Format:[/] {format}");
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
var result = await harness.GenerateReportAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 13;
|
|
return;
|
|
}
|
|
|
|
// If output path specified, file was already written by harness
|
|
if (!string.IsNullOrWhiteSpace(result.OutputPath))
|
|
{
|
|
if (format != "json")
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Report written to:[/] {Markup.Escape(result.OutputPath)}");
|
|
RenderDetscoreReportSummary(result.Report!, verbose);
|
|
}
|
|
else
|
|
{
|
|
// For JSON format to file, also output JSON to stdout for piping
|
|
var json = JsonSerializer.Serialize(result.Report, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// No output path - content was written to stdout by harness for markdown/csv
|
|
// For JSON, we output the structured result
|
|
if (format == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(result.Report, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
}
|
|
|
|
// Show warnings
|
|
if (result.Warnings.Count > 0 && format != "json")
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
|
|
}
|
|
}
|
|
|
|
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 generate determinism report.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 13;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
|
|
static void RenderDetscoreReportSummary(DeterminismReport report, bool verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
var summary = report.Summary;
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[grey]Total Releases:[/]", summary.TotalReleases.ToString());
|
|
grid.AddRow("[grey]Total Images:[/]", summary.TotalImages.ToString());
|
|
grid.AddRow("[grey]Average Score:[/]", $"{summary.AverageScore:P1}");
|
|
grid.AddRow("[grey]Score Range:[/]", $"{summary.MinScore:P1} - {summary.MaxScore:P1}");
|
|
grid.AddRow("[grey]Passed:[/]", $"[green]{summary.PassedCount}[/]");
|
|
grid.AddRow("[grey]Failed:[/]", summary.FailedCount > 0 ? $"[red]{summary.FailedCount}[/]" : "0");
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader($"[bold]{Markup.Escape(report.Title)}[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (summary.NonDeterministicArtifacts.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold yellow]Non-Deterministic Artifacts:[/]");
|
|
foreach (var artifact in summary.NonDeterministicArtifacts.Take(10))
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(artifact)}[/]");
|
|
}
|
|
if (summary.NonDeterministicArtifacts.Count > 10)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]... and {summary.NonDeterministicArtifacts.Count - 10} more[/]");
|
|
}
|
|
}
|
|
|
|
if (verbose && report.Releases.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Releases:[/]");
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Release");
|
|
table.AddColumn("Platform");
|
|
table.AddColumn("Score");
|
|
table.AddColumn("Images");
|
|
table.AddColumn("Status");
|
|
|
|
foreach (var release in report.Releases)
|
|
{
|
|
var status = release.Passed ? "[green]PASS[/]" : "[red]FAIL[/]";
|
|
var scoreColor = release.Passed ? "green" : "red";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(release.Release),
|
|
Markup.Escape(release.Platform),
|
|
$"[{scoreColor}]{release.OverallScore:P1}[/{scoreColor}]",
|
|
release.ImageCount.ToString(),
|
|
status);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-OBS-51-001: Handle obs top command
|
|
public static async Task HandleObsTopAsync(
|
|
IServiceProvider services,
|
|
string[] serviceNames,
|
|
string? tenant,
|
|
int refreshInterval,
|
|
bool includeQueues,
|
|
int maxAlerts,
|
|
string outputFormat,
|
|
bool offline,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-top");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.top", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "obs top");
|
|
activity?.SetTag("stellaops.cli.refresh", refreshInterval);
|
|
using var duration = CliMetrics.MeasureCommandDuration("obs top");
|
|
|
|
try
|
|
{
|
|
// Offline mode check
|
|
if (offline)
|
|
{
|
|
var error = new CliError(
|
|
CliErrorCodes.ObsOfflineViolation,
|
|
"Offline mode specified but obs top requires network access to fetch metrics.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
|
Environment.ExitCode = 5; // Per CLI guide: offline violation = 5
|
|
return;
|
|
}
|
|
|
|
var request = new ObsTopRequest
|
|
{
|
|
Services = serviceNames,
|
|
Tenant = tenant,
|
|
IncludeQueues = includeQueues,
|
|
RefreshInterval = refreshInterval,
|
|
MaxAlerts = maxAlerts
|
|
};
|
|
|
|
// Single fetch or streaming mode
|
|
if (refreshInterval <= 0)
|
|
{
|
|
await FetchAndRenderObsTop(client, request, outputFormat, verbose, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
// Streaming mode with live updates
|
|
await StreamObsTop(client, request, outputFormat, verbose, refreshInterval, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to fetch observability data.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 14;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static async Task FetchAndRenderObsTop(
|
|
IObservabilityClient client,
|
|
ObsTopRequest request,
|
|
string outputFormat,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await client.GetHealthSummaryAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 14;
|
|
return;
|
|
}
|
|
|
|
var summary = result.Summary!;
|
|
|
|
switch (outputFormat)
|
|
{
|
|
case "json":
|
|
var json = JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
break;
|
|
|
|
case "ndjson":
|
|
// Output each service as a separate NDJSON line
|
|
foreach (var service in summary.Services)
|
|
{
|
|
var line = JsonSerializer.Serialize(service);
|
|
Console.WriteLine(line);
|
|
}
|
|
break;
|
|
|
|
default: // table
|
|
RenderObsTopTable(summary, verbose);
|
|
break;
|
|
}
|
|
|
|
Environment.ExitCode = 0;
|
|
}
|
|
|
|
private static async Task StreamObsTop(
|
|
IObservabilityClient client,
|
|
ObsTopRequest request,
|
|
string outputFormat,
|
|
bool verbose,
|
|
int intervalSeconds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var isFirstRender = true;
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
var result = await client.GetHealthSummaryAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken).ConfigureAwait(false);
|
|
continue;
|
|
}
|
|
|
|
var summary = result.Summary!;
|
|
|
|
if (outputFormat == "json" || outputFormat == "ndjson")
|
|
{
|
|
// For JSON streaming, output each fetch as a line
|
|
var json = JsonSerializer.Serialize(summary);
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
// Clear screen and render table
|
|
if (!isFirstRender)
|
|
{
|
|
AnsiConsole.Clear();
|
|
}
|
|
isFirstRender = false;
|
|
|
|
RenderObsTopTable(summary, verbose);
|
|
AnsiConsole.MarkupLine($"\n[grey]Refreshing every {intervalSeconds}s. Press Ctrl+C to stop.[/]");
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
Environment.ExitCode = 0;
|
|
}
|
|
|
|
private static void RenderObsTopTable(PlatformHealthSummary summary, bool verbose)
|
|
{
|
|
// Overall status header
|
|
var statusColor = summary.OverallStatus switch
|
|
{
|
|
"healthy" => "green",
|
|
"degraded" => "yellow",
|
|
"unhealthy" => "red",
|
|
_ => "grey"
|
|
};
|
|
|
|
AnsiConsole.MarkupLine($"[bold]Platform Health: [{statusColor}]{summary.OverallStatus.ToUpperInvariant()}[/{statusColor}][/]");
|
|
AnsiConsole.MarkupLine($"[grey]Updated: {summary.Timestamp.ToString("u", CultureInfo.InvariantCulture)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Error Budget: {summary.GlobalErrorBudget:P1} remaining[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Services table
|
|
if (summary.Services.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Services:[/]");
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Service");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Availability");
|
|
table.AddColumn("SLO Target");
|
|
table.AddColumn("Error Budget");
|
|
table.AddColumn("P95 Latency");
|
|
table.AddColumn("Burn Rate");
|
|
|
|
foreach (var service in summary.Services)
|
|
{
|
|
var svcStatusColor = service.Status switch
|
|
{
|
|
"healthy" => "green",
|
|
"degraded" => "yellow",
|
|
"unhealthy" => "red",
|
|
_ => "grey"
|
|
};
|
|
|
|
var availColor = service.Availability >= service.SloTarget ? "green" : "red";
|
|
var budgetColor = service.ErrorBudgetRemaining >= 0.5 ? "green" :
|
|
service.ErrorBudgetRemaining >= 0.2 ? "yellow" : "red";
|
|
|
|
var latency = service.Latency is not null
|
|
? $"{service.Latency.P95Ms:F0}ms"
|
|
: "-";
|
|
var latencyColor = service.Latency is { Breaching: true } ? "red" : "green";
|
|
|
|
var burnRate = service.BurnRate is not null
|
|
? $"{service.BurnRate.Current:F1}x"
|
|
: "-";
|
|
var burnColor = service.BurnRate?.AlertLevel switch
|
|
{
|
|
"critical" => "red",
|
|
"warning" => "yellow",
|
|
_ => "green"
|
|
};
|
|
|
|
table.AddRow(
|
|
Markup.Escape(service.Service),
|
|
$"[{svcStatusColor}]{service.Status.ToUpperInvariant()}[/{svcStatusColor}]",
|
|
$"[{availColor}]{service.Availability:P2}[/{availColor}]",
|
|
$"{service.SloTarget:P2}",
|
|
$"[{budgetColor}]{service.ErrorBudgetRemaining:P1}[/{budgetColor}]",
|
|
$"[{latencyColor}]{latency}[/{latencyColor}]",
|
|
$"[{burnColor}]{burnRate}[/{burnColor}]");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Active alerts
|
|
if (summary.ActiveAlerts.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Active Alerts:[/]");
|
|
|
|
var alertTable = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
alertTable.AddColumn("Severity");
|
|
alertTable.AddColumn("Service");
|
|
alertTable.AddColumn("Type");
|
|
alertTable.AddColumn("Message");
|
|
alertTable.AddColumn("Started");
|
|
|
|
foreach (var alert in summary.ActiveAlerts)
|
|
{
|
|
var severityColor = alert.Severity == "critical" ? "red" : "yellow";
|
|
var age = DateTimeOffset.UtcNow - alert.StartedAt;
|
|
var ageStr = age.TotalHours >= 1
|
|
? $"{age.TotalHours:F0}h ago"
|
|
: $"{age.TotalMinutes:F0}m ago";
|
|
|
|
alertTable.AddRow(
|
|
$"[{severityColor}]{alert.Severity.ToUpperInvariant()}[/{severityColor}]",
|
|
Markup.Escape(alert.Service),
|
|
Markup.Escape(alert.Type),
|
|
Markup.Escape(alert.Message.Length > 50 ? alert.Message[..47] + "..." : alert.Message),
|
|
ageStr);
|
|
}
|
|
|
|
AnsiConsole.Write(alertTable);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Queue health (verbose mode or if alerting)
|
|
if (verbose)
|
|
{
|
|
var allQueues = summary.Services
|
|
.SelectMany(s => s.Queues.Select(q => (Service: s.Service, Queue: q)))
|
|
.ToList();
|
|
|
|
if (allQueues.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Queue Health:[/]");
|
|
|
|
var queueTable = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
queueTable.AddColumn("Service");
|
|
queueTable.AddColumn("Queue");
|
|
queueTable.AddColumn("Depth");
|
|
queueTable.AddColumn("Oldest");
|
|
queueTable.AddColumn("Throughput");
|
|
queueTable.AddColumn("Success");
|
|
|
|
foreach (var (svc, queue) in allQueues)
|
|
{
|
|
var depthColor = queue.Alerting ? "red" :
|
|
queue.Depth > queue.DepthThreshold * 0.8 ? "yellow" : "green";
|
|
|
|
queueTable.AddRow(
|
|
Markup.Escape(svc),
|
|
Markup.Escape(queue.Name),
|
|
$"[{depthColor}]{queue.Depth}[/{depthColor}]",
|
|
queue.OldestMessageAge.TotalMinutes > 0 ? $"{queue.OldestMessageAge.TotalMinutes:F0}m" : "-",
|
|
$"{queue.Throughput:F1}/s",
|
|
$"{queue.SuccessRate:P1}");
|
|
}
|
|
|
|
AnsiConsole.Write(queueTable);
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-OBS-52-001: Handle obs trace command
|
|
public static async Task HandleObsTraceAsync(
|
|
IServiceProvider services,
|
|
string traceId,
|
|
string? tenant,
|
|
bool includeEvidence,
|
|
string outputFormat,
|
|
bool offline,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-trace");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.trace", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "obs trace");
|
|
activity?.SetTag("stellaops.cli.traceId", traceId);
|
|
using var duration = CliMetrics.MeasureCommandDuration("obs trace");
|
|
|
|
try
|
|
{
|
|
if (offline)
|
|
{
|
|
var error = new CliError(
|
|
CliErrorCodes.ObsOfflineViolation,
|
|
"Offline mode specified but obs trace requires network access.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
|
Environment.ExitCode = 5;
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(traceId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Trace ID is required.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Echo trace ID on stderr for scripting
|
|
if (verbose)
|
|
{
|
|
Console.Error.WriteLine($"trace_id={traceId}");
|
|
}
|
|
|
|
var request = new ObsTraceRequest
|
|
{
|
|
TraceId = traceId,
|
|
Tenant = tenant,
|
|
IncludeEvidence = includeEvidence
|
|
};
|
|
|
|
var result = await client.GetTraceAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = result.Errors.Any(e => e.Contains("not found")) ? 4 : 14;
|
|
return;
|
|
}
|
|
|
|
var trace = result.Trace!;
|
|
|
|
if (outputFormat == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(trace, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderTrace(trace, verbose);
|
|
}
|
|
|
|
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 trace.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 14;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderTrace(DistributedTrace trace, bool verbose)
|
|
{
|
|
var statusColor = trace.Status == "error" ? "red" : "green";
|
|
|
|
AnsiConsole.MarkupLine($"[bold]Trace: {Markup.Escape(trace.TraceId)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Status:[/] [{statusColor}]{trace.Status.ToUpperInvariant()}[/{statusColor}]");
|
|
AnsiConsole.MarkupLine($"[grey]Duration:[/] {trace.Duration.TotalMilliseconds:F0}ms");
|
|
AnsiConsole.MarkupLine($"[grey]Started:[/] {trace.StartTime.ToString("u", CultureInfo.InvariantCulture)}");
|
|
AnsiConsole.MarkupLine($"[grey]Services:[/] {string.Join(", ", trace.Services)}");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
// Spans table
|
|
if (trace.Spans.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Spans:[/]");
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Service");
|
|
table.AddColumn("Operation");
|
|
table.AddColumn("Duration");
|
|
table.AddColumn("Status");
|
|
|
|
foreach (var span in trace.Spans.OrderBy(s => s.StartTime))
|
|
{
|
|
var spanStatusColor = span.Status == "error" ? "red" : "green";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(span.ServiceName),
|
|
Markup.Escape(span.OperationName.Length > 40 ? span.OperationName[..37] + "..." : span.OperationName),
|
|
$"{span.Duration.TotalMilliseconds:F0}ms",
|
|
$"[{spanStatusColor}]{span.Status}[/{spanStatusColor}]");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
// Evidence links
|
|
if (trace.EvidenceLinks.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Evidence Links:[/]");
|
|
|
|
var evidenceTable = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
evidenceTable.AddColumn("Type");
|
|
evidenceTable.AddColumn("URI");
|
|
evidenceTable.AddColumn("Timestamp");
|
|
|
|
foreach (var link in trace.EvidenceLinks)
|
|
{
|
|
var uri = link.Uri.Length > 50 ? link.Uri[..47] + "..." : link.Uri;
|
|
evidenceTable.AddRow(
|
|
Markup.Escape(link.Type),
|
|
$"[link]{Markup.Escape(uri)}[/]",
|
|
link.Timestamp.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
AnsiConsole.Write(evidenceTable);
|
|
}
|
|
}
|
|
|
|
// CLI-OBS-52-001: Handle obs logs command
|
|
public static async Task HandleObsLogsAsync(
|
|
IServiceProvider services,
|
|
DateTimeOffset from,
|
|
DateTimeOffset to,
|
|
string? tenant,
|
|
string[] serviceNames,
|
|
string[] levels,
|
|
string? query,
|
|
int pageSize,
|
|
string? pageToken,
|
|
string outputFormat,
|
|
bool offline,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-logs");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.logs", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "obs logs");
|
|
using var duration = CliMetrics.MeasureCommandDuration("obs logs");
|
|
|
|
try
|
|
{
|
|
if (offline)
|
|
{
|
|
var error = new CliError(
|
|
CliErrorCodes.ObsOfflineViolation,
|
|
"Offline mode specified but obs logs requires network access.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
|
Environment.ExitCode = 5;
|
|
return;
|
|
}
|
|
|
|
// Validate time window (max 24h as guardrail)
|
|
var timeSpan = to - from;
|
|
if (timeSpan > TimeSpan.FromHours(24))
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Warning:[/] Time window exceeds 24 hours. Results may be truncated.");
|
|
}
|
|
|
|
// Clamp page size
|
|
pageSize = Math.Clamp(pageSize, 1, 500);
|
|
|
|
var request = new ObsLogsRequest
|
|
{
|
|
From = from,
|
|
To = to,
|
|
Tenant = tenant,
|
|
Services = serviceNames,
|
|
Levels = levels,
|
|
Query = query,
|
|
PageSize = pageSize,
|
|
PageToken = pageToken
|
|
};
|
|
|
|
var result = await client.GetLogsAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 14;
|
|
return;
|
|
}
|
|
|
|
switch (outputFormat)
|
|
{
|
|
case "json":
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
break;
|
|
|
|
case "ndjson":
|
|
foreach (var log in result.Logs)
|
|
{
|
|
var line = JsonSerializer.Serialize(log);
|
|
Console.WriteLine(line);
|
|
}
|
|
break;
|
|
|
|
default: // table
|
|
RenderLogs(result, verbose);
|
|
break;
|
|
}
|
|
|
|
// Show pagination token if available
|
|
if (!string.IsNullOrWhiteSpace(result.NextPageToken) && outputFormat == "table")
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[grey]Next page: --page-token {Markup.Escape(result.NextPageToken)}[/]");
|
|
}
|
|
|
|
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 logs.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 14;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderLogs(ObsLogsResult result, bool verbose)
|
|
{
|
|
if (result.Logs.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No logs found for the specified time window.[/]");
|
|
return;
|
|
}
|
|
|
|
if (result.TotalCount.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Showing {result.Logs.Count} of {result.TotalCount} logs[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Showing {result.Logs.Count} logs[/]");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded);
|
|
|
|
table.AddColumn("Timestamp");
|
|
table.AddColumn("Level");
|
|
table.AddColumn("Service");
|
|
table.AddColumn("Message");
|
|
|
|
if (verbose)
|
|
{
|
|
table.AddColumn("Trace ID");
|
|
}
|
|
|
|
foreach (var log in result.Logs)
|
|
{
|
|
var levelColor = log.Level switch
|
|
{
|
|
"error" => "red",
|
|
"warn" => "yellow",
|
|
"debug" => "grey",
|
|
_ => "white"
|
|
};
|
|
|
|
var message = log.Message.Length > 60 ? log.Message[..57] + "..." : log.Message;
|
|
|
|
if (verbose)
|
|
{
|
|
var traceId = log.TraceId ?? "-";
|
|
traceId = traceId.Length > 16 ? traceId[..16] + "..." : traceId;
|
|
|
|
table.AddRow(
|
|
log.Timestamp.ToString("HH:mm:ss.fff"),
|
|
$"[{levelColor}]{log.Level.ToUpperInvariant()}[/{levelColor}]",
|
|
Markup.Escape(log.Service),
|
|
Markup.Escape(message),
|
|
$"[grey]{Markup.Escape(traceId)}[/]");
|
|
}
|
|
else
|
|
{
|
|
table.AddRow(
|
|
log.Timestamp.ToString("HH:mm:ss.fff"),
|
|
$"[{levelColor}]{log.Level.ToUpperInvariant()}[/{levelColor}]",
|
|
Markup.Escape(log.Service),
|
|
Markup.Escape(message));
|
|
}
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
// CLI-OBS-55-001: Handle obs incident-mode enable command
|
|
public static async Task HandleObsIncidentModeEnableAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
int ttlMinutes,
|
|
int retentionDays,
|
|
string? reason,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-incident-mode");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.incident-mode.enable", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "obs incident-mode enable");
|
|
using var duration = CliMetrics.MeasureCommandDuration("obs incident-mode enable");
|
|
|
|
try
|
|
{
|
|
var request = new IncidentModeEnableRequest
|
|
{
|
|
Tenant = tenant,
|
|
TtlMinutes = ttlMinutes,
|
|
RetentionExtensionDays = retentionDays,
|
|
Reason = reason
|
|
};
|
|
|
|
if (!emitJson)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold yellow]Enabling incident mode...[/]");
|
|
}
|
|
|
|
var result = await client.EnableIncidentModeAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 14;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderIncidentModeState(result.State!, "ENABLED", verbose);
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
|
|
{
|
|
AnsiConsole.MarkupLine($"\n[grey]Audit event: {Markup.Escape(result.AuditEventId)}[/]");
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
|
|
}
|
|
}
|
|
|
|
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 enable incident mode.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 14;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
// CLI-OBS-55-001: Handle obs incident-mode disable command
|
|
public static async Task HandleObsIncidentModeDisableAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? reason,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-incident-mode");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.incident-mode.disable", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "obs incident-mode disable");
|
|
using var duration = CliMetrics.MeasureCommandDuration("obs incident-mode disable");
|
|
|
|
try
|
|
{
|
|
var request = new IncidentModeDisableRequest
|
|
{
|
|
Tenant = tenant,
|
|
Reason = reason
|
|
};
|
|
|
|
if (!emitJson)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Disabling incident mode...[/]");
|
|
}
|
|
|
|
var result = await client.DisableIncidentModeAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 14;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Incident mode disabled.[/]");
|
|
|
|
if (result.PreviousState is { } prev)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Was enabled since: {prev.SetAt?.ToString("u", CultureInfo.InvariantCulture) ?? "unknown"}[/]");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit event: {Markup.Escape(result.AuditEventId)}[/]");
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
|
|
}
|
|
}
|
|
|
|
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 disable incident mode.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 14;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
// CLI-OBS-55-001: Handle obs incident-mode status command
|
|
public static async Task HandleObsIncidentModeStatusAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-incident-mode");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.incident-mode.status", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "obs incident-mode status");
|
|
using var duration = CliMetrics.MeasureCommandDuration("obs incident-mode status");
|
|
|
|
try
|
|
{
|
|
var result = await client.GetIncidentModeStatusAsync(tenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 14;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result.State, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
var state = result.State!;
|
|
var statusLabel = state.Enabled ? "ENABLED" : "DISABLED";
|
|
RenderIncidentModeState(state, statusLabel, verbose);
|
|
}
|
|
|
|
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 get incident mode status.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 14;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderIncidentModeState(IncidentModeState state, string statusLabel, bool verbose)
|
|
{
|
|
var statusColor = state.Enabled ? "yellow" : "green";
|
|
|
|
AnsiConsole.MarkupLine($"[bold]Incident Mode: [{statusColor}]{statusLabel}[/{statusColor}][/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
if (!string.IsNullOrWhiteSpace(state.Tenant))
|
|
{
|
|
grid.AddRow("[grey]Tenant:[/]", Markup.Escape(state.Tenant));
|
|
}
|
|
|
|
if (state.SetAt.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Enabled at:[/]", state.SetAt.Value.ToString("u", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
if (state.ExpiresAt.HasValue)
|
|
{
|
|
var remaining = state.ExpiresAt.Value - DateTimeOffset.UtcNow;
|
|
var expiresStr = remaining.TotalMinutes > 0
|
|
? $"{state.ExpiresAt.Value:u} ({remaining.TotalMinutes:F0}m remaining)"
|
|
: $"{state.ExpiresAt.Value:u} (expired)";
|
|
grid.AddRow("[grey]Expires at:[/]", expiresStr);
|
|
}
|
|
|
|
if (state.Enabled)
|
|
{
|
|
grid.AddRow("[grey]Retention extension:[/]", $"{state.RetentionExtensionDays} days");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(state.Actor) && verbose)
|
|
{
|
|
grid.AddRow("[grey]Actor:[/]", Markup.Escape(state.Actor));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(state.Source) && verbose)
|
|
{
|
|
grid.AddRow("[grey]Source:[/]", Markup.Escape(state.Source));
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Incident Mode Status[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (state.Enabled)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[yellow]Effects while enabled:[/]");
|
|
AnsiConsole.MarkupLine(" - Extended retention for forensic bundles");
|
|
AnsiConsole.MarkupLine(" - Debug artefacts captured in evidence locker");
|
|
AnsiConsole.MarkupLine(" - 100% sampling rate for telemetry");
|
|
AnsiConsole.MarkupLine(" - `incident=true` tag on all logs/metrics/traces");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Pack Commands (CLI-PACKS-42-001)
|
|
|
|
public static async Task HandlePackPlanAsync(
|
|
IServiceProvider services,
|
|
string packId,
|
|
string? version,
|
|
string? inputsPath,
|
|
bool dryRun,
|
|
string? outputPath,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool offline,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-plan");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.plan", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "pack plan");
|
|
activity?.SetTag("stellaops.cli.pack_id", packId);
|
|
using var duration = CliMetrics.MeasureCommandDuration("pack plan");
|
|
|
|
try
|
|
{
|
|
if (offline)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Pack plan requires network access.");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(packId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Pack ID is required.");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
// Load inputs if provided
|
|
Dictionary<string, object>? inputs = null;
|
|
if (!string.IsNullOrWhiteSpace(inputsPath))
|
|
{
|
|
var inputsFullPath = Path.GetFullPath(inputsPath);
|
|
if (!File.Exists(inputsFullPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Inputs file not found: {Markup.Escape(inputsFullPath)}");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
var inputsJson = await File.ReadAllTextAsync(inputsFullPath, cancellationToken).ConfigureAwait(false);
|
|
inputs = JsonSerializer.Deserialize<Dictionary<string, object>>(inputsJson);
|
|
}
|
|
|
|
var request = new PackPlanRequest
|
|
{
|
|
PackId = packId,
|
|
Version = version,
|
|
Inputs = inputs,
|
|
Tenant = tenant,
|
|
DryRun = dryRun,
|
|
ValidateOnly = dryRun
|
|
};
|
|
|
|
var result = await client.PlanAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
foreach (var ve in result.ValidationErrors)
|
|
{
|
|
var pathInfo = !string.IsNullOrWhiteSpace(ve.Path) ? $" at {ve.Path}" : "";
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ve.Code)}:[/] {Markup.Escape(ve.Message)}{pathInfo}");
|
|
}
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
// Write output if path specified
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(Path.GetFullPath(outputPath), json, cancellationToken).ConfigureAwait(false);
|
|
logger.LogInformation("Plan written to {Path}.", Path.GetFullPath(outputPath));
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderPackPlan(result, verbose);
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
|
|
}
|
|
|
|
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 plan pack.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 15;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandlePackRunAsync(
|
|
IServiceProvider services,
|
|
string packId,
|
|
string? version,
|
|
string? inputsPath,
|
|
string? planId,
|
|
bool wait,
|
|
int timeoutMinutes,
|
|
string[] labels,
|
|
string? outputPath,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool offline,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-run");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.run", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "pack run");
|
|
activity?.SetTag("stellaops.cli.pack_id", packId);
|
|
using var duration = CliMetrics.MeasureCommandDuration("pack run");
|
|
|
|
try
|
|
{
|
|
if (offline)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Pack run requires network access.");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(packId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Pack ID is required.");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
// Load inputs if provided
|
|
Dictionary<string, object>? inputs = null;
|
|
if (!string.IsNullOrWhiteSpace(inputsPath))
|
|
{
|
|
var inputsFullPath = Path.GetFullPath(inputsPath);
|
|
if (!File.Exists(inputsFullPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Inputs file not found: {Markup.Escape(inputsFullPath)}");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
var inputsJson = await File.ReadAllTextAsync(inputsFullPath, cancellationToken).ConfigureAwait(false);
|
|
inputs = JsonSerializer.Deserialize<Dictionary<string, object>>(inputsJson);
|
|
}
|
|
|
|
// Parse labels
|
|
var labelsDict = new Dictionary<string, string>();
|
|
foreach (var label in labels)
|
|
{
|
|
var parts = label.Split('=', 2);
|
|
if (parts.Length == 2)
|
|
{
|
|
labelsDict[parts[0]] = parts[1];
|
|
}
|
|
}
|
|
|
|
var request = new PackRunRequest
|
|
{
|
|
PackId = packId,
|
|
Version = version,
|
|
PlanId = planId,
|
|
Inputs = inputs,
|
|
Tenant = tenant,
|
|
Labels = labelsDict.Count > 0 ? labelsDict : null,
|
|
WaitForCompletion = wait,
|
|
TimeoutMinutes = timeoutMinutes
|
|
};
|
|
|
|
var result = await client.RunAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
// Write output if path specified
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(Path.GetFullPath(outputPath), json, cancellationToken).ConfigureAwait(false);
|
|
logger.LogInformation("Run result written to {Path}.", Path.GetFullPath(outputPath));
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderPackRunStatus(result.Run!, verbose);
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
|
|
}
|
|
|
|
// Set exit code based on run status
|
|
var exitCode = result.Run?.Status switch
|
|
{
|
|
"succeeded" => 0,
|
|
"failed" => 15,
|
|
"waiting_approval" => 0, // Pending approval is not an error
|
|
_ => 0
|
|
};
|
|
Environment.ExitCode = exitCode;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to run pack.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 15;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandlePackPushAsync(
|
|
IServiceProvider services,
|
|
string path,
|
|
string? name,
|
|
string? version,
|
|
bool sign,
|
|
string? keyId,
|
|
bool force,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-push");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.push", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "pack push");
|
|
using var duration = CliMetrics.MeasureCommandDuration("pack push");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Pack path is required.");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
var request = new PackPushRequest
|
|
{
|
|
PackPath = path,
|
|
Name = name,
|
|
Version = version,
|
|
Tenant = tenant,
|
|
Sign = sign,
|
|
KeyId = keyId,
|
|
Force = force
|
|
};
|
|
|
|
var result = await client.PushAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Pack pushed successfully.[/]");
|
|
if (result.Pack is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($" Pack: {Markup.Escape(result.Pack.Id)}");
|
|
AnsiConsole.MarkupLine($" Version: {Markup.Escape(result.Pack.Version)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.Digest))
|
|
{
|
|
AnsiConsole.MarkupLine($" Digest: {Markup.Escape(result.Digest)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.RekorLogId))
|
|
{
|
|
AnsiConsole.MarkupLine($" Rekor Log ID: {Markup.Escape(result.RekorLogId)}");
|
|
}
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
|
|
}
|
|
|
|
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 push pack.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 15;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandlePackPullAsync(
|
|
IServiceProvider services,
|
|
string packId,
|
|
string? version,
|
|
string? outputPath,
|
|
bool noVerify,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-pull");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.pull", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "pack pull");
|
|
activity?.SetTag("stellaops.cli.pack_id", packId);
|
|
using var duration = CliMetrics.MeasureCommandDuration("pack pull");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(packId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Pack ID is required.");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
var request = new PackPullRequest
|
|
{
|
|
PackId = packId,
|
|
Version = version,
|
|
OutputPath = outputPath,
|
|
Tenant = tenant,
|
|
Verify = !noVerify
|
|
};
|
|
|
|
var result = await client.PullAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Pack pulled successfully.[/]");
|
|
AnsiConsole.MarkupLine($" Output: {Markup.Escape(result.OutputPath ?? "unknown")}");
|
|
if (result.Verified)
|
|
{
|
|
AnsiConsole.MarkupLine(" [green]Signature verified[/]");
|
|
}
|
|
else if (!noVerify)
|
|
{
|
|
AnsiConsole.MarkupLine(" [yellow]Signature not verified[/]");
|
|
}
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
|
|
}
|
|
|
|
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 pull pack.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 15;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandlePackVerifyAsync(
|
|
IServiceProvider services,
|
|
string? path,
|
|
string? packId,
|
|
string? version,
|
|
string? digest,
|
|
bool noRekor,
|
|
bool noExpiry,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-verify");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.verify", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "pack verify");
|
|
using var duration = CliMetrics.MeasureCommandDuration("pack verify");
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path) && string.IsNullOrWhiteSpace(packId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Either --path or --pack-id is required.");
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
var request = new PackVerifyRequest
|
|
{
|
|
PackPath = path,
|
|
PackId = packId,
|
|
Version = version,
|
|
Digest = digest,
|
|
Tenant = tenant,
|
|
CheckRekor = !noRekor,
|
|
CheckExpiry = !noExpiry
|
|
};
|
|
|
|
var result = await client.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
foreach (var ve in result.ValidationErrors)
|
|
{
|
|
var pathInfo = !string.IsNullOrWhiteSpace(ve.Path) ? $" at {ve.Path}" : "";
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ve.Code)}:[/] {Markup.Escape(ve.Message)}{pathInfo}");
|
|
}
|
|
Environment.ExitCode = 15;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderPackVerifyResult(result, verbose);
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
|
|
}
|
|
|
|
var allValid = result.SignatureValid && result.DigestMatch && result.SchemaValid
|
|
&& (result.RekorVerified ?? true) && (result.CertificateValid ?? true);
|
|
Environment.ExitCode = allValid ? 0 : 15;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to verify pack.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 15;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderPackPlan(PackPlanResult result, bool verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Pack Execution Plan[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.PlanHash))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Plan Hash:[/] {Markup.Escape(result.PlanHash)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.PlanId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Plan ID:[/] {Markup.Escape(result.PlanId)}");
|
|
}
|
|
if (result.EstimatedDuration.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Estimated Duration:[/] {result.EstimatedDuration.Value.TotalMinutes:F1} minutes");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
if (result.Steps.Count > 0)
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn("Step");
|
|
table.AddColumn("Action");
|
|
table.AddColumn("Depends On");
|
|
if (verbose)
|
|
{
|
|
table.AddColumn("Condition");
|
|
}
|
|
|
|
foreach (var step in result.Steps)
|
|
{
|
|
var dependsOn = step.DependsOn.Count > 0 ? string.Join(", ", step.DependsOn) : "-";
|
|
var row = new List<string>
|
|
{
|
|
Markup.Escape(step.Name),
|
|
Markup.Escape(step.Action),
|
|
Markup.Escape(dependsOn)
|
|
};
|
|
|
|
if (verbose)
|
|
{
|
|
row.Add(Markup.Escape(step.Condition ?? "-"));
|
|
}
|
|
|
|
table.AddRow(row.ToArray());
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
if (result.RequiresApproval)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[yellow]This plan requires approval before execution.[/]");
|
|
if (result.ApprovalGates.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($" Approval gates: {string.Join(", ", result.ApprovalGates)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void RenderPackRunStatus(PackRunStatus status, bool verbose)
|
|
{
|
|
var statusColor = status.Status switch
|
|
{
|
|
"succeeded" => "green",
|
|
"failed" => "red",
|
|
"running" => "blue",
|
|
"waiting_approval" => "yellow",
|
|
"cancelled" => "grey",
|
|
_ => "white"
|
|
};
|
|
|
|
AnsiConsole.MarkupLine($"[bold]Pack Run: [{statusColor}]{status.Status.ToUpperInvariant()}[/{statusColor}][/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
AnsiConsole.MarkupLine($"[grey]Run ID:[/] {Markup.Escape(status.RunId)}");
|
|
AnsiConsole.MarkupLine($"[grey]Pack:[/] {Markup.Escape(status.PackId)} v{Markup.Escape(status.Version)}");
|
|
|
|
if (status.StartedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Started:[/] {status.StartedAt.Value:u}");
|
|
}
|
|
if (status.Duration.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Duration:[/] {status.Duration.Value.TotalSeconds:F1}s");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(status.Actor) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Actor:[/] {Markup.Escape(status.Actor)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(status.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(status.AuditEventId)}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(status.Error))
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(status.Error)}");
|
|
}
|
|
|
|
if (status.StepStatuses.Count > 0 && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
var table = new Table();
|
|
table.AddColumn("Step");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Duration");
|
|
|
|
foreach (var step in status.StepStatuses)
|
|
{
|
|
var stepColor = step.Status switch
|
|
{
|
|
"succeeded" => "green",
|
|
"failed" => "red",
|
|
"running" => "blue",
|
|
"skipped" => "grey",
|
|
_ => "white"
|
|
};
|
|
|
|
var durationStr = step.Duration.HasValue ? $"{step.Duration.Value.TotalSeconds:F1}s" : "-";
|
|
table.AddRow(
|
|
Markup.Escape(step.Name),
|
|
$"[{stepColor}]{Markup.Escape(step.Status)}[/{stepColor}]",
|
|
durationStr);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
if (status.Artifacts.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Artifacts:[/]");
|
|
foreach (var artifact in status.Artifacts)
|
|
{
|
|
AnsiConsole.MarkupLine($" - {Markup.Escape(artifact.Name)} ({Markup.Escape(artifact.Type)})");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void RenderPackVerifyResult(PackVerifyResult result, bool verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Pack Verification Result[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
|
|
grid.AddRow("[grey]Signature:[/]", result.SignatureValid ? "[green]Valid[/]" : "[red]Invalid[/]");
|
|
grid.AddRow("[grey]Digest Match:[/]", result.DigestMatch ? "[green]Yes[/]" : "[red]No[/]");
|
|
grid.AddRow("[grey]Schema:[/]", result.SchemaValid ? "[green]Valid[/]" : "[red]Invalid[/]");
|
|
|
|
if (result.RekorVerified.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Rekor Verified:[/]", result.RekorVerified.Value ? "[green]Yes[/]" : "[red]No[/]");
|
|
}
|
|
|
|
if (result.CertificateValid.HasValue)
|
|
{
|
|
grid.AddRow("[grey]Certificate:[/]", result.CertificateValid.Value ? "[green]Valid[/]" : "[red]Invalid[/]");
|
|
}
|
|
|
|
if (result.CertificateExpiry.HasValue && verbose)
|
|
{
|
|
var remaining = result.CertificateExpiry.Value - DateTimeOffset.UtcNow;
|
|
var expiryColor = remaining.TotalDays > 30 ? "green" : remaining.TotalDays > 7 ? "yellow" : "red";
|
|
grid.AddRow("[grey]Cert Expires:[/]", $"[{expiryColor}]{result.CertificateExpiry.Value:u}[/{expiryColor}]");
|
|
}
|
|
|
|
var panel = new Panel(grid)
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Verification Summary[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (result.Pack is not null && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[grey]Pack:[/] {Markup.Escape(result.Pack.Id)} v{Markup.Escape(result.Pack.Version)}");
|
|
if (!string.IsNullOrWhiteSpace(result.Pack.Author))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Author:[/] {Markup.Escape(result.Pack.Author)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-PACKS-43-001: Advanced pack features handlers
|
|
|
|
internal static async Task<int> HandlePackRunsListAsync(
|
|
IServiceProvider services,
|
|
string? packId,
|
|
string? status,
|
|
string? actor,
|
|
DateTimeOffset? since,
|
|
DateTimeOffset? until,
|
|
int pageSize,
|
|
string? pageToken,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackRunListRequest
|
|
{
|
|
PackId = packId,
|
|
Status = status,
|
|
Actor = actor,
|
|
Since = since,
|
|
Until = until,
|
|
PageSize = pageSize,
|
|
PageToken = pageToken,
|
|
Tenant = tenant
|
|
};
|
|
|
|
var result = await client.ListRunsAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (result.Runs.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No pack runs found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Run ID");
|
|
table.AddColumn("Pack");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Started");
|
|
table.AddColumn("Duration");
|
|
if (verbose)
|
|
{
|
|
table.AddColumn("Actor");
|
|
}
|
|
|
|
foreach (var run in result.Runs)
|
|
{
|
|
var statusColor = GetPackRunStatusColor(run.Status);
|
|
var duration = run.Duration.HasValue ? $"{run.Duration.Value.TotalSeconds:F0}s" : "-";
|
|
|
|
var row = new List<string>
|
|
{
|
|
Markup.Escape(run.RunId),
|
|
$"{Markup.Escape(run.PackId)}:{Markup.Escape(run.Version)}",
|
|
statusColor,
|
|
run.StartedAt?.ToString("u") ?? "-",
|
|
duration
|
|
};
|
|
|
|
if (verbose)
|
|
{
|
|
row.Add(Markup.Escape(run.Actor ?? "-"));
|
|
}
|
|
|
|
table.AddRow(row.ToArray());
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (result.TotalCount.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Showing {result.Runs.Count} of {result.TotalCount} runs[/]");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.NextPageToken))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]More results available. Use --page-token {Markup.Escape(result.NextPageToken)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackRunsShowAsync(
|
|
IServiceProvider services,
|
|
string runId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var result = await client.GetRunStatusAsync(runId, tenant, cancellationToken);
|
|
|
|
if (result is null)
|
|
{
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(new { error = "Run not found" }, JsonOutputOptions));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Run '{Markup.Escape(runId)}' not found.[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
RenderPackRunStatus(result, verbose);
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackRunsCancelAsync(
|
|
IServiceProvider services,
|
|
string runId,
|
|
string? reason,
|
|
bool force,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackCancelRequest
|
|
{
|
|
RunId = runId,
|
|
Tenant = tenant,
|
|
Reason = reason,
|
|
Force = force
|
|
};
|
|
|
|
var result = await client.CancelRunAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 15;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to cancel run:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Run '{Markup.Escape(runId)}' cancelled successfully.[/]");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackRunsPauseAsync(
|
|
IServiceProvider services,
|
|
string runId,
|
|
string? reason,
|
|
string? stepId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackApprovalPauseRequest
|
|
{
|
|
RunId = runId,
|
|
Tenant = tenant,
|
|
Reason = reason,
|
|
StepId = stepId
|
|
};
|
|
|
|
var result = await client.PauseForApprovalAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 15;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to pause run:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[yellow]Run '{Markup.Escape(runId)}' paused for approval.[/]");
|
|
|
|
if (result.Run is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetPackRunStatusColor(result.Run.Status)}");
|
|
if (!string.IsNullOrWhiteSpace(result.Run.CurrentStep))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Paused at step:[/] {Markup.Escape(result.Run.CurrentStep)}");
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[grey]Resume with:[/] stella pack runs resume {Markup.Escape(runId)}");
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackRunsResumeAsync(
|
|
IServiceProvider services,
|
|
string runId,
|
|
bool approve,
|
|
string? reason,
|
|
string? stepId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackApprovalResumeRequest
|
|
{
|
|
RunId = runId,
|
|
Tenant = tenant,
|
|
Approved = approve,
|
|
Reason = reason,
|
|
StepId = stepId
|
|
};
|
|
|
|
var result = await client.ResumeWithApprovalAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 15;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to resume run:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
if (approve)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Run '{Markup.Escape(runId)}' approved and resumed.[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Run '{Markup.Escape(runId)}' rejected.[/]");
|
|
}
|
|
|
|
if (result.Run is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetPackRunStatusColor(result.Run.Status)}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackRunsLogsAsync(
|
|
IServiceProvider services,
|
|
string runId,
|
|
string? stepId,
|
|
int? tail,
|
|
DateTimeOffset? since,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackLogsRequest
|
|
{
|
|
RunId = runId,
|
|
Tenant = tenant,
|
|
StepId = stepId,
|
|
Tail = tail,
|
|
Since = since
|
|
};
|
|
|
|
var result = await client.GetLogsAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 15;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to get logs:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
if (result.Logs.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No logs found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
foreach (var log in result.Logs)
|
|
{
|
|
var levelColor = log.Level.ToLowerInvariant() switch
|
|
{
|
|
"error" => "red",
|
|
"warn" => "yellow",
|
|
"debug" => "grey",
|
|
_ => "white"
|
|
};
|
|
|
|
var stepInfo = verbose && !string.IsNullOrWhiteSpace(log.StepId) ? $"[{Markup.Escape(log.StepId)}] " : "";
|
|
var timestamp = verbose ? $"[grey]{log.Timestamp:HH:mm:ss}[/] " : "";
|
|
|
|
AnsiConsole.MarkupLine($"{timestamp}{stepInfo}[{levelColor}]{Markup.Escape(log.Message)}[/{levelColor}]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackSecretsInjectAsync(
|
|
IServiceProvider services,
|
|
string runId,
|
|
string secretRef,
|
|
string provider,
|
|
string? envVar,
|
|
string? path,
|
|
string? stepId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackSecretInjectRequest
|
|
{
|
|
RunId = runId,
|
|
Tenant = tenant,
|
|
SecretRef = secretRef,
|
|
SecretProvider = provider,
|
|
TargetEnvVar = envVar,
|
|
TargetPath = path,
|
|
StepId = stepId
|
|
};
|
|
|
|
var result = await client.InjectSecretAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 15;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to inject secret:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Secret injected successfully.[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Secret Ref:[/] {Markup.Escape(secretRef)}");
|
|
AnsiConsole.MarkupLine($"[grey]Provider:[/] {Markup.Escape(provider)}");
|
|
|
|
if (!string.IsNullOrWhiteSpace(envVar))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Environment Variable:[/] {Markup.Escape(envVar)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(path))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]File Path:[/] {Markup.Escape(path)}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackCacheListAsync(
|
|
IServiceProvider services,
|
|
string? cacheDir,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackCacheRequest
|
|
{
|
|
Action = "list",
|
|
CacheDir = cacheDir
|
|
};
|
|
|
|
var result = await client.ManageCacheAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 15;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to list cache:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
if (result.Entries.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]Cache is empty.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Pack");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("Size");
|
|
table.AddColumn("Cached At");
|
|
if (verbose)
|
|
{
|
|
table.AddColumn("Digest");
|
|
table.AddColumn("Verified");
|
|
}
|
|
|
|
foreach (var entry in result.Entries)
|
|
{
|
|
var row = new List<string>
|
|
{
|
|
Markup.Escape(entry.PackId),
|
|
Markup.Escape(entry.Version),
|
|
FormatBytes(entry.Size),
|
|
entry.CachedAt.ToString("u")
|
|
};
|
|
|
|
if (verbose)
|
|
{
|
|
row.Add(Markup.Escape(entry.Digest.Length > 12 ? entry.Digest[..12] + "..." : entry.Digest));
|
|
row.Add(entry.Verified ? "[green]Yes[/]" : "[yellow]No[/]");
|
|
}
|
|
|
|
table.AddRow(row.ToArray());
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine($"[grey]Total cache size:[/] {FormatBytes(result.TotalSize)}");
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackCacheAddAsync(
|
|
IServiceProvider services,
|
|
string packId,
|
|
string? version,
|
|
string? cacheDir,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackCacheRequest
|
|
{
|
|
Action = "add",
|
|
PackId = packId,
|
|
Version = version,
|
|
CacheDir = cacheDir
|
|
};
|
|
|
|
var result = await client.ManageCacheAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 15;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to add to cache:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Pack added to cache.[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Pack:[/] {Markup.Escape(packId)}");
|
|
if (!string.IsNullOrWhiteSpace(version))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Version:[/] {Markup.Escape(version)}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandlePackCachePruneAsync(
|
|
IServiceProvider services,
|
|
int? maxAgeDays,
|
|
long? maxSizeMb,
|
|
bool dryRun,
|
|
string? cacheDir,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IPackClient>();
|
|
|
|
var request = new PackCacheRequest
|
|
{
|
|
Action = dryRun ? "prune_preview" : "prune",
|
|
CacheDir = cacheDir,
|
|
MaxAge = maxAgeDays.HasValue ? TimeSpan.FromDays(maxAgeDays.Value) : null,
|
|
MaxSize = maxSizeMb.HasValue ? maxSizeMb.Value * 1024 * 1024 : null
|
|
};
|
|
|
|
var result = await client.ManageCacheAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 15;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to prune cache:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 15;
|
|
}
|
|
|
|
if (dryRun)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]DRY RUN - No changes made[/]");
|
|
}
|
|
|
|
if (result.PrunedCount == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No packs to prune.[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]{(dryRun ? "Would prune" : "Pruned")} {result.PrunedCount} pack(s).[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Space {(dryRun ? "to be " : "")}freed:[/] {FormatBytes(result.PrunedSize)}");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[grey]Cache size {(dryRun ? "after prune" : "now")}:[/] {FormatBytes(result.TotalSize)}");
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static string GetPackRunStatusColor(string status)
|
|
{
|
|
return status.ToLowerInvariant() switch
|
|
{
|
|
"pending" => "[yellow]pending[/]",
|
|
"running" => "[blue]running[/]",
|
|
"succeeded" => "[green]succeeded[/]",
|
|
"failed" => "[red]failed[/]",
|
|
"cancelled" => "[grey]cancelled[/]",
|
|
"waiting_approval" => "[yellow]waiting_approval[/]",
|
|
_ => Markup.Escape(status)
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Exceptions Commands (CLI-EXC-25-001)
|
|
|
|
/// <summary>
|
|
/// Handles 'stella exceptions list' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleExceptionsListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? vuln,
|
|
string? scopeType,
|
|
string? scopeValue,
|
|
string[]? status,
|
|
string? owner,
|
|
string? effectType,
|
|
DateTimeOffset? expiringBefore,
|
|
bool includeExpired,
|
|
int pageSize,
|
|
string? pageToken,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IExceptionClient>();
|
|
|
|
var request = new ExceptionListRequest
|
|
{
|
|
Tenant = tenant,
|
|
Vuln = vuln,
|
|
ScopeType = scopeType,
|
|
ScopeValue = scopeValue,
|
|
Statuses = status?.Length > 0 ? status : null,
|
|
Owner = owner,
|
|
EffectType = effectType,
|
|
ExpiringBefore = expiringBefore,
|
|
IncludeExpired = includeExpired,
|
|
PageSize = pageSize,
|
|
PageToken = pageToken
|
|
};
|
|
|
|
try
|
|
{
|
|
var response = await client.ListAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Exceptions.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No exceptions found matching criteria.[/]");
|
|
return 0;
|
|
}
|
|
|
|
RenderExceptionList(response, verbose);
|
|
|
|
if (!string.IsNullOrWhiteSpace(response.NextPageToken))
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[grey]Next page token: {Markup.Escape(response.NextPageToken)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error listing exceptions: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella exceptions show' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleExceptionsShowAsync(
|
|
IServiceProvider services,
|
|
string exceptionId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IExceptionClient>();
|
|
|
|
try
|
|
{
|
|
var exception = await client.GetAsync(exceptionId, tenant, cancellationToken);
|
|
|
|
if (exception is null)
|
|
{
|
|
var error = new CliError(CliErrorCodes.ExcNotFound, $"Exception '{exceptionId}' not found.");
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(exception, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
RenderExceptionDetail(exception, verbose);
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error fetching exception: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella exceptions create' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleExceptionsCreateAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string vuln,
|
|
string scopeType,
|
|
string scopeValue,
|
|
string effectId,
|
|
string justification,
|
|
string owner,
|
|
DateTimeOffset? expiration,
|
|
string[]? evidence,
|
|
string? policyBinding,
|
|
bool stage,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IExceptionClient>();
|
|
|
|
var evidenceRefs = evidence?.Select(e =>
|
|
{
|
|
// Format: type:uri[:digest]
|
|
var parts = e.Split(':', 3);
|
|
return new ExceptionEvidenceRef
|
|
{
|
|
Type = parts.Length > 0 ? parts[0] : "ticket",
|
|
Uri = parts.Length > 1 ? parts[1] : e,
|
|
Digest = parts.Length > 2 ? parts[2] : null
|
|
};
|
|
}).ToList();
|
|
|
|
var request = new ExceptionCreateRequest
|
|
{
|
|
Tenant = tenant,
|
|
Vuln = vuln,
|
|
Scope = new ExceptionScope
|
|
{
|
|
Type = scopeType,
|
|
Value = scopeValue
|
|
},
|
|
EffectId = effectId,
|
|
Justification = justification,
|
|
Owner = owner,
|
|
Expiration = expiration,
|
|
EvidenceRefs = evidenceRefs,
|
|
PolicyBinding = policyBinding,
|
|
Stage = stage
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.CreateAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 16;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to create exception:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 16;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[green]Exception created successfully.[/]");
|
|
if (result.Exception is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]ID:[/] {Markup.Escape(result.Exception.Id)}");
|
|
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetStatusColor(result.Exception.Status)}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error creating exception: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella exceptions promote' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleExceptionsPromoteAsync(
|
|
IServiceProvider services,
|
|
string exceptionId,
|
|
string? tenant,
|
|
string targetStatus,
|
|
string? comment,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IExceptionClient>();
|
|
|
|
var request = new ExceptionPromoteRequest
|
|
{
|
|
ExceptionId = exceptionId,
|
|
Tenant = tenant,
|
|
TargetStatus = targetStatus,
|
|
Comment = comment
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.PromoteAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 16;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to promote exception:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 16;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[green]Exception promoted successfully.[/]");
|
|
if (result.Exception is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]ID:[/] {Markup.Escape(result.Exception.Id)}");
|
|
AnsiConsole.MarkupLine($"[grey]New Status:[/] {GetStatusColor(result.Exception.Status)}");
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error promoting exception: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella exceptions revoke' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleExceptionsRevokeAsync(
|
|
IServiceProvider services,
|
|
string exceptionId,
|
|
string? tenant,
|
|
string? reason,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IExceptionClient>();
|
|
|
|
var request = new ExceptionRevokeRequest
|
|
{
|
|
ExceptionId = exceptionId,
|
|
Tenant = tenant,
|
|
Reason = reason
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.RevokeAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 16;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to revoke exception:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 16;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[green]Exception revoked successfully.[/]");
|
|
if (result.Exception is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]ID:[/] {Markup.Escape(result.Exception.Id)}");
|
|
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetStatusColor(result.Exception.Status)}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error revoking exception: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella exceptions import' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleExceptionsImportAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string file,
|
|
bool stage,
|
|
string? source,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IExceptionClient>();
|
|
|
|
if (!File.Exists(file))
|
|
{
|
|
var error = new CliError(CliErrorCodes.ExcImportFailed, $"File not found: {file}");
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
|
|
var request = new ExceptionImportRequest
|
|
{
|
|
Tenant = tenant,
|
|
Stage = stage,
|
|
Source = source
|
|
};
|
|
|
|
try
|
|
{
|
|
await using var stream = File.OpenRead(file);
|
|
var result = await client.ImportAsync(request, stream, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 16;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Import failed:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]Line {error.Line}: {Markup.Escape(error.Message)}[/]");
|
|
if (!string.IsNullOrWhiteSpace(error.Field))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Field: {Markup.Escape(error.Field)}[/]");
|
|
}
|
|
}
|
|
return 16;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[green]Import completed successfully.[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Imported:[/] {result.Imported}");
|
|
AnsiConsole.MarkupLine($"[grey]Skipped:[/] {result.Skipped}");
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error importing exceptions: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella exceptions export' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleExceptionsExportAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string[]? status,
|
|
string format,
|
|
string output,
|
|
bool includeManifest,
|
|
bool signed,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IExceptionClient>();
|
|
|
|
var request = new ExceptionExportRequest
|
|
{
|
|
Tenant = tenant,
|
|
Statuses = status?.Length > 0 ? status : null,
|
|
Format = format,
|
|
IncludeManifest = includeManifest,
|
|
Signed = signed
|
|
};
|
|
|
|
try
|
|
{
|
|
var (content, manifest) = await client.ExportAsync(request, cancellationToken);
|
|
|
|
// Write content to output file
|
|
await using (var fileStream = File.Create(output))
|
|
{
|
|
await content.CopyToAsync(fileStream, cancellationToken);
|
|
}
|
|
|
|
if (json && manifest is not null)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(manifest, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[green]Export completed successfully.[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Output:[/] {Markup.Escape(output)}");
|
|
|
|
if (manifest is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Count:[/] {manifest.Count}");
|
|
AnsiConsole.MarkupLine($"[grey]SHA256:[/] {Markup.Escape(manifest.Sha256)}");
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Generated:[/] {manifest.GeneratedAt:u}");
|
|
if (!string.IsNullOrWhiteSpace(manifest.SignatureUri))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Signature:[/] {Markup.Escape(manifest.SignatureUri)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error exporting exceptions: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
private static void RenderExceptionList(ExceptionListResponse response, bool verbose)
|
|
{
|
|
var table = new Table();
|
|
table.Border = TableBorder.Rounded;
|
|
|
|
table.AddColumn("ID");
|
|
table.AddColumn("Vuln");
|
|
table.AddColumn("Scope");
|
|
table.AddColumn("Effect");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Owner");
|
|
table.AddColumn("Expires");
|
|
|
|
foreach (var exc in response.Exceptions)
|
|
{
|
|
var scopeText = $"{exc.Scope.Type}:{Truncate(exc.Scope.Value, 30)}";
|
|
var effectText = exc.Effect?.EffectType ?? exc.EffectId;
|
|
var expiresText = exc.Expiration?.ToString("yyyy-MM-dd") ?? "-";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(Truncate(exc.Id, 12)),
|
|
Markup.Escape(exc.Vuln),
|
|
Markup.Escape(scopeText),
|
|
Markup.Escape(effectText),
|
|
GetStatusColor(exc.Status),
|
|
Markup.Escape(Truncate(exc.Owner, 20)),
|
|
expiresText
|
|
);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (response.TotalCount.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Total: {response.TotalCount} exceptions[/]");
|
|
}
|
|
}
|
|
|
|
private static void RenderExceptionDetail(ExceptionInstance exc, bool verbose)
|
|
{
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[grey]ID:[/]", Markup.Escape(exc.Id))
|
|
.AddRow("[grey]Tenant:[/]", Markup.Escape(exc.Tenant))
|
|
.AddRow("[grey]Vulnerability:[/]", Markup.Escape(exc.Vuln))
|
|
.AddRow("[grey]Status:[/]", GetStatusColor(exc.Status))
|
|
.AddRow("[grey]Owner:[/]", Markup.Escape(exc.Owner))
|
|
.AddRow("[grey]Effect:[/]", Markup.Escape(exc.Effect?.EffectType ?? exc.EffectId))
|
|
.AddRow("[grey]Created:[/]", exc.CreatedAt.ToString("u"))
|
|
.AddRow("[grey]Updated:[/]", exc.UpdatedAt.ToString("u"))
|
|
.AddRow("[grey]Expires:[/]", exc.Expiration?.ToString("u") ?? "[grey]Never[/]"))
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Exception Details[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Scope
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Scope[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Type:[/] {Markup.Escape(exc.Scope.Type)}");
|
|
AnsiConsole.MarkupLine($" [grey]Value:[/] {Markup.Escape(exc.Scope.Value)}");
|
|
|
|
if (exc.Scope.RuleNames?.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Rules:[/] {string.Join(", ", exc.Scope.RuleNames.Select(Markup.Escape))}");
|
|
}
|
|
|
|
if (exc.Scope.Severities?.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Severities:[/] {string.Join(", ", exc.Scope.Severities.Select(Markup.Escape))}");
|
|
}
|
|
|
|
// Justification
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Justification[/]");
|
|
AnsiConsole.MarkupLine($" {Markup.Escape(exc.Justification)}");
|
|
|
|
// Evidence
|
|
if (exc.EvidenceRefs.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Evidence[/]");
|
|
foreach (var ev in exc.EvidenceRefs)
|
|
{
|
|
AnsiConsole.MarkupLine($" • [{Markup.Escape(ev.Type)}] {Markup.Escape(ev.Uri)}");
|
|
if (!string.IsNullOrWhiteSpace(ev.Digest) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Digest: {Markup.Escape(ev.Digest)}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Approval info
|
|
if (!string.IsNullOrWhiteSpace(exc.ApprovedBy) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Approval[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Approved By:[/] {Markup.Escape(exc.ApprovedBy)}");
|
|
if (exc.ApprovedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Approved At:[/] {exc.ApprovedAt.Value:u}");
|
|
}
|
|
}
|
|
|
|
// Effect details
|
|
if (exc.Effect is not null && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Effect Details[/]");
|
|
AnsiConsole.MarkupLine($" [grey]ID:[/] {Markup.Escape(exc.Effect.Id)}");
|
|
AnsiConsole.MarkupLine($" [grey]Type:[/] {Markup.Escape(exc.Effect.EffectType)}");
|
|
if (!string.IsNullOrWhiteSpace(exc.Effect.Name))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Name:[/] {Markup.Escape(exc.Effect.Name)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(exc.Effect.DowngradeSeverity))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Downgrade To:[/] {Markup.Escape(exc.Effect.DowngradeSeverity)}");
|
|
}
|
|
if (exc.Effect.MaxDurationDays.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Max Duration:[/] {exc.Effect.MaxDurationDays} days");
|
|
}
|
|
}
|
|
|
|
// Metadata
|
|
if (exc.Metadata?.Count > 0 && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Metadata[/]");
|
|
foreach (var kvp in exc.Metadata)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(kvp.Key)}:[/] {Markup.Escape(kvp.Value)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string GetStatusColor(string status)
|
|
{
|
|
return status.ToLowerInvariant() switch
|
|
{
|
|
"draft" => "[grey]draft[/]",
|
|
"staged" => "[blue]staged[/]",
|
|
"active" => "[green]active[/]",
|
|
"expired" => "[yellow]expired[/]",
|
|
"revoked" => "[red]revoked[/]",
|
|
_ => Markup.Escape(status)
|
|
};
|
|
}
|
|
|
|
private static string Truncate(string value, int maxLength)
|
|
{
|
|
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
|
return value;
|
|
return value[..(maxLength - 3)] + "...";
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Orchestrator Commands (CLI-ORCH-32-001)
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch sources list' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchSourcesListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? type,
|
|
string? status,
|
|
bool? enabled,
|
|
string? host,
|
|
string? tag,
|
|
int pageSize,
|
|
string? pageToken,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new SourceListRequest
|
|
{
|
|
Tenant = tenant,
|
|
Type = type,
|
|
Status = status,
|
|
Enabled = enabled,
|
|
Host = host,
|
|
Tag = tag,
|
|
PageSize = pageSize,
|
|
PageToken = pageToken
|
|
};
|
|
|
|
try
|
|
{
|
|
var response = await client.ListSourcesAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Sources.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No sources found matching criteria.[/]");
|
|
return 0;
|
|
}
|
|
|
|
RenderSourceList(response, verbose);
|
|
|
|
if (!string.IsNullOrWhiteSpace(response.NextPageToken))
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[grey]Next page token: {Markup.Escape(response.NextPageToken)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error listing sources: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch sources show' command.
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchSourcesShowAsync(
|
|
IServiceProvider services,
|
|
string sourceId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
try
|
|
{
|
|
var source = await client.GetSourceAsync(sourceId, tenant, cancellationToken);
|
|
|
|
if (source is null)
|
|
{
|
|
var error = new CliError(CliErrorCodes.OrchSourceNotFound, $"Source '{sourceId}' not found.");
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(source, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
RenderSourceDetail(source, verbose);
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error fetching source: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
private static void RenderSourceList(SourceListResponse response, bool verbose)
|
|
{
|
|
var table = new Table();
|
|
table.Border = TableBorder.Rounded;
|
|
|
|
table.AddColumn("ID");
|
|
table.AddColumn("Name");
|
|
table.AddColumn("Type");
|
|
table.AddColumn("Host");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Last Run");
|
|
if (verbose)
|
|
{
|
|
table.AddColumn("Priority");
|
|
table.AddColumn("Success Rate");
|
|
}
|
|
|
|
foreach (var src in response.Sources)
|
|
{
|
|
var lastRunText = src.LastRun?.CompletedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-";
|
|
var statusMarkup = GetSourceStatusColor(src.Status);
|
|
|
|
if (verbose)
|
|
{
|
|
var successRate = src.Metrics is not null && src.Metrics.TotalRuns > 0
|
|
? $"{(double)src.Metrics.SuccessfulRuns / src.Metrics.TotalRuns * 100:F1}%"
|
|
: "-";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(TruncateText(src.Id, 12)),
|
|
Markup.Escape(TruncateText(src.Name, 20)),
|
|
Markup.Escape(src.Type),
|
|
Markup.Escape(TruncateText(src.Host, 25)),
|
|
statusMarkup,
|
|
lastRunText,
|
|
src.Priority.ToString(),
|
|
successRate
|
|
);
|
|
}
|
|
else
|
|
{
|
|
table.AddRow(
|
|
Markup.Escape(TruncateText(src.Id, 12)),
|
|
Markup.Escape(TruncateText(src.Name, 20)),
|
|
Markup.Escape(src.Type),
|
|
Markup.Escape(TruncateText(src.Host, 25)),
|
|
statusMarkup,
|
|
lastRunText
|
|
);
|
|
}
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (response.TotalCount.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Total: {response.TotalCount} sources[/]");
|
|
}
|
|
}
|
|
|
|
private static void RenderSourceDetail(OrchestratorSource src, bool verbose)
|
|
{
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[grey]ID:[/]", Markup.Escape(src.Id))
|
|
.AddRow("[grey]Name:[/]", Markup.Escape(src.Name))
|
|
.AddRow("[grey]Tenant:[/]", Markup.Escape(src.Tenant))
|
|
.AddRow("[grey]Type:[/]", Markup.Escape(src.Type))
|
|
.AddRow("[grey]Host:[/]", Markup.Escape(src.Host))
|
|
.AddRow("[grey]Status:[/]", GetSourceStatusColor(src.Status))
|
|
.AddRow("[grey]Enabled:[/]", src.Enabled ? "[green]Yes[/]" : "[red]No[/]")
|
|
.AddRow("[grey]Priority:[/]", src.Priority.ToString())
|
|
.AddRow("[grey]Created:[/]", src.CreatedAt.ToString("u"))
|
|
.AddRow("[grey]Updated:[/]", src.UpdatedAt.ToString("u")))
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold]Source Details[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Pause info
|
|
if (!string.IsNullOrWhiteSpace(src.PausedBy) || src.PausedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold yellow]Pause Info[/]");
|
|
if (src.PausedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Paused At:[/] {src.PausedAt.Value:u}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(src.PausedBy))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Paused By:[/] {Markup.Escape(src.PausedBy)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(src.PauseReason))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Reason:[/] {Markup.Escape(src.PauseReason)}");
|
|
}
|
|
}
|
|
|
|
// Schedule
|
|
if (src.Schedule is not null)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Schedule[/]");
|
|
if (!string.IsNullOrWhiteSpace(src.Schedule.Cron))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Cron:[/] {Markup.Escape(src.Schedule.Cron)}");
|
|
}
|
|
if (src.Schedule.IntervalMinutes.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Interval:[/] {src.Schedule.IntervalMinutes} minutes");
|
|
}
|
|
if (src.Schedule.NextRunAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Next Run:[/] {src.Schedule.NextRunAt.Value:u}");
|
|
}
|
|
AnsiConsole.MarkupLine($" [grey]Timezone:[/] {Markup.Escape(src.Schedule.Timezone)}");
|
|
}
|
|
|
|
// Rate limit
|
|
if (src.RateLimit is not null)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Rate Limit[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Max/min:[/] {src.RateLimit.MaxRequestsPerMinute}");
|
|
if (src.RateLimit.MaxRequestsPerHour.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Max/hour:[/] {src.RateLimit.MaxRequestsPerHour}");
|
|
}
|
|
AnsiConsole.MarkupLine($" [grey]Burst:[/] {src.RateLimit.BurstSize}");
|
|
if (src.RateLimit.CurrentTokens.HasValue && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Current Tokens:[/] {src.RateLimit.CurrentTokens:F2}");
|
|
}
|
|
if (src.RateLimit.ThrottledUntil.HasValue)
|
|
{
|
|
var remaining = src.RateLimit.ThrottledUntil.Value - DateTimeOffset.UtcNow;
|
|
var throttleColor = remaining.TotalMinutes > 5 ? "red" : "yellow";
|
|
AnsiConsole.MarkupLine($" [grey]Throttled Until:[/] [{throttleColor}]{src.RateLimit.ThrottledUntil.Value:u}[/{throttleColor}]");
|
|
}
|
|
}
|
|
|
|
// Last run
|
|
if (src.LastRun is not null && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Last Run[/]");
|
|
if (!string.IsNullOrWhiteSpace(src.LastRun.RunId))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Run ID:[/] {Markup.Escape(src.LastRun.RunId)}");
|
|
}
|
|
if (src.LastRun.StartedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Started:[/] {src.LastRun.StartedAt.Value:u}");
|
|
}
|
|
if (src.LastRun.CompletedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Completed:[/] {src.LastRun.CompletedAt.Value:u}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(src.LastRun.Status))
|
|
{
|
|
var runStatusColor = src.LastRun.Status.ToLowerInvariant() switch
|
|
{
|
|
"succeeded" or "success" => "green",
|
|
"failed" or "error" => "red",
|
|
"running" => "blue",
|
|
_ => "grey"
|
|
};
|
|
AnsiConsole.MarkupLine($" [grey]Status:[/] [{runStatusColor}]{Markup.Escape(src.LastRun.Status)}[/{runStatusColor}]");
|
|
}
|
|
if (src.LastRun.ItemsProcessed.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Items Processed:[/] {src.LastRun.ItemsProcessed}");
|
|
}
|
|
if (src.LastRun.ItemsFailed.HasValue && src.LastRun.ItemsFailed > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Items Failed:[/] [red]{src.LastRun.ItemsFailed}[/]");
|
|
}
|
|
if (src.LastRun.DurationMs.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Duration:[/] {src.LastRun.DurationMs}ms");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(src.LastRun.ErrorMessage))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Error:[/] [red]{Markup.Escape(src.LastRun.ErrorMessage)}[/]");
|
|
}
|
|
}
|
|
|
|
// Metrics
|
|
if (src.Metrics is not null && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Metrics[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Total Runs:[/] {src.Metrics.TotalRuns}");
|
|
AnsiConsole.MarkupLine($" [grey]Successful:[/] [green]{src.Metrics.SuccessfulRuns}[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Failed:[/] [red]{src.Metrics.FailedRuns}[/]");
|
|
if (src.Metrics.TotalRuns > 0)
|
|
{
|
|
var successRate = (double)src.Metrics.SuccessfulRuns / src.Metrics.TotalRuns * 100;
|
|
var rateColor = successRate >= 95 ? "green" : successRate >= 80 ? "yellow" : "red";
|
|
AnsiConsole.MarkupLine($" [grey]Success Rate:[/] [{rateColor}]{successRate:F1}%[/{rateColor}]");
|
|
}
|
|
if (src.Metrics.AverageDurationMs.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Avg Duration:[/] {src.Metrics.AverageDurationMs:F0}ms");
|
|
}
|
|
if (src.Metrics.UptimePercent.HasValue)
|
|
{
|
|
var uptimeColor = src.Metrics.UptimePercent >= 99 ? "green" : src.Metrics.UptimePercent >= 95 ? "yellow" : "red";
|
|
AnsiConsole.MarkupLine($" [grey]Uptime:[/] [{uptimeColor}]{src.Metrics.UptimePercent:F2}%[/{uptimeColor}]");
|
|
}
|
|
}
|
|
|
|
// Tags
|
|
if (src.Tags.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Tags[/]");
|
|
AnsiConsole.MarkupLine($" {string.Join(", ", src.Tags.Select(t => $"[blue]{Markup.Escape(t)}[/]"))}");
|
|
}
|
|
|
|
// Metadata
|
|
if (src.Metadata?.Count > 0 && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]Metadata[/]");
|
|
foreach (var kvp in src.Metadata)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(kvp.Key)}:[/] {Markup.Escape(kvp.Value)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string GetSourceStatusColor(string status)
|
|
{
|
|
return status.ToLowerInvariant() switch
|
|
{
|
|
"active" => "[green]active[/]",
|
|
"paused" => "[yellow]paused[/]",
|
|
"disabled" => "[grey]disabled[/]",
|
|
"throttled" => "[orange1]throttled[/]",
|
|
"error" => "[red]error[/]",
|
|
_ => Markup.Escape(status)
|
|
};
|
|
}
|
|
|
|
private static string TruncateText(string value, int maxLength)
|
|
{
|
|
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
|
return value;
|
|
return value[..(maxLength - 3)] + "...";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch sources test' command.
|
|
/// CLI-ORCH-33-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchSourcesTestAsync(
|
|
IServiceProvider services,
|
|
string sourceId,
|
|
string? tenant,
|
|
int timeout,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new SourceTestRequest
|
|
{
|
|
SourceId = sourceId,
|
|
Tenant = tenant,
|
|
TimeoutSeconds = timeout
|
|
};
|
|
|
|
try
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Testing source '{Markup.Escape(sourceId)}'...[/]");
|
|
|
|
var result = await client.TestSourceAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success && result.Reachable ? 0 : 17;
|
|
}
|
|
|
|
if (!result.Success || !result.Reachable)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Test failed for source '{Markup.Escape(sourceId)}'[/]");
|
|
if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(result.ErrorMessage)}[/]");
|
|
}
|
|
return 17;
|
|
}
|
|
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[grey]Source ID:[/]", Markup.Escape(result.SourceId))
|
|
.AddRow("[grey]Reachable:[/]", "[green]Yes[/]")
|
|
.AddRow("[grey]Latency:[/]", result.LatencyMs.HasValue ? $"{result.LatencyMs}ms" : "-")
|
|
.AddRow("[grey]Status Code:[/]", result.StatusCode.HasValue ? result.StatusCode.ToString()! : "-")
|
|
.AddRow("[grey]Tested At:[/]", result.TestedAt.ToString("u")))
|
|
{
|
|
Border = BoxBorder.Rounded,
|
|
Header = new PanelHeader("[bold green]Connection Test Passed[/]")
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (verbose)
|
|
{
|
|
if (result.TlsValid.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold]TLS Information[/]");
|
|
AnsiConsole.MarkupLine($" [grey]Valid:[/] {(result.TlsValid.Value ? "[green]Yes[/]" : "[red]No[/]")}");
|
|
if (result.TlsExpiry.HasValue)
|
|
{
|
|
var remaining = result.TlsExpiry.Value - DateTimeOffset.UtcNow;
|
|
var expiryColor = remaining.TotalDays > 30 ? "green" : remaining.TotalDays > 7 ? "yellow" : "red";
|
|
AnsiConsole.MarkupLine($" [grey]Expires:[/] [{expiryColor}]{result.TlsExpiry.Value:u}[/{expiryColor}] ({remaining.TotalDays:F0} days)");
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error testing source: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch sources pause' command.
|
|
/// CLI-ORCH-33-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchSourcesPauseAsync(
|
|
IServiceProvider services,
|
|
string sourceId,
|
|
string? tenant,
|
|
string? reason,
|
|
int? durationMinutes,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new SourcePauseRequest
|
|
{
|
|
SourceId = sourceId,
|
|
Tenant = tenant,
|
|
Reason = reason,
|
|
DurationMinutes = durationMinutes
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.PauseSourceAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 17;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to pause source:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 17;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Source '{Markup.Escape(sourceId)}' paused successfully.[/]");
|
|
|
|
if (result.Source is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetSourceStatusColor(result.Source.Status)}");
|
|
if (result.Source.PausedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Paused At:[/] {result.Source.PausedAt.Value:u}");
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
if (durationMinutes.HasValue)
|
|
{
|
|
var resumeAt = DateTimeOffset.UtcNow.AddMinutes(durationMinutes.Value);
|
|
AnsiConsole.MarkupLine($"[grey]Auto-resume at:[/] {resumeAt:u}");
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error pausing source: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch sources resume' command.
|
|
/// CLI-ORCH-33-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchSourcesResumeAsync(
|
|
IServiceProvider services,
|
|
string sourceId,
|
|
string? tenant,
|
|
string? reason,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new SourceResumeRequest
|
|
{
|
|
SourceId = sourceId,
|
|
Tenant = tenant,
|
|
Reason = reason
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.ResumeSourceAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 17;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to resume source:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 17;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Source '{Markup.Escape(sourceId)}' resumed successfully.[/]");
|
|
|
|
if (result.Source is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetSourceStatusColor(result.Source.Status)}");
|
|
if (result.Source.Schedule?.NextRunAt.HasValue == true)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Next Run:[/] {result.Source.Schedule.NextRunAt.Value:u}");
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error resuming source: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
// CLI-ORCH-34-001: Backfill handlers
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch backfill start' command.
|
|
/// CLI-ORCH-34-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchBackfillStartAsync(
|
|
IServiceProvider services,
|
|
string sourceId,
|
|
string? tenant,
|
|
DateTimeOffset from,
|
|
DateTimeOffset to,
|
|
bool dryRun,
|
|
int priority,
|
|
int concurrency,
|
|
int batchSize,
|
|
bool resume,
|
|
string? filter,
|
|
bool force,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new BackfillRequest
|
|
{
|
|
SourceId = sourceId,
|
|
Tenant = tenant,
|
|
From = from,
|
|
To = to,
|
|
DryRun = dryRun,
|
|
Priority = priority,
|
|
Concurrency = concurrency,
|
|
BatchSize = batchSize,
|
|
Resume = resume,
|
|
Filter = filter,
|
|
Force = force
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.StartBackfillAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 17;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to start backfill:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 17;
|
|
}
|
|
|
|
if (dryRun)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]DRY RUN - No changes will be made[/]");
|
|
AnsiConsole.MarkupLine("");
|
|
}
|
|
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[grey]Backfill ID:[/]", Markup.Escape(result.BackfillId ?? "-"))
|
|
.AddRow("[grey]Source:[/]", Markup.Escape(sourceId))
|
|
.AddRow("[grey]Status:[/]", GetBackfillStatusColor(result.Status))
|
|
.AddRow("[grey]Period:[/]", $"{from:u} → {to:u}")
|
|
.AddRow("[grey]Estimated Items:[/]", result.EstimatedItems?.ToString("N0") ?? "Unknown")
|
|
.AddRow("[grey]Estimated Duration:[/]", FormatDuration(result.EstimatedDurationMs)))
|
|
{
|
|
Header = new PanelHeader(dryRun ? "[yellow]Backfill Preview[/]" : "[green]Backfill Started[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[grey]Priority:[/] {priority}");
|
|
AnsiConsole.MarkupLine($"[grey]Concurrency:[/] {concurrency}");
|
|
AnsiConsole.MarkupLine($"[grey]Batch Size:[/] {batchSize}");
|
|
if (!string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Filter:[/] {Markup.Escape(filter)}");
|
|
}
|
|
if (force)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Force mode: Existing data will be overwritten[/]");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
if (!dryRun && !string.IsNullOrWhiteSpace(result.BackfillId))
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine($"[grey]Monitor progress with:[/] stella orch backfill status {Markup.Escape(result.BackfillId)}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error starting backfill: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch backfill status' command.
|
|
/// CLI-ORCH-34-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchBackfillStatusAsync(
|
|
IServiceProvider services,
|
|
string backfillId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
try
|
|
{
|
|
var result = await client.GetBackfillAsync(backfillId, tenant, cancellationToken);
|
|
|
|
if (result is null)
|
|
{
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(new { error = "Backfill not found" }, JsonOutputOptions));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Backfill '{Markup.Escape(backfillId)}' not found.[/]");
|
|
}
|
|
return 17;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
var progressPercent = result.EstimatedItems.HasValue && result.EstimatedItems > 0
|
|
? (double)result.ProcessedItems / result.EstimatedItems.Value * 100
|
|
: 0;
|
|
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[grey]Backfill ID:[/]", Markup.Escape(result.BackfillId ?? backfillId))
|
|
.AddRow("[grey]Source:[/]", Markup.Escape(result.SourceId))
|
|
.AddRow("[grey]Status:[/]", GetBackfillStatusColor(result.Status))
|
|
.AddRow("[grey]Period:[/]", $"{result.From:u} → {result.To:u}")
|
|
.AddRow("[grey]Progress:[/]", $"{result.ProcessedItems:N0} / {(result.EstimatedItems?.ToString("N0") ?? "?")} ({progressPercent:F1}%)")
|
|
.AddRow("[grey]Failed:[/]", result.FailedItems > 0 ? $"[red]{result.FailedItems:N0}[/]" : "0")
|
|
.AddRow("[grey]Skipped:[/]", result.SkippedItems > 0 ? $"[yellow]{result.SkippedItems:N0}[/]" : "0"))
|
|
{
|
|
Header = new PanelHeader("[blue]Backfill Status[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
if (result.StartedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Started:[/] {result.StartedAt.Value:u}");
|
|
}
|
|
if (result.CompletedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Completed:[/] {result.CompletedAt.Value:u}");
|
|
}
|
|
if (result.ActualDurationMs.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Duration:[/] {FormatDuration(result.ActualDurationMs)}");
|
|
}
|
|
else if (result.StartedAt.HasValue)
|
|
{
|
|
var elapsed = DateTimeOffset.UtcNow - result.StartedAt.Value;
|
|
AnsiConsole.MarkupLine($"[grey]Elapsed:[/] {FormatDuration((long)elapsed.TotalMilliseconds)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
}
|
|
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error getting backfill status: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch backfill list' command.
|
|
/// CLI-ORCH-34-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchBackfillListAsync(
|
|
IServiceProvider services,
|
|
string? sourceId,
|
|
string? status,
|
|
string? tenant,
|
|
int pageSize,
|
|
string? pageToken,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new BackfillListRequest
|
|
{
|
|
SourceId = sourceId,
|
|
Status = status,
|
|
Tenant = tenant,
|
|
PageSize = pageSize,
|
|
PageToken = pageToken
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.ListBackfillsAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (result.Backfills.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No backfill operations found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("ID");
|
|
table.AddColumn("Source");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Period");
|
|
table.AddColumn("Progress");
|
|
table.AddColumn("Started");
|
|
|
|
foreach (var backfill in result.Backfills)
|
|
{
|
|
var progressStr = backfill.EstimatedItems.HasValue && backfill.EstimatedItems > 0
|
|
? $"{(double)backfill.ProcessedItems / backfill.EstimatedItems.Value * 100:F0}%"
|
|
: $"{backfill.ProcessedItems:N0}";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(backfill.BackfillId ?? "-"),
|
|
Markup.Escape(backfill.SourceId),
|
|
GetBackfillStatusColor(backfill.Status),
|
|
$"{backfill.From:yyyy-MM-dd} → {backfill.To:yyyy-MM-dd}",
|
|
progressStr,
|
|
backfill.StartedAt?.ToString("u") ?? "-"
|
|
);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (result.TotalCount.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Showing {result.Backfills.Count} of {result.TotalCount} backfills[/]");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.NextPageToken))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]More results available. Use --page-token {Markup.Escape(result.NextPageToken)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error listing backfills: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch backfill cancel' command.
|
|
/// CLI-ORCH-34-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchBackfillCancelAsync(
|
|
IServiceProvider services,
|
|
string backfillId,
|
|
string? tenant,
|
|
string? reason,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new BackfillCancelRequest
|
|
{
|
|
BackfillId = backfillId,
|
|
Tenant = tenant,
|
|
Reason = reason
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.CancelBackfillAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 17;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to cancel backfill:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 17;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Backfill '{Markup.Escape(backfillId)}' cancelled successfully.[/]");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error cancelling backfill: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
// CLI-ORCH-34-001: Quota handlers
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch quotas get' command.
|
|
/// CLI-ORCH-34-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchQuotasGetAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? sourceId,
|
|
string? resourceType,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new QuotaGetRequest
|
|
{
|
|
Tenant = tenant,
|
|
SourceId = sourceId,
|
|
ResourceType = resourceType
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.GetQuotasAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (result.Quotas.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]No quotas configured.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Tenant");
|
|
table.AddColumn("Resource");
|
|
table.AddColumn("Used");
|
|
table.AddColumn("Limit");
|
|
table.AddColumn("Remaining");
|
|
table.AddColumn("Period");
|
|
table.AddColumn("Status");
|
|
|
|
foreach (var quota in result.Quotas)
|
|
{
|
|
var usagePercent = quota.Limit > 0 ? (double)quota.Used / quota.Limit * 100 : 0;
|
|
var statusColor = quota.IsExceeded ? "[red]EXCEEDED[/]"
|
|
: quota.IsWarning ? "[yellow]WARNING[/]"
|
|
: "[green]OK[/]";
|
|
|
|
var usedFormatted = FormatQuotaValue(quota.Used, quota.ResourceType);
|
|
var limitFormatted = FormatQuotaValue(quota.Limit, quota.ResourceType);
|
|
var remainingFormatted = FormatQuotaValue(quota.Remaining, quota.ResourceType);
|
|
|
|
table.AddRow(
|
|
Markup.Escape(quota.Tenant),
|
|
Markup.Escape(quota.ResourceType),
|
|
$"{usedFormatted} ({usagePercent:F1}%)",
|
|
limitFormatted,
|
|
remainingFormatted,
|
|
Markup.Escape(quota.Period),
|
|
statusColor
|
|
);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("");
|
|
foreach (var quota in result.Quotas.Where(q => q.IsWarning || q.IsExceeded))
|
|
{
|
|
if (quota.IsExceeded)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]EXCEEDED:[/] {Markup.Escape(quota.ResourceType)} - Resets at {quota.ResetAt:u}");
|
|
}
|
|
else if (quota.IsWarning)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]WARNING:[/] {Markup.Escape(quota.ResourceType)} at {(double)quota.Used / quota.Limit * 100:F1}% of limit");
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error getting quotas: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch quotas set' command.
|
|
/// CLI-ORCH-34-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchQuotasSetAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string? sourceId,
|
|
string resourceType,
|
|
long limit,
|
|
string period,
|
|
double warningThreshold,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new QuotaSetRequest
|
|
{
|
|
Tenant = tenant,
|
|
SourceId = sourceId,
|
|
ResourceType = resourceType,
|
|
Limit = limit,
|
|
Period = period,
|
|
WarningThreshold = warningThreshold
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.SetQuotaAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 17;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to set quota:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 17;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Quota set successfully.[/]");
|
|
|
|
if (result.Quota is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Tenant:[/] {Markup.Escape(result.Quota.Tenant)}");
|
|
AnsiConsole.MarkupLine($"[grey]Resource:[/] {Markup.Escape(result.Quota.ResourceType)}");
|
|
AnsiConsole.MarkupLine($"[grey]Limit:[/] {FormatQuotaValue(result.Quota.Limit, result.Quota.ResourceType)}");
|
|
AnsiConsole.MarkupLine($"[grey]Period:[/] {Markup.Escape(result.Quota.Period)}");
|
|
AnsiConsole.MarkupLine($"[grey]Warning Threshold:[/] {result.Quota.WarningThreshold * 100:F0}%");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error setting quota: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles 'stella orch quotas reset' command.
|
|
/// CLI-ORCH-34-001
|
|
/// </summary>
|
|
internal static async Task<int> HandleOrchQuotasResetAsync(
|
|
IServiceProvider services,
|
|
string tenant,
|
|
string? sourceId,
|
|
string resourceType,
|
|
string? reason,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<IOrchestratorClient>();
|
|
|
|
var request = new QuotaResetRequest
|
|
{
|
|
Tenant = tenant,
|
|
SourceId = sourceId,
|
|
ResourceType = resourceType,
|
|
Reason = reason
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.ResetQuotaAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return result.Success ? 0 : 17;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to reset quota:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
return 17;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Quota usage reset successfully.[/]");
|
|
|
|
if (result.Quota is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Tenant:[/] {Markup.Escape(result.Quota.Tenant)}");
|
|
AnsiConsole.MarkupLine($"[grey]Resource:[/] {Markup.Escape(result.Quota.ResourceType)}");
|
|
AnsiConsole.MarkupLine($"[grey]New Usage:[/] {FormatQuotaValue(result.Quota.Used, result.Quota.ResourceType)}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error resetting quota: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
private static string GetBackfillStatusColor(string status)
|
|
{
|
|
return status.ToLowerInvariant() switch
|
|
{
|
|
"pending" => "[yellow]pending[/]",
|
|
"running" => "[blue]running[/]",
|
|
"completed" => "[green]completed[/]",
|
|
"failed" => "[red]failed[/]",
|
|
"cancelled" => "[grey]cancelled[/]",
|
|
"dry_run" => "[cyan]dry_run[/]",
|
|
_ => Markup.Escape(status)
|
|
};
|
|
}
|
|
|
|
private static string FormatDuration(long? milliseconds)
|
|
{
|
|
if (!milliseconds.HasValue)
|
|
return "Unknown";
|
|
|
|
var ts = TimeSpan.FromMilliseconds(milliseconds.Value);
|
|
if (ts.TotalDays >= 1)
|
|
return $"{ts.TotalDays:F1} days";
|
|
if (ts.TotalHours >= 1)
|
|
return $"{ts.TotalHours:F1} hours";
|
|
if (ts.TotalMinutes >= 1)
|
|
return $"{ts.TotalMinutes:F1} minutes";
|
|
return $"{ts.TotalSeconds:F1} seconds";
|
|
}
|
|
|
|
private static string FormatQuotaValue(long value, string resourceType)
|
|
{
|
|
return resourceType.ToLowerInvariant() switch
|
|
{
|
|
"data_ingested_bytes" or "storage_bytes" => FormatBytes(value),
|
|
_ => value.ToString("N0")
|
|
};
|
|
}
|
|
|
|
private static string FormatBytes(long bytes)
|
|
{
|
|
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
|
double len = bytes;
|
|
int order = 0;
|
|
while (len >= 1024 && order < sizes.Length - 1)
|
|
{
|
|
order++;
|
|
len = len / 1024;
|
|
}
|
|
return $"{len:F1} {sizes[order]}";
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region CLI-PARITY-41-001: SBOM Commands
|
|
|
|
internal static async Task<int> HandleSbomListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? imageRef,
|
|
string? digest,
|
|
string? format,
|
|
DateTimeOffset? createdAfter,
|
|
DateTimeOffset? createdBefore,
|
|
bool? hasVulnerabilities,
|
|
int limit,
|
|
int? offset,
|
|
string? cursor,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<ISbomClient>();
|
|
|
|
var request = new SbomListRequest
|
|
{
|
|
Tenant = tenant,
|
|
ImageRef = imageRef,
|
|
Digest = digest,
|
|
Format = format,
|
|
CreatedAfter = createdAfter,
|
|
CreatedBefore = createdBefore,
|
|
HasVulnerabilities = hasVulnerabilities,
|
|
Limit = limit,
|
|
Offset = offset,
|
|
Cursor = cursor
|
|
};
|
|
|
|
try
|
|
{
|
|
var response = await client.ListAsync(request, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Items.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No SBOMs found matching the criteria.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded)
|
|
.AddColumn("SBOM ID")
|
|
.AddColumn("Image")
|
|
.AddColumn("Format")
|
|
.AddColumn("Components")
|
|
.AddColumn("Vulns")
|
|
.AddColumn("Det. Score")
|
|
.AddColumn("Created");
|
|
|
|
foreach (var sbom in response.Items)
|
|
{
|
|
var detScore = sbom.DeterminismScore.HasValue
|
|
? $"{sbom.DeterminismScore.Value:P0}"
|
|
: "-";
|
|
var detColor = sbom.DeterminismScore switch
|
|
{
|
|
>= 0.95 => "green",
|
|
>= 0.80 => "yellow",
|
|
_ => "red"
|
|
};
|
|
|
|
table.AddRow(
|
|
Markup.Escape(sbom.SbomId),
|
|
Markup.Escape(sbom.ImageRef ?? "-"),
|
|
Markup.Escape(sbom.Format),
|
|
sbom.ComponentCount.ToString(),
|
|
GetVulnCountMarkup(sbom.VulnerabilityCount),
|
|
$"[{detColor}]{detScore}[/]",
|
|
sbom.CreatedAt.ToString("yyyy-MM-dd HH:mm"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Total: {response.Total} | Showing: {response.Items.Count} | Has more: {response.HasMore}[/]");
|
|
if (!string.IsNullOrWhiteSpace(response.NextCursor))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Next cursor: {Markup.Escape(response.NextCursor)}[/]");
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error listing SBOMs: {Markup.Escape(error.Message)}[/]");
|
|
return 18;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomShowAsync(
|
|
IServiceProvider services,
|
|
string sbomId,
|
|
string? tenant,
|
|
bool includeComponents,
|
|
bool includeVulnerabilities,
|
|
bool includeLicenses,
|
|
bool explain,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<ISbomClient>();
|
|
|
|
try
|
|
{
|
|
var sbom = await client.GetAsync(
|
|
sbomId,
|
|
tenant,
|
|
includeComponents,
|
|
includeVulnerabilities,
|
|
includeLicenses,
|
|
explain,
|
|
cancellationToken);
|
|
|
|
if (sbom is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]SBOM '{Markup.Escape(sbomId)}' not found.[/]");
|
|
return 18;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(sbom, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
// Header panel
|
|
var headerGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn();
|
|
|
|
headerGrid.AddRow("[grey]SBOM ID:[/]", Markup.Escape(sbom.SbomId));
|
|
if (!string.IsNullOrWhiteSpace(sbom.ImageRef))
|
|
headerGrid.AddRow("[grey]Image:[/]", Markup.Escape(sbom.ImageRef));
|
|
if (!string.IsNullOrWhiteSpace(sbom.Digest))
|
|
headerGrid.AddRow("[grey]Digest:[/]", Markup.Escape(sbom.Digest));
|
|
headerGrid.AddRow("[grey]Format:[/]", $"{Markup.Escape(sbom.Format)} {Markup.Escape(sbom.FormatVersion ?? "")}");
|
|
headerGrid.AddRow("[grey]Created:[/]", sbom.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss") + " UTC");
|
|
headerGrid.AddRow("[grey]Components:[/]", sbom.ComponentCount.ToString());
|
|
headerGrid.AddRow("[grey]Vulnerabilities:[/]", GetVulnCountMarkup(sbom.VulnerabilityCount));
|
|
headerGrid.AddRow("[grey]Licenses:[/]", sbom.LicensesDetected.ToString());
|
|
|
|
if (sbom.DeterminismScore.HasValue)
|
|
{
|
|
var detColor = sbom.DeterminismScore.Value switch
|
|
{
|
|
>= 0.95 => "green",
|
|
>= 0.80 => "yellow",
|
|
_ => "red"
|
|
};
|
|
headerGrid.AddRow("[grey]Determinism:[/]", $"[{detColor}]{sbom.DeterminismScore.Value:P1}[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(headerGrid).Header("SBOM Details").Border(BoxBorder.Rounded));
|
|
|
|
// Attestation info
|
|
if (sbom.Attestation is not null)
|
|
{
|
|
var attGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn();
|
|
|
|
attGrid.AddRow("[grey]Signed:[/]", sbom.Attestation.Signed ? "[green]Yes[/]" : "[yellow]No[/]");
|
|
if (!string.IsNullOrWhiteSpace(sbom.Attestation.SignatureAlgorithm))
|
|
attGrid.AddRow("[grey]Algorithm:[/]", Markup.Escape(sbom.Attestation.SignatureAlgorithm));
|
|
if (sbom.Attestation.SignedAt.HasValue)
|
|
attGrid.AddRow("[grey]Signed At:[/]", sbom.Attestation.SignedAt.Value.ToString("yyyy-MM-dd HH:mm:ss") + " UTC");
|
|
if (sbom.Attestation.RekorLogIndex.HasValue)
|
|
attGrid.AddRow("[grey]Rekor Index:[/]", sbom.Attestation.RekorLogIndex.Value.ToString());
|
|
if (!string.IsNullOrWhiteSpace(sbom.Attestation.CertificateSubject))
|
|
attGrid.AddRow("[grey]Cert Subject:[/]", Markup.Escape(sbom.Attestation.CertificateSubject));
|
|
|
|
AnsiConsole.Write(new Panel(attGrid).Header("Attestation").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
// Components table
|
|
if (includeComponents && sbom.Components is { Count: > 0 })
|
|
{
|
|
var compTable = new Table()
|
|
.Border(TableBorder.Simple)
|
|
.AddColumn("Name")
|
|
.AddColumn("Version")
|
|
.AddColumn("Type")
|
|
.AddColumn("Licenses");
|
|
|
|
foreach (var comp in sbom.Components.Take(verbose ? 100 : 25))
|
|
{
|
|
compTable.AddRow(
|
|
Markup.Escape(comp.Name),
|
|
Markup.Escape(comp.Version ?? "-"),
|
|
Markup.Escape(comp.Type ?? "-"),
|
|
Markup.Escape(string.Join(", ", comp.Licenses ?? [])));
|
|
}
|
|
|
|
if (sbom.Components.Count > (verbose ? 100 : 25))
|
|
{
|
|
compTable.AddRow($"[grey]... and {sbom.Components.Count - (verbose ? 100 : 25)} more[/]", "", "", "");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(compTable).Header($"Components ({sbom.Components.Count})").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
// Vulnerabilities table
|
|
if (includeVulnerabilities && sbom.Vulnerabilities is { Count: > 0 })
|
|
{
|
|
var vulnTable = new Table()
|
|
.Border(TableBorder.Simple)
|
|
.AddColumn("CVE")
|
|
.AddColumn("Severity")
|
|
.AddColumn("Score")
|
|
.AddColumn("Component")
|
|
.AddColumn("VEX Status");
|
|
|
|
foreach (var vuln in sbom.Vulnerabilities.Take(verbose ? 50 : 20))
|
|
{
|
|
vulnTable.AddRow(
|
|
Markup.Escape(vuln.VulnerabilityId),
|
|
GetSeverityMarkup(vuln.Severity),
|
|
vuln.Score?.ToString("F1") ?? "-",
|
|
Markup.Escape(vuln.AffectedComponent ?? "-"),
|
|
GetVexStatusMarkup(vuln.VexStatus));
|
|
}
|
|
|
|
if (sbom.Vulnerabilities.Count > (verbose ? 50 : 20))
|
|
{
|
|
vulnTable.AddRow($"[grey]... and {sbom.Vulnerabilities.Count - (verbose ? 50 : 20)} more[/]", "", "", "", "");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(vulnTable).Header($"Vulnerabilities ({sbom.Vulnerabilities.Count})").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
// Licenses table
|
|
if (includeLicenses && sbom.Licenses is { Count: > 0 })
|
|
{
|
|
var licTable = new Table()
|
|
.Border(TableBorder.Simple)
|
|
.AddColumn("License")
|
|
.AddColumn("ID")
|
|
.AddColumn("Components");
|
|
|
|
foreach (var lic in sbom.Licenses.OrderByDescending(l => l.ComponentCount).Take(20))
|
|
{
|
|
licTable.AddRow(
|
|
Markup.Escape(lic.Name),
|
|
Markup.Escape(lic.Id ?? "-"),
|
|
lic.ComponentCount.ToString());
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(licTable).Header($"Licenses ({sbom.Licenses.Count} unique)").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
// Explain panel
|
|
if (explain && sbom.Explain is not null)
|
|
{
|
|
if (sbom.Explain.DeterminismFactors is { Count: > 0 })
|
|
{
|
|
var factorTable = new Table()
|
|
.Border(TableBorder.Simple)
|
|
.AddColumn("Factor")
|
|
.AddColumn("Impact")
|
|
.AddColumn("Score")
|
|
.AddColumn("Details");
|
|
|
|
foreach (var factor in sbom.Explain.DeterminismFactors)
|
|
{
|
|
var impactColor = factor.Impact.ToLowerInvariant() switch
|
|
{
|
|
"positive" => "green",
|
|
"negative" => "red",
|
|
_ => "yellow"
|
|
};
|
|
|
|
factorTable.AddRow(
|
|
Markup.Escape(factor.Factor),
|
|
$"[{impactColor}]{Markup.Escape(factor.Impact)}[/]",
|
|
$"{factor.Score:F2}",
|
|
Markup.Escape(factor.Details ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(factorTable).Header("Determinism Factors").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
if (sbom.Explain.CompositionPath is { Count: > 0 })
|
|
{
|
|
var pathTable = new Table()
|
|
.Border(TableBorder.Simple)
|
|
.AddColumn("Step")
|
|
.AddColumn("Operation")
|
|
.AddColumn("Input")
|
|
.AddColumn("Digest");
|
|
|
|
foreach (var step in sbom.Explain.CompositionPath)
|
|
{
|
|
pathTable.AddRow(
|
|
step.Step.ToString(),
|
|
Markup.Escape(step.Operation),
|
|
Markup.Escape(step.Input ?? "-"),
|
|
Markup.Escape(step.Digest?[..16] ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(pathTable).Header("Composition Path").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
if (sbom.Explain.Warnings is { Count: > 0 })
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Warnings:[/]");
|
|
foreach (var warning in sbom.Explain.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]• {Markup.Escape(warning)}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error getting SBOM: {Markup.Escape(error.Message)}[/]");
|
|
return 18;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomCompareAsync(
|
|
IServiceProvider services,
|
|
string baseSbomId,
|
|
string targetSbomId,
|
|
string? tenant,
|
|
bool includeUnchanged,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<ISbomClient>();
|
|
|
|
var request = new SbomCompareRequest
|
|
{
|
|
Tenant = tenant,
|
|
BaseSbomId = baseSbomId,
|
|
TargetSbomId = targetSbomId,
|
|
IncludeUnchanged = includeUnchanged
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await client.CompareAsync(request, cancellationToken);
|
|
|
|
if (result is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to compare SBOMs. One or both SBOMs may not exist.[/]");
|
|
return 18;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
// Summary panel
|
|
var summaryGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddColumn();
|
|
|
|
summaryGrid.AddRow("[grey]Base SBOM:[/]", Markup.Escape(result.BaseSbomId), "");
|
|
summaryGrid.AddRow("[grey]Target SBOM:[/]", Markup.Escape(result.TargetSbomId), "");
|
|
summaryGrid.AddRow("", "", "");
|
|
summaryGrid.AddRow("[grey]Components:[/]",
|
|
$"[green]+{result.Summary.ComponentsAdded}[/]",
|
|
$"[red]-{result.Summary.ComponentsRemoved}[/]");
|
|
summaryGrid.AddRow("[grey]Modified:[/]", $"[yellow]~{result.Summary.ComponentsModified}[/]",
|
|
includeUnchanged ? $"[grey]={result.Summary.ComponentsUnchanged}[/]" : "");
|
|
summaryGrid.AddRow("[grey]Vulnerabilities:[/]",
|
|
$"[red]+{result.Summary.VulnerabilitiesAdded}[/]",
|
|
$"[green]-{result.Summary.VulnerabilitiesRemoved}[/]");
|
|
summaryGrid.AddRow("[grey]Licenses:[/]",
|
|
$"[blue]+{result.Summary.LicensesAdded}[/]",
|
|
$"[blue]-{result.Summary.LicensesRemoved}[/]");
|
|
|
|
AnsiConsole.Write(new Panel(summaryGrid).Header("Comparison Summary").Border(BoxBorder.Rounded));
|
|
|
|
// Component changes
|
|
if (result.ComponentChanges is { Count: > 0 })
|
|
{
|
|
var compTable = new Table()
|
|
.Border(TableBorder.Simple)
|
|
.AddColumn("Change")
|
|
.AddColumn("Component")
|
|
.AddColumn("Base Version")
|
|
.AddColumn("Target Version");
|
|
|
|
foreach (var change in result.ComponentChanges.Take(verbose ? 100 : 30))
|
|
{
|
|
var changeColor = change.ChangeType.ToLowerInvariant() switch
|
|
{
|
|
"added" => "green",
|
|
"removed" => "red",
|
|
"modified" => "yellow",
|
|
_ => "grey"
|
|
};
|
|
|
|
compTable.AddRow(
|
|
$"[{changeColor}]{Markup.Escape(change.ChangeType)}[/]",
|
|
Markup.Escape(change.ComponentName),
|
|
Markup.Escape(change.BaseVersion ?? "-"),
|
|
Markup.Escape(change.TargetVersion ?? "-"));
|
|
}
|
|
|
|
if (result.ComponentChanges.Count > (verbose ? 100 : 30))
|
|
{
|
|
compTable.AddRow($"[grey]... and {result.ComponentChanges.Count - (verbose ? 100 : 30)} more[/]", "", "", "");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(compTable).Header($"Component Changes ({result.ComponentChanges.Count})").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
// Vulnerability changes
|
|
if (result.VulnerabilityChanges is { Count: > 0 })
|
|
{
|
|
var vulnTable = new Table()
|
|
.Border(TableBorder.Simple)
|
|
.AddColumn("Change")
|
|
.AddColumn("CVE")
|
|
.AddColumn("Severity")
|
|
.AddColumn("Component")
|
|
.AddColumn("Reason");
|
|
|
|
foreach (var change in result.VulnerabilityChanges.Take(verbose ? 50 : 20))
|
|
{
|
|
var changeColor = change.ChangeType.ToLowerInvariant() switch
|
|
{
|
|
"added" => "red",
|
|
"removed" => "green",
|
|
_ => "yellow"
|
|
};
|
|
|
|
vulnTable.AddRow(
|
|
$"[{changeColor}]{Markup.Escape(change.ChangeType)}[/]",
|
|
Markup.Escape(change.VulnerabilityId),
|
|
GetSeverityMarkup(change.Severity),
|
|
Markup.Escape(change.AffectedComponent ?? "-"),
|
|
Markup.Escape(change.Reason ?? "-"));
|
|
}
|
|
|
|
if (result.VulnerabilityChanges.Count > (verbose ? 50 : 20))
|
|
{
|
|
vulnTable.AddRow($"[grey]... and {result.VulnerabilityChanges.Count - (verbose ? 50 : 20)} more[/]", "", "", "", "");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(vulnTable).Header($"Vulnerability Changes ({result.VulnerabilityChanges.Count})").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
// License changes
|
|
if (result.LicenseChanges is { Count: > 0 })
|
|
{
|
|
var licTable = new Table()
|
|
.Border(TableBorder.Simple)
|
|
.AddColumn("Change")
|
|
.AddColumn("License")
|
|
.AddColumn("Components");
|
|
|
|
foreach (var change in result.LicenseChanges)
|
|
{
|
|
var changeColor = change.ChangeType.ToLowerInvariant() switch
|
|
{
|
|
"added" => "blue",
|
|
"removed" => "grey",
|
|
_ => "yellow"
|
|
};
|
|
|
|
licTable.AddRow(
|
|
$"[{changeColor}]{Markup.Escape(change.ChangeType)}[/]",
|
|
Markup.Escape(change.LicenseName),
|
|
change.ComponentCount.ToString());
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(licTable).Header($"License Changes ({result.LicenseChanges.Count})").Border(BoxBorder.Rounded));
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error comparing SBOMs: {Markup.Escape(error.Message)}[/]");
|
|
return 18;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomExportAsync(
|
|
IServiceProvider services,
|
|
string sbomId,
|
|
string? tenant,
|
|
string format,
|
|
string? formatVersion,
|
|
string? output,
|
|
bool signed,
|
|
bool includeVex,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<ISbomClient>();
|
|
|
|
var request = new SbomExportRequest
|
|
{
|
|
Tenant = tenant,
|
|
SbomId = sbomId,
|
|
Format = format,
|
|
FormatVersion = formatVersion,
|
|
Signed = signed,
|
|
IncludeVex = includeVex
|
|
};
|
|
|
|
try
|
|
{
|
|
var (contentStream, result) = await client.ExportAsync(request, cancellationToken);
|
|
|
|
if (result is null || !result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to export SBOM:[/]");
|
|
if (result?.Errors is not null)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 18;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(output))
|
|
{
|
|
// Write to file
|
|
await using var fileStream = File.Create(output);
|
|
await contentStream.CopyToAsync(fileStream, cancellationToken);
|
|
|
|
AnsiConsole.MarkupLine($"[green]SBOM exported successfully to: {Markup.Escape(output)}[/]");
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Format:[/] {Markup.Escape(result.Format)}");
|
|
if (result.Signed)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Signed:[/] [green]Yes[/]");
|
|
if (!string.IsNullOrWhiteSpace(result.SignatureKeyId))
|
|
AnsiConsole.MarkupLine($"[grey]Key ID:[/] {Markup.Escape(result.SignatureKeyId)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.Digest))
|
|
AnsiConsole.MarkupLine($"[grey]Digest:[/] {Markup.Escape(result.DigestAlgorithm ?? "sha256")}:{Markup.Escape(result.Digest)}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Write to stdout
|
|
using var reader = new StreamReader(contentStream);
|
|
var content = await reader.ReadToEndAsync(cancellationToken);
|
|
Console.Write(content);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error exporting SBOM: {Markup.Escape(error.Message)}[/]");
|
|
return 18;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomParityMatrixAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = services.GetRequiredService<ISbomClient>();
|
|
|
|
try
|
|
{
|
|
var response = await client.GetParityMatrixAsync(tenant, cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOutputOptions));
|
|
return 0;
|
|
}
|
|
|
|
// Summary panel
|
|
var summaryGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn();
|
|
|
|
summaryGrid.AddRow("[grey]Total Commands:[/]", response.Summary.TotalCommands.ToString());
|
|
summaryGrid.AddRow("[grey]Full Parity:[/]", $"[green]{response.Summary.FullParity}[/]");
|
|
summaryGrid.AddRow("[grey]Partial Parity:[/]", $"[yellow]{response.Summary.PartialParity}[/]");
|
|
summaryGrid.AddRow("[grey]No Parity:[/]", $"[red]{response.Summary.NoParity}[/]");
|
|
summaryGrid.AddRow("[grey]Deterministic:[/]", response.Summary.DeterministicCommands.ToString());
|
|
summaryGrid.AddRow("[grey]--explain Support:[/]", response.Summary.ExplainEnabledCommands.ToString());
|
|
summaryGrid.AddRow("[grey]Offline Capable:[/]", response.Summary.OfflineCapableCommands.ToString());
|
|
if (!string.IsNullOrWhiteSpace(response.CliVersion))
|
|
summaryGrid.AddRow("[grey]CLI Version:[/]", Markup.Escape(response.CliVersion));
|
|
summaryGrid.AddRow("[grey]Generated:[/]", response.GeneratedAt.ToString("yyyy-MM-dd HH:mm:ss") + " UTC");
|
|
|
|
AnsiConsole.Write(new Panel(summaryGrid).Header("CLI Parity Matrix Summary").Border(BoxBorder.Rounded));
|
|
|
|
// Commands table
|
|
if (response.Entries.Count > 0)
|
|
{
|
|
var table = new Table()
|
|
.Border(TableBorder.Rounded)
|
|
.AddColumn("Command Group")
|
|
.AddColumn("Command")
|
|
.AddColumn("CLI Support")
|
|
.AddColumn("Det.")
|
|
.AddColumn("Explain")
|
|
.AddColumn("Offline");
|
|
|
|
foreach (var entry in response.Entries.OrderBy(e => e.CommandGroup).ThenBy(e => e.Command))
|
|
{
|
|
var supportColor = entry.CliSupport.ToLowerInvariant() switch
|
|
{
|
|
"full" => "green",
|
|
"partial" => "yellow",
|
|
"none" => "red",
|
|
_ => "grey"
|
|
};
|
|
|
|
table.AddRow(
|
|
Markup.Escape(entry.CommandGroup),
|
|
Markup.Escape(entry.Command),
|
|
$"[{supportColor}]{Markup.Escape(entry.CliSupport)}[/]",
|
|
entry.Deterministic ? "[green]✓[/]" : "[grey]-[/]",
|
|
entry.ExplainSupport ? "[green]✓[/]" : "[grey]-[/]",
|
|
entry.OfflineSupport ? "[green]✓[/]" : "[grey]-[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (verbose)
|
|
{
|
|
// Show notes for entries with notes
|
|
var entriesWithNotes = response.Entries.Where(e => !string.IsNullOrWhiteSpace(e.Notes)).ToList();
|
|
if (entriesWithNotes.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[grey]Notes:[/]");
|
|
foreach (var entry in entriesWithNotes)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(entry.CommandGroup)} {Markup.Escape(entry.Command)}:[/] {Markup.Escape(entry.Notes!)}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromException(ex);
|
|
AnsiConsole.MarkupLine($"[red]Error getting parity matrix: {Markup.Escape(error.Message)}[/]");
|
|
return 18;
|
|
}
|
|
}
|
|
|
|
private static string GetVulnCountMarkup(int count)
|
|
{
|
|
return count switch
|
|
{
|
|
0 => "[green]0[/]",
|
|
<= 5 => $"[yellow]{count}[/]",
|
|
_ => $"[red]{count}[/]"
|
|
};
|
|
}
|
|
|
|
private static string GetSeverityMarkup(string? severity)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(severity))
|
|
return "[grey]-[/]";
|
|
|
|
return severity.ToLowerInvariant() switch
|
|
{
|
|
"critical" => "[red]CRITICAL[/]",
|
|
"high" => "[red]HIGH[/]",
|
|
"medium" => "[yellow]MEDIUM[/]",
|
|
"low" => "[blue]LOW[/]",
|
|
"none" or "informational" => "[grey]NONE[/]",
|
|
_ => Markup.Escape(severity)
|
|
};
|
|
}
|
|
|
|
private static string GetVexStatusMarkup(string? status)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(status))
|
|
return "[grey]-[/]";
|
|
|
|
return status.ToLowerInvariant() switch
|
|
{
|
|
"affected" => "[red]affected[/]",
|
|
"not_affected" => "[green]not_affected[/]",
|
|
"fixed" => "[green]fixed[/]",
|
|
"under_investigation" => "[yellow]investigating[/]",
|
|
_ => Markup.Escape(status)
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Export Handlers (CLI-EXPORT-35-037)
|
|
|
|
internal static async Task<int> HandleExportProfilesListAsync(
|
|
IServiceProvider services,
|
|
int? limit,
|
|
string? cursor,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<IExportCenterClient>();
|
|
|
|
var response = await client.ListProfilesAsync(cursor, limit, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Profiles.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No export profiles found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Profile ID");
|
|
table.AddColumn("Name");
|
|
table.AddColumn("Adapter");
|
|
table.AddColumn("Format");
|
|
table.AddColumn("Signing");
|
|
table.AddColumn("Created");
|
|
table.AddColumn("Updated");
|
|
|
|
foreach (var profile in response.Profiles)
|
|
{
|
|
table.AddRow(
|
|
Markup.Escape(profile.ProfileId),
|
|
Markup.Escape(profile.Name),
|
|
Markup.Escape(profile.Adapter),
|
|
Markup.Escape(profile.OutputFormat),
|
|
profile.SigningEnabled ? "[green]Yes[/]" : "[grey]No[/]",
|
|
profile.CreatedAt.ToString("u", CultureInfo.InvariantCulture),
|
|
profile.UpdatedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandleExportProfileShowAsync(
|
|
IServiceProvider services,
|
|
string profileId,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<IExportCenterClient>();
|
|
|
|
var profile = await client.GetProfileAsync(profileId, cancellationToken).ConfigureAwait(false);
|
|
if (profile is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Profile not found:[/] {Markup.Escape(profileId)}");
|
|
return 1;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(profile, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
var profileTable = new Table { Border = TableBorder.Rounded };
|
|
profileTable.AddColumn("Field");
|
|
profileTable.AddColumn("Value");
|
|
profileTable.AddRow("Profile ID", Markup.Escape(profile.ProfileId));
|
|
profileTable.AddRow("Name", Markup.Escape(profile.Name));
|
|
profileTable.AddRow("Description", string.IsNullOrWhiteSpace(profile.Description) ? "[grey]-[/]" : Markup.Escape(profile.Description));
|
|
profileTable.AddRow("Adapter", Markup.Escape(profile.Adapter));
|
|
profileTable.AddRow("Format", Markup.Escape(profile.OutputFormat));
|
|
profileTable.AddRow("Signing", profile.SigningEnabled ? "[green]Enabled[/]" : "[grey]Disabled[/]");
|
|
profileTable.AddRow("Created", profile.CreatedAt.ToString("u", CultureInfo.InvariantCulture));
|
|
profileTable.AddRow("Updated", profile.UpdatedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
|
|
|
|
if (profile.Selectors is { Count: > 0 })
|
|
{
|
|
var selectorTable = new Table { Title = new TableTitle("Selectors") };
|
|
selectorTable.AddColumn("Key");
|
|
selectorTable.AddColumn("Value");
|
|
foreach (var selector in profile.Selectors)
|
|
{
|
|
selectorTable.AddRow(Markup.Escape(selector.Key), Markup.Escape(selector.Value));
|
|
}
|
|
|
|
AnsiConsole.Write(profileTable);
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.Write(selectorTable);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.Write(profileTable);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandleExportRunsListAsync(
|
|
IServiceProvider services,
|
|
string? profileId,
|
|
int? limit,
|
|
string? cursor,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<IExportCenterClient>();
|
|
|
|
var response = await client.ListRunsAsync(profileId, cursor, limit, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Runs.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No export runs found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Run ID");
|
|
table.AddColumn("Profile");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Progress");
|
|
table.AddColumn("Started");
|
|
table.AddColumn("Completed");
|
|
table.AddColumn("Bundle");
|
|
|
|
foreach (var run in response.Runs)
|
|
{
|
|
table.AddRow(
|
|
Markup.Escape(run.RunId),
|
|
Markup.Escape(run.ProfileId),
|
|
Markup.Escape(run.Status),
|
|
run.Progress.HasValue ? $"{run.Progress.Value}%" : "[grey]-[/]",
|
|
run.StartedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]",
|
|
run.CompletedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]",
|
|
string.IsNullOrWhiteSpace(run.BundleHash) ? "[grey]-[/]" : Markup.Escape(run.BundleHash));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
if (response.HasMore && !string.IsNullOrWhiteSpace(response.ContinuationToken))
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]More available. Use --cursor {Markup.Escape(response.ContinuationToken)}[/]");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandleExportRunShowAsync(
|
|
IServiceProvider services,
|
|
string runId,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<IExportCenterClient>();
|
|
|
|
var run = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
|
|
if (run is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Run not found:[/] {Markup.Escape(runId)}");
|
|
return 1;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(run, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("Field");
|
|
table.AddColumn("Value");
|
|
table.AddRow("Run ID", Markup.Escape(run.RunId));
|
|
table.AddRow("Profile ID", Markup.Escape(run.ProfileId));
|
|
table.AddRow("Status", Markup.Escape(run.Status));
|
|
table.AddRow("Progress", run.Progress.HasValue ? $"{run.Progress.Value}%" : "[grey]-[/]");
|
|
table.AddRow("Started", run.StartedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
|
|
table.AddRow("Completed", run.CompletedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
|
|
table.AddRow("Bundle Hash", string.IsNullOrWhiteSpace(run.BundleHash) ? "[grey]-[/]" : Markup.Escape(run.BundleHash));
|
|
table.AddRow("Bundle URL", string.IsNullOrWhiteSpace(run.BundleUrl) ? "[grey]-[/]" : Markup.Escape(run.BundleUrl));
|
|
table.AddRow("Error Code", string.IsNullOrWhiteSpace(run.ErrorCode) ? "[grey]-[/]" : Markup.Escape(run.ErrorCode));
|
|
table.AddRow("Error Message", string.IsNullOrWhiteSpace(run.ErrorMessage) ? "[grey]-[/]" : Markup.Escape(run.ErrorMessage));
|
|
|
|
AnsiConsole.Write(table);
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandleExportRunDownloadAsync(
|
|
IServiceProvider services,
|
|
string runId,
|
|
string outputPath,
|
|
bool overwrite,
|
|
string? verifyHash,
|
|
string runType,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<IExportCenterClient>();
|
|
|
|
if (File.Exists(outputPath) && !overwrite)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Output file already exists:[/] {Markup.Escape(outputPath)} (use --overwrite to replace)");
|
|
return 1;
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath)) ?? ".");
|
|
|
|
Stream? stream = null;
|
|
if (string.Equals(runType, "attestation", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
stream = await client.DownloadAttestationExportAsync(runId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
stream = await client.DownloadEvidenceExportAsync(runId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
if (stream is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Export bundle not available for run:[/] {Markup.Escape(runId)}");
|
|
return 1;
|
|
}
|
|
|
|
await using (stream)
|
|
await using (var fileStream = File.Create(outputPath))
|
|
{
|
|
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(verifyHash))
|
|
{
|
|
await using var file = File.OpenRead(outputPath);
|
|
var hash = await SHA256.HashDataAsync(file, cancellationToken).ConfigureAwait(false);
|
|
var hashString = Convert.ToHexString(hash).ToLowerInvariant();
|
|
if (!string.Equals(hashString, verifyHash.Trim(), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Hash verification failed.[/] expected={Markup.Escape(verifyHash)}, actual={hashString}");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Bundle written to[/] {Markup.Escape(outputPath)}");
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandleExportStartEvidenceAsync(
|
|
IServiceProvider services,
|
|
string profileId,
|
|
string[]? selectors,
|
|
string? callbackUrl,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<IExportCenterClient>();
|
|
|
|
var selectorMap = ParseSelectorMap(selectors);
|
|
var request = new CreateEvidenceExportRequest(profileId, selectorMap, callbackUrl);
|
|
var response = await client.CreateEvidenceExportAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Export started.[/] runId={Markup.Escape(response.RunId)} status={Markup.Escape(response.Status)}");
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandleExportStartAttestationAsync(
|
|
IServiceProvider services,
|
|
string profileId,
|
|
string[]? selectors,
|
|
bool includeTransparencyLog,
|
|
string? callbackUrl,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<IExportCenterClient>();
|
|
|
|
var selectorMap = ParseSelectorMap(selectors);
|
|
var request = new CreateAttestationExportRequest(profileId, selectorMap, includeTransparencyLog, callbackUrl);
|
|
var response = await client.CreateAttestationExportAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Attestation export started.[/] runId={Markup.Escape(response.RunId)} status={Markup.Escape(response.Status)}");
|
|
return 0;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string>? ParseSelectorMap(string[]? selectors)
|
|
{
|
|
if (selectors is null || selectors.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var selector in selectors)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(selector))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var parts = selector.Split('=', 2, StringSplitOptions.TrimEntries);
|
|
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Ignoring selector with invalid format (expected key=value):[/] {Markup.Escape(selector)}");
|
|
continue;
|
|
}
|
|
|
|
result[parts[0]] = parts[1];
|
|
}
|
|
|
|
return result.Count == 0 ? null : result;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Notify Handlers (CLI-PARITY-41-002)
|
|
|
|
internal static async Task<int> HandleNotifySimulateAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? eventsFile,
|
|
string? rulesFile,
|
|
bool enabledOnly,
|
|
int? lookbackMinutes,
|
|
int? maxEvents,
|
|
string? eventKind,
|
|
bool includeNonMatches,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
var eventsPayload = LoadJsonElement(eventsFile);
|
|
var rulesPayload = LoadJsonElement(rulesFile);
|
|
|
|
var request = new NotifySimulationRequest
|
|
{
|
|
TenantId = tenant,
|
|
Events = eventsPayload,
|
|
Rules = rulesPayload,
|
|
EnabledRulesOnly = enabledOnly,
|
|
HistoricalLookbackMinutes = lookbackMinutes,
|
|
MaxEvents = maxEvents,
|
|
EventKindFilter = eventKind,
|
|
IncludeNonMatches = includeNonMatches
|
|
};
|
|
|
|
var result = await client.SimulateAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine(result.SimulationId is null
|
|
? "[yellow]Simulation completed.[/]"
|
|
: $"[green]Simulation {Markup.Escape(result.SimulationId)} completed.[/]");
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Total Events");
|
|
table.AddColumn("Total Rules");
|
|
table.AddColumn("Matched Events");
|
|
table.AddColumn("Actions");
|
|
table.AddColumn("Duration (ms)");
|
|
|
|
table.AddRow(
|
|
(result.TotalEvents ?? 0).ToString(CultureInfo.InvariantCulture),
|
|
(result.TotalRules ?? 0).ToString(CultureInfo.InvariantCulture),
|
|
(result.MatchedEvents ?? 0).ToString(CultureInfo.InvariantCulture),
|
|
(result.TotalActionsTriggered ?? 0).ToString(CultureInfo.InvariantCulture),
|
|
result.DurationMs?.ToString("0.00", CultureInfo.InvariantCulture) ?? "-");
|
|
|
|
AnsiConsole.Write(table);
|
|
return 0;
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifyAckAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? incidentId,
|
|
string? token,
|
|
string? acknowledgedBy,
|
|
string? comment,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
if (string.IsNullOrWhiteSpace(token) && string.IsNullOrWhiteSpace(incidentId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Either --token or --incident-id is required.[/]");
|
|
return 1;
|
|
}
|
|
|
|
var request = new NotifyAckRequest
|
|
{
|
|
TenantId = tenant,
|
|
IncidentId = incidentId,
|
|
Token = token,
|
|
AcknowledgedBy = acknowledgedBy,
|
|
Comment = comment
|
|
};
|
|
|
|
var result = await client.AckAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Acknowledge failed:[/] {Markup.Escape(result.Error ?? "unknown error")}");
|
|
return 1;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Acknowledged.[/] incidentId={Markup.Escape(result.IncidentId ?? incidentId ?? "n/a")}");
|
|
return 0;
|
|
}
|
|
|
|
private static JsonElement? LoadJsonElement(string? filePath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
var content = File.ReadAllText(filePath);
|
|
using var doc = JsonDocument.Parse(content);
|
|
return doc.RootElement.Clone();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Failed to load JSON from {Markup.Escape(filePath)}:[/] {Markup.Escape(ex.Message)}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifyChannelsListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? channelType,
|
|
bool? enabled,
|
|
int? limit,
|
|
string? cursor,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
try
|
|
{
|
|
var request = new NotifyChannelListRequest
|
|
{
|
|
Tenant = tenant,
|
|
Type = channelType,
|
|
Enabled = enabled,
|
|
Limit = limit,
|
|
Cursor = cursor
|
|
};
|
|
|
|
var response = await client.ListChannelsAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Items.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No notification channels found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Channel ID");
|
|
table.AddColumn("Name");
|
|
table.AddColumn("Type");
|
|
table.AddColumn("Enabled");
|
|
table.AddColumn("Deliveries");
|
|
table.AddColumn("Failure Rate");
|
|
table.AddColumn("Updated");
|
|
|
|
foreach (var channel in response.Items)
|
|
{
|
|
var enabledMarkup = channel.Enabled ? "[green]Yes[/]" : "[grey]No[/]";
|
|
var failureRateMarkup = GetFailureRateMarkup(channel.FailureRate);
|
|
|
|
table.AddRow(
|
|
Markup.Escape(channel.ChannelId),
|
|
Markup.Escape(channel.DisplayName ?? channel.Name),
|
|
GetChannelTypeMarkup(channel.Type),
|
|
enabledMarkup,
|
|
channel.DeliveryCount.ToString(),
|
|
failureRateMarkup,
|
|
channel.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor {Markup.Escape(response.NextCursor)}[/]");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[grey]Total: {response.Total}[/]");
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifyChannelsShowAsync(
|
|
IServiceProvider services,
|
|
string channelId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
try
|
|
{
|
|
var channel = await client.GetChannelAsync(channelId, tenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (channel == null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Channel not found: {Markup.Escape(channelId)}[/]");
|
|
return CliError.FromHttpStatus(404).ExitCode;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(channel, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Channel ID[/]", Markup.Escape(channel.ChannelId))
|
|
.AddRow("[bold]Name[/]", Markup.Escape(channel.DisplayName ?? channel.Name))
|
|
.AddRow("[bold]Type[/]", GetChannelTypeMarkup(channel.Type))
|
|
.AddRow("[bold]Enabled[/]", channel.Enabled ? "[green]Yes[/]" : "[grey]No[/]")
|
|
.AddRow("[bold]Tenant[/]", Markup.Escape(channel.TenantId))
|
|
.AddRow("[bold]Description[/]", Markup.Escape(channel.Description ?? "-"))
|
|
.AddRow("[bold]Created[/]", channel.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Updated[/]", channel.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Created By[/]", Markup.Escape(channel.CreatedBy ?? "-"))
|
|
.AddRow("[bold]Updated By[/]", Markup.Escape(channel.UpdatedBy ?? "-")));
|
|
panel.Header = new PanelHeader("Channel Details");
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Configuration
|
|
if (channel.Config != null)
|
|
{
|
|
var configGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Secret Ref[/]", Markup.Escape(channel.Config.SecretRef))
|
|
.AddRow("[bold]Target[/]", Markup.Escape(channel.Config.Target ?? "-"))
|
|
.AddRow("[bold]Endpoint[/]", Markup.Escape(channel.Config.Endpoint ?? "-"));
|
|
|
|
if (channel.Config.Limits != null)
|
|
{
|
|
configGrid.AddRow("[bold]Concurrency[/]", channel.Config.Limits.Concurrency?.ToString() ?? "-");
|
|
configGrid.AddRow("[bold]Requests/Min[/]", channel.Config.Limits.RequestsPerMinute?.ToString() ?? "-");
|
|
configGrid.AddRow("[bold]Timeout (s)[/]", channel.Config.Limits.TimeoutSeconds?.ToString() ?? "-");
|
|
configGrid.AddRow("[bold]Max Batch Size[/]", channel.Config.Limits.MaxBatchSize?.ToString() ?? "-");
|
|
}
|
|
|
|
var configPanel = new Panel(configGrid) { Header = new PanelHeader("Configuration") };
|
|
AnsiConsole.Write(configPanel);
|
|
}
|
|
|
|
// Statistics
|
|
if (channel.Stats != null)
|
|
{
|
|
var statsGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Total Deliveries[/]", channel.Stats.TotalDeliveries.ToString())
|
|
.AddRow("[bold]Successful[/]", $"[green]{channel.Stats.SuccessfulDeliveries}[/]")
|
|
.AddRow("[bold]Failed[/]", channel.Stats.FailedDeliveries > 0 ? $"[red]{channel.Stats.FailedDeliveries}[/]" : "0")
|
|
.AddRow("[bold]Throttled[/]", channel.Stats.ThrottledDeliveries > 0 ? $"[yellow]{channel.Stats.ThrottledDeliveries}[/]" : "0")
|
|
.AddRow("[bold]Last Delivery[/]", channel.Stats.LastDeliveryAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
|
|
.AddRow("[bold]Avg Latency[/]", channel.Stats.AvgLatencyMs.HasValue ? $"{channel.Stats.AvgLatencyMs:F1} ms" : "-");
|
|
|
|
var statsPanel = new Panel(statsGrid) { Header = new PanelHeader("Statistics") };
|
|
AnsiConsole.Write(statsPanel);
|
|
}
|
|
|
|
// Health
|
|
if (channel.Health != null)
|
|
{
|
|
var healthMarkup = channel.Health.Status.ToLowerInvariant() switch
|
|
{
|
|
"healthy" => "[green]HEALTHY[/]",
|
|
"degraded" => "[yellow]DEGRADED[/]",
|
|
"unhealthy" => "[red]UNHEALTHY[/]",
|
|
_ => Markup.Escape(channel.Health.Status)
|
|
};
|
|
|
|
var healthGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Status[/]", healthMarkup)
|
|
.AddRow("[bold]Last Check[/]", channel.Health.LastCheckAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
|
|
.AddRow("[bold]Consecutive Failures[/]", channel.Health.ConsecutiveFailures > 0 ? $"[red]{channel.Health.ConsecutiveFailures}[/]" : "0");
|
|
|
|
if (!string.IsNullOrWhiteSpace(channel.Health.ErrorMessage))
|
|
{
|
|
healthGrid.AddRow("[bold]Error[/]", $"[red]{Markup.Escape(channel.Health.ErrorMessage)}[/]");
|
|
}
|
|
|
|
var healthPanel = new Panel(healthGrid) { Header = new PanelHeader("Health") };
|
|
AnsiConsole.Write(healthPanel);
|
|
}
|
|
|
|
// Labels
|
|
if (channel.Labels is { Count: > 0 })
|
|
{
|
|
var labelsTable = new Table();
|
|
labelsTable.AddColumn("Key");
|
|
labelsTable.AddColumn("Value");
|
|
foreach (var label in channel.Labels)
|
|
{
|
|
labelsTable.AddRow(Markup.Escape(label.Key), Markup.Escape(label.Value));
|
|
}
|
|
AnsiConsole.Write(new Panel(labelsTable) { Header = new PanelHeader("Labels") });
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifyChannelsTestAsync(
|
|
IServiceProvider services,
|
|
string channelId,
|
|
string? tenant,
|
|
string? message,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
try
|
|
{
|
|
var request = new NotifyChannelTestRequest
|
|
{
|
|
ChannelId = channelId,
|
|
Tenant = tenant,
|
|
Message = message
|
|
};
|
|
|
|
var result = await client.TestChannelAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Test successful for channel {Markup.Escape(channelId)}[/]");
|
|
if (result.LatencyMs.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Latency: {result.LatencyMs} ms[/]");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.DeliveryId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Delivery ID: {Markup.Escape(result.DeliveryId)}[/]");
|
|
}
|
|
return 0;
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Test failed for channel {Markup.Escape(channelId)}[/]");
|
|
if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(result.ErrorMessage)}[/]");
|
|
}
|
|
if (result.ResponseCode.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Response code: {result.ResponseCode}[/]");
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifyRulesListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
bool? enabled,
|
|
string? eventType,
|
|
string? channelId,
|
|
int? limit,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
try
|
|
{
|
|
var request = new NotifyRuleListRequest
|
|
{
|
|
Tenant = tenant,
|
|
Enabled = enabled,
|
|
EventType = eventType,
|
|
ChannelId = channelId,
|
|
Limit = limit
|
|
};
|
|
|
|
var response = await client.ListRulesAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Items.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No notification rules found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Rule ID");
|
|
table.AddColumn("Name");
|
|
table.AddColumn("Enabled");
|
|
table.AddColumn("Priority");
|
|
table.AddColumn("Event Types");
|
|
table.AddColumn("Channels");
|
|
table.AddColumn("Matches");
|
|
|
|
foreach (var rule in response.Items)
|
|
{
|
|
var enabledMarkup = rule.Enabled ? "[green]Yes[/]" : "[grey]No[/]";
|
|
var eventTypes = rule.EventTypes.Count > 0 ? string.Join(", ", rule.EventTypes.Take(3)) : "-";
|
|
if (rule.EventTypes.Count > 3)
|
|
{
|
|
eventTypes += $" (+{rule.EventTypes.Count - 3})";
|
|
}
|
|
var channelCount = rule.ChannelIds.Count.ToString();
|
|
|
|
table.AddRow(
|
|
Markup.Escape(rule.RuleId),
|
|
Markup.Escape(rule.Name),
|
|
enabledMarkup,
|
|
rule.Priority.ToString(),
|
|
Markup.Escape(eventTypes),
|
|
channelCount,
|
|
rule.MatchCount.ToString());
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (response.HasMore)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]More results available.[/]");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[grey]Total: {response.Total}[/]");
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifyDeliveriesListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? status,
|
|
string? eventType,
|
|
string? channelId,
|
|
DateTimeOffset? since,
|
|
DateTimeOffset? until,
|
|
int? limit,
|
|
string? cursor,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
try
|
|
{
|
|
var request = new NotifyDeliveryListRequest
|
|
{
|
|
Tenant = tenant,
|
|
Status = status,
|
|
EventType = eventType,
|
|
ChannelId = channelId,
|
|
Since = since,
|
|
Until = until,
|
|
Limit = limit,
|
|
Cursor = cursor
|
|
};
|
|
|
|
var response = await client.ListDeliveriesAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Items.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No notification deliveries found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Delivery ID");
|
|
table.AddColumn("Channel");
|
|
table.AddColumn("Type");
|
|
table.AddColumn("Event");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Attempts");
|
|
table.AddColumn("Latency");
|
|
table.AddColumn("Created");
|
|
|
|
foreach (var delivery in response.Items)
|
|
{
|
|
var statusMarkup = GetDeliveryStatusMarkup(delivery.Status);
|
|
var latency = delivery.LatencyMs.HasValue ? $"{delivery.LatencyMs} ms" : "-";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(delivery.DeliveryId),
|
|
Markup.Escape(delivery.ChannelName ?? delivery.ChannelId),
|
|
GetChannelTypeMarkup(delivery.ChannelType),
|
|
Markup.Escape(delivery.EventType),
|
|
statusMarkup,
|
|
delivery.AttemptCount.ToString(),
|
|
latency,
|
|
delivery.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor {Markup.Escape(response.NextCursor)}[/]");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[grey]Total: {response.Total}[/]");
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifyDeliveriesShowAsync(
|
|
IServiceProvider services,
|
|
string deliveryId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
try
|
|
{
|
|
var delivery = await client.GetDeliveryAsync(deliveryId, tenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (delivery == null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Delivery not found: {Markup.Escape(deliveryId)}[/]");
|
|
return CliError.FromHttpStatus(404).ExitCode;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(delivery, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Delivery ID[/]", Markup.Escape(delivery.DeliveryId))
|
|
.AddRow("[bold]Channel[/]", Markup.Escape(delivery.ChannelName ?? delivery.ChannelId))
|
|
.AddRow("[bold]Channel Type[/]", GetChannelTypeMarkup(delivery.ChannelType))
|
|
.AddRow("[bold]Event Type[/]", Markup.Escape(delivery.EventType))
|
|
.AddRow("[bold]Status[/]", GetDeliveryStatusMarkup(delivery.Status))
|
|
.AddRow("[bold]Subject[/]", Markup.Escape(delivery.Subject ?? "-"))
|
|
.AddRow("[bold]Tenant[/]", Markup.Escape(delivery.TenantId))
|
|
.AddRow("[bold]Rule ID[/]", Markup.Escape(delivery.RuleId ?? "-"))
|
|
.AddRow("[bold]Event ID[/]", Markup.Escape(delivery.EventId ?? "-"))
|
|
.AddRow("[bold]Created[/]", delivery.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Sent[/]", delivery.SentAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
|
|
.AddRow("[bold]Failed[/]", delivery.FailedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
|
|
.AddRow("[bold]Idempotency Key[/]", Markup.Escape(delivery.IdempotencyKey ?? "-")));
|
|
panel.Header = new PanelHeader("Delivery Details");
|
|
AnsiConsole.Write(panel);
|
|
|
|
if (!string.IsNullOrWhiteSpace(delivery.ErrorMessage))
|
|
{
|
|
AnsiConsole.Write(new Panel($"[red]{Markup.Escape(delivery.ErrorMessage)}[/]") { Header = new PanelHeader("Error") });
|
|
}
|
|
|
|
// Attempts
|
|
if (delivery.Attempts is { Count: > 0 })
|
|
{
|
|
var attemptsTable = new Table();
|
|
attemptsTable.AddColumn("#");
|
|
attemptsTable.AddColumn("Status");
|
|
attemptsTable.AddColumn("Attempted");
|
|
attemptsTable.AddColumn("Latency");
|
|
attemptsTable.AddColumn("Response");
|
|
attemptsTable.AddColumn("Error");
|
|
|
|
foreach (var attempt in delivery.Attempts)
|
|
{
|
|
var attemptStatusMarkup = GetDeliveryStatusMarkup(attempt.Status);
|
|
var latency = attempt.LatencyMs.HasValue ? $"{attempt.LatencyMs} ms" : "-";
|
|
var responseCode = attempt.ResponseCode?.ToString() ?? "-";
|
|
var errorMsg = attempt.ErrorMessage ?? "-";
|
|
if (errorMsg.Length > 50)
|
|
{
|
|
errorMsg = errorMsg[..47] + "...";
|
|
}
|
|
|
|
attemptsTable.AddRow(
|
|
attempt.AttemptNumber.ToString(),
|
|
attemptStatusMarkup,
|
|
attempt.AttemptedAt.ToString("yyyy-MM-dd HH:mm:ss"),
|
|
latency,
|
|
responseCode,
|
|
Markup.Escape(errorMsg));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(attemptsTable) { Header = new PanelHeader($"Attempts ({delivery.AttemptCount})") });
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifyDeliveriesRetryAsync(
|
|
IServiceProvider services,
|
|
string deliveryId,
|
|
string? tenant,
|
|
string? idempotencyKey,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
try
|
|
{
|
|
var request = new NotifyRetryRequest
|
|
{
|
|
DeliveryId = deliveryId,
|
|
Tenant = tenant,
|
|
IdempotencyKey = idempotencyKey
|
|
};
|
|
|
|
var result = await client.RetryDeliveryAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Retry queued for delivery {Markup.Escape(deliveryId)}[/]");
|
|
if (!string.IsNullOrWhiteSpace(result.NewStatus))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]New status: {GetDeliveryStatusMarkup(result.NewStatus)}[/]");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Audit event: {Markup.Escape(result.AuditEventId)}[/]");
|
|
}
|
|
return 0;
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Retry failed for delivery {Markup.Escape(deliveryId)}[/]");
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleNotifySendAsync(
|
|
IServiceProvider services,
|
|
string eventType,
|
|
string body,
|
|
string? tenant,
|
|
string? channelId,
|
|
string? subject,
|
|
string? severity,
|
|
string[]? metadata,
|
|
string? idempotencyKey,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<INotifyClient>();
|
|
|
|
try
|
|
{
|
|
// Parse metadata key=value pairs
|
|
Dictionary<string, string>? metadataDict = null;
|
|
if (metadata is { Length: > 0 })
|
|
{
|
|
metadataDict = new Dictionary<string, string>();
|
|
foreach (var item in metadata)
|
|
{
|
|
var parts = item.Split('=', 2);
|
|
if (parts.Length == 2)
|
|
{
|
|
metadataDict[parts[0]] = parts[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
var request = new NotifySendRequest
|
|
{
|
|
EventType = eventType,
|
|
Body = body,
|
|
Tenant = tenant,
|
|
ChannelId = channelId,
|
|
Subject = subject,
|
|
Severity = severity,
|
|
Metadata = metadataDict,
|
|
IdempotencyKey = idempotencyKey
|
|
};
|
|
|
|
var result = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
|
|
if (result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Notification sent successfully[/]");
|
|
if (!string.IsNullOrWhiteSpace(result.EventId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Event ID: {Markup.Escape(result.EventId)}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine($"[grey]Channels matched: {result.ChannelsMatched}[/]");
|
|
if (result.DeliveryIds is { Count: > 0 })
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Deliveries: {string.Join(", ", result.DeliveryIds.Take(5))}[/]");
|
|
if (result.DeliveryIds.Count > 5)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey] (+{result.DeliveryIds.Count - 5} more)[/]");
|
|
}
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.IdempotencyKey))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Idempotency key: {Markup.Escape(result.IdempotencyKey)}[/]");
|
|
}
|
|
return 0;
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Failed to send notification[/]");
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
private static string GetChannelTypeMarkup(string type)
|
|
{
|
|
return type.ToLowerInvariant() switch
|
|
{
|
|
"slack" => "[bold purple]Slack[/]",
|
|
"teams" => "[bold blue]Teams[/]",
|
|
"email" => "[bold cyan]Email[/]",
|
|
"webhook" => "[bold yellow]Webhook[/]",
|
|
"pagerduty" => "[bold green]PagerDuty[/]",
|
|
"opsgenie" => "[bold orange1]OpsGenie[/]",
|
|
"cli" => "[bold grey]CLI[/]",
|
|
"inappinbox" or "inapp" => "[bold grey]InApp[/]",
|
|
_ => Markup.Escape(type)
|
|
};
|
|
}
|
|
|
|
private static string GetDeliveryStatusMarkup(string status)
|
|
{
|
|
return status.ToLowerInvariant() switch
|
|
{
|
|
"pending" => "[yellow]Pending[/]",
|
|
"sent" => "[green]Sent[/]",
|
|
"failed" => "[red]Failed[/]",
|
|
"throttled" => "[orange1]Throttled[/]",
|
|
"digested" => "[blue]Digested[/]",
|
|
"dropped" => "[grey]Dropped[/]",
|
|
_ => Markup.Escape(status)
|
|
};
|
|
}
|
|
|
|
private static string GetFailureRateMarkup(double? rate)
|
|
{
|
|
if (!rate.HasValue)
|
|
return "[grey]-[/]";
|
|
|
|
return rate.Value switch
|
|
{
|
|
0 => "[green]0%[/]",
|
|
< 0.1 => $"[green]{rate.Value:P1}[/]",
|
|
< 0.25 => $"[yellow]{rate.Value:P1}[/]",
|
|
_ => $"[red]{rate.Value:P1}[/]"
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Sbomer Handlers (CLI-SBOM-60-001)
|
|
|
|
internal static async Task<int> HandleSbomerLayerListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? scanId,
|
|
string? imageRef,
|
|
string? digest,
|
|
int? limit,
|
|
string? cursor,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var request = new SbomerLayerListRequest
|
|
{
|
|
Tenant = tenant,
|
|
ScanId = scanId,
|
|
ImageRef = imageRef,
|
|
Digest = digest,
|
|
Limit = limit,
|
|
Cursor = cursor
|
|
};
|
|
|
|
var response = await client.ListLayersAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
if (response.Items.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No layer fragments found.[/]");
|
|
return 0;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(response.ImageRef))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Image: {Markup.Escape(response.ImageRef)}[/]");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(response.ScanId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Scan ID: {Markup.Escape(response.ScanId)}[/]");
|
|
}
|
|
|
|
var table = new Table();
|
|
table.AddColumn("Layer Digest");
|
|
table.AddColumn("Fragment SHA256");
|
|
table.AddColumn("Components");
|
|
table.AddColumn("Format");
|
|
table.AddColumn("DSSE");
|
|
table.AddColumn("Created");
|
|
|
|
foreach (var fragment in response.Items)
|
|
{
|
|
var layerDigestShort = fragment.LayerDigest.Length > 20
|
|
? fragment.LayerDigest[..20] + "..."
|
|
: fragment.LayerDigest;
|
|
var fragmentHashShort = fragment.FragmentSha256.Length > 16
|
|
? fragment.FragmentSha256[..16] + "..."
|
|
: fragment.FragmentSha256;
|
|
var dsseStatus = fragment.SignatureValid switch
|
|
{
|
|
true => "[green]Valid[/]",
|
|
false => "[red]Invalid[/]",
|
|
null => string.IsNullOrWhiteSpace(fragment.DsseEnvelopeSha256) ? "[grey]-[/]" : "[yellow]?[/]"
|
|
};
|
|
|
|
table.AddRow(
|
|
Markup.Escape(layerDigestShort),
|
|
Markup.Escape(fragmentHashShort),
|
|
fragment.ComponentCount.ToString(),
|
|
Markup.Escape(fragment.Format),
|
|
dsseStatus,
|
|
fragment.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor {Markup.Escape(response.NextCursor)}[/]");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[grey]Total: {response.Total}[/]");
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomerLayerShowAsync(
|
|
IServiceProvider services,
|
|
string layerDigest,
|
|
string? tenant,
|
|
string? scanId,
|
|
bool includeComponents,
|
|
bool includeDsse,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var request = new SbomerLayerShowRequest
|
|
{
|
|
LayerDigest = layerDigest,
|
|
Tenant = tenant,
|
|
ScanId = scanId,
|
|
IncludeComponents = includeComponents,
|
|
IncludeDsse = includeDsse
|
|
};
|
|
|
|
var detail = await client.GetLayerAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (detail == null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Layer fragment not found: {Markup.Escape(layerDigest)}[/]");
|
|
return CliError.FromHttpStatus(404).ExitCode;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(detail, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
var fragment = detail.Fragment;
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Layer Digest[/]", Markup.Escape(fragment.LayerDigest))
|
|
.AddRow("[bold]Fragment SHA256[/]", Markup.Escape(fragment.FragmentSha256))
|
|
.AddRow("[bold]Components[/]", fragment.ComponentCount.ToString())
|
|
.AddRow("[bold]Format[/]", Markup.Escape(fragment.Format))
|
|
.AddRow("[bold]Created[/]", fragment.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Signature Algorithm[/]", Markup.Escape(fragment.SignatureAlgorithm ?? "-"))
|
|
.AddRow("[bold]Signature Valid[/]", fragment.SignatureValid switch
|
|
{
|
|
true => "[green]Yes[/]",
|
|
false => "[red]No[/]",
|
|
null => "[grey]-[/]"
|
|
}));
|
|
panel.Header = new PanelHeader("Layer Fragment");
|
|
AnsiConsole.Write(panel);
|
|
|
|
// DSSE Envelope
|
|
if (includeDsse && detail.DsseEnvelope != null)
|
|
{
|
|
var dsseGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Payload Type[/]", Markup.Escape(detail.DsseEnvelope.PayloadType))
|
|
.AddRow("[bold]Payload SHA256[/]", Markup.Escape(detail.DsseEnvelope.PayloadSha256))
|
|
.AddRow("[bold]Envelope SHA256[/]", Markup.Escape(detail.DsseEnvelope.EnvelopeSha256));
|
|
|
|
var dssePanel = new Panel(dsseGrid) { Header = new PanelHeader("DSSE Envelope") };
|
|
AnsiConsole.Write(dssePanel);
|
|
|
|
if (detail.DsseEnvelope.Signatures.Count > 0)
|
|
{
|
|
var sigTable = new Table();
|
|
sigTable.AddColumn("Key ID");
|
|
sigTable.AddColumn("Algorithm");
|
|
sigTable.AddColumn("Valid");
|
|
sigTable.AddColumn("Certificate Subject");
|
|
sigTable.AddColumn("Expiry");
|
|
|
|
foreach (var sig in detail.DsseEnvelope.Signatures)
|
|
{
|
|
var validMarkup = sig.Valid switch
|
|
{
|
|
true => "[green]Yes[/]",
|
|
false => "[red]No[/]",
|
|
null => "[grey]-[/]"
|
|
};
|
|
|
|
sigTable.AddRow(
|
|
Markup.Escape(sig.KeyId ?? "-"),
|
|
Markup.Escape(sig.Algorithm),
|
|
validMarkup,
|
|
Markup.Escape(sig.CertificateSubject ?? "-"),
|
|
sig.CertificateExpiry?.ToString("yyyy-MM-dd") ?? "-");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(sigTable) { Header = new PanelHeader("Signatures") });
|
|
}
|
|
}
|
|
|
|
// Merkle Proof
|
|
if (detail.MerkleProof != null)
|
|
{
|
|
var merkleGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Leaf Hash[/]", Markup.Escape(detail.MerkleProof.LeafHash))
|
|
.AddRow("[bold]Root Hash[/]", Markup.Escape(detail.MerkleProof.RootHash))
|
|
.AddRow("[bold]Leaf Index[/]", detail.MerkleProof.LeafIndex.ToString())
|
|
.AddRow("[bold]Tree Size[/]", detail.MerkleProof.TreeSize.ToString())
|
|
.AddRow("[bold]Valid[/]", detail.MerkleProof.Valid switch
|
|
{
|
|
true => "[green]Yes[/]",
|
|
false => "[red]No[/]",
|
|
null => "[grey]-[/]"
|
|
});
|
|
|
|
AnsiConsole.Write(new Panel(merkleGrid) { Header = new PanelHeader("Merkle Proof") });
|
|
}
|
|
|
|
// Components
|
|
if (includeComponents && fragment.Components is { Count: > 0 })
|
|
{
|
|
var compTable = new Table();
|
|
compTable.AddColumn("PURL / Name");
|
|
compTable.AddColumn("Version");
|
|
compTable.AddColumn("Type");
|
|
|
|
foreach (var comp in fragment.Components.Take(50))
|
|
{
|
|
compTable.AddRow(
|
|
Markup.Escape(comp.Purl ?? comp.Name),
|
|
Markup.Escape(comp.Version ?? "-"),
|
|
Markup.Escape(comp.Type ?? "-"));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(compTable) { Header = new PanelHeader($"Components ({fragment.Components.Count})") });
|
|
|
|
if (fragment.Components.Count > 50)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey] (+{fragment.Components.Count - 50} more components)[/]");
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomerLayerVerifyAsync(
|
|
IServiceProvider services,
|
|
string layerDigest,
|
|
string? tenant,
|
|
string? scanId,
|
|
string? verifiersPath,
|
|
bool offline,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var request = new SbomerLayerVerifyRequest
|
|
{
|
|
LayerDigest = layerDigest,
|
|
Tenant = tenant,
|
|
ScanId = scanId,
|
|
VerifiersPath = verifiersPath,
|
|
Offline = offline
|
|
};
|
|
|
|
var result = await client.VerifyLayerAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Valid ? 0 : 20;
|
|
}
|
|
|
|
if (result.Valid)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Layer fragment verification PASSED[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Layer: {Markup.Escape(result.LayerDigest)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]DSSE Valid: {(result.DsseValid ? "[green]Yes[/]" : "[red]No[/]")}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Content Hash Match: {(result.ContentHashMatch ? "[green]Yes[/]" : "[red]No[/]")}[/]");
|
|
if (result.MerkleProofValid.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Merkle Proof Valid: {(result.MerkleProofValid.Value ? "[green]Yes[/]" : "[red]No[/]")}[/]");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.SignatureAlgorithm))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Signature Algorithm: {Markup.Escape(result.SignatureAlgorithm)}[/]");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Layer fragment verification FAILED[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Layer: {Markup.Escape(result.LayerDigest)}[/]");
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (result.Warnings is { Count: > 0 })
|
|
{
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
}
|
|
|
|
return result.Valid ? 0 : 20;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomerComposeAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? scanId,
|
|
string? imageRef,
|
|
string? digest,
|
|
string? outputPath,
|
|
string? format,
|
|
bool verifyFragments,
|
|
string? verifiersPath,
|
|
bool offline,
|
|
bool emitManifest,
|
|
bool emitMerkle,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var request = new SbomerComposeRequest
|
|
{
|
|
Tenant = tenant,
|
|
ScanId = scanId,
|
|
ImageRef = imageRef,
|
|
Digest = digest,
|
|
OutputPath = outputPath,
|
|
Format = format,
|
|
VerifyFragments = verifyFragments,
|
|
VerifiersPath = verifiersPath,
|
|
Offline = offline,
|
|
EmitCompositionManifest = emitManifest,
|
|
EmitMerkleDiagnostics = emitMerkle
|
|
};
|
|
|
|
var result = await client.ComposeAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Success ? 0 : 20;
|
|
}
|
|
|
|
if (result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]SBOM composition successful[/]");
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Scan ID[/]", Markup.Escape(result.ScanId))
|
|
.AddRow("[bold]Fragments[/]", result.FragmentCount.ToString())
|
|
.AddRow("[bold]Total Components[/]", result.TotalComponents.ToString())
|
|
.AddRow("[bold]Deterministic[/]", result.Deterministic ? "[green]Yes[/]" : "[yellow]No[/]");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.ComposedSha256))
|
|
grid.AddRow("[bold]Composed SHA256[/]", Markup.Escape(result.ComposedSha256));
|
|
if (!string.IsNullOrWhiteSpace(result.MerkleRoot))
|
|
grid.AddRow("[bold]Merkle Root[/]", Markup.Escape(result.MerkleRoot));
|
|
if (!string.IsNullOrWhiteSpace(result.OutputPath))
|
|
grid.AddRow("[bold]Output[/]", Markup.Escape(result.OutputPath));
|
|
if (!string.IsNullOrWhiteSpace(result.CompositionManifestPath))
|
|
grid.AddRow("[bold]Manifest[/]", Markup.Escape(result.CompositionManifestPath));
|
|
if (!string.IsNullOrWhiteSpace(result.MerkleDiagnosticsPath))
|
|
grid.AddRow("[bold]Merkle Diagnostics[/]", Markup.Escape(result.MerkleDiagnosticsPath));
|
|
if (result.Duration.HasValue)
|
|
grid.AddRow("[bold]Duration[/]", $"{result.Duration.Value.TotalSeconds:F2}s");
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Composition Result") });
|
|
|
|
if (result.FragmentVerifications is { Count: > 0 })
|
|
{
|
|
var failedVerifications = result.FragmentVerifications.Where(v => !v.Valid).ToList();
|
|
if (failedVerifications.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]{failedVerifications.Count} fragment(s) failed verification[/]");
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]SBOM composition failed[/]");
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (result.Warnings is { Count: > 0 })
|
|
{
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
}
|
|
|
|
return result.Success ? 0 : 20;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomerCompositionShowAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? scanId,
|
|
string? compositionPath,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var request = new SbomerCompositionShowRequest
|
|
{
|
|
Tenant = tenant,
|
|
ScanId = scanId,
|
|
CompositionPath = compositionPath
|
|
};
|
|
|
|
var manifest = await client.GetCompositionManifestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (manifest == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Composition manifest not found[/]");
|
|
return CliError.FromHttpStatus(404).ExitCode;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(manifest, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Version[/]", Markup.Escape(manifest.Version))
|
|
.AddRow("[bold]Scan ID[/]", Markup.Escape(manifest.ScanId))
|
|
.AddRow("[bold]Image[/]", Markup.Escape(manifest.ImageRef ?? "-"))
|
|
.AddRow("[bold]Digest[/]", Markup.Escape(manifest.Digest ?? "-"))
|
|
.AddRow("[bold]Merkle Root[/]", Markup.Escape(manifest.MerkleRoot))
|
|
.AddRow("[bold]Composed SHA256[/]", Markup.Escape(manifest.ComposedSha256))
|
|
.AddRow("[bold]Created[/]", manifest.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
|
|
.AddRow("[bold]Fragments[/]", manifest.Fragments.Count.ToString()));
|
|
panel.Header = new PanelHeader("Composition Manifest");
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Fragments table
|
|
if (manifest.Fragments.Count > 0)
|
|
{
|
|
var fragTable = new Table();
|
|
fragTable.AddColumn("#");
|
|
fragTable.AddColumn("Layer Digest");
|
|
fragTable.AddColumn("Fragment SHA256");
|
|
fragTable.AddColumn("Components");
|
|
fragTable.AddColumn("DSSE");
|
|
|
|
foreach (var frag in manifest.Fragments.OrderBy(f => f.Order))
|
|
{
|
|
var layerShort = frag.LayerDigest.Length > 20 ? frag.LayerDigest[..20] + "..." : frag.LayerDigest;
|
|
var fragShort = frag.FragmentSha256.Length > 16 ? frag.FragmentSha256[..16] + "..." : frag.FragmentSha256;
|
|
var dsseMarkup = string.IsNullOrWhiteSpace(frag.DsseEnvelopeSha256) ? "[grey]-[/]" : "[green]Yes[/]";
|
|
|
|
fragTable.AddRow(
|
|
frag.Order.ToString(),
|
|
Markup.Escape(layerShort),
|
|
Markup.Escape(fragShort),
|
|
frag.ComponentCount.ToString(),
|
|
dsseMarkup);
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(fragTable) { Header = new PanelHeader("Fragments (Canonical Order)") });
|
|
}
|
|
|
|
// Properties
|
|
if (manifest.Properties is { Count: > 0 })
|
|
{
|
|
var propTable = new Table();
|
|
propTable.AddColumn("Property");
|
|
propTable.AddColumn("Value");
|
|
|
|
foreach (var prop in manifest.Properties)
|
|
{
|
|
propTable.AddRow(Markup.Escape(prop.Key), Markup.Escape(prop.Value));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(propTable) { Header = new PanelHeader("Properties") });
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomerCompositionVerifyAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? scanId,
|
|
string? compositionPath,
|
|
string? sbomPath,
|
|
string? verifiersPath,
|
|
bool offline,
|
|
bool recompose,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var request = new SbomerCompositionVerifyRequest
|
|
{
|
|
Tenant = tenant,
|
|
ScanId = scanId,
|
|
CompositionPath = compositionPath,
|
|
SbomPath = sbomPath,
|
|
VerifiersPath = verifiersPath,
|
|
Offline = offline,
|
|
Recompose = recompose
|
|
};
|
|
|
|
var result = await client.VerifyCompositionAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Valid ? 0 : 20;
|
|
}
|
|
|
|
if (result.Valid)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Composition verification PASSED[/]");
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Scan ID[/]", Markup.Escape(result.ScanId))
|
|
.AddRow("[bold]Merkle Root Match[/]", result.MerkleRootMatch ? "[green]Yes[/]" : "[red]No[/]")
|
|
.AddRow("[bold]Composed Hash Match[/]", result.ComposedHashMatch ? "[green]Yes[/]" : "[red]No[/]")
|
|
.AddRow("[bold]All Fragments Valid[/]", result.AllFragmentsValid ? "[green]Yes[/]" : "[red]No[/]")
|
|
.AddRow("[bold]Fragment Count[/]", result.FragmentCount.ToString())
|
|
.AddRow("[bold]Deterministic[/]", result.Deterministic ? "[green]Yes[/]" : "[yellow]No[/]");
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.RecomposedHash))
|
|
grid.AddRow("[bold]Recomposed Hash[/]", Markup.Escape(result.RecomposedHash));
|
|
if (!string.IsNullOrWhiteSpace(result.ExpectedHash))
|
|
grid.AddRow("[bold]Expected Hash[/]", Markup.Escape(result.ExpectedHash));
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Verification Result") });
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Composition verification FAILED[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Scan ID: {Markup.Escape(result.ScanId)}[/]");
|
|
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (result.Warnings is { Count: > 0 })
|
|
{
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
}
|
|
|
|
return result.Valid ? 0 : 20;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomerCompositionMerkleAsync(
|
|
IServiceProvider services,
|
|
string scanId,
|
|
string? tenant,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var diagnostics = await client.GetMerkleDiagnosticsAsync(scanId, tenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (diagnostics == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Merkle diagnostics not found[/]");
|
|
return CliError.FromHttpStatus(404).ExitCode;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(diagnostics, JsonOptions));
|
|
return 0;
|
|
}
|
|
|
|
var panel = new Panel(new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Scan ID[/]", Markup.Escape(diagnostics.ScanId))
|
|
.AddRow("[bold]Root Hash[/]", Markup.Escape(diagnostics.RootHash))
|
|
.AddRow("[bold]Tree Size[/]", diagnostics.TreeSize.ToString())
|
|
.AddRow("[bold]Valid[/]", diagnostics.Valid ? "[green]Yes[/]" : "[red]No[/]")
|
|
.AddRow("[bold]Created[/]", diagnostics.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")));
|
|
panel.Header = new PanelHeader("Merkle Tree Diagnostics");
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Leaves
|
|
if (diagnostics.Leaves.Count > 0)
|
|
{
|
|
var leafTable = new Table();
|
|
leafTable.AddColumn("Index");
|
|
leafTable.AddColumn("Layer Digest");
|
|
leafTable.AddColumn("Leaf Hash");
|
|
leafTable.AddColumn("Fragment SHA256");
|
|
|
|
foreach (var leaf in diagnostics.Leaves.OrderBy(l => l.Index))
|
|
{
|
|
var layerShort = leaf.LayerDigest.Length > 20 ? leaf.LayerDigest[..20] + "..." : leaf.LayerDigest;
|
|
var leafHashShort = leaf.Hash.Length > 16 ? leaf.Hash[..16] + "..." : leaf.Hash;
|
|
var fragHashShort = leaf.FragmentSha256.Length > 16 ? leaf.FragmentSha256[..16] + "..." : leaf.FragmentSha256;
|
|
|
|
leafTable.AddRow(
|
|
leaf.Index.ToString(),
|
|
Markup.Escape(layerShort),
|
|
Markup.Escape(leafHashShort),
|
|
Markup.Escape(fragHashShort));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(leafTable) { Header = new PanelHeader("Merkle Leaves") });
|
|
}
|
|
|
|
// Intermediate nodes (verbose only)
|
|
if (verbose && diagnostics.IntermediateNodes is { Count: > 0 })
|
|
{
|
|
var nodeTable = new Table();
|
|
nodeTable.AddColumn("Level");
|
|
nodeTable.AddColumn("Index");
|
|
nodeTable.AddColumn("Hash");
|
|
nodeTable.AddColumn("Left Child");
|
|
nodeTable.AddColumn("Right Child");
|
|
|
|
foreach (var node in diagnostics.IntermediateNodes.OrderBy(n => n.Level).ThenBy(n => n.Index))
|
|
{
|
|
var hashShort = node.Hash.Length > 16 ? node.Hash[..16] + "..." : node.Hash;
|
|
var leftShort = (node.LeftChild?.Length > 12 ? node.LeftChild[..12] + "..." : node.LeftChild) ?? "-";
|
|
var rightShort = (node.RightChild?.Length > 12 ? node.RightChild[..12] + "..." : node.RightChild) ?? "-";
|
|
|
|
nodeTable.AddRow(
|
|
node.Level.ToString(),
|
|
node.Index.ToString(),
|
|
Markup.Escape(hashShort),
|
|
Markup.Escape(leftShort),
|
|
Markup.Escape(rightShort));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(nodeTable) { Header = new PanelHeader("Intermediate Nodes") });
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
// CLI-SBOM-60-002: Drift detection handlers
|
|
|
|
internal static async Task<int> HandleSbomerDriftAnalyzeAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? scanId,
|
|
string? baselineScanId,
|
|
string? sbomPath,
|
|
string? baselinePath,
|
|
string? compositionPath,
|
|
bool explain,
|
|
bool offline,
|
|
string? offlineKitPath,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var options = services.GetRequiredService<StellaOpsCliOptions>();
|
|
|
|
if (!offline && !OfflineModeGuard.IsNetworkAllowed(options, "sbomer drift analyze"))
|
|
{
|
|
return CliError.FromCode(CliError.OfflineMode).ExitCode;
|
|
}
|
|
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var request = new SbomerDriftRequest
|
|
{
|
|
Tenant = tenant,
|
|
ScanId = scanId,
|
|
BaselineScanId = baselineScanId,
|
|
SbomPath = sbomPath,
|
|
BaselinePath = baselinePath,
|
|
CompositionPath = compositionPath,
|
|
Explain = explain,
|
|
Offline = offline,
|
|
OfflineKitPath = offlineKitPath
|
|
};
|
|
|
|
var result = await client.AnalyzeDriftAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.HasDrift && !result.Deterministic ? 1 : 0;
|
|
}
|
|
|
|
// Summary panel
|
|
var statusColor = result.HasDrift ? (result.Deterministic ? "yellow" : "red") : "green";
|
|
var statusText = result.HasDrift ? (result.Deterministic ? "Drift Detected (Deterministic)" : "Drift Detected (Non-Deterministic)") : "No Drift";
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Status[/]", $"[{statusColor}]{Markup.Escape(statusText)}[/]")
|
|
.AddRow("[bold]Scan ID[/]", Markup.Escape(result.ScanId))
|
|
.AddRow("[bold]Baseline Scan ID[/]", Markup.Escape(result.BaselineScanId ?? "N/A"))
|
|
.AddRow("[bold]Current Hash[/]", Markup.Escape(result.CurrentHash ?? "N/A"))
|
|
.AddRow("[bold]Baseline Hash[/]", Markup.Escape(result.BaselineHash ?? "N/A"));
|
|
|
|
var panel = new Panel(grid) { Header = new PanelHeader("Drift Analysis") };
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Summary statistics
|
|
if (result.Summary != null)
|
|
{
|
|
var summaryGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Components Added[/]", result.Summary.ComponentsAdded.ToString())
|
|
.AddRow("[bold]Components Removed[/]", result.Summary.ComponentsRemoved.ToString())
|
|
.AddRow("[bold]Components Modified[/]", result.Summary.ComponentsModified.ToString())
|
|
.AddRow("[bold]Array Ordering Drifts[/]", result.Summary.ArrayOrderingDrifts.ToString())
|
|
.AddRow("[bold]Timestamp Drifts[/]", result.Summary.TimestampDrifts.ToString())
|
|
.AddRow("[bold]Key Ordering Drifts[/]", result.Summary.KeyOrderingDrifts.ToString())
|
|
.AddRow("[bold]Whitespace Drifts[/]", result.Summary.WhitespaceDrifts.ToString())
|
|
.AddRow("[bold]Total Drifts[/]", $"[bold]{result.Summary.TotalDrifts}[/]");
|
|
|
|
AnsiConsole.Write(new Panel(summaryGrid) { Header = new PanelHeader("Summary") });
|
|
}
|
|
|
|
// Drift details table
|
|
if (result.Details is { Count: > 0 })
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn("Path");
|
|
table.AddColumn("Type");
|
|
table.AddColumn("Severity");
|
|
table.AddColumn("Breaks Determinism");
|
|
table.AddColumn("Description");
|
|
|
|
foreach (var detail in result.Details.Take(50)) // Limit to 50 for display
|
|
{
|
|
var severityColor = detail.Severity switch
|
|
{
|
|
"error" => "red",
|
|
"warning" => "yellow",
|
|
_ => "dim"
|
|
};
|
|
var pathShort = detail.Path.Length > 40 ? "..." + detail.Path[^37..] : detail.Path;
|
|
var descShort = detail.Description.Length > 50 ? detail.Description[..47] + "..." : detail.Description;
|
|
|
|
table.AddRow(
|
|
Markup.Escape(pathShort),
|
|
Markup.Escape(detail.Type),
|
|
$"[{severityColor}]{Markup.Escape(detail.Severity)}[/]",
|
|
detail.BreaksDeterminism ? "[red]Yes[/]" : "[green]No[/]",
|
|
Markup.Escape(descShort));
|
|
}
|
|
|
|
if (result.Details.Count > 50)
|
|
{
|
|
AnsiConsole.MarkupLine($"[dim]Showing 50 of {result.Details.Count} drifts. Use --json for full list.[/]");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(table) { Header = new PanelHeader("Drift Details") });
|
|
}
|
|
|
|
// Explanations (when --explain is used)
|
|
if (explain && result.Explanations is { Count: > 0 })
|
|
{
|
|
AnsiConsole.MarkupLine("\n[bold blue]Drift Explanations[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
foreach (var explanation in result.Explanations)
|
|
{
|
|
var expGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Path[/]", Markup.Escape(explanation.Path))
|
|
.AddRow("[bold]Reason[/]", Markup.Escape(explanation.Reason));
|
|
|
|
if (!string.IsNullOrWhiteSpace(explanation.ExpectedBehavior))
|
|
expGrid.AddRow("[bold]Expected[/]", Markup.Escape(explanation.ExpectedBehavior));
|
|
if (!string.IsNullOrWhiteSpace(explanation.ActualBehavior))
|
|
expGrid.AddRow("[bold]Actual[/]", Markup.Escape(explanation.ActualBehavior));
|
|
if (!string.IsNullOrWhiteSpace(explanation.RootCause))
|
|
expGrid.AddRow("[bold]Root Cause[/]", Markup.Escape(explanation.RootCause));
|
|
if (!string.IsNullOrWhiteSpace(explanation.Remediation))
|
|
expGrid.AddRow("[bold cyan]Remediation[/]", $"[cyan]{Markup.Escape(explanation.Remediation)}[/]");
|
|
if (!string.IsNullOrWhiteSpace(explanation.DocumentationUrl))
|
|
expGrid.AddRow("[bold]Documentation[/]", $"[link]{Markup.Escape(explanation.DocumentationUrl)}[/]");
|
|
|
|
if (explanation.AffectedLayers is { Count: > 0 })
|
|
{
|
|
var layersList = string.Join(", ", explanation.AffectedLayers.Select(l => l.Length > 20 ? l[..17] + "..." : l));
|
|
expGrid.AddRow("[bold]Affected Layers[/]", Markup.Escape(layersList));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(expGrid) { Header = new PanelHeader($"Explanation: {(explanation.Path.Length > 30 ? "..." + explanation.Path[^27..] : explanation.Path)}") });
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
}
|
|
|
|
// Errors and warnings
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
|
|
if (verbose && result.Warnings is { Count: > 0 })
|
|
{
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
}
|
|
|
|
// Return non-zero if non-deterministic drift detected
|
|
return result.HasDrift && !result.Deterministic ? 1 : 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleSbomerDriftVerifyAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? scanId,
|
|
string? sbomPath,
|
|
string? compositionPath,
|
|
string? verifiersPath,
|
|
string? offlineKitPath,
|
|
bool recomposeLocally,
|
|
bool validateFragments,
|
|
bool checkMerkle,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SetVerbosity(services, verbose);
|
|
var client = services.GetRequiredService<ISbomerClient>();
|
|
|
|
try
|
|
{
|
|
var request = new SbomerDriftVerifyRequest
|
|
{
|
|
Tenant = tenant,
|
|
ScanId = scanId,
|
|
SbomPath = sbomPath,
|
|
CompositionPath = compositionPath,
|
|
VerifiersPath = verifiersPath,
|
|
OfflineKitPath = offlineKitPath,
|
|
RecomposeLocally = recomposeLocally,
|
|
ValidateFragments = validateFragments,
|
|
CheckMerkleProofs = checkMerkle
|
|
};
|
|
|
|
var result = await client.VerifyDriftAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
|
|
return result.Valid ? 0 : 1;
|
|
}
|
|
|
|
// Main status panel
|
|
var statusColor = result.Valid ? "green" : "red";
|
|
var statusText = result.Valid ? "Verified" : "Verification Failed";
|
|
|
|
var grid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Status[/]", $"[{statusColor}]{Markup.Escape(statusText)}[/]")
|
|
.AddRow("[bold]Scan ID[/]", Markup.Escape(result.ScanId))
|
|
.AddRow("[bold]Deterministic[/]", result.Deterministic ? "[green]Yes[/]" : "[red]No[/]")
|
|
.AddRow("[bold]Composition Valid[/]", result.CompositionValid ? "[green]Yes[/]" : "[red]No[/]")
|
|
.AddRow("[bold]Fragments Valid[/]", result.FragmentsValid ? "[green]Yes[/]" : "[red]No[/]")
|
|
.AddRow("[bold]Merkle Proofs Valid[/]", result.MerkleProofsValid ? "[green]Yes[/]" : "[red]No[/]");
|
|
|
|
if (result.RecomposedHashMatch.HasValue)
|
|
{
|
|
grid.AddRow("[bold]Recomposed Hash Match[/]", result.RecomposedHashMatch.Value ? "[green]Yes[/]" : "[red]No[/]");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.CurrentHash))
|
|
{
|
|
var hashShort = result.CurrentHash.Length > 32 ? result.CurrentHash[..32] + "..." : result.CurrentHash;
|
|
grid.AddRow("[bold]Current Hash[/]", Markup.Escape(hashShort));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result.RecomposedHash))
|
|
{
|
|
var hashShort = result.RecomposedHash.Length > 32 ? result.RecomposedHash[..32] + "..." : result.RecomposedHash;
|
|
grid.AddRow("[bold]Recomposed Hash[/]", Markup.Escape(hashShort));
|
|
}
|
|
|
|
var panel = new Panel(grid) { Header = new PanelHeader("Drift Verification") };
|
|
AnsiConsole.Write(panel);
|
|
|
|
// Offline kit info
|
|
if (result.OfflineKitInfo != null)
|
|
{
|
|
var kitGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Path[/]", Markup.Escape(result.OfflineKitInfo.Path))
|
|
.AddRow("[bold]Version[/]", Markup.Escape(result.OfflineKitInfo.Version ?? "N/A"))
|
|
.AddRow("[bold]Created[/]", result.OfflineKitInfo.CreatedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "N/A")
|
|
.AddRow("[bold]Fragment Count[/]", result.OfflineKitInfo.FragmentCount.ToString())
|
|
.AddRow("[bold]Verifiers Present[/]", result.OfflineKitInfo.VerifiersPresent ? "[green]Yes[/]" : "[yellow]No[/]")
|
|
.AddRow("[bold]Composition Manifest[/]", result.OfflineKitInfo.CompositionManifestPresent ? "[green]Yes[/]" : "[yellow]No[/]");
|
|
|
|
AnsiConsole.Write(new Panel(kitGrid) { Header = new PanelHeader("Offline Kit") });
|
|
}
|
|
|
|
// Fragment verifications (verbose mode)
|
|
if (verbose && result.FragmentVerifications is { Count: > 0 })
|
|
{
|
|
var fragTable = new Table();
|
|
fragTable.AddColumn("Layer Digest");
|
|
fragTable.AddColumn("Valid");
|
|
fragTable.AddColumn("DSSE");
|
|
fragTable.AddColumn("Content Hash");
|
|
fragTable.AddColumn("Merkle");
|
|
fragTable.AddColumn("Algorithm");
|
|
|
|
foreach (var frag in result.FragmentVerifications)
|
|
{
|
|
var layerShort = frag.LayerDigest.Length > 20 ? frag.LayerDigest[..17] + "..." : frag.LayerDigest;
|
|
|
|
fragTable.AddRow(
|
|
Markup.Escape(layerShort),
|
|
frag.Valid ? "[green]Yes[/]" : "[red]No[/]",
|
|
frag.DsseValid ? "[green]Yes[/]" : "[red]No[/]",
|
|
frag.ContentHashMatch ? "[green]Yes[/]" : "[red]No[/]",
|
|
frag.MerkleProofValid.HasValue ? (frag.MerkleProofValid.Value ? "[green]Yes[/]" : "[red]No[/]") : "[dim]N/A[/]",
|
|
Markup.Escape(frag.SignatureAlgorithm ?? "N/A"));
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(fragTable) { Header = new PanelHeader("Fragment Verifications") });
|
|
}
|
|
|
|
// Drift result if present
|
|
if (result.DriftResult != null && result.DriftResult.HasDrift)
|
|
{
|
|
var driftColor = result.DriftResult.Deterministic ? "yellow" : "red";
|
|
var driftStatus = result.DriftResult.Deterministic ? "Deterministic Drift" : "Non-Deterministic Drift";
|
|
|
|
var driftGrid = new Grid()
|
|
.AddColumn()
|
|
.AddColumn()
|
|
.AddRow("[bold]Status[/]", $"[{driftColor}]{Markup.Escape(driftStatus)}[/]");
|
|
|
|
if (result.DriftResult.Summary != null)
|
|
{
|
|
driftGrid.AddRow("[bold]Total Drifts[/]", result.DriftResult.Summary.TotalDrifts.ToString());
|
|
driftGrid.AddRow("[bold]Components Changed[/]", $"+{result.DriftResult.Summary.ComponentsAdded}/-{result.DriftResult.Summary.ComponentsRemoved}/~{result.DriftResult.Summary.ComponentsModified}");
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(driftGrid) { Header = new PanelHeader("Drift Summary") });
|
|
}
|
|
|
|
// Errors and warnings
|
|
if (result.Errors is { Count: > 0 })
|
|
{
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
|
|
}
|
|
}
|
|
|
|
if (verbose && result.Warnings is { Count: > 0 })
|
|
{
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
|
|
}
|
|
}
|
|
|
|
return result.Valid ? 0 : 1;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
|
|
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
|
|
return error.ExitCode;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Risk Commands (CLI-RISK-66-001 through CLI-RISK-68-001)
|
|
|
|
// CLI-RISK-66-001: Risk profile list
|
|
public static async Task HandleRiskProfileListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
bool includeDisabled,
|
|
string? category,
|
|
int? limit,
|
|
int? offset,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("risk-profile-list");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.risk.profile.list", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "risk profile list");
|
|
using var duration = CliMetrics.MeasureCommandDuration("risk profile list");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Listing risk profiles: includeDisabled={IncludeDisabled}, category={Category}",
|
|
includeDisabled, category);
|
|
|
|
var request = new RiskProfileListRequest
|
|
{
|
|
IncludeDisabled = includeDisabled,
|
|
Category = category,
|
|
Limit = limit,
|
|
Offset = offset,
|
|
Tenant = effectiveTenant
|
|
};
|
|
|
|
var response = await client.ListRiskProfilesAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(response, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderRiskProfileListTable(response);
|
|
}
|
|
|
|
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 list risk profiles.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderRiskProfileListTable(RiskProfileListResponse response)
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn(new TableColumn("[bold]Profile ID[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Name[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Category[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Version[/]").RightAligned());
|
|
table.AddColumn(new TableColumn("[bold]Rules[/]").RightAligned());
|
|
table.AddColumn(new TableColumn("[bold]Enabled[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Built-In[/]").Centered());
|
|
|
|
foreach (var profile in response.Profiles)
|
|
{
|
|
var enabledStatus = profile.Enabled ? "[green]Yes[/]" : "[grey]No[/]";
|
|
var builtInStatus = profile.BuiltIn ? "[cyan]Yes[/]" : "-";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(profile.ProfileId),
|
|
Markup.Escape(profile.Name),
|
|
Markup.Escape(profile.Category),
|
|
profile.Version.ToString(),
|
|
profile.RuleCount.ToString(),
|
|
enabledStatus,
|
|
builtInStatus);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Total:[/] {response.Total} | [grey]Showing:[/] {response.Profiles.Count} (offset {response.Offset})");
|
|
}
|
|
|
|
// CLI-RISK-66-002: Risk simulate
|
|
public static async Task HandleRiskSimulateAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? profileId,
|
|
string? sbomId,
|
|
string? sbomPath,
|
|
string? assetId,
|
|
bool diffMode,
|
|
string? baselineProfileId,
|
|
bool emitJson,
|
|
bool emitCsv,
|
|
string? outputPath,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("risk-simulate");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.risk.simulate", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "risk simulate");
|
|
using var duration = CliMetrics.MeasureCommandDuration("risk simulate");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
// Validate input: at least one of sbomId, sbomPath, or assetId required
|
|
if (string.IsNullOrWhiteSpace(sbomId) && string.IsNullOrWhiteSpace(sbomPath) && string.IsNullOrWhiteSpace(assetId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] At least one of --sbom-id, --sbom-path, or --asset-id is required.");
|
|
Environment.ExitCode = 4;
|
|
return;
|
|
}
|
|
|
|
logger.LogDebug("Simulating risk: profileId={ProfileId}, sbomId={SbomId}, sbomPath={SbomPath}, assetId={AssetId}, diffMode={DiffMode}",
|
|
profileId, sbomId, sbomPath, assetId, diffMode);
|
|
|
|
var request = new RiskSimulateRequest
|
|
{
|
|
ProfileId = profileId,
|
|
SbomId = sbomId,
|
|
SbomPath = sbomPath,
|
|
AssetId = assetId,
|
|
DiffMode = diffMode,
|
|
BaselineProfileId = baselineProfileId,
|
|
Tenant = effectiveTenant
|
|
};
|
|
|
|
var result = await client.SimulateRiskAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
string output;
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
output = JsonSerializer.Serialize(result, jsonOptions);
|
|
}
|
|
else if (emitCsv)
|
|
{
|
|
output = RenderRiskSimulateCsv(result);
|
|
}
|
|
else
|
|
{
|
|
RenderRiskSimulateTable(result);
|
|
output = string.Empty;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(outputPath) && !string.IsNullOrEmpty(output))
|
|
{
|
|
await File.WriteAllTextAsync(outputPath, output, cancellationToken).ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine($"Output written to [cyan]{Markup.Escape(outputPath)}[/]");
|
|
}
|
|
else if (!string.IsNullOrEmpty(output))
|
|
{
|
|
AnsiConsole.WriteLine(output);
|
|
}
|
|
|
|
Environment.ExitCode = result.Success ? 0 : 1;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to simulate risk.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderRiskSimulateTable(RiskSimulateResult result)
|
|
{
|
|
// Header panel
|
|
var gradeColor = GetRiskGradeColor(result.Grade);
|
|
var headerPanel = new Panel(new Markup($"[bold]{Markup.Escape(result.ProfileName)}[/] (ID: {Markup.Escape(result.ProfileId)})"))
|
|
{
|
|
Header = new PanelHeader("[bold]Risk Simulation[/]"),
|
|
Border = BoxBorder.Rounded
|
|
};
|
|
AnsiConsole.Write(headerPanel);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Score summary
|
|
AnsiConsole.MarkupLine($"[bold]Overall Score:[/] [{gradeColor}]{result.OverallScore:F1}[/] ([{gradeColor}]{Markup.Escape(result.Grade)}[/])");
|
|
AnsiConsole.MarkupLine($"[bold]Simulated At:[/] {result.SimulatedAt:yyyy-MM-dd HH:mm:ss}");
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Findings summary
|
|
var findingsGrid = new Grid();
|
|
findingsGrid.AddColumn();
|
|
findingsGrid.AddColumn();
|
|
findingsGrid.AddColumn();
|
|
findingsGrid.AddColumn();
|
|
findingsGrid.AddColumn();
|
|
findingsGrid.AddColumn();
|
|
findingsGrid.AddRow(
|
|
"[bold]Critical[/]",
|
|
"[bold]High[/]",
|
|
"[bold]Medium[/]",
|
|
"[bold]Low[/]",
|
|
"[bold]Info[/]",
|
|
"[bold]Total[/]");
|
|
findingsGrid.AddRow(
|
|
$"[red]{result.Findings.Critical}[/]",
|
|
$"[#FFA500]{result.Findings.High}[/]",
|
|
$"[yellow]{result.Findings.Medium}[/]",
|
|
$"[blue]{result.Findings.Low}[/]",
|
|
$"[grey]{result.Findings.Info}[/]",
|
|
$"[bold]{result.Findings.Total}[/]");
|
|
AnsiConsole.Write(new Panel(findingsGrid) { Header = new PanelHeader("[bold]Findings Summary[/]"), Border = BoxBorder.Rounded });
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Diff information if available
|
|
if (result.Diff is not null)
|
|
{
|
|
var diffColor = result.Diff.Improved ? "green" : "red";
|
|
var diffSymbol = result.Diff.Improved ? "-" : "+";
|
|
AnsiConsole.MarkupLine($"[bold]Diff Mode:[/] [{diffColor}]{diffSymbol}{Math.Abs(result.Diff.Delta):F1}[/] (Baseline: {result.Diff.BaselineScore:F1} -> Candidate: {result.Diff.CandidateScore:F1})");
|
|
AnsiConsole.MarkupLine($"[grey]Findings Added:[/] {result.Diff.FindingsAdded} | [grey]Findings Removed:[/] {result.Diff.FindingsRemoved}");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Component scores
|
|
if (result.ComponentScores.Count > 0)
|
|
{
|
|
var componentTable = new Table();
|
|
componentTable.AddColumn(new TableColumn("[bold]Component[/]").LeftAligned());
|
|
componentTable.AddColumn(new TableColumn("[bold]Score[/]").RightAligned());
|
|
componentTable.AddColumn(new TableColumn("[bold]Grade[/]").Centered());
|
|
componentTable.AddColumn(new TableColumn("[bold]Findings[/]").RightAligned());
|
|
|
|
foreach (var component in result.ComponentScores)
|
|
{
|
|
var componentGradeColor = GetRiskGradeColor(component.Grade);
|
|
componentTable.AddRow(
|
|
Markup.Escape(component.ComponentName),
|
|
$"{component.Score:F1}",
|
|
$"[{componentGradeColor}]{Markup.Escape(component.Grade)}[/]",
|
|
component.FindingCount.ToString());
|
|
}
|
|
|
|
AnsiConsole.Write(componentTable);
|
|
}
|
|
|
|
// Errors
|
|
if (result.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string RenderRiskSimulateCsv(RiskSimulateResult result)
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.AppendLine("ComponentId,ComponentName,Score,Grade,FindingCount");
|
|
foreach (var component in result.ComponentScores)
|
|
{
|
|
sb.AppendLine($"\"{component.ComponentId}\",\"{component.ComponentName}\",{component.Score:F2},{component.Grade},{component.FindingCount}");
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string GetRiskGradeColor(string grade) => grade.ToUpperInvariant() switch
|
|
{
|
|
"A" or "A+" => "green",
|
|
"B" or "B+" => "cyan",
|
|
"C" or "C+" => "yellow",
|
|
"D" or "D+" => "#FFA500",
|
|
"F" => "red",
|
|
_ => "grey"
|
|
};
|
|
|
|
// CLI-RISK-67-001: Risk results
|
|
public static async Task HandleRiskResultsAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? assetId,
|
|
string? sbomId,
|
|
string? profileId,
|
|
string? minSeverity,
|
|
double? maxScore,
|
|
bool includeExplain,
|
|
int? limit,
|
|
int? offset,
|
|
bool emitJson,
|
|
bool emitCsv,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("risk-results");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.risk.results", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "risk results");
|
|
using var duration = CliMetrics.MeasureCommandDuration("risk results");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Getting risk results: assetId={AssetId}, sbomId={SbomId}, profileId={ProfileId}, minSeverity={MinSeverity}",
|
|
assetId, sbomId, profileId, minSeverity);
|
|
|
|
var request = new RiskResultsRequest
|
|
{
|
|
AssetId = assetId,
|
|
SbomId = sbomId,
|
|
ProfileId = profileId,
|
|
MinSeverity = minSeverity,
|
|
MaxScore = maxScore,
|
|
IncludeExplain = includeExplain,
|
|
Limit = limit,
|
|
Offset = offset,
|
|
Tenant = effectiveTenant
|
|
};
|
|
|
|
var response = await client.GetRiskResultsAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(response, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else if (emitCsv)
|
|
{
|
|
RenderRiskResultsCsv(response);
|
|
}
|
|
else
|
|
{
|
|
RenderRiskResultsTable(response, includeExplain);
|
|
}
|
|
|
|
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 get risk results.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderRiskResultsTable(RiskResultsResponse response, bool includeExplain)
|
|
{
|
|
// Summary panel
|
|
var summaryGrid = new Grid();
|
|
summaryGrid.AddColumn();
|
|
summaryGrid.AddColumn();
|
|
summaryGrid.AddColumn();
|
|
summaryGrid.AddColumn();
|
|
summaryGrid.AddRow(
|
|
$"[bold]Average Score:[/] {response.Summary.AverageScore:F1}",
|
|
$"[bold]Min:[/] {response.Summary.MinScore:F1}",
|
|
$"[bold]Max:[/] {response.Summary.MaxScore:F1}",
|
|
$"[bold]Assets:[/] {response.Summary.AssetCount}");
|
|
AnsiConsole.Write(new Panel(summaryGrid) { Header = new PanelHeader("[bold]Summary[/]"), Border = BoxBorder.Rounded });
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Results table
|
|
var table = new Table();
|
|
table.AddColumn(new TableColumn("[bold]Result ID[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Asset[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Profile[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Score[/]").RightAligned());
|
|
table.AddColumn(new TableColumn("[bold]Grade[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Severity[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Findings[/]").RightAligned());
|
|
table.AddColumn(new TableColumn("[bold]Evaluated[/]").RightAligned());
|
|
|
|
foreach (var result in response.Results)
|
|
{
|
|
var gradeColor = GetRiskGradeColor(result.Grade);
|
|
var severityColor = GetSeverityColor(result.Severity);
|
|
|
|
table.AddRow(
|
|
Markup.Escape(TruncateId(result.ResultId)),
|
|
Markup.Escape(result.AssetName ?? result.AssetId),
|
|
Markup.Escape(result.ProfileName ?? result.ProfileId),
|
|
$"{result.Score:F1}",
|
|
$"[{gradeColor}]{Markup.Escape(result.Grade)}[/]",
|
|
$"[{severityColor}]{Markup.Escape(result.Severity.ToUpperInvariant())}[/]",
|
|
result.FindingCount.ToString(),
|
|
result.EvaluatedAt.ToString("yyyy-MM-dd"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Total:[/] {response.Total} | [grey]Showing:[/] {response.Results.Count} (offset {response.Offset})");
|
|
|
|
// Explanations if requested
|
|
if (includeExplain)
|
|
{
|
|
foreach (var result in response.Results.Where(r => r.Explain != null))
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[bold]Explanation for {Markup.Escape(TruncateId(result.ResultId))}:[/]");
|
|
|
|
if (result.Explain!.Factors.Count > 0)
|
|
{
|
|
var factorTable = new Table();
|
|
factorTable.AddColumn("Factor");
|
|
factorTable.AddColumn("Weight");
|
|
factorTable.AddColumn("Contribution");
|
|
foreach (var factor in result.Explain.Factors)
|
|
{
|
|
factorTable.AddRow(
|
|
Markup.Escape(factor.Name),
|
|
$"{factor.Weight:F2}",
|
|
$"{factor.Contribution:F2}");
|
|
}
|
|
AnsiConsole.Write(factorTable);
|
|
}
|
|
|
|
if (result.Explain.Recommendations.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Recommendations:[/]");
|
|
foreach (var rec in result.Explain.Recommendations)
|
|
{
|
|
AnsiConsole.MarkupLine($" - {Markup.Escape(rec)}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void RenderRiskResultsCsv(RiskResultsResponse response)
|
|
{
|
|
Console.WriteLine("ResultId,AssetId,AssetName,ProfileId,ProfileName,Score,Grade,Severity,FindingCount,EvaluatedAt");
|
|
foreach (var result in response.Results)
|
|
{
|
|
Console.WriteLine($"\"{result.ResultId}\",\"{result.AssetId}\",\"{result.AssetName ?? ""}\",\"{result.ProfileId}\",\"{result.ProfileName ?? ""}\",{result.Score:F2},{result.Grade},{result.Severity},{result.FindingCount},{result.EvaluatedAt:O}");
|
|
}
|
|
}
|
|
|
|
private static string TruncateId(string id) => id.Length > 12 ? id[..12] + "..." : id;
|
|
|
|
// CLI-RISK-68-001: Risk bundle verify
|
|
public static async Task HandleRiskBundleVerifyAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string bundlePath,
|
|
string? signaturePath,
|
|
bool checkRekor,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("risk-bundle-verify");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.risk.bundle.verify", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "risk bundle verify");
|
|
using var duration = CliMetrics.MeasureCommandDuration("risk bundle verify");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
// Validate bundle path exists
|
|
if (!File.Exists(bundlePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle file not found: {Markup.Escape(bundlePath)}");
|
|
Environment.ExitCode = 4;
|
|
return;
|
|
}
|
|
|
|
// Validate signature path if provided
|
|
if (!string.IsNullOrWhiteSpace(signaturePath) && !File.Exists(signaturePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Signature file not found: {Markup.Escape(signaturePath)}");
|
|
Environment.ExitCode = 4;
|
|
return;
|
|
}
|
|
|
|
logger.LogDebug("Verifying risk bundle: bundlePath={BundlePath}, signaturePath={SignaturePath}, checkRekor={CheckRekor}",
|
|
bundlePath, signaturePath, checkRekor);
|
|
|
|
var request = new RiskBundleVerifyRequest
|
|
{
|
|
BundlePath = bundlePath,
|
|
SignaturePath = signaturePath,
|
|
CheckRekor = checkRekor,
|
|
Tenant = effectiveTenant
|
|
};
|
|
|
|
var result = await client.VerifyRiskBundleAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderRiskBundleVerifyResult(result, verbose);
|
|
}
|
|
|
|
Environment.ExitCode = result.Valid ? 0 : 1;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to verify risk bundle.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderRiskBundleVerifyResult(RiskBundleVerifyResult result, bool verbose)
|
|
{
|
|
// Validation status
|
|
var validStatus = result.Valid ? "[green]VALID[/]" : "[red]INVALID[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Bundle Status:[/] {validStatus}");
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Bundle info
|
|
var infoGrid = new Grid();
|
|
infoGrid.AddColumn();
|
|
infoGrid.AddColumn();
|
|
infoGrid.AddRow("[bold]Bundle ID:[/]", Markup.Escape(result.BundleId));
|
|
infoGrid.AddRow("[bold]Version:[/]", Markup.Escape(result.BundleVersion));
|
|
infoGrid.AddRow("[bold]Profiles:[/]", result.ProfileCount.ToString());
|
|
infoGrid.AddRow("[bold]Rules:[/]", result.RuleCount.ToString());
|
|
AnsiConsole.Write(new Panel(infoGrid) { Header = new PanelHeader("[bold]Bundle Information[/]"), Border = BoxBorder.Rounded });
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Signature info
|
|
if (result.SignatureValid.HasValue)
|
|
{
|
|
var sigStatus = result.SignatureValid.Value ? "[green]Valid[/]" : "[red]Invalid[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Signature:[/] {sigStatus}");
|
|
if (result.SignedBy is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Signed By:[/] {Markup.Escape(result.SignedBy)}");
|
|
}
|
|
if (result.SignedAt.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Signed At:[/] {result.SignedAt:yyyy-MM-dd HH:mm:ss}");
|
|
}
|
|
}
|
|
|
|
// Rekor info
|
|
if (result.RekorVerified.HasValue)
|
|
{
|
|
var rekorStatus = result.RekorVerified.Value ? "[green]Verified[/]" : "[yellow]Not Found[/]";
|
|
AnsiConsole.MarkupLine($"[bold]Rekor Transparency:[/] {rekorStatus}");
|
|
if (result.RekorLogIndex is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]Log Index:[/] {Markup.Escape(result.RekorLogIndex)}");
|
|
}
|
|
}
|
|
|
|
// Errors
|
|
if (result.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
}
|
|
|
|
// Warnings (verbose only)
|
|
if (verbose && result.Warnings.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Reachability Commands (CLI-SIG-26-001)
|
|
|
|
// CLI-SIG-26-001: Reachability upload-callgraph
|
|
public static async Task HandleReachabilityUploadCallGraphAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string callGraphPath,
|
|
string? scanId,
|
|
string? assetId,
|
|
string? format,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("reachability-upload");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.reachability.upload-callgraph", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "reachability upload-callgraph");
|
|
using var duration = CliMetrics.MeasureCommandDuration("reachability upload-callgraph");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
// Validate call graph path exists
|
|
if (!File.Exists(callGraphPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Call graph file not found: {Markup.Escape(callGraphPath)}");
|
|
Environment.ExitCode = 4;
|
|
return;
|
|
}
|
|
|
|
// Validate at least one of scanId or assetId
|
|
if (string.IsNullOrWhiteSpace(scanId) && string.IsNullOrWhiteSpace(assetId))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] At least one of --scan-id or --asset-id is required.");
|
|
Environment.ExitCode = 4;
|
|
return;
|
|
}
|
|
|
|
logger.LogDebug("Uploading call graph: path={Path}, scanId={ScanId}, assetId={AssetId}, format={Format}",
|
|
callGraphPath, scanId, assetId, format);
|
|
|
|
var request = new ReachabilityUploadCallGraphRequest
|
|
{
|
|
ScanId = scanId,
|
|
AssetId = assetId,
|
|
CallGraphPath = callGraphPath,
|
|
Format = format,
|
|
Tenant = effectiveTenant
|
|
};
|
|
|
|
await using var fileStream = File.OpenRead(callGraphPath);
|
|
|
|
var result = await AnsiConsole.Progress()
|
|
.AutoClear(false)
|
|
.Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new SpinnerColumn())
|
|
.StartAsync(async ctx =>
|
|
{
|
|
var task = ctx.AddTask("[cyan]Uploading call graph...[/]", maxValue: 100);
|
|
task.IsIndeterminate = true;
|
|
|
|
var uploadResult = await client.UploadCallGraphAsync(request, fileStream, cancellationToken).ConfigureAwait(false);
|
|
|
|
task.Value = 100;
|
|
task.StopTask();
|
|
|
|
return uploadResult;
|
|
}).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderReachabilityUploadResult(result);
|
|
}
|
|
|
|
Environment.ExitCode = result.Success ? 0 : 1;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
Environment.ExitCode = 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to upload call graph.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderReachabilityUploadResult(ReachabilityUploadCallGraphResult result)
|
|
{
|
|
var statusColor = result.Success ? "green" : "red";
|
|
AnsiConsole.MarkupLine($"[bold]Upload Status:[/] [{statusColor}]{(result.Success ? "SUCCESS" : "FAILED")}[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var infoGrid = new Grid();
|
|
infoGrid.AddColumn();
|
|
infoGrid.AddColumn();
|
|
infoGrid.AddRow("[bold]Call Graph ID:[/]", Markup.Escape(result.CallGraphId));
|
|
infoGrid.AddRow("[bold]Entries Processed:[/]", result.EntriesProcessed.ToString());
|
|
infoGrid.AddRow("[bold]Errors:[/]", result.ErrorsCount.ToString());
|
|
infoGrid.AddRow("[bold]Uploaded At:[/]", result.UploadedAt.ToString("yyyy-MM-dd HH:mm:ss"));
|
|
AnsiConsole.Write(new Panel(infoGrid) { Header = new PanelHeader("[bold]Upload Details[/]"), Border = BoxBorder.Rounded });
|
|
|
|
if (result.Errors.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// CLI-SIG-26-001: Reachability list
|
|
public static async Task HandleReachabilityListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? scanId,
|
|
string? assetId,
|
|
string? status,
|
|
int? limit,
|
|
int? offset,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("reachability-list");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.reachability.list", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "reachability list");
|
|
using var duration = CliMetrics.MeasureCommandDuration("reachability list");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Listing reachability analyses: scanId={ScanId}, assetId={AssetId}, status={Status}",
|
|
scanId, assetId, status);
|
|
|
|
var request = new ReachabilityListRequest
|
|
{
|
|
ScanId = scanId,
|
|
AssetId = assetId,
|
|
Status = status,
|
|
Limit = limit,
|
|
Offset = offset,
|
|
Tenant = effectiveTenant
|
|
};
|
|
|
|
var response = await client.ListReachabilityAnalysesAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(response, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderReachabilityListTable(response);
|
|
}
|
|
|
|
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 list reachability analyses.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderReachabilityListTable(ReachabilityListResponse response)
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn(new TableColumn("[bold]Analysis ID[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Asset[/]").LeftAligned());
|
|
table.AddColumn(new TableColumn("[bold]Status[/]").Centered());
|
|
table.AddColumn(new TableColumn("[bold]Reachable[/]").RightAligned());
|
|
table.AddColumn(new TableColumn("[bold]Unreachable[/]").RightAligned());
|
|
table.AddColumn(new TableColumn("[bold]Unknown[/]").RightAligned());
|
|
table.AddColumn(new TableColumn("[bold]Created[/]").RightAligned());
|
|
|
|
foreach (var analysis in response.Analyses)
|
|
{
|
|
var statusColor = GetReachabilityStatusColor(analysis.Status);
|
|
|
|
table.AddRow(
|
|
Markup.Escape(TruncateId(analysis.AnalysisId)),
|
|
Markup.Escape(analysis.AssetName ?? analysis.AssetId ?? analysis.ScanId ?? "-"),
|
|
$"[{statusColor}]{Markup.Escape(analysis.Status)}[/]",
|
|
analysis.ReachableCount.ToString(),
|
|
analysis.UnreachableCount.ToString(),
|
|
analysis.UnknownCount.ToString(),
|
|
analysis.CreatedAt.ToString("yyyy-MM-dd HH:mm"));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Total:[/] {response.Total} | [grey]Showing:[/] {response.Analyses.Count} (offset {response.Offset})");
|
|
}
|
|
|
|
private static string GetReachabilityStatusColor(string status) => status.ToLowerInvariant() switch
|
|
{
|
|
"completed" => "green",
|
|
"processing" => "cyan",
|
|
"pending" => "yellow",
|
|
"failed" => "red",
|
|
_ => "grey"
|
|
};
|
|
|
|
// CLI-SIG-26-001: Reachability explain
|
|
public static async Task HandleReachabilityExplainAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string analysisId,
|
|
string? vulnerabilityId,
|
|
string? packagePurl,
|
|
bool includeCallPaths,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("reachability-explain");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.reachability.explain", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "reachability explain");
|
|
using var duration = CliMetrics.MeasureCommandDuration("reachability explain");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
// Validate at least one of vulnerabilityId or packagePurl
|
|
if (string.IsNullOrWhiteSpace(vulnerabilityId) && string.IsNullOrWhiteSpace(packagePurl))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] At least one of --vuln-id or --purl is required.");
|
|
Environment.ExitCode = 4;
|
|
return;
|
|
}
|
|
|
|
logger.LogDebug("Explaining reachability: analysisId={AnalysisId}, vulnId={VulnId}, purl={Purl}",
|
|
analysisId, vulnerabilityId, packagePurl);
|
|
|
|
var request = new ReachabilityExplainRequest
|
|
{
|
|
AnalysisId = analysisId,
|
|
VulnerabilityId = vulnerabilityId,
|
|
PackagePurl = packagePurl,
|
|
IncludeCallPaths = includeCallPaths,
|
|
Tenant = effectiveTenant
|
|
};
|
|
|
|
var result = await client.ExplainReachabilityAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
RenderReachabilityExplainResult(result, includeCallPaths);
|
|
}
|
|
|
|
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 explain reachability.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static void RenderReachabilityExplainResult(ReachabilityExplainResult result, bool includeCallPaths)
|
|
{
|
|
// State header
|
|
var stateColor = GetReachabilityStateColor(result.ReachabilityState);
|
|
AnsiConsole.MarkupLine($"[bold]Reachability State:[/] [{stateColor}]{Markup.Escape(result.ReachabilityState.ToUpperInvariant())}[/]");
|
|
|
|
if (result.ReachabilityScore.HasValue)
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Reachability Score:[/] {result.ReachabilityScore:F2}");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[bold]Confidence:[/] {Markup.Escape(result.Confidence)}");
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Target info
|
|
var infoGrid = new Grid();
|
|
infoGrid.AddColumn();
|
|
infoGrid.AddColumn();
|
|
infoGrid.AddRow("[bold]Analysis ID:[/]", Markup.Escape(result.AnalysisId));
|
|
if (!string.IsNullOrWhiteSpace(result.VulnerabilityId))
|
|
{
|
|
infoGrid.AddRow("[bold]Vulnerability:[/]", Markup.Escape(result.VulnerabilityId));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.PackagePurl))
|
|
{
|
|
infoGrid.AddRow("[bold]Package:[/]", Markup.Escape(result.PackagePurl));
|
|
}
|
|
AnsiConsole.Write(new Panel(infoGrid) { Border = BoxBorder.Rounded });
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Reasoning
|
|
if (!string.IsNullOrWhiteSpace(result.Reasoning))
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Reasoning:[/]");
|
|
AnsiConsole.MarkupLine($" {Markup.Escape(result.Reasoning)}");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Affected functions
|
|
if (result.AffectedFunctions.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Affected Functions:[/]");
|
|
foreach (var func in result.AffectedFunctions)
|
|
{
|
|
var location = !string.IsNullOrWhiteSpace(func.FilePath) && func.LineNumber.HasValue
|
|
? $"{func.FilePath}:{func.LineNumber}"
|
|
: func.FilePath ?? "";
|
|
|
|
AnsiConsole.MarkupLine($" - [cyan]{Markup.Escape(func.Name)}[/]");
|
|
if (!string.IsNullOrWhiteSpace(func.ClassName))
|
|
{
|
|
AnsiConsole.MarkupLine($" Class: {Markup.Escape(func.ClassName)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(location))
|
|
{
|
|
AnsiConsole.MarkupLine($" Location: [grey]{Markup.Escape(location)}[/]");
|
|
}
|
|
}
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Call paths
|
|
if (includeCallPaths && result.CallPaths.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Call Paths ({result.CallPaths.Count}):[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
foreach (var path in result.CallPaths)
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Path {Markup.Escape(path.PathId)}[/] (depth {path.Depth}):");
|
|
|
|
// Entry point
|
|
AnsiConsole.MarkupLine($" [green]Entry:[/] {Markup.Escape(path.EntryPoint.Name)}");
|
|
|
|
// Intermediate frames
|
|
foreach (var frame in path.Frames)
|
|
{
|
|
AnsiConsole.MarkupLine($" -> {Markup.Escape(frame.Name)}");
|
|
}
|
|
|
|
// Vulnerable function
|
|
AnsiConsole.MarkupLine($" [red]Vulnerable:[/] {Markup.Escape(path.VulnerableFunction.Name)}");
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string GetReachabilityStateColor(string state) => state.ToLowerInvariant() switch
|
|
{
|
|
"reachable" => "red",
|
|
"unreachable" => "green",
|
|
"unknown" or "indeterminate" => "yellow",
|
|
_ => "grey"
|
|
};
|
|
|
|
#endregion
|
|
|
|
#region API Spec Commands (CLI-SDK-63-001)
|
|
|
|
public static async Task HandleApiSpecListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("api-spec-list");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.api.spec.list", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "api spec list");
|
|
using var duration = CliMetrics.MeasureCommandDuration("api spec list");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Listing API specs for tenant: {Tenant}", effectiveTenant ?? "(default)");
|
|
|
|
var result = await client.ListApiSpecsAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
// Render aggregate spec
|
|
if (result.Aggregate is not null)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Aggregate API Specification:[/]");
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
grid.AddRow("[bold]Version:[/]", Markup.Escape(result.Aggregate.Version));
|
|
if (!string.IsNullOrWhiteSpace(result.Aggregate.OpenApiVersion))
|
|
{
|
|
grid.AddRow("[bold]OpenAPI:[/]", Markup.Escape(result.Aggregate.OpenApiVersion));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.Aggregate.ETag))
|
|
{
|
|
grid.AddRow("[bold]ETag:[/]", Markup.Escape(result.Aggregate.ETag));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.Aggregate.Sha256))
|
|
{
|
|
grid.AddRow("[bold]SHA-256:[/]", Markup.Escape(result.Aggregate.Sha256));
|
|
}
|
|
if (result.Aggregate.LastModified.HasValue)
|
|
{
|
|
grid.AddRow("[bold]Last Modified:[/]", result.Aggregate.LastModified.Value.ToString("yyyy-MM-dd HH:mm:ss UTC"));
|
|
}
|
|
AnsiConsole.Write(new Panel(grid) { Border = BoxBorder.Rounded });
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// Render service specs
|
|
if (result.Specs.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Service API Specifications:[/]");
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("Service");
|
|
table.AddColumn("Version");
|
|
table.AddColumn("OpenAPI");
|
|
table.AddColumn("Formats");
|
|
table.AddColumn("ETag");
|
|
|
|
foreach (var spec in result.Specs.OrderBy(s => s.Service))
|
|
{
|
|
var formats = spec.Formats.Count > 0 ? string.Join(", ", spec.Formats) : "-";
|
|
table.AddRow(
|
|
Markup.Escape(spec.Service),
|
|
Markup.Escape(spec.Version),
|
|
Markup.Escape(spec.OpenApiVersion ?? "-"),
|
|
Markup.Escape(formats),
|
|
Markup.Escape(spec.ETag ?? "-")
|
|
);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No service specifications found.[/]");
|
|
}
|
|
}
|
|
|
|
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 list API specs.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleApiSpecDownloadAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string outputPath,
|
|
string? service,
|
|
string format,
|
|
bool overwrite,
|
|
string? etag,
|
|
string? checksum,
|
|
string checksumAlgorithm,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("api-spec-download");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.api.spec.download", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "api spec download");
|
|
using var duration = CliMetrics.MeasureCommandDuration("api spec download");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Downloading API spec: service={Service}, format={Format}, output={Output}",
|
|
service ?? "aggregate", format, outputPath);
|
|
|
|
var request = new ApiSpecDownloadRequest
|
|
{
|
|
Tenant = effectiveTenant,
|
|
OutputPath = outputPath,
|
|
Service = service,
|
|
Format = format,
|
|
Overwrite = overwrite,
|
|
ExpectedETag = etag,
|
|
ExpectedChecksum = checksum,
|
|
ChecksumAlgorithm = checksumAlgorithm
|
|
};
|
|
|
|
var result = await client.DownloadApiSpecAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
|
|
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error Code:[/] {Markup.Escape(result.ErrorCode)}");
|
|
}
|
|
}
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
var statusText = result.FromCache ? "[yellow]Using cached file[/]" : "[green]Downloaded[/]";
|
|
AnsiConsole.MarkupLine($"{statusText}");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
grid.AddRow("[bold]Path:[/]", Markup.Escape(result.Path ?? "-"));
|
|
grid.AddRow("[bold]Size:[/]", $"{result.SizeBytes:N0} bytes");
|
|
if (!string.IsNullOrWhiteSpace(result.ApiVersion))
|
|
{
|
|
grid.AddRow("[bold]API Version:[/]", Markup.Escape(result.ApiVersion));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.ETag))
|
|
{
|
|
grid.AddRow("[bold]ETag:[/]", Markup.Escape(result.ETag));
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(result.Checksum))
|
|
{
|
|
grid.AddRow($"[bold]{result.ChecksumAlgorithm?.ToUpperInvariant() ?? "Checksum"}:[/]", Markup.Escape(result.Checksum));
|
|
}
|
|
if (result.ChecksumVerified.HasValue)
|
|
{
|
|
var verifyStatus = result.ChecksumVerified.Value ? "[green]Verified[/]" : "[red]Mismatch[/]";
|
|
grid.AddRow("[bold]Checksum Verified:[/]", verifyStatus);
|
|
}
|
|
|
|
AnsiConsole.Write(new Panel(grid) { Border = BoxBorder.Rounded, Header = new PanelHeader("API Specification") });
|
|
}
|
|
|
|
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 download API spec.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region SDK Commands (CLI-SDK-64-001)
|
|
|
|
public static async Task HandleSdkUpdateAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? language,
|
|
bool checkOnly,
|
|
bool showChangelog,
|
|
bool showDeprecations,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("sdk-update");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.sdk.update", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "sdk update");
|
|
using var duration = CliMetrics.MeasureCommandDuration("sdk update");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Checking SDK updates: language={Language}, checkOnly={CheckOnly}", language ?? "all", checkOnly);
|
|
|
|
var request = new SdkUpdateRequest
|
|
{
|
|
Tenant = effectiveTenant,
|
|
Language = language,
|
|
CheckOnly = checkOnly,
|
|
IncludeChangelog = showChangelog,
|
|
IncludeDeprecations = showDeprecations
|
|
};
|
|
|
|
var result = await client.CheckSdkUpdatesAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
// Show update summary
|
|
var updatesAvailable = result.Updates.Where(u => u.UpdateAvailable).ToList();
|
|
if (updatesAvailable.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold cyan]SDK Updates Available ({updatesAvailable.Count}):[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("SDK");
|
|
table.AddColumn("Current");
|
|
table.AddColumn("Latest");
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Released");
|
|
|
|
foreach (var update in updatesAvailable)
|
|
{
|
|
var current = update.InstalledVersion ?? "[grey]Not installed[/]";
|
|
var released = update.ReleaseDate?.ToString("yyyy-MM-dd") ?? "-";
|
|
table.AddRow(
|
|
Markup.Escape(update.DisplayName ?? update.Language),
|
|
update.InstalledVersion is not null ? Markup.Escape(update.InstalledVersion) : "[grey]Not installed[/]",
|
|
$"[green]{Markup.Escape(update.LatestVersion)}[/]",
|
|
Markup.Escape(update.PackageName),
|
|
released
|
|
);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Show changelog if requested
|
|
if (showChangelog)
|
|
{
|
|
foreach (var update in updatesAvailable.Where(u => u.Changelog?.Count > 0))
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Changelog for {Markup.Escape(update.DisplayName ?? update.Language)}:[/]");
|
|
foreach (var entry in update.Changelog!.Take(5))
|
|
{
|
|
var typeIcon = entry.Type?.ToLowerInvariant() switch
|
|
{
|
|
"feature" => "[green]+[/]",
|
|
"fix" => "[yellow]~[/]",
|
|
"breaking" => "[red]![/]",
|
|
"deprecation" => "[yellow]?[/]",
|
|
_ => " "
|
|
};
|
|
var breakingMark = entry.IsBreaking ? " [red](BREAKING)[/]" : "";
|
|
AnsiConsole.MarkupLine($" {typeIcon} [{(entry.IsBreaking ? "red" : "white")}]{Markup.Escape(entry.Version)}[/]: {Markup.Escape(entry.Description)}{breakingMark}");
|
|
}
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]All SDKs are up to date.[/]");
|
|
}
|
|
|
|
// Show deprecations if requested
|
|
if (showDeprecations && result.Deprecations.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[bold yellow]Deprecation Notices ({result.Deprecations.Count}):[/]");
|
|
|
|
foreach (var deprecation in result.Deprecations)
|
|
{
|
|
var severityColor = deprecation.Severity.ToLowerInvariant() switch
|
|
{
|
|
"critical" => "red",
|
|
"warning" => "yellow",
|
|
_ => "grey"
|
|
};
|
|
AnsiConsole.MarkupLine($" [{severityColor}][{deprecation.Severity.ToUpperInvariant()}][/] {Markup.Escape(deprecation.Language)}: {Markup.Escape(deprecation.Feature)}");
|
|
AnsiConsole.MarkupLine($" {Markup.Escape(deprecation.Message)}");
|
|
if (!string.IsNullOrWhiteSpace(deprecation.Replacement))
|
|
{
|
|
AnsiConsole.MarkupLine($" [green]Replacement:[/] {Markup.Escape(deprecation.Replacement)}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(deprecation.RemovedInVersion))
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]Removed in:[/] {Markup.Escape(deprecation.RemovedInVersion)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Checked at: {result.CheckedAt:yyyy-MM-dd HH:mm:ss UTC}[/]");
|
|
}
|
|
|
|
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 check SDK updates.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleSdkListAsync(
|
|
IServiceProvider services,
|
|
string? tenant,
|
|
string? language,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("sdk-list");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.sdk.list", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "sdk list");
|
|
using var duration = CliMetrics.MeasureCommandDuration("sdk list");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Listing installed SDKs: language={Language}", language ?? "all");
|
|
|
|
var result = await client.ListInstalledSdksAsync(language, effectiveTenant, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
if (result.Sdks.Count == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]No SDKs found.[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Installed SDKs ({result.Sdks.Count}):[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("Language");
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Installed");
|
|
table.AddColumn("Latest");
|
|
table.AddColumn("API Support");
|
|
table.AddColumn("Status");
|
|
|
|
foreach (var sdk in result.Sdks.OrderBy(s => s.Language))
|
|
{
|
|
var apiSupport = sdk.MinApiVersion is not null && sdk.MaxApiVersion is not null
|
|
? $"{sdk.MinApiVersion} - {sdk.MaxApiVersion}"
|
|
: "-";
|
|
var status = sdk.UpdateAvailable
|
|
? "[yellow]Update available[/]"
|
|
: "[green]Up to date[/]";
|
|
|
|
table.AddRow(
|
|
Markup.Escape(sdk.DisplayName ?? sdk.Language),
|
|
Markup.Escape(sdk.PackageName),
|
|
Markup.Escape(sdk.InstalledVersion ?? "-"),
|
|
Markup.Escape(sdk.LatestVersion),
|
|
Markup.Escape(apiSupport),
|
|
status
|
|
);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
}
|
|
|
|
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 list SDKs.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Mirror Commands (CLI-AIRGAP-56-001)
|
|
|
|
/// <summary>
|
|
/// Handler for 'stella mirror create' command.
|
|
/// Creates an air-gap mirror bundle for offline distribution.
|
|
/// </summary>
|
|
public static async Task HandleMirrorCreateAsync(
|
|
IServiceProvider services,
|
|
string domainId,
|
|
string outputDirectory,
|
|
string? format,
|
|
string? tenant,
|
|
string? displayName,
|
|
string? targetRepository,
|
|
IReadOnlyList<string>? providers,
|
|
bool includeSignatures,
|
|
bool includeAttestations,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("mirror-create");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.mirror.create", System.Diagnostics.ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "mirror create");
|
|
activity?.SetTag("stellaops.cli.mirror.domain", domainId);
|
|
using var duration = CliMetrics.MeasureCommandDuration("mirror create");
|
|
|
|
try
|
|
{
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
|
}
|
|
|
|
logger.LogDebug("Creating mirror bundle: domain={DomainId}, output={OutputDir}, format={Format}",
|
|
domainId, outputDirectory, format ?? "all");
|
|
|
|
// Validate domain ID
|
|
var validDomains = new[] { "vex-advisories", "vulnerability-feeds", "policy-packs", "scanner-bundles", "offline-kit" };
|
|
if (!validDomains.Contains(domainId, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Domain '{Markup.Escape(domainId)}' is not a standard domain. Standard domains: {string.Join(", ", validDomains)}");
|
|
}
|
|
|
|
// Ensure output directory exists
|
|
var resolvedOutput = Path.GetFullPath(outputDirectory);
|
|
if (!Directory.Exists(resolvedOutput))
|
|
{
|
|
Directory.CreateDirectory(resolvedOutput);
|
|
logger.LogDebug("Created output directory: {OutputDir}", resolvedOutput);
|
|
}
|
|
|
|
// Generate bundle timestamp
|
|
var generatedAt = DateTimeOffset.UtcNow;
|
|
var bundleId = $"{domainId}-{generatedAt:yyyyMMddHHmmss}";
|
|
|
|
// Create the request model
|
|
var request = new MirrorCreateRequest
|
|
{
|
|
DomainId = domainId,
|
|
DisplayName = displayName ?? $"{domainId} Mirror Bundle",
|
|
TargetRepository = targetRepository,
|
|
Format = format,
|
|
Providers = providers,
|
|
OutputDirectory = resolvedOutput,
|
|
IncludeSignatures = includeSignatures,
|
|
IncludeAttestations = includeAttestations,
|
|
Tenant = effectiveTenant
|
|
};
|
|
|
|
// Build exports list based on domain
|
|
var exports = new List<MirrorBundleExport>();
|
|
long totalSize = 0;
|
|
|
|
// For now, create a placeholder export entry
|
|
// In production this would call backend APIs to get actual exports
|
|
var exportId = Guid.NewGuid().ToString();
|
|
var placeholderContent = JsonSerializer.Serialize(new
|
|
{
|
|
schemaVersion = 1,
|
|
domain = domainId,
|
|
generatedAt = generatedAt,
|
|
tenant = effectiveTenant,
|
|
format,
|
|
providers
|
|
}, new JsonSerializerOptions { WriteIndented = true });
|
|
|
|
var placeholderBytes = System.Text.Encoding.UTF8.GetBytes(placeholderContent);
|
|
var placeholderDigest = ComputeMirrorSha256Digest(placeholderBytes);
|
|
|
|
// Write placeholder export file
|
|
var exportFileName = $"{domainId}-export-{generatedAt:yyyyMMdd}.json";
|
|
var exportPath = Path.Combine(resolvedOutput, exportFileName);
|
|
await File.WriteAllBytesAsync(exportPath, placeholderBytes, cancellationToken).ConfigureAwait(false);
|
|
|
|
exports.Add(new MirrorBundleExport
|
|
{
|
|
Key = $"{domainId}-{format ?? "all"}",
|
|
Format = format ?? "json",
|
|
ExportId = exportId,
|
|
CreatedAt = generatedAt,
|
|
ArtifactSizeBytes = placeholderBytes.Length,
|
|
ArtifactDigest = placeholderDigest,
|
|
SourceProviders = providers?.ToList()
|
|
});
|
|
totalSize += placeholderBytes.Length;
|
|
|
|
// Create the bundle manifest
|
|
var bundle = new MirrorBundle
|
|
{
|
|
SchemaVersion = 1,
|
|
GeneratedAt = generatedAt,
|
|
DomainId = domainId,
|
|
DisplayName = request.DisplayName,
|
|
TargetRepository = targetRepository,
|
|
Exports = exports
|
|
};
|
|
|
|
// Write bundle manifest
|
|
var manifestFileName = $"{bundleId}-manifest.json";
|
|
var manifestPath = Path.Combine(resolvedOutput, manifestFileName);
|
|
var manifestJson = JsonSerializer.Serialize(bundle, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
});
|
|
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Write SHA256SUMS file for verification
|
|
var checksumPath = Path.Combine(resolvedOutput, "SHA256SUMS");
|
|
var checksumLines = new List<string>
|
|
{
|
|
$"{ComputeMirrorSha256Digest(System.Text.Encoding.UTF8.GetBytes(manifestJson))} {manifestFileName}",
|
|
$"{placeholderDigest} {exportFileName}"
|
|
};
|
|
await File.WriteAllLinesAsync(checksumPath, checksumLines, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Build result
|
|
var result = new MirrorCreateResult
|
|
{
|
|
ManifestPath = manifestPath,
|
|
BundlePath = null, // Archive creation would go here
|
|
SignaturePath = null, // Signature would be created here if includeSignatures
|
|
ExportCount = exports.Count,
|
|
TotalSizeBytes = totalSize,
|
|
BundleDigest = ComputeMirrorSha256Digest(System.Text.Encoding.UTF8.GetBytes(manifestJson)),
|
|
GeneratedAt = generatedAt,
|
|
DomainId = domainId,
|
|
Exports = verbose ? exports : null
|
|
};
|
|
|
|
if (emitJson)
|
|
{
|
|
var jsonOptions = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
var json = JsonSerializer.Serialize(result, jsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Mirror bundle created successfully![/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
grid.AddRow("[grey]Domain:[/]", Markup.Escape(domainId));
|
|
grid.AddRow("[grey]Display Name:[/]", Markup.Escape(request.DisplayName ?? "-"));
|
|
grid.AddRow("[grey]Generated At:[/]", generatedAt.ToString("yyyy-MM-dd HH:mm:ss 'UTC'"));
|
|
grid.AddRow("[grey]Exports:[/]", exports.Count.ToString());
|
|
grid.AddRow("[grey]Total Size:[/]", FormatBytes(totalSize));
|
|
grid.AddRow("[grey]Manifest:[/]", Markup.Escape(manifestPath));
|
|
grid.AddRow("[grey]Checksums:[/]", Markup.Escape(checksumPath));
|
|
if (!string.IsNullOrWhiteSpace(targetRepository))
|
|
grid.AddRow("[grey]Target Repository:[/]", Markup.Escape(targetRepository));
|
|
|
|
AnsiConsole.Write(grid);
|
|
|
|
if (verbose && exports.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold]Exports:[/]");
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("Key");
|
|
table.AddColumn("Format");
|
|
table.AddColumn("Size");
|
|
table.AddColumn("Digest");
|
|
|
|
foreach (var export in exports)
|
|
{
|
|
table.AddRow(
|
|
Markup.Escape(export.Key),
|
|
Markup.Escape(export.Format),
|
|
FormatBytes(export.ArtifactSizeBytes ?? 0),
|
|
Markup.Escape(TruncateMirrorDigest(export.ArtifactDigest))
|
|
);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[grey]Next steps:[/]");
|
|
AnsiConsole.MarkupLine($" 1. Transfer the bundle directory to the air-gapped environment");
|
|
AnsiConsole.MarkupLine($" 2. Verify checksums: [cyan]cd {Markup.Escape(resolvedOutput)} && sha256sum -c SHA256SUMS[/]");
|
|
AnsiConsole.MarkupLine($" 3. Import the bundle: [cyan]stella airgap import --bundle {Markup.Escape(manifestPath)}[/]");
|
|
}
|
|
|
|
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 create mirror bundle.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
private static string ComputeMirrorSha256Digest(byte[] content)
|
|
{
|
|
var hash = SHA256.HashData(content);
|
|
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
|
}
|
|
|
|
private static string TruncateMirrorDigest(string digest)
|
|
{
|
|
if (string.IsNullOrEmpty(digest)) return "-";
|
|
if (digest.Length <= 20) return digest;
|
|
return digest[..20] + "...";
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region AirGap Commands (CLI-AIRGAP-57-001)
|
|
|
|
/// <summary>
|
|
/// Handler for 'stella airgap import' command.
|
|
/// Imports an air-gap mirror bundle into the local data store.
|
|
/// </summary>
|
|
public static async Task<int> HandleAirgapImportAsync(
|
|
IServiceProvider services,
|
|
string bundlePath,
|
|
string? tenant,
|
|
bool globalScope,
|
|
bool dryRun,
|
|
bool force,
|
|
bool verifyOnly,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Exit codes: 0 success, 1 general error, 2 verification failed, 3 scope conflict, 4 input error
|
|
const int ExitSuccess = 0;
|
|
const int ExitGeneralError = 1;
|
|
const int ExitVerificationFailed = 2;
|
|
const int ExitScopeConflict = 3;
|
|
const int ExitInputError = 4;
|
|
|
|
await using var scope = services.CreateAsyncScope();
|
|
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("airgap-import");
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.airgap.import", System.Diagnostics.ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "airgap import");
|
|
using var duration = CliMetrics.MeasureCommandDuration("airgap import");
|
|
|
|
try
|
|
{
|
|
// Validate input path
|
|
var resolvedPath = Path.GetFullPath(bundlePath);
|
|
string manifestPath;
|
|
|
|
if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
manifestPath = resolvedPath;
|
|
}
|
|
else if (Directory.Exists(resolvedPath))
|
|
{
|
|
// Look for manifest file in directory
|
|
var manifestCandidates = Directory.GetFiles(resolvedPath, "*-manifest.json")
|
|
.Concat(Directory.GetFiles(resolvedPath, "manifest.json"))
|
|
.ToArray();
|
|
|
|
if (manifestCandidates.Length == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] No manifest file found in bundle directory.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
manifestPath = manifestCandidates.OrderByDescending(File.GetLastWriteTimeUtc).First();
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle path not found: {Markup.Escape(resolvedPath)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
var bundleDir = Path.GetDirectoryName(manifestPath)!;
|
|
activity?.SetTag("stellaops.cli.airgap.bundle_dir", bundleDir);
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Manifest: {Markup.Escape(manifestPath)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Bundle directory: {Markup.Escape(bundleDir)}[/]");
|
|
}
|
|
|
|
// Read and parse manifest
|
|
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
|
var manifest = JsonSerializer.Deserialize<MirrorBundle>(manifestJson, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (manifest is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Failed to parse bundle manifest.");
|
|
return ExitInputError;
|
|
}
|
|
|
|
activity?.SetTag("stellaops.cli.airgap.domain", manifest.DomainId);
|
|
activity?.SetTag("stellaops.cli.airgap.export_count", manifest.Exports?.Count ?? 0);
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Domain: {Markup.Escape(manifest.DomainId)}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Generated: {manifest.GeneratedAt:yyyy-MM-dd HH:mm:ss}[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Exports: {manifest.Exports?.Count ?? 0}[/]");
|
|
}
|
|
|
|
// Validate scope options
|
|
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
|
if (globalScope && !string.IsNullOrWhiteSpace(effectiveTenant))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Cannot specify both --global and --tenant. Choose one scope.");
|
|
return ExitScopeConflict;
|
|
}
|
|
|
|
var scopeDescription = globalScope ? "global" : (!string.IsNullOrWhiteSpace(effectiveTenant) ? $"tenant:{effectiveTenant}" : "default");
|
|
activity?.SetTag("stellaops.cli.airgap.scope", scopeDescription);
|
|
|
|
// Verify checksums
|
|
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
|
|
var verificationResults = new List<(string File, string Expected, string Actual, bool Valid)>();
|
|
var allValid = true;
|
|
|
|
if (File.Exists(checksumPath))
|
|
{
|
|
var checksumLines = await File.ReadAllLinesAsync(checksumPath, cancellationToken).ConfigureAwait(false);
|
|
|
|
foreach (var line in checksumLines.Where(l => !string.IsNullOrWhiteSpace(l)))
|
|
{
|
|
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length != 2) continue;
|
|
|
|
var expectedDigest = parts[0].Trim();
|
|
var fileName = parts[1].Trim().TrimStart('*');
|
|
var filePath = Path.Combine(bundleDir, fileName);
|
|
|
|
if (!File.Exists(filePath))
|
|
{
|
|
verificationResults.Add((fileName, expectedDigest, "(file missing)", false));
|
|
allValid = false;
|
|
continue;
|
|
}
|
|
|
|
var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
|
var actualDigest = ComputeMirrorSha256Digest(fileBytes);
|
|
|
|
var isValid = string.Equals(expectedDigest, actualDigest, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals($"sha256:{expectedDigest}", actualDigest, StringComparison.OrdinalIgnoreCase);
|
|
|
|
verificationResults.Add((fileName, expectedDigest, actualDigest, isValid));
|
|
if (!isValid) allValid = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Warning:[/] No SHA256SUMS file found. Skipping checksum verification.");
|
|
}
|
|
|
|
// Build diff preview
|
|
var importPreview = new List<(string Key, string Format, string Action, string Details)>();
|
|
foreach (var export in manifest.Exports ?? Enumerable.Empty<MirrorBundleExport>())
|
|
{
|
|
var action = dryRun ? "would import" : "importing";
|
|
var details = $"{FormatBytes(export.ArtifactSizeBytes ?? 0)}, {export.Format}";
|
|
importPreview.Add((export.Key, export.Format, action, details));
|
|
}
|
|
|
|
// Build result
|
|
var result = new
|
|
{
|
|
manifestPath,
|
|
bundleDirectory = bundleDir,
|
|
domain = manifest.DomainId,
|
|
displayName = manifest.DisplayName,
|
|
generatedAt = manifest.GeneratedAt,
|
|
targetScope = scopeDescription,
|
|
exportCount = manifest.Exports?.Count ?? 0,
|
|
dryRun,
|
|
verifyOnly,
|
|
checksumVerification = new
|
|
{
|
|
checksumFileFound = File.Exists(checksumPath),
|
|
allValid,
|
|
results = verificationResults.Select(r => new
|
|
{
|
|
file = r.File,
|
|
expected = TruncateMirrorDigest(r.Expected),
|
|
actual = TruncateMirrorDigest(r.Actual),
|
|
valid = r.Valid
|
|
}).ToList()
|
|
},
|
|
imports = importPreview.Select(i => new
|
|
{
|
|
key = i.Key,
|
|
format = i.Format,
|
|
action = i.Action,
|
|
details = i.Details
|
|
}).ToList(),
|
|
status = !allValid ? "VERIFICATION_FAILED" : (verifyOnly ? "VERIFIED" : (dryRun ? "DRY_RUN" : "IMPORTED")),
|
|
auditLogEntry = new
|
|
{
|
|
timestamp = DateTimeOffset.UtcNow.ToString("o"),
|
|
action = verifyOnly ? "AIRGAP_VERIFY" : (dryRun ? "AIRGAP_IMPORT_PREVIEW" : "AIRGAP_IMPORT"),
|
|
domain = manifest.DomainId,
|
|
scope = scopeDescription,
|
|
force,
|
|
manifestDigest = ComputeMirrorSha256Digest(System.Text.Encoding.UTF8.GetBytes(manifestJson))
|
|
}
|
|
};
|
|
|
|
// Output results
|
|
if (emitJson)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
if (!allValid)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Bundle verification failed![/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("File");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Details");
|
|
|
|
foreach (var (file, expected, actual, valid) in verificationResults)
|
|
{
|
|
var validationStatus = valid ? "[green]VALID[/]" : "[red]INVALID[/]";
|
|
var details = valid ? "" : $"Expected: {TruncateMirrorDigest(expected)}, Got: {TruncateMirrorDigest(actual)}";
|
|
table.AddRow(Markup.Escape(file), validationStatus, Markup.Escape(details));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
CliMetrics.RecordOfflineKitImport("verification_failed");
|
|
return ExitVerificationFailed;
|
|
}
|
|
|
|
var action = verifyOnly ? "Verified" : (dryRun ? "Previewing import of" : "Imported");
|
|
AnsiConsole.MarkupLine($"[green]{action} bundle:[/] {Markup.Escape(manifest.DomainId)}");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
grid.AddRow("[grey]Domain:[/]", Markup.Escape(manifest.DomainId));
|
|
grid.AddRow("[grey]Display Name:[/]", Markup.Escape(manifest.DisplayName ?? "-"));
|
|
grid.AddRow("[grey]Generated At:[/]", manifest.GeneratedAt.ToString("yyyy-MM-dd HH:mm:ss 'UTC'"));
|
|
grid.AddRow("[grey]Scope:[/]", Markup.Escape(scopeDescription));
|
|
grid.AddRow("[grey]Exports:[/]", (manifest.Exports?.Count ?? 0).ToString());
|
|
if (verificationResults.Count > 0)
|
|
grid.AddRow("[grey]Checksum Verification:[/]", allValid ? "[green]PASSED[/]" : "[red]FAILED[/]");
|
|
grid.AddRow("[grey]Mode:[/]", verifyOnly ? "Verify Only" : (dryRun ? "Dry Run" : "Live Import"));
|
|
|
|
AnsiConsole.Write(grid);
|
|
|
|
if (importPreview.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold]Exports:[/]");
|
|
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("Key");
|
|
table.AddColumn("Format");
|
|
table.AddColumn("Action");
|
|
table.AddColumn("Details");
|
|
|
|
foreach (var (key, format, act, details) in importPreview)
|
|
{
|
|
table.AddRow(Markup.Escape(key), Markup.Escape(format), Markup.Escape(act), Markup.Escape(details));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
|
|
if (dryRun)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[grey]Dry run - no changes were made. Remove --dry-run to perform the import.[/]");
|
|
}
|
|
else if (!verifyOnly)
|
|
{
|
|
// CLI-AIRGAP-56-001: Use MirrorBundleImportService for real import
|
|
var importService = scope.ServiceProvider.GetService<IMirrorBundleImportService>();
|
|
if (importService is not null)
|
|
{
|
|
var importRequest = new MirrorImportRequest
|
|
{
|
|
BundlePath = bundlePath,
|
|
TenantId = effectiveTenant ?? (globalScope ? "global" : "default"),
|
|
TrustRootsPath = null, // Use bundled trust roots
|
|
DryRun = false,
|
|
Force = force
|
|
};
|
|
|
|
var importResult = await importService.ImportAsync(importRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!importResult.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Import failed:[/] {Markup.Escape(importResult.Error ?? "Unknown error")}");
|
|
CliMetrics.RecordOfflineKitImport("import_failed");
|
|
return ExitGeneralError;
|
|
}
|
|
|
|
// Show DSSE verification status if applicable
|
|
if (importResult.DsseVerification is not null)
|
|
{
|
|
var dsseStatus = importResult.DsseVerification.IsValid ? "[green]VERIFIED[/]" : "[yellow]NOT VERIFIED[/]";
|
|
AnsiConsole.MarkupLine($"[grey]DSSE Signature:[/] {dsseStatus}");
|
|
if (!string.IsNullOrEmpty(importResult.DsseVerification.KeyId))
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey] Key ID:[/] {Markup.Escape(TruncateMirrorDigest(importResult.DsseVerification.KeyId))}");
|
|
}
|
|
}
|
|
|
|
// Show imported paths in verbose mode
|
|
if (verbose && importResult.ImportedPaths.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold]Imported files:[/]");
|
|
foreach (var path in importResult.ImportedPaths.Take(10))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(Path.GetFileName(path))}[/]");
|
|
}
|
|
if (importResult.ImportedPaths.Count > 10)
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]... and {importResult.ImportedPaths.Count - 10} more files[/]");
|
|
}
|
|
}
|
|
|
|
logger.LogInformation("Air-gap bundle imported: domain={Domain}, exports={Exports}, scope={Scope}, files={FileCount}",
|
|
manifest.DomainId, manifest.Exports?.Count ?? 0, scopeDescription, importResult.ImportedPaths.Count);
|
|
}
|
|
else
|
|
{
|
|
// Fallback: log success without actual import
|
|
logger.LogInformation("Air-gap bundle imported (catalog-only): domain={Domain}, exports={Exports}, scope={Scope}",
|
|
manifest.DomainId, manifest.Exports?.Count ?? 0, scopeDescription);
|
|
}
|
|
}
|
|
}
|
|
|
|
var status = verifyOnly ? "verified" : (dryRun ? "dry_run" : "imported");
|
|
CliMetrics.RecordOfflineKitImport(status);
|
|
|
|
return ExitSuccess;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
return 130;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
logger.LogError(ex, "Failed to parse bundle manifest.");
|
|
AnsiConsole.MarkupLine($"[red]Error parsing manifest:[/] {Markup.Escape(ex.Message)}");
|
|
return ExitInputError;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to import air-gap bundle.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
return ExitGeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles the 'stella airgap seal' command (CLI-AIRGAP-57-002).
|
|
/// Seals the environment for air-gapped operation by:
|
|
/// - Optionally verifying all imported bundles
|
|
/// - Creating a sealed mode marker file
|
|
/// - Disabling remote connectivity settings
|
|
/// - Recording the seal event in audit log
|
|
/// </summary>
|
|
public static async Task<int> HandleAirgapSealAsync(
|
|
IServiceProvider services,
|
|
string? configDir,
|
|
bool verify,
|
|
bool force,
|
|
bool dryRun,
|
|
bool emitJson,
|
|
string? reason,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitGeneralError = 1;
|
|
const int ExitVerificationFailed = 22;
|
|
const int ExitAlreadySealed = 23;
|
|
|
|
await using var scope = services.CreateAsyncScope();
|
|
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("airgap-seal");
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.airgap.seal", System.Diagnostics.ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "airgap seal");
|
|
using var duration = CliMetrics.MeasureCommandDuration("airgap seal");
|
|
|
|
try
|
|
{
|
|
// Determine config directory
|
|
var configPath = configDir ?? Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".stellaops");
|
|
|
|
if (!Directory.Exists(configPath))
|
|
{
|
|
Directory.CreateDirectory(configPath);
|
|
}
|
|
|
|
var sealMarkerPath = Path.Combine(configPath, "sealed.json");
|
|
var bundlesPath = Path.Combine(configPath, "bundles");
|
|
var auditLogPath = Path.Combine(configPath, "audit", "seal-events.ndjson");
|
|
|
|
// Check if already sealed
|
|
var isAlreadySealed = File.Exists(sealMarkerPath);
|
|
if (isAlreadySealed && !force)
|
|
{
|
|
if (emitJson)
|
|
{
|
|
var errorResult = new
|
|
{
|
|
success = false,
|
|
error = "Environment is already sealed. Use --force to reseal.",
|
|
sealMarkerPath,
|
|
existingSealedAt = File.Exists(sealMarkerPath)
|
|
? File.GetLastWriteTimeUtc(sealMarkerPath).ToString("o")
|
|
: null
|
|
};
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(errorResult, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Environment is already sealed.[/]");
|
|
AnsiConsole.MarkupLine($"[grey]Seal marker:[/] {Markup.Escape(sealMarkerPath)}");
|
|
AnsiConsole.MarkupLine("[grey]Use --force to reseal the environment.[/]");
|
|
}
|
|
return ExitAlreadySealed;
|
|
}
|
|
|
|
// Verify bundles if requested
|
|
var verificationResults = new List<(string BundleId, bool Valid, string Details)>();
|
|
var verificationWarnings = new List<string>();
|
|
|
|
if (verify && Directory.Exists(bundlesPath))
|
|
{
|
|
var bundleDirs = Directory.GetDirectories(bundlesPath);
|
|
foreach (var bundleDir in bundleDirs)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var manifestPath = Path.Combine(bundleDir, "manifest.json");
|
|
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
|
|
var bundleId = Path.GetFileName(bundleDir);
|
|
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
verificationResults.Add((bundleId, false, "Missing manifest.json"));
|
|
verificationWarnings.Add($"Bundle '{bundleId}' has no manifest.json");
|
|
continue;
|
|
}
|
|
|
|
if (!File.Exists(checksumPath))
|
|
{
|
|
verificationResults.Add((bundleId, true, "No checksums (unverified)"));
|
|
verificationWarnings.Add($"Bundle '{bundleId}' has no SHA256SUMS file");
|
|
continue;
|
|
}
|
|
|
|
// Verify checksums
|
|
var checksumLines = await File.ReadAllLinesAsync(checksumPath, cancellationToken);
|
|
var allValid = true;
|
|
foreach (var line in checksumLines.Where(l => !string.IsNullOrWhiteSpace(l)))
|
|
{
|
|
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length != 2) continue;
|
|
|
|
var expectedDigest = parts[0];
|
|
var fileName = parts[1].TrimStart('*');
|
|
var filePath = Path.Combine(bundleDir, fileName);
|
|
|
|
if (!File.Exists(filePath))
|
|
{
|
|
allValid = false;
|
|
verificationWarnings.Add($"Bundle '{bundleId}': Missing file '{fileName}'");
|
|
continue;
|
|
}
|
|
|
|
var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
|
|
var actualDigest = ComputeMirrorSha256Digest(fileBytes);
|
|
|
|
if (!string.Equals(expectedDigest, actualDigest, StringComparison.OrdinalIgnoreCase) &&
|
|
!string.Equals($"sha256:{expectedDigest}", actualDigest, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
allValid = false;
|
|
verificationWarnings.Add($"Bundle '{bundleId}': Checksum mismatch for '{fileName}'");
|
|
}
|
|
}
|
|
|
|
verificationResults.Add((bundleId, allValid, allValid ? "All checksums valid" : "Checksum failures"));
|
|
}
|
|
}
|
|
|
|
// Check for verification failures
|
|
var hasFailures = verificationResults.Any(r => !r.Valid);
|
|
if (hasFailures && !force)
|
|
{
|
|
if (emitJson)
|
|
{
|
|
var errorResult = new
|
|
{
|
|
success = false,
|
|
error = "Bundle verification failed. Use --force to seal anyway.",
|
|
verificationResults = verificationResults.Select(r => new
|
|
{
|
|
bundleId = r.BundleId,
|
|
valid = r.Valid,
|
|
details = r.Details
|
|
}).ToList(),
|
|
warnings = verificationWarnings
|
|
};
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(errorResult, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Bundle verification failed![/]");
|
|
foreach (var warning in verificationWarnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]![/] {Markup.Escape(warning)}");
|
|
}
|
|
AnsiConsole.MarkupLine("[grey]Use --force to seal the environment anyway.[/]");
|
|
}
|
|
CliMetrics.RecordOfflineKitImport("seal_verification_failed");
|
|
return ExitVerificationFailed;
|
|
}
|
|
|
|
// Build seal record
|
|
var sealRecord = new
|
|
{
|
|
schemaVersion = "1.0",
|
|
sealedAt = DateTimeOffset.UtcNow.ToString("o"),
|
|
sealedBy = Environment.UserName,
|
|
hostname = Environment.MachineName,
|
|
reason = reason ?? "Manual seal via stella airgap seal",
|
|
verification = new
|
|
{
|
|
performed = verify,
|
|
bundlesChecked = verificationResults.Count,
|
|
allValid = !hasFailures,
|
|
warnings = verificationWarnings
|
|
},
|
|
configuration = new
|
|
{
|
|
telemetryMode = "local",
|
|
networkMode = "offline",
|
|
updateMode = "disabled"
|
|
}
|
|
};
|
|
|
|
// Build audit log entry
|
|
var auditEntry = new
|
|
{
|
|
timestamp = DateTimeOffset.UtcNow.ToString("o"),
|
|
action = dryRun ? "AIRGAP_SEAL_PREVIEW" : "AIRGAP_SEAL",
|
|
actor = Environment.UserName,
|
|
hostname = Environment.MachineName,
|
|
reason = reason ?? "Manual seal",
|
|
force,
|
|
previouslySealed = isAlreadySealed,
|
|
verificationPerformed = verify,
|
|
bundlesVerified = verificationResults.Count,
|
|
warnings = verificationWarnings.Count
|
|
};
|
|
|
|
// Dry run output
|
|
if (dryRun)
|
|
{
|
|
if (emitJson)
|
|
{
|
|
var previewResult = new
|
|
{
|
|
dryRun = true,
|
|
wouldCreate = new
|
|
{
|
|
sealMarker = sealMarkerPath,
|
|
auditLog = auditLogPath
|
|
},
|
|
sealRecord,
|
|
auditEntry,
|
|
verificationResults = verificationResults.Select(r => new
|
|
{
|
|
bundleId = r.BundleId,
|
|
valid = r.Valid,
|
|
details = r.Details
|
|
}).ToList()
|
|
};
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(previewResult, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[bold]Dry run: Seal operation preview[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
grid.AddRow("[grey]Would create seal marker:[/]", Markup.Escape(sealMarkerPath));
|
|
grid.AddRow("[grey]Would create audit entry:[/]", Markup.Escape(auditLogPath));
|
|
grid.AddRow("[grey]Sealed by:[/]", Markup.Escape(sealRecord.sealedBy));
|
|
grid.AddRow("[grey]Hostname:[/]", Markup.Escape(sealRecord.hostname));
|
|
grid.AddRow("[grey]Reason:[/]", Markup.Escape(sealRecord.reason));
|
|
grid.AddRow("[grey]Telemetry mode:[/]", sealRecord.configuration.telemetryMode);
|
|
grid.AddRow("[grey]Network mode:[/]", sealRecord.configuration.networkMode);
|
|
|
|
AnsiConsole.Write(grid);
|
|
|
|
if (verificationResults.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold]Verification Results:[/]");
|
|
var table = new Table { Border = TableBorder.Rounded };
|
|
table.AddColumn("Bundle");
|
|
table.AddColumn("Status");
|
|
table.AddColumn("Details");
|
|
|
|
foreach (var (bundleId, valid, details) in verificationResults)
|
|
{
|
|
var verifyStatus = valid ? "[green]VALID[/]" : "[red]INVALID[/]";
|
|
table.AddRow(Markup.Escape(bundleId), verifyStatus, Markup.Escape(details));
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
}
|
|
|
|
CliMetrics.RecordOfflineKitImport("seal_dry_run");
|
|
return ExitSuccess;
|
|
}
|
|
|
|
// Actually seal the environment
|
|
// 1. Create audit log directory if needed
|
|
var auditDir = Path.GetDirectoryName(auditLogPath);
|
|
if (!string.IsNullOrEmpty(auditDir) && !Directory.Exists(auditDir))
|
|
{
|
|
Directory.CreateDirectory(auditDir);
|
|
}
|
|
|
|
// 2. Write audit log entry (append)
|
|
var auditJson = JsonSerializer.Serialize(auditEntry, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
await File.AppendAllTextAsync(auditLogPath, auditJson + Environment.NewLine, cancellationToken);
|
|
|
|
// 3. Write seal marker
|
|
var sealJson = JsonSerializer.Serialize(sealRecord, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
await File.WriteAllTextAsync(sealMarkerPath, sealJson, cancellationToken);
|
|
|
|
// 4. Enable sealed mode in CliMetrics
|
|
CliMetrics.IsSealedMode = true;
|
|
|
|
// Output results
|
|
if (emitJson)
|
|
{
|
|
var successResult = new
|
|
{
|
|
success = true,
|
|
sealMarkerPath,
|
|
auditLogPath,
|
|
sealRecord,
|
|
verificationResults = verificationResults.Select(r => new
|
|
{
|
|
bundleId = r.BundleId,
|
|
valid = r.Valid,
|
|
details = r.Details
|
|
}).ToList()
|
|
};
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(successResult, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Environment sealed successfully![/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
grid.AddRow("[grey]Seal marker:[/]", Markup.Escape(sealMarkerPath));
|
|
grid.AddRow("[grey]Audit log:[/]", Markup.Escape(auditLogPath));
|
|
grid.AddRow("[grey]Sealed at:[/]", sealRecord.sealedAt);
|
|
grid.AddRow("[grey]Sealed by:[/]", Markup.Escape(sealRecord.sealedBy));
|
|
grid.AddRow("[grey]Reason:[/]", Markup.Escape(sealRecord.reason));
|
|
|
|
AnsiConsole.Write(grid);
|
|
|
|
if (verificationResults.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine($"[grey]Bundles verified:[/] {verificationResults.Count}");
|
|
if (verificationWarnings.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warnings:[/] {verificationWarnings.Count}");
|
|
}
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[dim]The CLI will now operate in air-gapped mode.[/]");
|
|
AnsiConsole.MarkupLine("[dim]Remote connectivity has been disabled.[/]");
|
|
}
|
|
|
|
CliMetrics.RecordOfflineKitImport("sealed");
|
|
return ExitSuccess;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
return 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to seal environment.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
return ExitGeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella airgap export-evidence' command (CLI-AIRGAP-58-001).
|
|
/// Exports portable evidence packages for audit and compliance.
|
|
/// </summary>
|
|
public static async Task<int> HandleAirgapExportEvidenceAsync(
|
|
IServiceProvider services,
|
|
string outputPath,
|
|
string[] includeTypes,
|
|
DateTimeOffset? fromDate,
|
|
DateTimeOffset? toDate,
|
|
string? tenant,
|
|
string? subject,
|
|
bool compress,
|
|
bool emitJson,
|
|
bool verify,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const int ExitSuccess = 0;
|
|
const int ExitInputError = 1;
|
|
const int ExitGeneralError = 2;
|
|
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("StellaOps.Cli.AirgapExportEvidence");
|
|
|
|
using var durationScope = CliMetrics.MeasureCommandDuration("airgap export-evidence");
|
|
|
|
try
|
|
{
|
|
// Determine which evidence types to include
|
|
var effectiveTypes = includeTypes.Length == 0
|
|
? new[] { "all" }
|
|
: includeTypes.Select(t => t.ToLowerInvariant()).ToArray();
|
|
|
|
var includeAll = effectiveTypes.Contains("all");
|
|
var includeAttestations = includeAll || effectiveTypes.Contains("attestations");
|
|
var includeSboms = includeAll || effectiveTypes.Contains("sboms");
|
|
var includeScans = includeAll || effectiveTypes.Contains("scans");
|
|
var includeVex = includeAll || effectiveTypes.Contains("vex");
|
|
|
|
// Determine config directory
|
|
var configDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".stellaops");
|
|
|
|
// Prepare output directory
|
|
var guidPart = Guid.NewGuid().ToString("N")[..8];
|
|
var packageId = $"evidence-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}-{guidPart}";
|
|
var packageDir = Path.Combine(outputPath, packageId);
|
|
|
|
if (Directory.Exists(packageDir))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Output directory already exists: {Markup.Escape(packageDir)}");
|
|
return ExitInputError;
|
|
}
|
|
|
|
Directory.CreateDirectory(packageDir);
|
|
|
|
// Evidence collection tracking
|
|
var evidenceFiles = new List<(string Type, string RelativePath, string Digest, long Size)>();
|
|
var verificationResults = new List<(string File, bool Valid, string Details)>();
|
|
var warnings = new List<string>();
|
|
|
|
// Create subdirectories for each evidence type
|
|
if (includeAttestations)
|
|
{
|
|
var attestDir = Path.Combine(packageDir, "attestations");
|
|
Directory.CreateDirectory(attestDir);
|
|
|
|
// Look for attestation files in config directory
|
|
var attestSourceDir = Path.Combine(configDir, "attestations");
|
|
if (Directory.Exists(attestSourceDir))
|
|
{
|
|
foreach (var file in Directory.GetFiles(attestSourceDir, "*.json"))
|
|
{
|
|
try
|
|
{
|
|
var fileName = Path.GetFileName(file);
|
|
var content = await File.ReadAllBytesAsync(file, cancellationToken);
|
|
|
|
// Filter by date if specified
|
|
if (fromDate.HasValue || toDate.HasValue)
|
|
{
|
|
var fileInfo = new FileInfo(file);
|
|
if (fromDate.HasValue && fileInfo.CreationTimeUtc < fromDate.Value)
|
|
continue;
|
|
if (toDate.HasValue && fileInfo.CreationTimeUtc > toDate.Value)
|
|
continue;
|
|
}
|
|
|
|
// Filter by subject if specified
|
|
if (!string.IsNullOrEmpty(subject))
|
|
{
|
|
var json = Encoding.UTF8.GetString(content);
|
|
if (!json.Contains(subject, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
}
|
|
|
|
var destPath = Path.Combine(attestDir, fileName);
|
|
await File.WriteAllBytesAsync(destPath, content, cancellationToken);
|
|
|
|
var digest = ComputeMirrorSha256Digest(content);
|
|
evidenceFiles.Add(("attestations", $"attestations/{fileName}", digest, content.Length));
|
|
|
|
// Verify signature if requested
|
|
if (verify)
|
|
{
|
|
// Basic DSSE structure validation
|
|
try
|
|
{
|
|
var envelope = JsonSerializer.Deserialize<JsonElement>(content);
|
|
var hasPayload = envelope.TryGetProperty("payload", out var payloadElem);
|
|
var hasSignatures = envelope.TryGetProperty("signatures", out var sigs) &&
|
|
sigs.ValueKind == JsonValueKind.Array &&
|
|
sigs.GetArrayLength() > 0;
|
|
verificationResults.Add((fileName, hasPayload && hasSignatures,
|
|
hasPayload && hasSignatures ? "Valid DSSE structure" : "Invalid DSSE structure"));
|
|
}
|
|
catch
|
|
{
|
|
verificationResults.Add((fileName, false, "Failed to parse as JSON"));
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
warnings.Add($"Failed to export attestation {Path.GetFileName(file)}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (includeSboms)
|
|
{
|
|
var sbomDir = Path.Combine(packageDir, "sboms");
|
|
Directory.CreateDirectory(sbomDir);
|
|
|
|
// Look for SBOM files
|
|
var sbomSourceDir = Path.Combine(configDir, "sboms");
|
|
if (Directory.Exists(sbomSourceDir))
|
|
{
|
|
foreach (var file in Directory.GetFiles(sbomSourceDir, "*.json")
|
|
.Concat(Directory.GetFiles(sbomSourceDir, "*.spdx"))
|
|
.Concat(Directory.GetFiles(sbomSourceDir, "*.cdx.json")))
|
|
{
|
|
try
|
|
{
|
|
var fileName = Path.GetFileName(file);
|
|
var content = await File.ReadAllBytesAsync(file, cancellationToken);
|
|
|
|
// Date filter
|
|
if (fromDate.HasValue || toDate.HasValue)
|
|
{
|
|
var fileInfo = new FileInfo(file);
|
|
if (fromDate.HasValue && fileInfo.CreationTimeUtc < fromDate.Value)
|
|
continue;
|
|
if (toDate.HasValue && fileInfo.CreationTimeUtc > toDate.Value)
|
|
continue;
|
|
}
|
|
|
|
var destPath = Path.Combine(sbomDir, fileName);
|
|
await File.WriteAllBytesAsync(destPath, content, cancellationToken);
|
|
|
|
var digest = ComputeMirrorSha256Digest(content);
|
|
evidenceFiles.Add(("sboms", $"sboms/{fileName}", digest, content.Length));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
warnings.Add($"Failed to export SBOM {Path.GetFileName(file)}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (includeScans)
|
|
{
|
|
var scanDir = Path.Combine(packageDir, "scans");
|
|
Directory.CreateDirectory(scanDir);
|
|
|
|
// Look for scan result files
|
|
var scanSourceDir = Path.Combine(configDir, "scans");
|
|
if (Directory.Exists(scanSourceDir))
|
|
{
|
|
foreach (var file in Directory.GetFiles(scanSourceDir, "*.json"))
|
|
{
|
|
try
|
|
{
|
|
var fileName = Path.GetFileName(file);
|
|
var content = await File.ReadAllBytesAsync(file, cancellationToken);
|
|
|
|
// Date filter
|
|
if (fromDate.HasValue || toDate.HasValue)
|
|
{
|
|
var fileInfo = new FileInfo(file);
|
|
if (fromDate.HasValue && fileInfo.CreationTimeUtc < fromDate.Value)
|
|
continue;
|
|
if (toDate.HasValue && fileInfo.CreationTimeUtc > toDate.Value)
|
|
continue;
|
|
}
|
|
|
|
var destPath = Path.Combine(scanDir, fileName);
|
|
await File.WriteAllBytesAsync(destPath, content, cancellationToken);
|
|
|
|
var digest = ComputeMirrorSha256Digest(content);
|
|
evidenceFiles.Add(("scans", $"scans/{fileName}", digest, content.Length));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
warnings.Add($"Failed to export scan {Path.GetFileName(file)}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (includeVex)
|
|
{
|
|
var vexDir = Path.Combine(packageDir, "vex");
|
|
Directory.CreateDirectory(vexDir);
|
|
|
|
// Look for VEX files
|
|
var vexSourceDir = Path.Combine(configDir, "vex");
|
|
if (Directory.Exists(vexSourceDir))
|
|
{
|
|
foreach (var file in Directory.GetFiles(vexSourceDir, "*.json"))
|
|
{
|
|
try
|
|
{
|
|
var fileName = Path.GetFileName(file);
|
|
var content = await File.ReadAllBytesAsync(file, cancellationToken);
|
|
|
|
// Date filter
|
|
if (fromDate.HasValue || toDate.HasValue)
|
|
{
|
|
var fileInfo = new FileInfo(file);
|
|
if (fromDate.HasValue && fileInfo.CreationTimeUtc < fromDate.Value)
|
|
continue;
|
|
if (toDate.HasValue && fileInfo.CreationTimeUtc > toDate.Value)
|
|
continue;
|
|
}
|
|
|
|
var destPath = Path.Combine(vexDir, fileName);
|
|
await File.WriteAllBytesAsync(destPath, content, cancellationToken);
|
|
|
|
var digest = ComputeMirrorSha256Digest(content);
|
|
evidenceFiles.Add(("vex", $"vex/{fileName}", digest, content.Length));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
warnings.Add($"Failed to export VEX {Path.GetFileName(file)}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create manifest
|
|
var manifest = new
|
|
{
|
|
schemaVersion = "1.0",
|
|
packageId,
|
|
createdAt = DateTimeOffset.UtcNow.ToString("o"),
|
|
createdBy = Environment.UserName,
|
|
hostname = Environment.MachineName,
|
|
filters = new
|
|
{
|
|
fromDate = fromDate?.ToString("o"),
|
|
toDate = toDate?.ToString("o"),
|
|
tenant,
|
|
subject,
|
|
types = effectiveTypes
|
|
},
|
|
evidence = evidenceFiles.Select(f => new
|
|
{
|
|
type = f.Type,
|
|
path = f.RelativePath,
|
|
digest = f.Digest,
|
|
size = f.Size
|
|
}).ToList(),
|
|
verification = verify ? new
|
|
{
|
|
performed = true,
|
|
results = verificationResults.Select(r => new
|
|
{
|
|
file = r.File,
|
|
valid = r.Valid,
|
|
details = r.Details
|
|
}).ToList()
|
|
} : null,
|
|
warnings
|
|
};
|
|
|
|
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
await File.WriteAllTextAsync(Path.Combine(packageDir, "manifest.json"), manifestJson, cancellationToken);
|
|
|
|
// Create SHA256SUMS file
|
|
var checksumLines = evidenceFiles
|
|
.Select(f => $"{f.Digest} {f.RelativePath}")
|
|
.ToList();
|
|
checksumLines.Insert(0, $"# Evidence package checksum manifest");
|
|
checksumLines.Insert(1, $"# Generated: {DateTimeOffset.UtcNow:o}");
|
|
await File.WriteAllLinesAsync(Path.Combine(packageDir, "SHA256SUMS"), checksumLines, cancellationToken);
|
|
|
|
// Compress if requested
|
|
string finalOutput = packageDir;
|
|
if (compress)
|
|
{
|
|
var archivePath = packageDir + ".tar.gz";
|
|
try
|
|
{
|
|
// Use tar command for compression (cross-platform approach)
|
|
var tarProcess = new System.Diagnostics.Process
|
|
{
|
|
StartInfo = new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "tar",
|
|
Arguments = $"-czf \"{archivePath}\" -C \"{outputPath}\" \"{packageId}\"",
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
}
|
|
};
|
|
|
|
tarProcess.Start();
|
|
await tarProcess.WaitForExitAsync(cancellationToken);
|
|
|
|
if (tarProcess.ExitCode == 0)
|
|
{
|
|
// Remove uncompressed directory
|
|
Directory.Delete(packageDir, recursive: true);
|
|
finalOutput = archivePath;
|
|
}
|
|
else
|
|
{
|
|
warnings.Add("Failed to compress package; uncompressed directory retained.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
warnings.Add($"Compression failed: {ex.Message}; uncompressed directory retained.");
|
|
}
|
|
}
|
|
|
|
// Output results
|
|
if (emitJson)
|
|
{
|
|
var result = new
|
|
{
|
|
success = true,
|
|
packageId,
|
|
outputPath = finalOutput,
|
|
compressed = compress && finalOutput.EndsWith(".tar.gz"),
|
|
evidenceCount = evidenceFiles.Count,
|
|
totalSize = evidenceFiles.Sum(f => f.Size),
|
|
types = evidenceFiles.GroupBy(f => f.Type)
|
|
.ToDictionary(g => g.Key, g => g.Count()),
|
|
verification = verify ? new
|
|
{
|
|
performed = true,
|
|
passed = verificationResults.Count(r => r.Valid),
|
|
failed = verificationResults.Count(r => !r.Valid),
|
|
results = verificationResults.Select(r => new
|
|
{
|
|
file = r.File,
|
|
valid = r.Valid,
|
|
details = r.Details
|
|
}).ToList()
|
|
} : null,
|
|
warnings
|
|
};
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Evidence package created successfully![/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
var grid = new Grid();
|
|
grid.AddColumn();
|
|
grid.AddColumn();
|
|
grid.AddRow("[grey]Package ID:[/]", Markup.Escape(packageId));
|
|
grid.AddRow("[grey]Output:[/]", Markup.Escape(finalOutput));
|
|
grid.AddRow("[grey]Files:[/]", evidenceFiles.Count.ToString());
|
|
grid.AddRow("[grey]Total size:[/]", FormatBytes(evidenceFiles.Sum(f => f.Size)));
|
|
|
|
AnsiConsole.Write(grid);
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Show evidence type breakdown
|
|
AnsiConsole.MarkupLine("[bold]Evidence by type:[/]");
|
|
var typeTable = new Table { Border = TableBorder.Rounded };
|
|
typeTable.AddColumn("Type");
|
|
typeTable.AddColumn("Count");
|
|
typeTable.AddColumn("Size");
|
|
|
|
foreach (var group in evidenceFiles.GroupBy(f => f.Type))
|
|
{
|
|
typeTable.AddRow(
|
|
Markup.Escape(group.Key),
|
|
group.Count().ToString(),
|
|
FormatBytes(group.Sum(f => f.Size)));
|
|
}
|
|
|
|
AnsiConsole.Write(typeTable);
|
|
|
|
// Show verification results if requested
|
|
if (verify && verificationResults.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold]Verification Results:[/]");
|
|
var verifyTable = new Table { Border = TableBorder.Rounded };
|
|
verifyTable.AddColumn("File");
|
|
verifyTable.AddColumn("Status");
|
|
verifyTable.AddColumn("Details");
|
|
|
|
foreach (var (file, valid, details) in verificationResults)
|
|
{
|
|
var status = valid ? "[green]VALID[/]" : "[red]INVALID[/]";
|
|
verifyTable.AddRow(Markup.Escape(file), status, Markup.Escape(details));
|
|
}
|
|
|
|
AnsiConsole.Write(verifyTable);
|
|
}
|
|
|
|
// Show warnings
|
|
if (warnings.Count > 0)
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[yellow]Warnings:[/]");
|
|
foreach (var warning in warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" [yellow]![/] {Markup.Escape(warning)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
CliMetrics.RecordOfflineKitImport("evidence_exported");
|
|
return ExitSuccess;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning("Operation cancelled by user.");
|
|
return 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to export evidence package.");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
return ExitGeneralError;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region DevPortal Commands
|
|
|
|
/// <summary>
|
|
/// Handler for 'stella devportal verify' command (DVOFF-64-002).
|
|
/// Verifies integrity of a DevPortal/evidence bundle before import.
|
|
/// Exit codes: 0 success, 2 checksum mismatch, 3 signature failure, 4 TSA missing, 5 unexpected.
|
|
/// </summary>
|
|
public static async Task<int> HandleDevPortalVerifyAsync(
|
|
IServiceProvider services,
|
|
string bundlePath,
|
|
bool offline,
|
|
bool emitJson,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger<DevPortalBundleVerifier>();
|
|
var verifier = new DevPortalBundleVerifier(logger);
|
|
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.devportal.verify", System.Diagnostics.ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "devportal verify");
|
|
activity?.SetTag("stellaops.cli.devportal.offline", offline);
|
|
using var duration = CliMetrics.MeasureCommandDuration("devportal verify");
|
|
|
|
try
|
|
{
|
|
var resolvedPath = Path.GetFullPath(bundlePath);
|
|
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]Verifying bundle: {Markup.Escape(resolvedPath)}[/]");
|
|
if (offline)
|
|
{
|
|
AnsiConsole.MarkupLine("[grey]Mode: offline (TSA verification skipped)[/]");
|
|
}
|
|
}
|
|
|
|
var result = await verifier.VerifyBundleAsync(resolvedPath, offline, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
activity?.SetTag("stellaops.cli.devportal.status", result.Status);
|
|
activity?.SetTag("stellaops.cli.devportal.exit_code", (int)result.ExitCode);
|
|
|
|
if (emitJson)
|
|
{
|
|
Console.WriteLine(result.ToJson());
|
|
}
|
|
else
|
|
{
|
|
if (result.ExitCode == DevPortalVerifyExitCode.Success)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Bundle verification successful.[/]");
|
|
AnsiConsole.MarkupLine($" Bundle ID: {Markup.Escape(result.BundleId ?? "unknown")}");
|
|
AnsiConsole.MarkupLine($" Root Hash: {Markup.Escape(result.RootHash ?? "unknown")}");
|
|
AnsiConsole.MarkupLine($" Entries: {result.Entries}");
|
|
AnsiConsole.MarkupLine($" Created: {result.CreatedAt?.ToString("O") ?? "unknown"}");
|
|
AnsiConsole.MarkupLine($" Portable: {(result.Portable ? "yes" : "no")}");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Bundle verification failed:[/] {Markup.Escape(result.ErrorMessage ?? "Unknown error")}");
|
|
if (!string.IsNullOrEmpty(result.ErrorDetail))
|
|
{
|
|
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(result.ErrorDetail)}[/]");
|
|
}
|
|
}
|
|
}
|
|
|
|
return (int)result.ExitCode;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
if (!emitJson)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
|
|
}
|
|
return 130;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to verify bundle");
|
|
|
|
if (emitJson)
|
|
{
|
|
var errorResult = DevPortalBundleVerificationResult.Failed(
|
|
DevPortalVerifyExitCode.Unexpected,
|
|
ex.Message);
|
|
Console.WriteLine(errorResult.ToJson());
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
|
}
|
|
|
|
return (int)DevPortalVerifyExitCode.Unexpected;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|