using System; using System.Buffers; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; 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 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 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 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 readonly IReadOnlyDictionary EmptyLabelSelectors = new ReadOnlyDictionary(new Dictionary(0, StringComparer.OrdinalIgnoreCase)); 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 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; } } }