1146 lines
42 KiB
C#
1146 lines
42 KiB
C#
using System;
|
|
using System.Buffers;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "scanner download");
|
|
activity?.SetTag("stellaops.cli.channel", channel);
|
|
using var duration = CliMetrics.MeasureCommandDuration("scanner download");
|
|
|
|
try
|
|
{
|
|
var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (result.FromCache)
|
|
{
|
|
logger.LogInformation("Using cached scanner at {Path}.", result.Path);
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
|
|
}
|
|
|
|
CliMetrics.RecordScannerDownload(channel, result.FromCache);
|
|
|
|
if (install)
|
|
{
|
|
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
|
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
|
CliMetrics.RecordScannerInstall(channel);
|
|
}
|
|
|
|
Environment.ExitCode = 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to download scanner bundle.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleScannerRunAsync(
|
|
IServiceProvider services,
|
|
string runner,
|
|
string entry,
|
|
string targetDirectory,
|
|
IReadOnlyList<string> arguments,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal);
|
|
activity?.SetTag("stellaops.cli.command", "scan run");
|
|
activity?.SetTag("stellaops.cli.runner", runner);
|
|
activity?.SetTag("stellaops.cli.entry", entry);
|
|
activity?.SetTag("stellaops.cli.target", targetDirectory);
|
|
using var duration = CliMetrics.MeasureCommandDuration("scan run");
|
|
|
|
try
|
|
{
|
|
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
|
var resultsDirectory = options.ResultsDirectory;
|
|
|
|
var executionResult = await executor.RunAsync(
|
|
runner,
|
|
entry,
|
|
targetDirectory,
|
|
resultsDirectory,
|
|
arguments,
|
|
verbose,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
Environment.ExitCode = executionResult.ExitCode;
|
|
CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
|
|
|
|
if (executionResult.ExitCode == 0)
|
|
{
|
|
var backend = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
logger.LogInformation("Uploading scan artefact {Path}...", executionResult.ResultsPath);
|
|
await backend.UploadScanResultsAsync(executionResult.ResultsPath, cancellationToken).ConfigureAwait(false);
|
|
logger.LogInformation("Scan artefact uploaded.");
|
|
activity?.SetTag("stellaops.cli.results", executionResult.ResultsPath);
|
|
}
|
|
else
|
|
{
|
|
logger.LogWarning("Skipping automatic upload because scan exited with code {Code}.", executionResult.ExitCode);
|
|
}
|
|
|
|
logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath);
|
|
activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Scanner execution failed.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleScanUploadAsync(
|
|
IServiceProvider services,
|
|
string file,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "scan upload");
|
|
activity?.SetTag("stellaops.cli.file", file);
|
|
using var duration = CliMetrics.MeasureCommandDuration("scan upload");
|
|
|
|
try
|
|
{
|
|
var 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<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "db fetch");
|
|
activity?.SetTag("stellaops.cli.source", source);
|
|
activity?.SetTag("stellaops.cli.stage", stage);
|
|
if (!string.IsNullOrWhiteSpace(mode))
|
|
{
|
|
activity?.SetTag("stellaops.cli.mode", mode);
|
|
}
|
|
using var duration = CliMetrics.MeasureCommandDuration("db fetch");
|
|
|
|
try
|
|
{
|
|
var jobKind = $"source:{source}:{stage}";
|
|
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
if (!string.IsNullOrWhiteSpace(mode))
|
|
{
|
|
parameters["mode"] = mode;
|
|
}
|
|
|
|
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Connector job failed.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleMergeJobAsync(
|
|
IServiceProvider services,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "db merge");
|
|
using var duration = CliMetrics.MeasureCommandDuration("db merge");
|
|
|
|
try
|
|
{
|
|
await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Merge job failed.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleExportJobAsync(
|
|
IServiceProvider services,
|
|
string format,
|
|
bool delta,
|
|
bool? publishFull,
|
|
bool? publishDelta,
|
|
bool? includeFull,
|
|
bool? includeDelta,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
|
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
|
var previousLevel = verbosity.MinimumLevel;
|
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
|
using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
|
|
activity?.SetTag("stellaops.cli.command", "db export");
|
|
activity?.SetTag("stellaops.cli.format", format);
|
|
activity?.SetTag("stellaops.cli.delta", delta);
|
|
using var duration = CliMetrics.MeasureCommandDuration("db export");
|
|
activity?.SetTag("stellaops.cli.publish_full", publishFull);
|
|
activity?.SetTag("stellaops.cli.publish_delta", publishDelta);
|
|
activity?.SetTag("stellaops.cli.include_full", includeFull);
|
|
activity?.SetTag("stellaops.cli.include_delta", includeDelta);
|
|
|
|
try
|
|
{
|
|
var jobKind = format switch
|
|
{
|
|
"trivy-db" or "trivy" => "export:trivy-db",
|
|
_ => "export:json"
|
|
};
|
|
|
|
var isTrivy = jobKind == "export:trivy-db";
|
|
if (isTrivy
|
|
&& !publishFull.HasValue
|
|
&& !publishDelta.HasValue
|
|
&& !includeFull.HasValue
|
|
&& !includeDelta.HasValue
|
|
&& AnsiConsole.Profile.Capabilities.Interactive)
|
|
{
|
|
var overrides = TrivyDbExportPrompt.PromptOverrides();
|
|
publishFull = overrides.publishFull;
|
|
publishDelta = overrides.publishDelta;
|
|
includeFull = overrides.includeFull;
|
|
includeDelta = overrides.includeDelta;
|
|
}
|
|
|
|
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["delta"] = delta
|
|
};
|
|
if (publishFull.HasValue)
|
|
{
|
|
parameters["publishFull"] = publishFull.Value;
|
|
}
|
|
if (publishDelta.HasValue)
|
|
{
|
|
parameters["publishDelta"] = publishDelta.Value;
|
|
}
|
|
if (includeFull.HasValue)
|
|
{
|
|
parameters["includeFull"] = includeFull.Value;
|
|
}
|
|
if (includeDelta.HasValue)
|
|
{
|
|
parameters["includeDelta"] = includeDelta.Value;
|
|
}
|
|
|
|
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Export job failed.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
verbosity.MinimumLevel = previousLevel;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleAuthLoginAsync(
|
|
IServiceProvider services,
|
|
StellaOpsCliOptions options,
|
|
bool verbose,
|
|
bool force,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-login");
|
|
Environment.ExitCode = 0;
|
|
|
|
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
|
{
|
|
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
|
if (tokenClient is null)
|
|
{
|
|
logger.LogError("Authority client is not available. Ensure AddStellaOpsAuthClient is registered in Program.cs.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
|
|
if (string.IsNullOrWhiteSpace(cacheKey))
|
|
{
|
|
logger.LogError("Authority configuration is incomplete; unable to determine cache key.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (force)
|
|
{
|
|
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
var scopeName = AuthorityTokenUtilities.ResolveScope(options);
|
|
StellaOpsTokenResult token;
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(options.Authority.Password))
|
|
{
|
|
logger.LogError("Authority password must be provided when username is configured.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
token = await tokenClient.RequestPasswordTokenAsync(
|
|
options.Authority.Username,
|
|
options.Authority.Password!,
|
|
scopeName,
|
|
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<ILoggerFactory>().CreateLogger("auth-logout");
|
|
Environment.ExitCode = 0;
|
|
|
|
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
|
if (tokenClient is null)
|
|
{
|
|
logger.LogInformation("No authority client registered; nothing to remove.");
|
|
return;
|
|
}
|
|
|
|
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
|
|
if (string.IsNullOrWhiteSpace(cacheKey))
|
|
{
|
|
logger.LogInformation("Authority configuration missing; no cached tokens to remove.");
|
|
return;
|
|
}
|
|
|
|
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation("Cleared cached token for {Authority}.", options.Authority?.Url ?? "authority");
|
|
}
|
|
}
|
|
|
|
public static async Task HandleAuthStatusAsync(
|
|
IServiceProvider services,
|
|
StellaOpsCliOptions options,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-status");
|
|
Environment.ExitCode = 0;
|
|
|
|
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
|
{
|
|
logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
|
if (tokenClient is null)
|
|
{
|
|
logger.LogInformation("Authority client not registered; no cached tokens available.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
|
|
if (string.IsNullOrWhiteSpace(cacheKey))
|
|
{
|
|
logger.LogInformation("Authority configuration incomplete; no cached tokens available.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
|
if (entry is null)
|
|
{
|
|
logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
logger.LogInformation("Cached token for {Authority} expires at {Expires}.", options.Authority.Url, entry.ExpiresAtUtc.ToString("u"));
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
|
|
}
|
|
}
|
|
|
|
public static async Task HandleAuthWhoAmIAsync(
|
|
IServiceProvider services,
|
|
StellaOpsCliOptions options,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-whoami");
|
|
Environment.ExitCode = 0;
|
|
|
|
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
|
{
|
|
logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
|
if (tokenClient is null)
|
|
{
|
|
logger.LogInformation("Authority client not registered; no cached tokens available.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
|
|
if (string.IsNullOrWhiteSpace(cacheKey))
|
|
{
|
|
logger.LogInformation("Authority configuration incomplete; no cached tokens available.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
|
if (entry is null)
|
|
{
|
|
logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var grantType = string.IsNullOrWhiteSpace(options.Authority.Username) ? "client_credentials" : "password";
|
|
var now = DateTimeOffset.UtcNow;
|
|
var remaining = entry.ExpiresAtUtc - now;
|
|
if (remaining < TimeSpan.Zero)
|
|
{
|
|
remaining = TimeSpan.Zero;
|
|
}
|
|
|
|
logger.LogInformation("Authority: {Authority}", options.Authority.Url);
|
|
logger.LogInformation("Grant type: {GrantType}", grantType);
|
|
logger.LogInformation("Token type: {TokenType}", entry.TokenType);
|
|
logger.LogInformation("Expires: {Expires} ({Remaining})", entry.ExpiresAtUtc.ToString("u"), FormatDuration(remaining));
|
|
|
|
if (entry.Scopes.Count > 0)
|
|
{
|
|
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
|
|
}
|
|
|
|
if (TryExtractJwtClaims(entry.AccessToken, out var claims, out var issuedAt, out var notBefore))
|
|
{
|
|
if (claims.TryGetValue("sub", out var subject) && !string.IsNullOrWhiteSpace(subject))
|
|
{
|
|
logger.LogInformation("Subject: {Subject}", subject);
|
|
}
|
|
|
|
if (claims.TryGetValue("client_id", out var clientId) && !string.IsNullOrWhiteSpace(clientId))
|
|
{
|
|
logger.LogInformation("Client ID (token): {ClientId}", clientId);
|
|
}
|
|
|
|
if (claims.TryGetValue("aud", out var audience) && !string.IsNullOrWhiteSpace(audience))
|
|
{
|
|
logger.LogInformation("Audience: {Audience}", audience);
|
|
}
|
|
|
|
if (claims.TryGetValue("iss", out var issuer) && !string.IsNullOrWhiteSpace(issuer))
|
|
{
|
|
logger.LogInformation("Issuer: {Issuer}", issuer);
|
|
}
|
|
|
|
if (issuedAt is not null)
|
|
{
|
|
logger.LogInformation("Issued at: {IssuedAt}", issuedAt.Value.ToString("u"));
|
|
}
|
|
|
|
if (notBefore is not null)
|
|
{
|
|
logger.LogInformation("Not before: {NotBefore}", notBefore.Value.ToString("u"));
|
|
}
|
|
|
|
var extraClaims = CollectAdditionalClaims(claims);
|
|
if (extraClaims.Count > 0 && verbose)
|
|
{
|
|
logger.LogInformation("Additional claims: {Claims}", string.Join(", ", extraClaims));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("Access token appears opaque; claims are unavailable.");
|
|
}
|
|
}
|
|
|
|
public static async Task HandleAuthRevokeExportAsync(
|
|
IServiceProvider services,
|
|
StellaOpsCliOptions options,
|
|
string? outputDirectory,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var scope = services.CreateAsyncScope();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-revoke-export");
|
|
Environment.ExitCode = 0;
|
|
|
|
try
|
|
{
|
|
var client = scope.ServiceProvider.GetRequiredService<IAuthorityRevocationClient>();
|
|
var result = await client.ExportAsync(verbose, cancellationToken).ConfigureAwait(false);
|
|
|
|
var directory = string.IsNullOrWhiteSpace(outputDirectory)
|
|
? Directory.GetCurrentDirectory()
|
|
: Path.GetFullPath(outputDirectory);
|
|
|
|
Directory.CreateDirectory(directory);
|
|
|
|
var bundlePath = Path.Combine(directory, "revocation-bundle.json");
|
|
var signaturePath = Path.Combine(directory, "revocation-bundle.json.jws");
|
|
var digestPath = Path.Combine(directory, "revocation-bundle.json.sha256");
|
|
|
|
await File.WriteAllBytesAsync(bundlePath, result.BundleBytes, cancellationToken).ConfigureAwait(false);
|
|
await File.WriteAllTextAsync(signaturePath, result.Signature, cancellationToken).ConfigureAwait(false);
|
|
await File.WriteAllTextAsync(digestPath, $"sha256:{result.Digest}", cancellationToken).ConfigureAwait(false);
|
|
|
|
var computedDigest = Convert.ToHexString(SHA256.HashData(result.BundleBytes)).ToLowerInvariant();
|
|
if (!string.Equals(computedDigest, result.Digest, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
logger.LogError("Digest mismatch. Expected {Expected} but computed {Actual}.", result.Digest, computedDigest);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Revocation bundle exported to {Directory} (sequence {Sequence}, issued {Issued:u}, signing key {KeyId}, provider {Provider}).",
|
|
directory,
|
|
result.Sequence,
|
|
result.IssuedAt,
|
|
string.IsNullOrWhiteSpace(result.SigningKeyId) ? "<unknown>" : result.SigningKeyId,
|
|
string.IsNullOrWhiteSpace(result.SigningProvider) ? "default" : result.SigningProvider);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to export revocation bundle.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
|
|
public static async Task HandleAuthRevokeVerifyAsync(
|
|
string bundlePath,
|
|
string signaturePath,
|
|
string keyPath,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(options =>
|
|
{
|
|
options.SingleLine = true;
|
|
options.TimestampFormat = "HH:mm:ss ";
|
|
}));
|
|
var logger = loggerFactory.CreateLogger("auth-revoke-verify");
|
|
Environment.ExitCode = 0;
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(signaturePath) || string.IsNullOrWhiteSpace(keyPath))
|
|
{
|
|
logger.LogError("Arguments --bundle, --signature, and --key are required.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var bundleBytes = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
|
var signatureContent = (await File.ReadAllTextAsync(signaturePath, cancellationToken).ConfigureAwait(false)).Trim();
|
|
var keyPem = await File.ReadAllTextAsync(keyPath, cancellationToken).ConfigureAwait(false);
|
|
|
|
var digest = Convert.ToHexString(SHA256.HashData(bundleBytes)).ToLowerInvariant();
|
|
logger.LogInformation("Bundle digest sha256:{Digest}", digest);
|
|
|
|
if (!TryParseDetachedJws(signatureContent, out var encodedHeader, out var encodedSignature))
|
|
{
|
|
logger.LogError("Signature is not in detached JWS format.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(encodedHeader));
|
|
using var headerDocument = JsonDocument.Parse(headerJson);
|
|
var header = headerDocument.RootElement;
|
|
|
|
if (!header.TryGetProperty("b64", out var b64Element) || b64Element.GetBoolean())
|
|
{
|
|
logger.LogError("Detached JWS header must include '\"b64\": false'.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var algorithm = header.TryGetProperty("alg", out var algElement) ? algElement.GetString() : SignatureAlgorithms.Es256;
|
|
if (string.IsNullOrWhiteSpace(algorithm))
|
|
{
|
|
algorithm = SignatureAlgorithms.Es256;
|
|
}
|
|
|
|
var providerHint = header.TryGetProperty("provider", out var providerElement)
|
|
? providerElement.GetString()
|
|
: null;
|
|
|
|
var keyId = header.TryGetProperty("kid", out var kidElement) ? kidElement.GetString() : null;
|
|
if (string.IsNullOrWhiteSpace(keyId))
|
|
{
|
|
keyId = Path.GetFileNameWithoutExtension(keyPath);
|
|
logger.LogWarning("JWS header missing 'kid'; using fallback key id {KeyId}.", keyId);
|
|
}
|
|
|
|
CryptoSigningKey signingKey;
|
|
try
|
|
{
|
|
signingKey = CreateVerificationSigningKey(keyId!, algorithm!, providerHint, keyPem, keyPath);
|
|
}
|
|
catch (Exception ex) when (ex is InvalidOperationException or CryptographicException)
|
|
{
|
|
logger.LogError(ex, "Failed to load verification key material.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var providers = new List<ICryptoProvider>
|
|
{
|
|
new DefaultCryptoProvider()
|
|
};
|
|
|
|
#if STELLAOPS_CRYPTO_SODIUM
|
|
providers.Add(new LibsodiumCryptoProvider());
|
|
#endif
|
|
|
|
foreach (var provider in providers)
|
|
{
|
|
if (provider.Supports(CryptoCapability.Verification, algorithm!))
|
|
{
|
|
provider.UpsertSigningKey(signingKey);
|
|
}
|
|
}
|
|
|
|
var preferredOrder = !string.IsNullOrWhiteSpace(providerHint)
|
|
? new[] { providerHint! }
|
|
: Array.Empty<string>();
|
|
var registry = new CryptoProviderRegistry(providers, preferredOrder);
|
|
CryptoSignerResolution resolution;
|
|
try
|
|
{
|
|
resolution = registry.ResolveSigner(
|
|
CryptoCapability.Verification,
|
|
algorithm!,
|
|
signingKey.Reference,
|
|
providerHint);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "No crypto provider available for verification (algorithm {Algorithm}).", algorithm);
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
var signingInputLength = encodedHeader.Length + 1 + bundleBytes.Length;
|
|
var buffer = ArrayPool<byte>.Shared.Rent(signingInputLength);
|
|
try
|
|
{
|
|
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
|
|
Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length);
|
|
buffer[headerBytes.Length] = (byte)'.';
|
|
Buffer.BlockCopy(bundleBytes, 0, buffer, headerBytes.Length + 1, bundleBytes.Length);
|
|
|
|
var signatureBytes = Base64UrlDecode(encodedSignature);
|
|
var verified = await resolution.Signer.VerifyAsync(
|
|
new ReadOnlyMemory<byte>(buffer, 0, signingInputLength),
|
|
signatureBytes,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!verified)
|
|
{
|
|
logger.LogError("Signature verification failed.");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.Shared.Return(buffer);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(providerHint) && !string.Equals(providerHint, resolution.ProviderName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
logger.LogWarning(
|
|
"Preferred provider '{Preferred}' unavailable; verification used '{Provider}'.",
|
|
providerHint,
|
|
resolution.ProviderName);
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Signature verified using algorithm {Algorithm} via provider {Provider} (kid {KeyId}).",
|
|
algorithm,
|
|
resolution.ProviderName,
|
|
signingKey.Reference.KeyId);
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation("JWS header: {Header}", headerJson);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to verify revocation bundle.");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
finally
|
|
{
|
|
loggerFactory.Dispose();
|
|
}
|
|
}
|
|
|
|
private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature)
|
|
{
|
|
encodedHeader = string.Empty;
|
|
encodedSignature = string.Empty;
|
|
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var parts = value.Split('.');
|
|
if (parts.Length != 3)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
encodedHeader = parts[0];
|
|
encodedSignature = parts[2];
|
|
return parts[1].Length == 0;
|
|
}
|
|
|
|
private static byte[] Base64UrlDecode(string value)
|
|
{
|
|
var normalized = value.Replace('-', '+').Replace('_', '/');
|
|
var padding = normalized.Length % 4;
|
|
if (padding == 2)
|
|
{
|
|
normalized += "==";
|
|
}
|
|
else if (padding == 3)
|
|
{
|
|
normalized += "=";
|
|
}
|
|
else if (padding == 1)
|
|
{
|
|
throw new FormatException("Invalid Base64Url value.");
|
|
}
|
|
|
|
return Convert.FromBase64String(normalized);
|
|
}
|
|
|
|
private static CryptoSigningKey CreateVerificationSigningKey(
|
|
string keyId,
|
|
string algorithm,
|
|
string? providerHint,
|
|
string keyPem,
|
|
string keyPath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(keyPem))
|
|
{
|
|
throw new InvalidOperationException("Verification key PEM content is empty.");
|
|
}
|
|
|
|
using var ecdsa = ECDsa.Create();
|
|
ecdsa.ImportFromPem(keyPem);
|
|
|
|
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
|
if (parameters.D is null || parameters.D.Length == 0)
|
|
{
|
|
parameters.D = new byte[] { 0x01 };
|
|
}
|
|
|
|
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["source"] = Path.GetFullPath(keyPath),
|
|
["verificationOnly"] = "true"
|
|
};
|
|
|
|
return new CryptoSigningKey(
|
|
new CryptoKeyReference(keyId, providerHint),
|
|
algorithm,
|
|
in parameters,
|
|
DateTimeOffset.UtcNow,
|
|
metadata: metadata);
|
|
}
|
|
|
|
private static string FormatDuration(TimeSpan duration)
|
|
{
|
|
if (duration <= TimeSpan.Zero)
|
|
{
|
|
return "expired";
|
|
}
|
|
|
|
if (duration.TotalDays >= 1)
|
|
{
|
|
var days = (int)duration.TotalDays;
|
|
var hours = duration.Hours;
|
|
return hours > 0
|
|
? FormattableString.Invariant($"{days}d {hours}h")
|
|
: FormattableString.Invariant($"{days}d");
|
|
}
|
|
|
|
if (duration.TotalHours >= 1)
|
|
{
|
|
return FormattableString.Invariant($"{(int)duration.TotalHours}h {duration.Minutes}m");
|
|
}
|
|
|
|
if (duration.TotalMinutes >= 1)
|
|
{
|
|
return FormattableString.Invariant($"{(int)duration.TotalMinutes}m {duration.Seconds}s");
|
|
}
|
|
|
|
return FormattableString.Invariant($"{duration.Seconds}s");
|
|
}
|
|
|
|
private static bool TryExtractJwtClaims(
|
|
string accessToken,
|
|
out Dictionary<string, string> claims,
|
|
out DateTimeOffset? issuedAt,
|
|
out DateTimeOffset? notBefore)
|
|
{
|
|
claims = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
issuedAt = null;
|
|
notBefore = null;
|
|
|
|
if (string.IsNullOrWhiteSpace(accessToken))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var parts = accessToken.Split('.');
|
|
if (parts.Length < 2)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!TryDecodeBase64Url(parts[1], out var payloadBytes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(payloadBytes);
|
|
foreach (var property in document.RootElement.EnumerateObject())
|
|
{
|
|
var value = FormatJsonValue(property.Value);
|
|
claims[property.Name] = value;
|
|
|
|
if (issuedAt is null && property.NameEquals("iat") && TryParseUnixSeconds(property.Value, out var parsedIat))
|
|
{
|
|
issuedAt = parsedIat;
|
|
}
|
|
|
|
if (notBefore is null && property.NameEquals("nbf") && TryParseUnixSeconds(property.Value, out var parsedNbf))
|
|
{
|
|
notBefore = parsedNbf;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
claims.Clear();
|
|
issuedAt = null;
|
|
notBefore = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryDecodeBase64Url(string value, out byte[] bytes)
|
|
{
|
|
bytes = Array.Empty<byte>();
|
|
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var normalized = value.Replace('-', '+').Replace('_', '/');
|
|
var padding = normalized.Length % 4;
|
|
if (padding is 2 or 3)
|
|
{
|
|
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
|
|
}
|
|
else if (padding == 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
bytes = Convert.FromBase64String(normalized);
|
|
return true;
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string FormatJsonValue(JsonElement element)
|
|
{
|
|
return element.ValueKind switch
|
|
{
|
|
JsonValueKind.String => element.GetString() ?? string.Empty,
|
|
JsonValueKind.Number => element.TryGetInt64(out var longValue)
|
|
? longValue.ToString(CultureInfo.InvariantCulture)
|
|
: element.GetDouble().ToString(CultureInfo.InvariantCulture),
|
|
JsonValueKind.True => "true",
|
|
JsonValueKind.False => "false",
|
|
JsonValueKind.Null => "null",
|
|
JsonValueKind.Array => FormatArray(element),
|
|
JsonValueKind.Object => element.GetRawText(),
|
|
_ => element.GetRawText()
|
|
};
|
|
}
|
|
|
|
private static string FormatArray(JsonElement array)
|
|
{
|
|
var values = new List<string>();
|
|
foreach (var item in array.EnumerateArray())
|
|
{
|
|
values.Add(FormatJsonValue(item));
|
|
}
|
|
|
|
return string.Join(", ", values);
|
|
}
|
|
|
|
private static bool TryParseUnixSeconds(JsonElement element, out DateTimeOffset value)
|
|
{
|
|
value = default;
|
|
|
|
if (element.ValueKind == JsonValueKind.Number)
|
|
{
|
|
if (element.TryGetInt64(out var seconds))
|
|
{
|
|
value = DateTimeOffset.FromUnixTimeSeconds(seconds);
|
|
return true;
|
|
}
|
|
|
|
if (element.TryGetDouble(out var doubleValue))
|
|
{
|
|
value = DateTimeOffset.FromUnixTimeSeconds((long)doubleValue);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (element.ValueKind == JsonValueKind.String)
|
|
{
|
|
var text = element.GetString();
|
|
if (!string.IsNullOrWhiteSpace(text) && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
|
|
{
|
|
value = DateTimeOffset.FromUnixTimeSeconds(seconds);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static List<string> CollectAdditionalClaims(Dictionary<string, string> claims)
|
|
{
|
|
var result = new List<string>();
|
|
foreach (var pair in claims)
|
|
{
|
|
if (CommonClaimNames.Contains(pair.Key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
result.Add(FormattableString.Invariant($"{pair.Key}={pair.Value}"));
|
|
}
|
|
|
|
result.Sort(StringComparer.OrdinalIgnoreCase);
|
|
return result;
|
|
}
|
|
|
|
private static readonly HashSet<string> CommonClaimNames = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"aud",
|
|
"client_id",
|
|
"exp",
|
|
"iat",
|
|
"iss",
|
|
"nbf",
|
|
"scope",
|
|
"scopes",
|
|
"sub",
|
|
"token_type",
|
|
"jti"
|
|
};
|
|
|
|
private static async Task TriggerJobAsync(
|
|
IBackendOperationsClient client,
|
|
ILogger logger,
|
|
string jobKind,
|
|
IDictionary<string, object?> parameters,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
|
if (result.Success)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(result.Location))
|
|
{
|
|
logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
|
|
}
|
|
else if (result.Run is not null)
|
|
{
|
|
logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("Job accepted.");
|
|
}
|
|
|
|
Environment.ExitCode = 0;
|
|
}
|
|
else
|
|
{
|
|
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
}
|