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.Serialization; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; using StellaOps.Auth.Client; using StellaOps.Cli.Configuration; using StellaOps.Cli.Prompts; using StellaOps.Cli.Services; using StellaOps.Cli.Services.Models; using StellaOps.Cli.Telemetry; using StellaOps.Cryptography; namespace StellaOps.Cli.Commands; internal static class CommandHandlers { public static async Task HandleScannerDownloadAsync( IServiceProvider services, string channel, string? output, bool overwrite, bool install, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-download"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "scanner download"); activity?.SetTag("stellaops.cli.channel", channel); using var duration = CliMetrics.MeasureCommandDuration("scanner download"); try { var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false); if (result.FromCache) { logger.LogInformation("Using cached scanner at {Path}.", result.Path); } else { logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes); } CliMetrics.RecordScannerDownload(channel, result.FromCache); if (install) { var installer = scope.ServiceProvider.GetRequiredService(); await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false); CliMetrics.RecordScannerInstall(channel); } Environment.ExitCode = 0; } catch (Exception ex) { logger.LogError(ex, "Failed to download scanner bundle."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleScannerRunAsync( IServiceProvider services, string runner, string entry, string targetDirectory, IReadOnlyList arguments, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var executor = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-run"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal); activity?.SetTag("stellaops.cli.command", "scan run"); activity?.SetTag("stellaops.cli.runner", runner); activity?.SetTag("stellaops.cli.entry", entry); activity?.SetTag("stellaops.cli.target", targetDirectory); using var duration = CliMetrics.MeasureCommandDuration("scan run"); try { var options = scope.ServiceProvider.GetRequiredService(); var resultsDirectory = options.ResultsDirectory; var executionResult = await executor.RunAsync( runner, entry, targetDirectory, resultsDirectory, arguments, verbose, cancellationToken).ConfigureAwait(false); Environment.ExitCode = executionResult.ExitCode; CliMetrics.RecordScanRun(runner, executionResult.ExitCode); if (executionResult.ExitCode == 0) { var backend = scope.ServiceProvider.GetRequiredService(); logger.LogInformation("Uploading scan artefact {Path}...", executionResult.ResultsPath); await backend.UploadScanResultsAsync(executionResult.ResultsPath, cancellationToken).ConfigureAwait(false); logger.LogInformation("Scan artefact uploaded."); activity?.SetTag("stellaops.cli.results", executionResult.ResultsPath); } else { logger.LogWarning("Skipping automatic upload because scan exited with code {Code}.", executionResult.ExitCode); } logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath); activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath); } catch (Exception ex) { logger.LogError(ex, "Scanner execution failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleScanUploadAsync( IServiceProvider services, string file, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("scanner-upload"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "scan upload"); activity?.SetTag("stellaops.cli.file", file); using var duration = CliMetrics.MeasureCommandDuration("scan upload"); try { var path = Path.GetFullPath(file); await client.UploadScanResultsAsync(path, 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 HandleSourcesIngestAsync( IServiceProvider services, bool dryRun, string source, string input, string? tenantOverride, string format, bool disableColor, string? output, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("sources-ingest"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.sources.ingest.dry_run", ActivityKind.Client); var statusMetric = "unknown"; using var duration = CliMetrics.MeasureCommandDuration("sources ingest dry-run"); try { if (!dryRun) { statusMetric = "unsupported"; logger.LogError("Only --dry-run mode is supported for 'stella sources ingest' at this time."); Environment.ExitCode = 1; return; } source = source?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(source)) { throw new InvalidOperationException("Source identifier must be provided."); } var formatNormalized = string.IsNullOrWhiteSpace(format) ? "table" : format.Trim().ToLowerInvariant(); if (formatNormalized is not ("table" or "json")) { throw new InvalidOperationException("Format must be either 'table' or 'json'."); } var tenant = ResolveTenant(tenantOverride); if (string.IsNullOrWhiteSpace(tenant)) { throw new InvalidOperationException("Tenant must be provided via --tenant or STELLA_TENANT."); } var payload = await LoadIngestInputAsync(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 outputPath = Path.GetFullPath(output); var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } var jsonReport = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(outputPath, jsonReport, cancellationToken).ConfigureAwait(false); logger.LogInformation("Dry-run report written to {Path}.", outputPath); } 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 HandleConnectorJobAsync( IServiceProvider services, string source, string stage, string? mode, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-connector"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "db fetch"); activity?.SetTag("stellaops.cli.source", source); activity?.SetTag("stellaops.cli.stage", stage); if (!string.IsNullOrWhiteSpace(mode)) { activity?.SetTag("stellaops.cli.mode", mode); } using var duration = CliMetrics.MeasureCommandDuration("db fetch"); try { var jobKind = $"source:{source}:{stage}"; var parameters = new Dictionary(StringComparer.Ordinal); if (!string.IsNullOrWhiteSpace(mode)) { parameters["mode"] = mode; } await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogError(ex, "Connector job failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleMergeJobAsync( IServiceProvider services, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-merge"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "db merge"); using var duration = CliMetrics.MeasureCommandDuration("db merge"); try { await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogError(ex, "Merge job failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleExportJobAsync( IServiceProvider services, string format, bool delta, bool? publishFull, bool? publishDelta, bool? includeFull, bool? includeDelta, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("db-export"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "db export"); activity?.SetTag("stellaops.cli.format", format); activity?.SetTag("stellaops.cli.delta", delta); using var duration = CliMetrics.MeasureCommandDuration("db export"); activity?.SetTag("stellaops.cli.publish_full", publishFull); activity?.SetTag("stellaops.cli.publish_delta", publishDelta); activity?.SetTag("stellaops.cli.include_full", includeFull); activity?.SetTag("stellaops.cli.include_delta", includeDelta); try { var jobKind = format switch { "trivy-db" or "trivy" => "export:trivy-db", _ => "export:json" }; var isTrivy = jobKind == "export:trivy-db"; if (isTrivy && !publishFull.HasValue && !publishDelta.HasValue && !includeFull.HasValue && !includeDelta.HasValue && AnsiConsole.Profile.Capabilities.Interactive) { var overrides = TrivyDbExportPrompt.PromptOverrides(); publishFull = overrides.publishFull; publishDelta = overrides.publishDelta; includeFull = overrides.includeFull; includeDelta = overrides.includeDelta; } var parameters = new Dictionary(StringComparer.Ordinal) { ["delta"] = delta }; if (publishFull.HasValue) { parameters["publishFull"] = publishFull.Value; } if (publishDelta.HasValue) { parameters["publishDelta"] = publishDelta.Value; } if (includeFull.HasValue) { parameters["includeFull"] = includeFull.Value; } if (includeDelta.HasValue) { parameters["includeDelta"] = includeDelta.Value; } await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogError(ex, "Export job failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static Task HandleExcititorInitAsync( IServiceProvider services, IReadOnlyList providers, bool resume, bool verbose, CancellationToken cancellationToken) { var normalizedProviders = NormalizeProviders(providers); var payload = new Dictionary(StringComparer.Ordinal); if (normalizedProviders.Count > 0) { payload["providers"] = normalizedProviders; } if (resume) { payload["resume"] = true; } return ExecuteExcititorCommandAsync( services, commandName: "excititor init", verbose, new Dictionary { ["providers"] = normalizedProviders.Count, ["resume"] = resume }, client => client.ExecuteExcititorOperationAsync("init", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), cancellationToken); } public static Task HandleExcititorPullAsync( IServiceProvider services, IReadOnlyList providers, DateTimeOffset? since, TimeSpan? window, bool force, bool verbose, CancellationToken cancellationToken) { var normalizedProviders = NormalizeProviders(providers); var payload = new Dictionary(StringComparer.Ordinal); if (normalizedProviders.Count > 0) { payload["providers"] = normalizedProviders; } if (since.HasValue) { payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); } if (window.HasValue) { payload["window"] = window.Value.ToString("c", CultureInfo.InvariantCulture); } if (force) { payload["force"] = true; } return ExecuteExcititorCommandAsync( services, commandName: "excititor pull", verbose, new Dictionary { ["providers"] = normalizedProviders.Count, ["force"] = force, ["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), ["window"] = window?.ToString("c", CultureInfo.InvariantCulture) }, client => client.ExecuteExcititorOperationAsync("ingest/run", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), cancellationToken); } public static Task HandleExcititorResumeAsync( IServiceProvider services, IReadOnlyList providers, string? checkpoint, bool verbose, CancellationToken cancellationToken) { var normalizedProviders = NormalizeProviders(providers); var payload = new Dictionary(StringComparer.Ordinal); if (normalizedProviders.Count > 0) { payload["providers"] = normalizedProviders; } if (!string.IsNullOrWhiteSpace(checkpoint)) { payload["checkpoint"] = checkpoint.Trim(); } return ExecuteExcititorCommandAsync( services, commandName: "excititor resume", verbose, new Dictionary { ["providers"] = normalizedProviders.Count, ["checkpoint"] = checkpoint }, client => client.ExecuteExcititorOperationAsync("ingest/resume", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), cancellationToken); } public static async Task HandleExcititorListProvidersAsync( IServiceProvider services, bool includeDisabled, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("excititor-list-providers"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.list-providers", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "excititor list-providers"); activity?.SetTag("stellaops.cli.include_disabled", includeDisabled); using var duration = CliMetrics.MeasureCommandDuration("excititor list-providers"); try { var providers = await client.GetExcititorProvidersAsync(includeDisabled, cancellationToken).ConfigureAwait(false); Environment.ExitCode = 0; logger.LogInformation("Providers returned: {Count}", providers.Count); if (providers.Count > 0) { if (AnsiConsole.Profile.Capabilities.Interactive) { var table = new Table().Border(TableBorder.Rounded).AddColumns("Provider", "Kind", "Trust", "Enabled", "Last Ingested"); foreach (var provider in providers) { table.AddRow( provider.Id, provider.Kind, string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier, provider.Enabled ? "yes" : "no", provider.LastIngestedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "unknown"); } AnsiConsole.Write(table); } else { foreach (var provider in providers) { logger.LogInformation("{ProviderId} [{Kind}] Enabled={Enabled} Trust={Trust} LastIngested={LastIngested}", provider.Id, provider.Kind, provider.Enabled ? "yes" : "no", string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier, provider.LastIngestedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown"); } } } } catch (Exception ex) { logger.LogError(ex, "Failed to list Excititor providers."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleExcititorExportAsync( IServiceProvider services, string format, bool delta, string? scope, DateTimeOffset? since, string? provider, string? outputPath, bool verbose, CancellationToken cancellationToken) { await using var scopeHandle = services.CreateAsyncScope(); var client = scopeHandle.ServiceProvider.GetRequiredService(); var logger = scopeHandle.ServiceProvider.GetRequiredService().CreateLogger("excititor-export"); var options = scopeHandle.ServiceProvider.GetRequiredService(); var verbosity = scopeHandle.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.export", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "excititor export"); activity?.SetTag("stellaops.cli.format", format); activity?.SetTag("stellaops.cli.delta", delta); if (!string.IsNullOrWhiteSpace(scope)) { activity?.SetTag("stellaops.cli.scope", scope); } if (since.HasValue) { activity?.SetTag("stellaops.cli.since", since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); } if (!string.IsNullOrWhiteSpace(provider)) { activity?.SetTag("stellaops.cli.provider", provider); } if (!string.IsNullOrWhiteSpace(outputPath)) { activity?.SetTag("stellaops.cli.output", outputPath); } using var duration = CliMetrics.MeasureCommandDuration("excititor export"); try { var payload = new Dictionary(StringComparer.Ordinal) { ["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(), ["delta"] = delta }; if (!string.IsNullOrWhiteSpace(scope)) { payload["scope"] = scope.Trim(); } if (since.HasValue) { payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); } if (!string.IsNullOrWhiteSpace(provider)) { payload["provider"] = provider.Trim(); } var result = await client.ExecuteExcititorOperationAsync( "export", HttpMethod.Post, RemoveNullValues(payload), cancellationToken).ConfigureAwait(false); if (!result.Success) { logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Excititor export failed." : result.Message); Environment.ExitCode = 1; return; } Environment.ExitCode = 0; var manifest = TryParseExportManifest(result.Payload); if (!string.IsNullOrWhiteSpace(result.Message) && (manifest is null || !string.Equals(result.Message, "ok", StringComparison.OrdinalIgnoreCase))) { logger.LogInformation(result.Message); } if (manifest is not null) { activity?.SetTag("stellaops.cli.export_id", manifest.ExportId); if (!string.IsNullOrWhiteSpace(manifest.Format)) { activity?.SetTag("stellaops.cli.export_format", manifest.Format); } if (manifest.FromCache.HasValue) { activity?.SetTag("stellaops.cli.export_cached", manifest.FromCache.Value); } if (manifest.SizeBytes.HasValue) { activity?.SetTag("stellaops.cli.export_size", manifest.SizeBytes.Value); } if (manifest.FromCache == true) { logger.LogInformation("Reusing cached export {ExportId} ({Format}).", manifest.ExportId, manifest.Format ?? "unknown"); } else { logger.LogInformation("Export ready: {ExportId} ({Format}).", manifest.ExportId, manifest.Format ?? "unknown"); } if (manifest.CreatedAt.HasValue) { logger.LogInformation("Created at {CreatedAt}.", manifest.CreatedAt.Value.ToString("u", CultureInfo.InvariantCulture)); } if (!string.IsNullOrWhiteSpace(manifest.Digest)) { var digestDisplay = BuildDigestDisplay(manifest.Algorithm, manifest.Digest); if (manifest.SizeBytes.HasValue) { logger.LogInformation("Digest {Digest} ({Size}).", digestDisplay, FormatSize(manifest.SizeBytes.Value)); } else { logger.LogInformation("Digest {Digest}.", digestDisplay); } } if (!string.IsNullOrWhiteSpace(manifest.RekorLocation)) { if (!string.IsNullOrWhiteSpace(manifest.RekorIndex)) { logger.LogInformation("Rekor entry: {Location} (index {Index}).", manifest.RekorLocation, manifest.RekorIndex); } else { logger.LogInformation("Rekor entry: {Location}.", manifest.RekorLocation); } } if (!string.IsNullOrWhiteSpace(manifest.RekorInclusionUrl) && !string.Equals(manifest.RekorInclusionUrl, manifest.RekorLocation, StringComparison.OrdinalIgnoreCase)) { logger.LogInformation("Rekor inclusion proof: {Url}.", manifest.RekorInclusionUrl); } if (!string.IsNullOrWhiteSpace(outputPath)) { var resolvedPath = ResolveExportOutputPath(outputPath!, manifest); var download = await client.DownloadExcititorExportAsync( manifest.ExportId, resolvedPath, manifest.Algorithm, manifest.Digest, cancellationToken).ConfigureAwait(false); activity?.SetTag("stellaops.cli.export_path", download.Path); if (download.FromCache) { logger.LogInformation("Export already cached at {Path} ({Size}).", download.Path, FormatSize(download.SizeBytes)); } else { logger.LogInformation("Export saved to {Path} ({Size}).", download.Path, FormatSize(download.SizeBytes)); } } else if (!string.IsNullOrWhiteSpace(result.Location)) { var downloadUrl = ResolveLocationUrl(options, result.Location); if (!string.IsNullOrWhiteSpace(downloadUrl)) { logger.LogInformation("Download URL: {Url}", downloadUrl); } else { logger.LogInformation("Download location: {Location}", result.Location); } } } else { if (!string.IsNullOrWhiteSpace(result.Location)) { var downloadUrl = ResolveLocationUrl(options, result.Location); if (!string.IsNullOrWhiteSpace(downloadUrl)) { logger.LogInformation("Download URL: {Url}", downloadUrl); } else { logger.LogInformation("Location: {Location}", result.Location); } } else if (string.IsNullOrWhiteSpace(result.Message)) { logger.LogInformation("Export request accepted."); } } } catch (Exception ex) { logger.LogError(ex, "Excititor export failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static Task HandleExcititorBackfillStatementsAsync( IServiceProvider services, DateTimeOffset? retrievedSince, bool force, int batchSize, int? maxDocuments, bool verbose, CancellationToken cancellationToken) { if (batchSize <= 0) { throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be greater than zero."); } if (maxDocuments.HasValue && maxDocuments.Value <= 0) { throw new ArgumentOutOfRangeException(nameof(maxDocuments), "Max documents must be greater than zero when specified."); } var payload = new Dictionary(StringComparer.Ordinal) { ["force"] = force, ["batchSize"] = batchSize, ["maxDocuments"] = maxDocuments }; if (retrievedSince.HasValue) { payload["retrievedSince"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); } var activityTags = new Dictionary(StringComparer.Ordinal) { ["stellaops.cli.force"] = force, ["stellaops.cli.batch_size"] = batchSize, ["stellaops.cli.max_documents"] = maxDocuments }; if (retrievedSince.HasValue) { activityTags["stellaops.cli.retrieved_since"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); } return ExecuteExcititorCommandAsync( services, commandName: "excititor backfill-statements", verbose, activityTags, client => client.ExecuteExcititorOperationAsync( "admin/backfill-statements", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), cancellationToken); } public static Task HandleExcititorVerifyAsync( IServiceProvider services, string? exportId, string? digest, string? attestationPath, bool verbose, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(exportId) && string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(attestationPath)) { var logger = services.GetRequiredService().CreateLogger("excititor-verify"); logger.LogError("At least one of --export-id, --digest, or --attestation must be provided."); Environment.ExitCode = 1; return Task.CompletedTask; } var payload = new Dictionary(StringComparer.Ordinal); if (!string.IsNullOrWhiteSpace(exportId)) { payload["exportId"] = exportId.Trim(); } if (!string.IsNullOrWhiteSpace(digest)) { payload["digest"] = digest.Trim(); } if (!string.IsNullOrWhiteSpace(attestationPath)) { var fullPath = Path.GetFullPath(attestationPath); if (!File.Exists(fullPath)) { var logger = services.GetRequiredService().CreateLogger("excititor-verify"); logger.LogError("Attestation file not found at {Path}.", fullPath); Environment.ExitCode = 1; return Task.CompletedTask; } var bytes = File.ReadAllBytes(fullPath); payload["attestation"] = new Dictionary(StringComparer.Ordinal) { ["fileName"] = Path.GetFileName(fullPath), ["base64"] = Convert.ToBase64String(bytes) }; } return ExecuteExcititorCommandAsync( services, commandName: "excititor verify", verbose, new Dictionary { ["export_id"] = exportId, ["digest"] = digest, ["attestation_path"] = attestationPath }, client => client.ExecuteExcititorOperationAsync("verify", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), cancellationToken); } public static Task HandleExcititorReconcileAsync( IServiceProvider services, IReadOnlyList providers, TimeSpan? maxAge, bool verbose, CancellationToken cancellationToken) { var normalizedProviders = NormalizeProviders(providers); var payload = new Dictionary(StringComparer.Ordinal); if (normalizedProviders.Count > 0) { payload["providers"] = normalizedProviders; } if (maxAge.HasValue) { payload["maxAge"] = maxAge.Value.ToString("c", CultureInfo.InvariantCulture); } return ExecuteExcititorCommandAsync( services, commandName: "excititor reconcile", verbose, new Dictionary { ["providers"] = normalizedProviders.Count, ["max_age"] = maxAge?.ToString("c", CultureInfo.InvariantCulture) }, client => client.ExecuteExcititorOperationAsync("reconcile", HttpMethod.Post, RemoveNullValues(payload), cancellationToken), cancellationToken); } public static async Task HandleRuntimePolicyTestAsync( IServiceProvider services, string? namespaceValue, IReadOnlyList imageArguments, string? filePath, IReadOnlyList labelArguments, bool outputJson, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("runtime-policy-test"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.runtime.policy.test", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "runtime policy test"); if (!string.IsNullOrWhiteSpace(namespaceValue)) { activity?.SetTag("stellaops.cli.namespace", namespaceValue); } using var duration = CliMetrics.MeasureCommandDuration("runtime policy test"); try { IReadOnlyList images; try { images = await GatherImageDigestsAsync(imageArguments, filePath, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or FileNotFoundException) { logger.LogError(ex, "Failed to gather image digests: {Message}", ex.Message); Environment.ExitCode = 9; return; } if (images.Count == 0) { logger.LogError("No image digests provided. Use --image, --file, or pipe digests via stdin."); Environment.ExitCode = 9; return; } IReadOnlyDictionary labels; try { labels = ParseLabelSelectors(labelArguments); } catch (ArgumentException ex) { logger.LogError(ex.Message); Environment.ExitCode = 9; return; } activity?.SetTag("stellaops.cli.images", images.Count); activity?.SetTag("stellaops.cli.labels", labels.Count); var request = new RuntimePolicyEvaluationRequest(namespaceValue, labels, images); var result = await client.EvaluateRuntimePolicyAsync(request, cancellationToken).ConfigureAwait(false); activity?.SetTag("stellaops.cli.ttl_seconds", result.TtlSeconds); Environment.ExitCode = 0; if (outputJson) { var json = BuildRuntimePolicyJson(result, images); Console.WriteLine(json); return; } if (result.ExpiresAtUtc.HasValue) { logger.LogInformation("Decision TTL: {TtlSeconds}s (expires {ExpiresAt})", result.TtlSeconds, result.ExpiresAtUtc.Value.ToString("u", CultureInfo.InvariantCulture)); } else { logger.LogInformation("Decision TTL: {TtlSeconds}s", result.TtlSeconds); } if (!string.IsNullOrWhiteSpace(result.PolicyRevision)) { logger.LogInformation("Policy revision: {Revision}", result.PolicyRevision); } DisplayRuntimePolicyResults(logger, result, images); } catch (Exception ex) { logger.LogError(ex, "Runtime policy evaluation failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleAuthLoginAsync( IServiceProvider services, StellaOpsCliOptions options, bool verbose, bool force, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-login"); Environment.ExitCode = 0; if (string.IsNullOrWhiteSpace(options.Authority?.Url)) { logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration."); Environment.ExitCode = 1; return; } var tokenClient = scope.ServiceProvider.GetService(); if (tokenClient is null) { logger.LogError("Authority client is not available. Ensure AddStellaOpsAuthClient is registered in Program.cs."); Environment.ExitCode = 1; return; } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); if (string.IsNullOrWhiteSpace(cacheKey)) { logger.LogError("Authority configuration is incomplete; unable to determine cache key."); Environment.ExitCode = 1; return; } try { if (force) { await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); } var scopeName = AuthorityTokenUtilities.ResolveScope(options); StellaOpsTokenResult token; if (!string.IsNullOrWhiteSpace(options.Authority.Username)) { if (string.IsNullOrWhiteSpace(options.Authority.Password)) { logger.LogError("Authority password must be provided when username is configured."); Environment.ExitCode = 1; return; } token = await tokenClient.RequestPasswordTokenAsync( options.Authority.Username, options.Authority.Password!, scopeName, cancellationToken).ConfigureAwait(false); } else { token = await tokenClient.RequestClientCredentialsTokenAsync(scopeName, cancellationToken).ConfigureAwait(false); } await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false); if (verbose) { logger.LogInformation("Authenticated with {Authority} (scopes: {Scopes}).", options.Authority.Url, string.Join(", ", token.Scopes)); } logger.LogInformation("Login successful. Access token expires at {Expires}.", token.ExpiresAtUtc.ToString("u")); } catch (Exception ex) { logger.LogError(ex, "Authentication failed: {Message}", ex.Message); Environment.ExitCode = 1; } } public static async Task HandleAuthLogoutAsync( IServiceProvider services, StellaOpsCliOptions options, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-logout"); Environment.ExitCode = 0; var tokenClient = scope.ServiceProvider.GetService(); if (tokenClient is null) { logger.LogInformation("No authority client registered; nothing to remove."); return; } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); if (string.IsNullOrWhiteSpace(cacheKey)) { logger.LogInformation("Authority configuration missing; no cached tokens to remove."); return; } await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (verbose) { logger.LogInformation("Cleared cached token for {Authority}.", options.Authority?.Url ?? "authority"); } } public static async Task HandleAuthStatusAsync( IServiceProvider services, StellaOpsCliOptions options, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-status"); Environment.ExitCode = 0; if (string.IsNullOrWhiteSpace(options.Authority?.Url)) { logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'."); Environment.ExitCode = 1; return; } var tokenClient = scope.ServiceProvider.GetService(); if (tokenClient is null) { logger.LogInformation("Authority client not registered; no cached tokens available."); Environment.ExitCode = 1; return; } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); if (string.IsNullOrWhiteSpace(cacheKey)) { logger.LogInformation("Authority configuration incomplete; no cached tokens available."); Environment.ExitCode = 1; return; } var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (entry is null) { logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url); Environment.ExitCode = 1; return; } logger.LogInformation("Cached token for {Authority} expires at {Expires}.", options.Authority.Url, entry.ExpiresAtUtc.ToString("u")); if (verbose) { logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); } } public static async Task HandleAuthWhoAmIAsync( IServiceProvider services, StellaOpsCliOptions options, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-whoami"); Environment.ExitCode = 0; if (string.IsNullOrWhiteSpace(options.Authority?.Url)) { logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'."); Environment.ExitCode = 1; return; } var tokenClient = scope.ServiceProvider.GetService(); if (tokenClient is null) { logger.LogInformation("Authority client not registered; no cached tokens available."); Environment.ExitCode = 1; return; } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); if (string.IsNullOrWhiteSpace(cacheKey)) { logger.LogInformation("Authority configuration incomplete; no cached tokens available."); Environment.ExitCode = 1; return; } var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (entry is null) { logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url); Environment.ExitCode = 1; return; } var grantType = string.IsNullOrWhiteSpace(options.Authority.Username) ? "client_credentials" : "password"; var now = DateTimeOffset.UtcNow; var remaining = entry.ExpiresAtUtc - now; if (remaining < TimeSpan.Zero) { remaining = TimeSpan.Zero; } logger.LogInformation("Authority: {Authority}", options.Authority.Url); logger.LogInformation("Grant type: {GrantType}", grantType); logger.LogInformation("Token type: {TokenType}", entry.TokenType); logger.LogInformation("Expires: {Expires} ({Remaining})", entry.ExpiresAtUtc.ToString("u"), FormatDuration(remaining)); if (entry.Scopes.Count > 0) { logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); } if (TryExtractJwtClaims(entry.AccessToken, out var claims, out var issuedAt, out var notBefore)) { if (claims.TryGetValue("sub", out var subject) && !string.IsNullOrWhiteSpace(subject)) { logger.LogInformation("Subject: {Subject}", subject); } if (claims.TryGetValue("client_id", out var clientId) && !string.IsNullOrWhiteSpace(clientId)) { logger.LogInformation("Client ID (token): {ClientId}", clientId); } if (claims.TryGetValue("aud", out var audience) && !string.IsNullOrWhiteSpace(audience)) { logger.LogInformation("Audience: {Audience}", audience); } if (claims.TryGetValue("iss", out var issuer) && !string.IsNullOrWhiteSpace(issuer)) { logger.LogInformation("Issuer: {Issuer}", issuer); } if (issuedAt is not null) { logger.LogInformation("Issued at: {IssuedAt}", issuedAt.Value.ToString("u")); } if (notBefore is not null) { logger.LogInformation("Not before: {NotBefore}", notBefore.Value.ToString("u")); } var extraClaims = CollectAdditionalClaims(claims); if (extraClaims.Count > 0 && verbose) { logger.LogInformation("Additional claims: {Claims}", string.Join(", ", extraClaims)); } } else { logger.LogInformation("Access token appears opaque; claims are unavailable."); } } public static async Task HandleAuthRevokeExportAsync( IServiceProvider services, StellaOpsCliOptions options, string? outputDirectory, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("auth-revoke-export"); Environment.ExitCode = 0; try { var client = scope.ServiceProvider.GetRequiredService(); var result = await client.ExportAsync(verbose, cancellationToken).ConfigureAwait(false); var directory = string.IsNullOrWhiteSpace(outputDirectory) ? Directory.GetCurrentDirectory() : Path.GetFullPath(outputDirectory); Directory.CreateDirectory(directory); var bundlePath = Path.Combine(directory, "revocation-bundle.json"); var signaturePath = Path.Combine(directory, "revocation-bundle.json.jws"); var digestPath = Path.Combine(directory, "revocation-bundle.json.sha256"); await File.WriteAllBytesAsync(bundlePath, result.BundleBytes, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(signaturePath, result.Signature, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(digestPath, $"sha256:{result.Digest}", cancellationToken).ConfigureAwait(false); var computedDigest = Convert.ToHexString(SHA256.HashData(result.BundleBytes)).ToLowerInvariant(); if (!string.Equals(computedDigest, result.Digest, StringComparison.OrdinalIgnoreCase)) { logger.LogError("Digest mismatch. Expected {Expected} but computed {Actual}.", result.Digest, computedDigest); Environment.ExitCode = 1; return; } logger.LogInformation( "Revocation bundle exported to {Directory} (sequence {Sequence}, issued {Issued:u}, signing key {KeyId}, provider {Provider}).", directory, result.Sequence, result.IssuedAt, string.IsNullOrWhiteSpace(result.SigningKeyId) ? "" : result.SigningKeyId, string.IsNullOrWhiteSpace(result.SigningProvider) ? "default" : result.SigningProvider); } catch (Exception ex) { logger.LogError(ex, "Failed to export revocation bundle."); Environment.ExitCode = 1; } } public static async Task HandleAuthRevokeVerifyAsync( string bundlePath, string signaturePath, string keyPath, bool verbose, CancellationToken cancellationToken) { var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(options => { options.SingleLine = true; options.TimestampFormat = "HH:mm:ss "; })); var logger = loggerFactory.CreateLogger("auth-revoke-verify"); Environment.ExitCode = 0; try { if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(signaturePath) || string.IsNullOrWhiteSpace(keyPath)) { logger.LogError("Arguments --bundle, --signature, and --key are required."); Environment.ExitCode = 1; return; } var bundleBytes = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false); var signatureContent = (await File.ReadAllTextAsync(signaturePath, cancellationToken).ConfigureAwait(false)).Trim(); var keyPem = await File.ReadAllTextAsync(keyPath, cancellationToken).ConfigureAwait(false); var digest = Convert.ToHexString(SHA256.HashData(bundleBytes)).ToLowerInvariant(); logger.LogInformation("Bundle digest sha256:{Digest}", digest); if (!TryParseDetachedJws(signatureContent, out var encodedHeader, out var encodedSignature)) { logger.LogError("Signature is not in detached JWS format."); Environment.ExitCode = 1; return; } var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(encodedHeader)); using var headerDocument = JsonDocument.Parse(headerJson); var header = headerDocument.RootElement; if (!header.TryGetProperty("b64", out var b64Element) || b64Element.GetBoolean()) { logger.LogError("Detached JWS header must include '\"b64\": false'."); Environment.ExitCode = 1; return; } var algorithm = header.TryGetProperty("alg", out var algElement) ? algElement.GetString() : SignatureAlgorithms.Es256; if (string.IsNullOrWhiteSpace(algorithm)) { algorithm = SignatureAlgorithms.Es256; } var providerHint = header.TryGetProperty("provider", out var providerElement) ? providerElement.GetString() : null; var keyId = header.TryGetProperty("kid", out var kidElement) ? kidElement.GetString() : null; if (string.IsNullOrWhiteSpace(keyId)) { keyId = Path.GetFileNameWithoutExtension(keyPath); logger.LogWarning("JWS header missing 'kid'; using fallback key id {KeyId}.", keyId); } CryptoSigningKey signingKey; try { signingKey = CreateVerificationSigningKey(keyId!, algorithm!, providerHint, keyPem, keyPath); } catch (Exception ex) when (ex is InvalidOperationException or CryptographicException) { logger.LogError(ex, "Failed to load verification key material."); Environment.ExitCode = 1; return; } var providers = new List { new DefaultCryptoProvider() }; #if STELLAOPS_CRYPTO_SODIUM providers.Add(new LibsodiumCryptoProvider()); #endif foreach (var provider in providers) { if (provider.Supports(CryptoCapability.Verification, algorithm!)) { provider.UpsertSigningKey(signingKey); } } var preferredOrder = !string.IsNullOrWhiteSpace(providerHint) ? new[] { providerHint! } : Array.Empty(); var registry = new CryptoProviderRegistry(providers, preferredOrder); CryptoSignerResolution resolution; try { resolution = registry.ResolveSigner( CryptoCapability.Verification, algorithm!, signingKey.Reference, providerHint); } catch (Exception ex) { logger.LogError(ex, "No crypto provider available for verification (algorithm {Algorithm}).", algorithm); Environment.ExitCode = 1; return; } var signingInputLength = encodedHeader.Length + 1 + bundleBytes.Length; var buffer = ArrayPool.Shared.Rent(signingInputLength); try { var headerBytes = Encoding.ASCII.GetBytes(encodedHeader); Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length); buffer[headerBytes.Length] = (byte)'.'; Buffer.BlockCopy(bundleBytes, 0, buffer, headerBytes.Length + 1, bundleBytes.Length); var signatureBytes = Base64UrlDecode(encodedSignature); var verified = await resolution.Signer.VerifyAsync( new ReadOnlyMemory(buffer, 0, signingInputLength), signatureBytes, cancellationToken).ConfigureAwait(false); if (!verified) { logger.LogError("Signature verification failed."); Environment.ExitCode = 1; return; } } finally { ArrayPool.Shared.Return(buffer); } if (!string.IsNullOrWhiteSpace(providerHint) && !string.Equals(providerHint, resolution.ProviderName, StringComparison.OrdinalIgnoreCase)) { logger.LogWarning( "Preferred provider '{Preferred}' unavailable; verification used '{Provider}'.", providerHint, resolution.ProviderName); } logger.LogInformation( "Signature verified using algorithm {Algorithm} via provider {Provider} (kid {KeyId}).", algorithm, resolution.ProviderName, signingKey.Reference.KeyId); if (verbose) { logger.LogInformation("JWS header: {Header}", headerJson); } } catch (Exception ex) { logger.LogError(ex, "Failed to verify revocation bundle."); Environment.ExitCode = 1; } finally { loggerFactory.Dispose(); } } public static async Task HandleVulnObservationsAsync( IServiceProvider services, string tenant, IReadOnlyList observationIds, IReadOnlyList aliases, IReadOnlyList purls, IReadOnlyList cpes, bool emitJson, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("vuln-observations"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.observations", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "vuln observations"); activity?.SetTag("stellaops.cli.tenant", tenant); using var duration = CliMetrics.MeasureCommandDuration("vuln observations"); try { tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty; if (string.IsNullOrWhiteSpace(tenant)) { throw new InvalidOperationException("Tenant must be provided."); } var query = new AdvisoryObservationsQuery( tenant, NormalizeSet(observationIds, toLower: false), NormalizeSet(aliases, toLower: true), NormalizeSet(purls, toLower: false), NormalizeSet(cpes, toLower: false)); 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); Environment.ExitCode = 0; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { logger.LogWarning("Operation cancelled by user."); Environment.ExitCode = 130; } catch (Exception ex) { logger.LogError(ex, "Failed to fetch observations from Concelier."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } static IReadOnlyList NormalizeSet(IReadOnlyList values, bool toLower) { if (values is null || values.Count == 0) { return Array.Empty(); } var set = new HashSet(StringComparer.Ordinal); foreach (var raw in values) { if (string.IsNullOrWhiteSpace(raw)) { continue; } var normalized = raw.Trim(); if (toLower) { normalized = normalized.ToLowerInvariant(); } set.Add(normalized); } return set.Count == 0 ? Array.Empty() : set.ToArray(); } static void RenderObservationTable(AdvisoryObservationsResponse response) { var observations = response.Observations ?? Array.Empty(); if (observations.Count == 0) { AnsiConsole.MarkupLine("[yellow]No observations matched the provided filters.[/]"); return; } var table = new Table() .Centered() .Border(TableBorder.Rounded); table.AddColumn("Observation"); table.AddColumn("Source"); table.AddColumn("Upstream Id"); table.AddColumn("Aliases"); table.AddColumn("PURLs"); table.AddColumn("CPEs"); table.AddColumn("Created (UTC)"); foreach (var observation in observations) { var sourceVendor = observation.Source?.Vendor ?? "(unknown)"; var upstreamId = observation.Upstream?.UpstreamId ?? "(unknown)"; var aliasesText = FormatList(observation.Linkset?.Aliases); var purlsText = FormatList(observation.Linkset?.Purls); var cpesText = FormatList(observation.Linkset?.Cpes); table.AddRow( Markup.Escape(observation.ObservationId), Markup.Escape(sourceVendor), Markup.Escape(upstreamId), Markup.Escape(aliasesText), Markup.Escape(purlsText), Markup.Escape(cpesText), observation.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture)); } AnsiConsole.Write(table); AnsiConsole.MarkupLine( "[green]{0}[/] observation(s). Aliases: [green]{1}[/], PURLs: [green]{2}[/], CPEs: [green]{3}[/].", observations.Count, response.Linkset?.Aliases?.Count ?? 0, response.Linkset?.Purls?.Count ?? 0, response.Linkset?.Cpes?.Count ?? 0); } static string FormatList(IReadOnlyList? values) { if (values is null || values.Count == 0) { return "(none)"; } const int MaxItems = 3; if (values.Count <= MaxItems) { return string.Join(", ", values); } var preview = values.Take(MaxItems); return $"{string.Join(", ", preview)} (+{values.Count - MaxItems})"; } } public static async Task HandleOfflineKitPullAsync( IServiceProvider services, string? bundleId, string? destinationDirectory, bool overwrite, bool resume, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var options = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("offline-kit-pull"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.pull", ActivityKind.Client); activity?.SetTag("stellaops.cli.bundle_id", string.IsNullOrWhiteSpace(bundleId) ? "latest" : bundleId); using var duration = CliMetrics.MeasureCommandDuration("offline kit pull"); try { var targetDirectory = string.IsNullOrWhiteSpace(destinationDirectory) ? options.Offline?.KitsDirectory ?? Path.Combine(Environment.CurrentDirectory, "offline-kits") : destinationDirectory; targetDirectory = Path.GetFullPath(targetDirectory); Directory.CreateDirectory(targetDirectory); var result = await client.DownloadOfflineKitAsync(bundleId, targetDirectory, overwrite, resume, cancellationToken).ConfigureAwait(false); logger.LogInformation( "Bundle {BundleId} stored at {Path} (captured {Captured:u}, sha256:{Digest}).", result.Descriptor.BundleId, result.BundlePath, result.Descriptor.CapturedAt, result.Descriptor.BundleSha256); logger.LogInformation("Manifest saved to {Manifest}.", result.ManifestPath); if (!string.IsNullOrWhiteSpace(result.MetadataPath)) { logger.LogDebug("Metadata recorded at {Metadata}.", result.MetadataPath); } if (result.BundleSignaturePath is not null) { logger.LogInformation("Bundle signature saved to {Signature}.", result.BundleSignaturePath); } if (result.ManifestSignaturePath is not null) { logger.LogInformation("Manifest signature saved to {Signature}.", result.ManifestSignaturePath); } CliMetrics.RecordOfflineKitDownload(result.Descriptor.Kind ?? "unknown", result.FromCache); activity?.SetTag("stellaops.cli.bundle_cache", result.FromCache); Environment.ExitCode = 0; } catch (Exception ex) { logger.LogError(ex, "Failed to download offline kit bundle."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandlePolicySimulateAsync( IServiceProvider services, string policyId, int? baseVersion, int? candidateVersion, IReadOnlyList sbomArguments, IReadOnlyList environmentArguments, string? format, string? outputPath, bool explain, bool failOnDiff, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("policy-simulate"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.policy.simulate", ActivityKind.Client); activity?.SetTag("stellaops.cli.command", "policy simulate"); activity?.SetTag("stellaops.cli.policy_id", policyId); if (baseVersion.HasValue) { activity?.SetTag("stellaops.cli.base_version", baseVersion.Value); } if (candidateVersion.HasValue) { activity?.SetTag("stellaops.cli.candidate_version", candidateVersion.Value); } 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); var input = new PolicySimulationInput( baseVersion, candidateVersion, sbomSet, environment, explain); 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); } var outputFormat = DeterminePolicySimulationFormat(format, outputPath); var payload = BuildPolicySimulationPayload(normalizedPolicyId, baseVersion, candidateVersion, sbomSet, environment, result); if (!string.IsNullOrWhiteSpace(outputPath)) { 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); } } catch (ArgumentException ex) { logger.LogError(ex.Message); CliMetrics.RecordPolicySimulation("error"); Environment.ExitCode = 64; } catch (PolicyApiException ex) { HandlePolicySimulationFailure(ex, logger); } catch (Exception ex) { logger.LogError(ex, "Policy simulation failed."); CliMetrics.RecordPolicySimulation("error"); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleOfflineKitImportAsync( IServiceProvider services, string bundlePath, string? manifestPath, string? bundleSignaturePath, string? manifestSignaturePath, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var options = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("offline-kit-import"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.import", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("offline kit import"); try { if (string.IsNullOrWhiteSpace(bundlePath)) { logger.LogError("Bundle path is required."); Environment.ExitCode = 1; return; } bundlePath = Path.GetFullPath(bundlePath); if (!File.Exists(bundlePath)) { logger.LogError("Bundle file {Path} not found.", bundlePath); Environment.ExitCode = 1; return; } var metadata = await LoadOfflineKitMetadataAsync(bundlePath, cancellationToken).ConfigureAwait(false); if (metadata is not null) { manifestPath ??= metadata.ManifestPath; bundleSignaturePath ??= metadata.BundleSignaturePath; manifestSignaturePath ??= metadata.ManifestSignaturePath; } manifestPath = NormalizeFilePath(manifestPath); bundleSignaturePath = NormalizeFilePath(bundleSignaturePath); manifestSignaturePath = NormalizeFilePath(manifestSignaturePath); if (manifestPath is null) { manifestPath = TryInferManifestPath(bundlePath); if (manifestPath is not null) { logger.LogDebug("Using inferred manifest path {Path}.", manifestPath); } } if (manifestPath is not null && !File.Exists(manifestPath)) { logger.LogError("Manifest file {Path} not found.", manifestPath); Environment.ExitCode = 1; return; } if (bundleSignaturePath is not null && !File.Exists(bundleSignaturePath)) { logger.LogWarning("Bundle signature {Path} not found; skipping.", bundleSignaturePath); bundleSignaturePath = null; } if (manifestSignaturePath is not null && !File.Exists(manifestSignaturePath)) { logger.LogWarning("Manifest signature {Path} not found; skipping.", manifestSignaturePath); manifestSignaturePath = null; } if (metadata is not null) { var computedBundleDigest = await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false); if (!DigestsEqual(computedBundleDigest, metadata.BundleSha256)) { logger.LogError("Bundle digest mismatch. Expected sha256:{Expected} but computed sha256:{Actual}.", metadata.BundleSha256, computedBundleDigest); Environment.ExitCode = 1; return; } if (manifestPath is not null) { var computedManifestDigest = await ComputeSha256Async(manifestPath, cancellationToken).ConfigureAwait(false); if (!DigestsEqual(computedManifestDigest, metadata.ManifestSha256)) { logger.LogError("Manifest digest mismatch. Expected sha256:{Expected} but computed sha256:{Actual}.", metadata.ManifestSha256, computedManifestDigest); Environment.ExitCode = 1; return; } } } var request = new OfflineKitImportRequest( bundlePath, manifestPath, bundleSignaturePath, manifestSignaturePath, metadata?.BundleId, metadata?.BundleSha256, metadata?.BundleSize, metadata?.CapturedAt, metadata?.Channel, metadata?.Kind, metadata?.IsDelta, metadata?.BaseBundleId, metadata?.ManifestSha256, metadata?.ManifestSize); var result = await client.ImportOfflineKitAsync(request, cancellationToken).ConfigureAwait(false); CliMetrics.RecordOfflineKitImport(result.Status); logger.LogInformation( "Import {ImportId} submitted at {Submitted:u} with status {Status}.", string.IsNullOrWhiteSpace(result.ImportId) ? "" : result.ImportId, result.SubmittedAt, string.IsNullOrWhiteSpace(result.Status) ? "queued" : result.Status); if (!string.IsNullOrWhiteSpace(result.Message)) { logger.LogInformation(result.Message); } Environment.ExitCode = 0; } catch (Exception ex) { logger.LogError(ex, "Offline kit import failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } public static async Task HandleOfflineKitStatusAsync( IServiceProvider services, bool asJson, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("offline-kit-status"); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.status", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("offline kit status"); try { var status = await client.GetOfflineKitStatusAsync(cancellationToken).ConfigureAwait(false); if (asJson) { var payload = new { bundleId = status.BundleId, channel = status.Channel, kind = status.Kind, isDelta = status.IsDelta, baseBundleId = status.BaseBundleId, capturedAt = status.CapturedAt, importedAt = status.ImportedAt, sha256 = status.BundleSha256, sizeBytes = status.BundleSize, components = status.Components.Select(component => new { component.Name, component.Version, component.Digest, component.CapturedAt, component.SizeBytes }) }; var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }); Console.WriteLine(json); } else { if (string.IsNullOrWhiteSpace(status.BundleId)) { logger.LogInformation("No offline kit bundle has been imported yet."); } else { logger.LogInformation( "Current bundle {BundleId} ({Kind}) captured {Captured:u}, imported {Imported:u}, sha256:{Digest}, size {Size}.", status.BundleId, status.Kind ?? "unknown", status.CapturedAt ?? default, status.ImportedAt ?? default, status.BundleSha256 ?? "", status.BundleSize.HasValue ? status.BundleSize.Value.ToString("N0", CultureInfo.InvariantCulture) : ""); } if (status.Components.Count > 0) { var table = new Table().AddColumns("Component", "Version", "Digest", "Captured", "Size (bytes)"); foreach (var component in status.Components) { table.AddRow( component.Name, string.IsNullOrWhiteSpace(component.Version) ? "-" : component.Version!, string.IsNullOrWhiteSpace(component.Digest) ? "-" : $"sha256:{component.Digest}", component.CapturedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "-", component.SizeBytes.HasValue ? component.SizeBytes.Value.ToString("N0", CultureInfo.InvariantCulture) : "-"); } AnsiConsole.Write(table); } } Environment.ExitCode = 0; } catch (Exception ex) { logger.LogError(ex, "Failed to read offline kit status."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } private static async Task LoadOfflineKitMetadataAsync(string bundlePath, CancellationToken cancellationToken) { var metadataPath = bundlePath + ".metadata.json"; if (!File.Exists(metadataPath)) { return null; } try { await using var stream = File.OpenRead(metadataPath); return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); } catch { return null; } } private static string? NormalizeFilePath(string? path) { if (string.IsNullOrWhiteSpace(path)) { return null; } return Path.GetFullPath(path); } private static string? TryInferManifestPath(string bundlePath) { var directory = Path.GetDirectoryName(bundlePath); if (string.IsNullOrWhiteSpace(directory)) { return null; } var baseName = Path.GetFileName(bundlePath); if (string.IsNullOrWhiteSpace(baseName)) { return null; } baseName = Path.GetFileNameWithoutExtension(baseName); if (baseName.EndsWith(".tar", StringComparison.OrdinalIgnoreCase)) { baseName = Path.GetFileNameWithoutExtension(baseName); } var candidates = new[] { Path.Combine(directory, $"offline-manifest-{baseName}.json"), Path.Combine(directory, "offline-manifest.json") }; foreach (var candidate in candidates) { if (File.Exists(candidate)) { return Path.GetFullPath(candidate); } } return Directory.EnumerateFiles(directory, "offline-manifest*.json").FirstOrDefault(); } private static bool DigestsEqual(string computed, string? expected) { if (string.IsNullOrWhiteSpace(expected)) { return true; } return string.Equals(NormalizeDigest(computed), NormalizeDigest(expected), StringComparison.OrdinalIgnoreCase); } private static string NormalizeDigest(string digest) { var value = digest.Trim(); if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) { value = value.Substring("sha256:".Length); } return value.ToLowerInvariant(); } private static async Task ComputeSha256Async(string path, CancellationToken cancellationToken) { await using var stream = File.OpenRead(path); var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); return Convert.ToHexString(hash).ToLowerInvariant(); } private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature) { encodedHeader = string.Empty; encodedSignature = string.Empty; if (string.IsNullOrWhiteSpace(value)) { return false; } var parts = value.Split('.'); if (parts.Length != 3) { return false; } encodedHeader = parts[0]; encodedSignature = parts[2]; return parts[1].Length == 0; } private static byte[] Base64UrlDecode(string value) { var normalized = value.Replace('-', '+').Replace('_', '/'); var padding = normalized.Length % 4; if (padding == 2) { normalized += "=="; } else if (padding == 3) { normalized += "="; } else if (padding == 1) { throw new FormatException("Invalid Base64Url value."); } return Convert.FromBase64String(normalized); } private static CryptoSigningKey CreateVerificationSigningKey( string keyId, string algorithm, string? providerHint, string keyPem, string keyPath) { if (string.IsNullOrWhiteSpace(keyPem)) { throw new InvalidOperationException("Verification key PEM content is empty."); } using var ecdsa = ECDsa.Create(); ecdsa.ImportFromPem(keyPem); var parameters = ecdsa.ExportParameters(includePrivateParameters: false); if (parameters.D is null || parameters.D.Length == 0) { parameters.D = new byte[] { 0x01 }; } var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["source"] = Path.GetFullPath(keyPath), ["verificationOnly"] = "true" }; return new CryptoSigningKey( new CryptoKeyReference(keyId, providerHint), algorithm, in parameters, DateTimeOffset.UtcNow, metadata: metadata); } private static string FormatDuration(TimeSpan duration) { if (duration <= TimeSpan.Zero) { return "expired"; } if (duration.TotalDays >= 1) { var days = (int)duration.TotalDays; var hours = duration.Hours; return hours > 0 ? FormattableString.Invariant($"{days}d {hours}h") : FormattableString.Invariant($"{days}d"); } if (duration.TotalHours >= 1) { return FormattableString.Invariant($"{(int)duration.TotalHours}h {duration.Minutes}m"); } if (duration.TotalMinutes >= 1) { return FormattableString.Invariant($"{(int)duration.TotalMinutes}m {duration.Seconds}s"); } return FormattableString.Invariant($"{duration.Seconds}s"); } private static bool TryExtractJwtClaims( string accessToken, out Dictionary claims, out DateTimeOffset? issuedAt, out DateTimeOffset? notBefore) { claims = new Dictionary(StringComparer.OrdinalIgnoreCase); issuedAt = null; notBefore = null; if (string.IsNullOrWhiteSpace(accessToken)) { return false; } var parts = accessToken.Split('.'); if (parts.Length < 2) { return false; } if (!TryDecodeBase64Url(parts[1], out var payloadBytes)) { return false; } try { using var document = JsonDocument.Parse(payloadBytes); foreach (var property in document.RootElement.EnumerateObject()) { var value = FormatJsonValue(property.Value); claims[property.Name] = value; if (issuedAt is null && property.NameEquals("iat") && TryParseUnixSeconds(property.Value, out var parsedIat)) { issuedAt = parsedIat; } if (notBefore is null && property.NameEquals("nbf") && TryParseUnixSeconds(property.Value, out var parsedNbf)) { notBefore = parsedNbf; } } return true; } catch (JsonException) { claims.Clear(); issuedAt = null; notBefore = null; return false; } } private static bool TryDecodeBase64Url(string value, out byte[] bytes) { bytes = Array.Empty(); if (string.IsNullOrWhiteSpace(value)) { return false; } var normalized = value.Replace('-', '+').Replace('_', '/'); var padding = normalized.Length % 4; if (padding is 2 or 3) { normalized = normalized.PadRight(normalized.Length + (4 - padding), '='); } else if (padding == 1) { return false; } try { bytes = Convert.FromBase64String(normalized); return true; } catch (FormatException) { return false; } } private static string FormatJsonValue(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString() ?? string.Empty, JsonValueKind.Number => element.TryGetInt64(out var longValue) ? longValue.ToString(CultureInfo.InvariantCulture) : element.GetDouble().ToString(CultureInfo.InvariantCulture), JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.Null => "null", JsonValueKind.Array => FormatArray(element), JsonValueKind.Object => element.GetRawText(), _ => element.GetRawText() }; } private static string FormatArray(JsonElement array) { var values = new List(); foreach (var item in array.EnumerateArray()) { values.Add(FormatJsonValue(item)); } return string.Join(", ", values); } private static bool TryParseUnixSeconds(JsonElement element, out DateTimeOffset value) { value = default; if (element.ValueKind == JsonValueKind.Number) { if (element.TryGetInt64(out var seconds)) { value = DateTimeOffset.FromUnixTimeSeconds(seconds); return true; } if (element.TryGetDouble(out var doubleValue)) { value = DateTimeOffset.FromUnixTimeSeconds((long)doubleValue); return true; } } if (element.ValueKind == JsonValueKind.String) { var text = element.GetString(); if (!string.IsNullOrWhiteSpace(text) && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) { value = DateTimeOffset.FromUnixTimeSeconds(seconds); return true; } } return false; } private static List CollectAdditionalClaims(Dictionary claims) { var result = new List(); foreach (var pair in claims) { if (CommonClaimNames.Contains(pair.Key)) { continue; } result.Add(FormattableString.Invariant($"{pair.Key}={pair.Value}")); } result.Sort(StringComparer.OrdinalIgnoreCase); return result; } private static readonly HashSet CommonClaimNames = new(StringComparer.OrdinalIgnoreCase) { "aud", "client_id", "exp", "iat", "iss", "nbf", "scope", "scopes", "sub", "token_type", "jti" }; private static async Task ExecuteExcititorCommandAsync( IServiceProvider services, string commandName, bool verbose, IDictionary? activityTags, Func> operation, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var client = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger(commandName.Replace(' ', '-')); var verbosity = scope.ServiceProvider.GetRequiredService(); var previousLevel = verbosity.MinimumLevel; verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; using var activity = CliActivitySource.Instance.StartActivity($"cli.{commandName.Replace(' ', '.')}" , ActivityKind.Client); activity?.SetTag("stellaops.cli.command", commandName); if (activityTags is not null) { foreach (var tag in activityTags) { activity?.SetTag(tag.Key, tag.Value); } } using var duration = CliMetrics.MeasureCommandDuration(commandName); try { var result = await operation(client).ConfigureAwait(false); if (result.Success) { if (!string.IsNullOrWhiteSpace(result.Message)) { logger.LogInformation(result.Message); } else { logger.LogInformation("Operation completed successfully."); } if (!string.IsNullOrWhiteSpace(result.Location)) { logger.LogInformation("Location: {Location}", result.Location); } if (result.Payload is JsonElement payload && payload.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null) { logger.LogDebug("Response payload: {Payload}", payload.ToString()); } Environment.ExitCode = 0; } else { logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Operation failed." : result.Message); Environment.ExitCode = 1; } } catch (Exception ex) { logger.LogError(ex, "Excititor operation failed."); Environment.ExitCode = 1; } finally { verbosity.MinimumLevel = previousLevel; } } private static async Task> GatherImageDigestsAsync( IReadOnlyList inline, string? filePath, CancellationToken cancellationToken) { var results = new List(); var seen = new HashSet(StringComparer.Ordinal); void AddCandidates(string? candidate) { foreach (var image in SplitImageCandidates(candidate)) { if (seen.Add(image)) { results.Add(image); } } } if (inline is not null) { foreach (var entry in inline) { AddCandidates(entry); } } if (!string.IsNullOrWhiteSpace(filePath)) { var path = Path.GetFullPath(filePath); if (!File.Exists(path)) { throw new FileNotFoundException("Input file not found.", path); } foreach (var line in File.ReadLines(path)) { cancellationToken.ThrowIfCancellationRequested(); AddCandidates(line); } } if (Console.IsInputRedirected) { while (!cancellationToken.IsCancellationRequested) { var line = await Console.In.ReadLineAsync().ConfigureAwait(false); if (line is null) { break; } AddCandidates(line); } } return new ReadOnlyCollection(results); } private static IEnumerable SplitImageCandidates(string? raw) { if (string.IsNullOrWhiteSpace(raw)) { yield break; } var candidate = raw.Trim(); var commentIndex = candidate.IndexOf('#'); if (commentIndex >= 0) { candidate = candidate[..commentIndex].Trim(); } if (candidate.Length == 0) { yield break; } var tokens = candidate.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); foreach (var token in tokens) { var trimmed = token.Trim(); if (trimmed.Length > 0) { yield return trimmed; } } } private static IReadOnlyDictionary ParseLabelSelectors(IReadOnlyList labelArguments) { if (labelArguments is null || labelArguments.Count == 0) { return EmptyLabelSelectors; } var labels = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var raw in labelArguments) { if (string.IsNullOrWhiteSpace(raw)) { continue; } var trimmed = raw.Trim(); var delimiter = trimmed.IndexOf('='); if (delimiter <= 0 || delimiter == trimmed.Length - 1) { throw new ArgumentException($"Invalid label '{raw}'. Expected key=value format."); } var key = trimmed[..delimiter].Trim(); var value = trimmed[(delimiter + 1)..].Trim(); if (key.Length == 0) { throw new ArgumentException($"Invalid label '{raw}'. Label key cannot be empty."); } labels[key] = value; } return labels.Count == 0 ? EmptyLabelSelectors : new ReadOnlyDictionary(labels); } private sealed record ExcititorExportManifestSummary( string ExportId, string? Format, string? Algorithm, string? Digest, long? SizeBytes, bool? FromCache, DateTimeOffset? CreatedAt, string? RekorLocation, string? RekorIndex, string? RekorInclusionUrl); private static ExcititorExportManifestSummary? TryParseExportManifest(JsonElement? payload) { if (payload is null || payload.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) { return null; } var element = payload.Value; var exportId = GetStringProperty(element, "exportId"); if (string.IsNullOrWhiteSpace(exportId)) { return null; } var format = GetStringProperty(element, "format"); var algorithm = default(string?); var digest = default(string?); if (TryGetPropertyCaseInsensitive(element, "artifact", out var artifact) && artifact.ValueKind == JsonValueKind.Object) { algorithm = GetStringProperty(artifact, "algorithm"); digest = GetStringProperty(artifact, "digest"); } var sizeBytes = GetInt64Property(element, "sizeBytes"); var fromCache = GetBooleanProperty(element, "fromCache"); var createdAt = GetDateTimeOffsetProperty(element, "createdAt"); string? rekorLocation = null; string? rekorIndex = null; string? rekorInclusion = null; if (TryGetPropertyCaseInsensitive(element, "attestation", out var attestation) && attestation.ValueKind == JsonValueKind.Object) { if (TryGetPropertyCaseInsensitive(attestation, "rekor", out var rekor) && rekor.ValueKind == JsonValueKind.Object) { rekorLocation = GetStringProperty(rekor, "location"); rekorIndex = GetStringProperty(rekor, "logIndex"); var inclusion = GetStringProperty(rekor, "inclusionProofUri"); if (!string.IsNullOrWhiteSpace(inclusion)) { rekorInclusion = inclusion; } } } return new ExcititorExportManifestSummary( exportId.Trim(), format, algorithm, digest, sizeBytes, fromCache, createdAt, rekorLocation, rekorIndex, rekorInclusion); } private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property) { if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property)) { return true; } if (element.ValueKind == JsonValueKind.Object) { foreach (var candidate in element.EnumerateObject()) { if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase)) { property = candidate.Value; return true; } } } property = default; return false; } private static string? GetStringProperty(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) { return property.ValueKind switch { JsonValueKind.String => property.GetString(), JsonValueKind.Number => property.ToString(), _ => null }; } return null; } private static bool? GetBooleanProperty(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) { return property.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed, _ => null }; } return null; } private static long? GetInt64Property(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) { if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value)) { return value; } if (property.ValueKind == JsonValueKind.String && long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { return parsed; } } return null; } private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value)) { return value.ToUniversalTime(); } return null; } private static string BuildDigestDisplay(string? algorithm, string digest) { if (string.IsNullOrWhiteSpace(digest)) { return string.Empty; } if (digest.Contains(':', StringComparison.Ordinal)) { return digest; } if (string.IsNullOrWhiteSpace(algorithm) || algorithm.Equals("sha256", StringComparison.OrdinalIgnoreCase)) { return $"sha256:{digest}"; } return $"{algorithm}:{digest}"; } private static string FormatSize(long sizeBytes) { if (sizeBytes < 0) { return $"{sizeBytes} bytes"; } string[] units = { "bytes", "KB", "MB", "GB", "TB" }; double size = sizeBytes; var unit = 0; while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; } return unit == 0 ? $"{sizeBytes} bytes" : $"{size:0.##} {units[unit]}"; } private static string ResolveExportOutputPath(string outputPath, ExcititorExportManifestSummary manifest) { if (string.IsNullOrWhiteSpace(outputPath)) { throw new ArgumentException("Output path must be provided.", nameof(outputPath)); } var fullPath = Path.GetFullPath(outputPath); if (Directory.Exists(fullPath) || outputPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) || outputPath.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)) { return Path.Combine(fullPath, BuildExportFileName(manifest)); } var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } return fullPath; } private static string BuildExportFileName(ExcititorExportManifestSummary manifest) { var token = !string.IsNullOrWhiteSpace(manifest.Digest) ? manifest.Digest! : manifest.ExportId; token = SanitizeToken(token); if (token.Length > 40) { token = token[..40]; } var extension = DetermineExportExtension(manifest.Format); return $"stellaops-excititor-{token}{extension}"; } private static string DetermineExportExtension(string? format) { if (string.IsNullOrWhiteSpace(format)) { return ".bin"; } return format switch { not null when format.Equals("jsonl", StringComparison.OrdinalIgnoreCase) => ".jsonl", not null when format.Equals("json", StringComparison.OrdinalIgnoreCase) => ".json", not null when format.Equals("openvex", StringComparison.OrdinalIgnoreCase) => ".json", not null when format.Equals("csaf", StringComparison.OrdinalIgnoreCase) => ".json", _ => ".bin" }; } private static string SanitizeToken(string token) { var builder = new StringBuilder(token.Length); foreach (var ch in token) { if (char.IsLetterOrDigit(ch)) { builder.Append(char.ToLowerInvariant(ch)); } } if (builder.Length == 0) { builder.Append("export"); } return builder.ToString(); } private static string? ResolveLocationUrl(StellaOpsCliOptions options, string location) { if (string.IsNullOrWhiteSpace(location)) { return null; } if (Uri.TryCreate(location, UriKind.Absolute, out var absolute)) { return absolute.ToString(); } if (!string.IsNullOrWhiteSpace(options?.BackendUrl) && Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri)) { if (!location.StartsWith("/", StringComparison.Ordinal)) { location = "/" + location; } return new Uri(baseUri, location).ToString(); } return location; } private static string BuildRuntimePolicyJson(RuntimePolicyEvaluationResult result, IReadOnlyList requestedImages) { var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys); var results = new Dictionary(StringComparer.Ordinal); foreach (var image in orderedImages) { if (result.Decisions.TryGetValue(image, out var decision)) { results[image] = BuildDecisionMap(decision); } } var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; var payload = new Dictionary(StringComparer.Ordinal) { ["ttlSeconds"] = result.TtlSeconds, ["expiresAtUtc"] = result.ExpiresAtUtc?.ToString("O", CultureInfo.InvariantCulture), ["policyRevision"] = result.PolicyRevision, ["results"] = results }; return JsonSerializer.Serialize(payload, options); } private static IDictionary BuildDecisionMap(RuntimePolicyImageDecision decision) { var map = new Dictionary(StringComparer.Ordinal) { ["policyVerdict"] = decision.PolicyVerdict, ["signed"] = decision.Signed, ["hasSbomReferrers"] = decision.HasSbomReferrers }; if (decision.Reasons.Count > 0) { map["reasons"] = decision.Reasons; } if (decision.Rekor is not null) { var rekorMap = new Dictionary(StringComparer.Ordinal); if (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid)) { rekorMap["uuid"] = decision.Rekor.Uuid; } if (!string.IsNullOrWhiteSpace(decision.Rekor.Url)) { rekorMap["url"] = decision.Rekor.Url; } if (decision.Rekor.Verified.HasValue) { rekorMap["verified"] = decision.Rekor.Verified; } if (rekorMap.Count > 0) { map["rekor"] = rekorMap; } } foreach (var kvp in decision.AdditionalProperties) { map[kvp.Key] = kvp.Value; } return map; } private static void DisplayRuntimePolicyResults(ILogger logger, RuntimePolicyEvaluationResult result, IReadOnlyList requestedImages) { var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys); var summary = new Dictionary(StringComparer.OrdinalIgnoreCase); if (AnsiConsole.Profile.Capabilities.Interactive) { var table = new Table().Border(TableBorder.Rounded) .AddColumns("Image", "Verdict", "Signed", "SBOM Ref", "Quieted", "Confidence", "Reasons", "Attestation"); foreach (var image in orderedImages) { if (result.Decisions.TryGetValue(image, out var decision)) { table.AddRow( image, decision.PolicyVerdict, FormatBoolean(decision.Signed), FormatBoolean(decision.HasSbomReferrers), FormatQuietedDisplay(decision.AdditionalProperties), FormatConfidenceDisplay(decision.AdditionalProperties), decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-", FormatAttestation(decision.Rekor)); summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1; if (decision.AdditionalProperties.Count > 0) { var metadata = string.Join(", ", decision.AdditionalProperties.Select(kvp => $"{kvp.Key}={FormatAdditionalValue(kvp.Value)}")); logger.LogDebug("Metadata for {Image}: {Metadata}", image, metadata); } } else { table.AddRow(image, "", "-", "-", "-", "-", "-", "-"); } } AnsiConsole.Write(table); } else { foreach (var image in orderedImages) { if (result.Decisions.TryGetValue(image, out var decision)) { var reasons = decision.Reasons.Count > 0 ? string.Join(", ", decision.Reasons) : "none"; logger.LogInformation( "{Image} -> verdict={Verdict} signed={Signed} sbomRef={Sbom} quieted={Quieted} confidence={Confidence} attestation={Attestation} reasons={Reasons}", image, decision.PolicyVerdict, FormatBoolean(decision.Signed), FormatBoolean(decision.HasSbomReferrers), FormatQuietedDisplay(decision.AdditionalProperties), FormatConfidenceDisplay(decision.AdditionalProperties), FormatAttestation(decision.Rekor), reasons); summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1; if (decision.AdditionalProperties.Count > 0) { var metadata = string.Join(", ", decision.AdditionalProperties.Select(kvp => $"{kvp.Key}={FormatAdditionalValue(kvp.Value)}")); logger.LogDebug("Metadata for {Image}: {Metadata}", image, metadata); } } else { logger.LogWarning("{Image} -> no decision returned by backend.", image); } } } if (summary.Count > 0) { var summaryText = string.Join(", ", summary.Select(kvp => $"{kvp.Key}:{kvp.Value}")); logger.LogInformation("Verdict summary: {Summary}", summaryText); } } private static IReadOnlyList BuildImageOrder(IReadOnlyList requestedImages, IEnumerable actual) { var order = new List(); var seen = new HashSet(StringComparer.Ordinal); if (requestedImages is not null) { foreach (var image in requestedImages) { if (!string.IsNullOrWhiteSpace(image)) { var trimmed = image.Trim(); if (seen.Add(trimmed)) { order.Add(trimmed); } } } } foreach (var image in actual) { if (!string.IsNullOrWhiteSpace(image)) { var trimmed = image.Trim(); if (seen.Add(trimmed)) { order.Add(trimmed); } } } return new ReadOnlyCollection(order); } private static string FormatBoolean(bool? value) => value is null ? "unknown" : value.Value ? "yes" : "no"; private static string FormatQuietedDisplay(IReadOnlyDictionary metadata) { var quieted = GetMetadataBoolean(metadata, "quieted", "quiet"); var quietedBy = GetMetadataString(metadata, "quietedBy", "quietedReason"); if (quieted is true) { return string.IsNullOrWhiteSpace(quietedBy) ? "yes" : $"yes ({quietedBy})"; } if (quieted is false) { return "no"; } return string.IsNullOrWhiteSpace(quietedBy) ? "-" : $"? ({quietedBy})"; } private static string FormatConfidenceDisplay(IReadOnlyDictionary metadata) { var confidence = GetMetadataDouble(metadata, "confidence"); var confidenceBand = GetMetadataString(metadata, "confidenceBand", "confidenceTier"); if (confidence.HasValue && !string.IsNullOrWhiteSpace(confidenceBand)) { return string.Format(CultureInfo.InvariantCulture, "{0:0.###} ({1})", confidence.Value, confidenceBand); } if (confidence.HasValue) { return confidence.Value.ToString("0.###", CultureInfo.InvariantCulture); } if (!string.IsNullOrWhiteSpace(confidenceBand)) { return confidenceBand!; } return "-"; } private static string FormatAttestation(RuntimePolicyRekorReference? rekor) { if (rekor is null) { return "-"; } var uuid = string.IsNullOrWhiteSpace(rekor.Uuid) ? null : rekor.Uuid; var url = string.IsNullOrWhiteSpace(rekor.Url) ? null : rekor.Url; var verified = rekor.Verified; var core = uuid ?? url; if (!string.IsNullOrEmpty(core)) { if (verified.HasValue) { var suffix = verified.Value ? " (verified)" : " (unverified)"; return core + suffix; } return core!; } if (verified.HasValue) { return verified.Value ? "verified" : "unverified"; } return "-"; } private static bool? GetMetadataBoolean(IReadOnlyDictionary metadata, params string[] keys) { foreach (var key in keys) { if (metadata.TryGetValue(key, out var value) && value is not null) { switch (value) { case bool b: return b; case string s when bool.TryParse(s, out var parsed): return parsed; } } } return null; } private static string? GetMetadataString(IReadOnlyDictionary metadata, params string[] keys) { foreach (var key in keys) { if (metadata.TryGetValue(key, out var value) && value is not null) { if (value is string s) { return string.IsNullOrWhiteSpace(s) ? null : s; } } } return null; } private static double? GetMetadataDouble(IReadOnlyDictionary metadata, params string[] keys) { foreach (var key in keys) { if (metadata.TryGetValue(key, out var value) && value is not null) { switch (value) { case double d: return d; case float f: return f; case decimal m: return (double)m; case long l: return l; case int i: return i; case string s when double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed): return parsed; } } } return null; } private static PolicySimulationOutputFormat DeterminePolicySimulationFormat(string? value, string? outputPath) { if (!string.IsNullOrWhiteSpace(value)) { return value.Trim().ToLowerInvariant() switch { "table" => PolicySimulationOutputFormat.Table, "json" => PolicySimulationOutputFormat.Json, _ => throw new ArgumentException("Invalid format. Use 'table' or 'json'.") }; } if (!string.IsNullOrWhiteSpace(outputPath) || Console.IsOutputRedirected) { return PolicySimulationOutputFormat.Json; } return PolicySimulationOutputFormat.Table; } private static object BuildPolicySimulationPayload( string policyId, int? baseVersion, int? candidateVersion, IReadOnlyList sbomSet, IReadOnlyDictionary environment, PolicySimulationResult result) => new { policyId, baseVersion, candidateVersion, sbomSet = sbomSet.Count == 0 ? Array.Empty() : sbomSet, environment = environment.Count == 0 ? null : environment, diff = result.Diff, explainUri = result.ExplainUri }; private static void RenderPolicySimulationResult( ILogger logger, object payload, PolicySimulationResult result, PolicySimulationOutputFormat format) { if (format == PolicySimulationOutputFormat.Json) { var json = JsonSerializer.Serialize(payload, SimulationJsonOptions); Console.WriteLine(json); return; } logger.LogInformation( "Policy diff summary — Added: {Added}, Removed: {Removed}, Unchanged: {Unchanged}.", result.Diff.Added, result.Diff.Removed, result.Diff.Unchanged); 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); } } private static IReadOnlyList NormalizePolicySbomSet(IReadOnlyList arguments) { if (arguments is null || arguments.Count == 0) { return EmptyPolicySbomSet; } var set = new SortedSet(StringComparer.Ordinal); foreach (var raw in arguments) { if (string.IsNullOrWhiteSpace(raw)) { continue; } var trimmed = raw.Trim(); if (trimmed.Length > 0) { set.Add(trimmed); } } if (set.Count == 0) { return EmptyPolicySbomSet; } var list = set.ToList(); return new ReadOnlyCollection(list); } private static IReadOnlyDictionary ParsePolicyEnvironment(IReadOnlyList arguments) { if (arguments is null || arguments.Count == 0) { return EmptyPolicyEnvironment; } var env = new SortedDictionary(StringComparer.Ordinal); foreach (var raw in arguments) { if (string.IsNullOrWhiteSpace(raw)) { continue; } var trimmed = raw.Trim(); var separator = trimmed.IndexOf('='); if (separator <= 0 || separator == trimmed.Length - 1) { throw new ArgumentException($"Invalid environment assignment '{raw}'. Expected key=value."); } var key = trimmed[..separator].Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(key)) { throw new ArgumentException($"Invalid environment assignment '{raw}'. Expected key=value."); } var valueToken = trimmed[(separator + 1)..].Trim(); env[key] = ParsePolicyEnvironmentValue(valueToken); } return env.Count == 0 ? EmptyPolicyEnvironment : new ReadOnlyDictionary(env); } private static object? ParsePolicyEnvironmentValue(string token) { if (string.IsNullOrWhiteSpace(token)) { return string.Empty; } var value = token; if ((value.Length >= 2 && value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal)) || (value.Length >= 2 && value.StartsWith("'", StringComparison.Ordinal) && value.EndsWith("'", StringComparison.Ordinal))) { value = value[1..^1]; } if (string.Equals(value, "null", StringComparison.OrdinalIgnoreCase)) { return null; } if (bool.TryParse(value, out var boolResult)) { return boolResult; } if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longResult)) { return longResult; } if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var doubleResult)) { return doubleResult; } return value; } private static async Task WriteSimulationOutputAsync(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 string FormatDelta(int? value) => value.HasValue ? value.Value.ToString("N0", CultureInfo.InvariantCulture) : "-"; private static readonly JsonSerializerOptions SimulationJsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private static readonly IReadOnlyDictionary EmptyPolicyEnvironment = new ReadOnlyDictionary(new Dictionary(0, StringComparer.Ordinal)); private static readonly IReadOnlyList EmptyPolicySbomSet = new ReadOnlyCollection(Array.Empty()); private static readonly IReadOnlyDictionary EmptyLabelSelectors = new ReadOnlyDictionary(new Dictionary(0, StringComparer.OrdinalIgnoreCase)); private enum PolicySimulationOutputFormat { Table, Json } private static string FormatAdditionalValue(object? value) { return value switch { null => "null", bool b => b ? "true" : "false", double d => d.ToString("G17", CultureInfo.InvariantCulture), float f => f.ToString("G9", CultureInfo.InvariantCulture), IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? string.Empty }; } private static IReadOnlyList NormalizeProviders(IReadOnlyList providers) { if (providers is null || providers.Count == 0) { return Array.Empty(); } var list = new List(); foreach (var provider in providers) { if (!string.IsNullOrWhiteSpace(provider)) { list.Add(provider.Trim()); } } return list.Count == 0 ? Array.Empty() : 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 LoadIngestInputAsync(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(uri, cancellationToken).ConfigureAwait(false); } return await LoadIngestInputFromFileAsync(input, cancellationToken).ConfigureAwait(false); } private static async Task LoadIngestInputFromHttpAsync(Uri uri, CancellationToken cancellationToken) { using var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }; using var httpClient = new HttpClient(handler); 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 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(); 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 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(); 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 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 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; } private static bool TryDecodeBase64(string text, out byte[] decoded) { decoded = Array.Empty(); 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 AocViolationExitCodeMap = new Dictionary(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 IDictionary RemoveNullValues(Dictionary 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 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; } } }