up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
444
src/Cli/StellaOps.Cli/Configuration/CliProfile.cs
Normal file
444
src/Cli/StellaOps.Cli/Configuration/CliProfile.cs
Normal file
@@ -0,0 +1,444 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// CLI profile for storing named configurations.
|
||||
/// Per CLI-CORE-41-001, supports profiles/contexts for multi-environment workflows.
|
||||
/// </summary>
|
||||
public sealed class CliProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile name (e.g., "prod", "staging", "dev").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backend URL for this profile.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Concelier URL for this profile.
|
||||
/// </summary>
|
||||
public string? ConcelierUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority URL for this profile.
|
||||
/// </summary>
|
||||
public string? AuthorityUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Client ID for this profile.
|
||||
/// </summary>
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default scope for this profile.
|
||||
/// </summary>
|
||||
public string? DefaultScope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy Studio scopes for this profile.
|
||||
/// CLI-POLICY-27-006: Supports the Policy Studio scope family.
|
||||
/// </summary>
|
||||
public PolicyStudioScopes? PolicyScopes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for this profile.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an air-gapped/offline profile.
|
||||
/// </summary>
|
||||
public bool IsOffline { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offline kit directory for this profile.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default output format for this profile.
|
||||
/// </summary>
|
||||
public string? DefaultOutputFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional profile-specific settings.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Settings { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when profile was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when profile was last modified.
|
||||
/// </summary>
|
||||
public DateTimeOffset ModifiedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Profile store configuration.
|
||||
/// </summary>
|
||||
public sealed class CliProfileStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Current active profile name.
|
||||
/// </summary>
|
||||
public string? CurrentProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All stored profiles.
|
||||
/// </summary>
|
||||
public Dictionary<string, CliProfile> Profiles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Default telemetry opt-in status.
|
||||
/// </summary>
|
||||
public bool? TelemetryEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages CLI profiles persistence.
|
||||
/// </summary>
|
||||
public sealed class CliProfileManager
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly string _profilesFilePath;
|
||||
private CliProfileStore? _store;
|
||||
|
||||
public CliProfileManager(string? profilesDirectory = null)
|
||||
{
|
||||
var directory = profilesDirectory ?? GetDefaultProfilesDirectory();
|
||||
_profilesFilePath = Path.Combine(directory, "profiles.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current profile store.
|
||||
/// </summary>
|
||||
public async Task<CliProfileStore> GetStoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store is not null)
|
||||
return _store;
|
||||
|
||||
_store = await LoadStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return _store;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active profile.
|
||||
/// </summary>
|
||||
public async Task<CliProfile?> GetCurrentProfileAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(store.CurrentProfile))
|
||||
return null;
|
||||
|
||||
store.Profiles.TryGetValue(store.CurrentProfile, out var profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by name.
|
||||
/// </summary>
|
||||
public async Task<CliProfile?> GetProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.Profiles.TryGetValue(name, out var profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all profile names.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> ListProfilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return store.Profiles.Keys.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a profile as the current active profile.
|
||||
/// </summary>
|
||||
public async Task SetCurrentProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!store.Profiles.ContainsKey(name))
|
||||
throw new InvalidOperationException($"Profile '{name}' does not exist.");
|
||||
|
||||
store.CurrentProfile = name;
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves or updates a profile.
|
||||
/// </summary>
|
||||
public async Task SaveProfileAsync(CliProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.Profiles[profile.Name] = profile with { ModifiedAt = DateTimeOffset.UtcNow };
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile.
|
||||
/// </summary>
|
||||
public async Task<bool> RemoveProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!store.Profiles.Remove(name))
|
||||
return false;
|
||||
|
||||
if (string.Equals(store.CurrentProfile, name, StringComparison.OrdinalIgnoreCase))
|
||||
store.CurrentProfile = null;
|
||||
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets or clears the telemetry opt-in status.
|
||||
/// </summary>
|
||||
public async Task SetTelemetryEnabledAsync(bool? enabled, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.TelemetryEnabled = enabled;
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the telemetry opt-in status.
|
||||
/// </summary>
|
||||
public async Task<bool?> GetTelemetryEnabledAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return store.TelemetryEnabled;
|
||||
}
|
||||
|
||||
private async Task<CliProfileStore> LoadStoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(_profilesFilePath))
|
||||
return new CliProfileStore();
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(_profilesFilePath);
|
||||
var store = await JsonSerializer.DeserializeAsync<CliProfileStore>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return store ?? new CliProfileStore();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new CliProfileStore();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveStoreAsync(CliProfileStore store, CancellationToken cancellationToken)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_profilesFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
await using var stream = File.Create(_profilesFilePath);
|
||||
await JsonSerializer.SerializeAsync(stream, store, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
_store = store;
|
||||
}
|
||||
|
||||
private static string GetDefaultProfilesDirectory()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(home))
|
||||
home = AppContext.BaseDirectory;
|
||||
|
||||
return Path.Combine(home, ".stellaops");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy Studio scope configuration.
|
||||
/// CLI-POLICY-27-006: Defines the Policy Studio scope family for CLI operations.
|
||||
/// </summary>
|
||||
public sealed class PolicyStudioScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Base scope for Policy Studio operations.
|
||||
/// Required for all policy commands.
|
||||
/// </summary>
|
||||
public const string PolicyRead = "stella.policy:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy write operations (create, update, delete).
|
||||
/// </summary>
|
||||
public const string PolicyWrite = "stella.policy:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy simulation operations.
|
||||
/// </summary>
|
||||
public const string PolicySimulate = "stella.policy:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy workflow operations (submit, review, approve, reject).
|
||||
/// </summary>
|
||||
public const string PolicyWorkflow = "stella.policy:workflow";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy publish/promote operations.
|
||||
/// </summary>
|
||||
public const string PolicyPublish = "stella.policy:publish";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy signing operations.
|
||||
/// </summary>
|
||||
public const string PolicySign = "stella.policy:sign";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for risk profile operations.
|
||||
/// </summary>
|
||||
public const string RiskRead = "stella.risk:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for risk simulation operations.
|
||||
/// </summary>
|
||||
public const string RiskSimulate = "stella.risk:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for reachability operations.
|
||||
/// </summary>
|
||||
public const string ReachabilityRead = "stella.reachability:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for reachability upload operations.
|
||||
/// </summary>
|
||||
public const string ReachabilityUpload = "stella.reachability:upload";
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy read operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyRead { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy write operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyWrite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy simulation is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicySimulate { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy workflow operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyWorkflow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy publish operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyPublish { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy signing is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicySign { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether risk operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnableRisk { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether reachability operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnableReachability { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the scope string for token requests.
|
||||
/// </summary>
|
||||
public string BuildScopeString()
|
||||
{
|
||||
var scopes = new List<string>();
|
||||
|
||||
if (EnablePolicyRead)
|
||||
scopes.Add(PolicyRead);
|
||||
if (EnablePolicyWrite)
|
||||
scopes.Add(PolicyWrite);
|
||||
if (EnablePolicySimulate)
|
||||
scopes.Add(PolicySimulate);
|
||||
if (EnablePolicyWorkflow)
|
||||
scopes.Add(PolicyWorkflow);
|
||||
if (EnablePolicyPublish)
|
||||
scopes.Add(PolicyPublish);
|
||||
if (EnablePolicySign)
|
||||
scopes.Add(PolicySign);
|
||||
if (EnableRisk)
|
||||
{
|
||||
scopes.Add(RiskRead);
|
||||
scopes.Add(RiskSimulate);
|
||||
}
|
||||
if (EnableReachability)
|
||||
{
|
||||
scopes.Add(ReachabilityRead);
|
||||
scopes.Add(ReachabilityUpload);
|
||||
}
|
||||
|
||||
return string.Join(" ", scopes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets guidance message for invalid_scope errors.
|
||||
/// </summary>
|
||||
public static string GetInvalidScopeGuidance(string? requiredScope)
|
||||
{
|
||||
var guidance = new System.Text.StringBuilder();
|
||||
guidance.AppendLine("The requested operation requires additional OAuth scopes.");
|
||||
guidance.AppendLine();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requiredScope))
|
||||
{
|
||||
guidance.AppendLine($"Required scope: {requiredScope}");
|
||||
guidance.AppendLine();
|
||||
}
|
||||
|
||||
guidance.AppendLine("To resolve this issue:");
|
||||
guidance.AppendLine(" 1. Check your CLI profile configuration: stella profile show");
|
||||
guidance.AppendLine(" 2. Update your profile with required scopes: stella profile edit <name>");
|
||||
guidance.AppendLine(" 3. Request additional scopes from your administrator");
|
||||
guidance.AppendLine(" 4. Re-authenticate with the updated scopes: stella auth login");
|
||||
guidance.AppendLine();
|
||||
guidance.AppendLine("Available Policy Studio scopes:");
|
||||
guidance.AppendLine($" - {PolicyRead} (read policies)");
|
||||
guidance.AppendLine($" - {PolicyWrite} (create/update policies)");
|
||||
guidance.AppendLine($" - {PolicySimulate} (simulate policy changes)");
|
||||
guidance.AppendLine($" - {PolicyWorkflow} (submit/review/approve policies)");
|
||||
guidance.AppendLine($" - {PolicyPublish} (publish/promote policies)");
|
||||
guidance.AppendLine($" - {PolicySign} (sign policies)");
|
||||
guidance.AppendLine($" - {RiskRead} (read risk profiles)");
|
||||
guidance.AppendLine($" - {RiskSimulate} (simulate risk scoring)");
|
||||
guidance.AppendLine($" - {ReachabilityRead} (read reachability analyses)");
|
||||
guidance.AppendLine($" - {ReachabilityUpload} (upload call graphs)");
|
||||
|
||||
return guidance.ToString();
|
||||
}
|
||||
}
|
||||
140
src/Cli/StellaOps.Cli/Configuration/GlobalOptions.cs
Normal file
140
src/Cli/StellaOps.Cli/Configuration/GlobalOptions.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Output;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Global command-line options available to all commands.
|
||||
/// Per CLI-CORE-41-001, provides global flags for profile, output, verbosity, and telemetry.
|
||||
/// </summary>
|
||||
public sealed class GlobalOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile name to use for this invocation.
|
||||
/// </summary>
|
||||
public string? Profile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format (json, yaml, table).
|
||||
/// </summary>
|
||||
public OutputFormat OutputFormat { get; set; } = OutputFormat.Table;
|
||||
|
||||
/// <summary>
|
||||
/// Verbose output (debug level logging).
|
||||
/// </summary>
|
||||
public bool Verbose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Quiet mode (errors only).
|
||||
/// </summary>
|
||||
public bool Quiet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Disable colored output.
|
||||
/// </summary>
|
||||
public bool NoColor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override backend URL for this invocation.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override tenant ID for this invocation.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dry run mode - show what would happen without executing.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates System.CommandLine options for global flags.
|
||||
/// </summary>
|
||||
public static IEnumerable<Option> CreateGlobalOptions()
|
||||
{
|
||||
yield return new Option<string?>(
|
||||
aliases: ["--profile", "-p"],
|
||||
description: "Profile name to use for this invocation");
|
||||
|
||||
yield return new Option<OutputFormat>(
|
||||
aliases: ["--output", "-o"],
|
||||
getDefaultValue: () => OutputFormat.Table,
|
||||
description: "Output format (table, json, yaml)");
|
||||
|
||||
yield return new Option<bool>(
|
||||
aliases: ["--verbose", "-v"],
|
||||
description: "Enable verbose output");
|
||||
|
||||
yield return new Option<bool>(
|
||||
aliases: ["--quiet", "-q"],
|
||||
description: "Quiet mode - suppress non-error output");
|
||||
|
||||
yield return new Option<bool>(
|
||||
name: "--no-color",
|
||||
description: "Disable colored output");
|
||||
|
||||
yield return new Option<string?>(
|
||||
name: "--backend-url",
|
||||
description: "Override backend URL for this invocation");
|
||||
|
||||
yield return new Option<string?>(
|
||||
name: "--tenant-id",
|
||||
description: "Override tenant ID for this invocation");
|
||||
|
||||
yield return new Option<bool>(
|
||||
name: "--dry-run",
|
||||
description: "Show what would happen without executing");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses global options from invocation context.
|
||||
/// </summary>
|
||||
public static GlobalOptions FromInvocationContext(System.CommandLine.Invocation.InvocationContext context)
|
||||
{
|
||||
var options = new GlobalOptions();
|
||||
|
||||
var profileOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--profile"));
|
||||
if (profileOption is not null)
|
||||
options.Profile = context.ParseResult.GetValueForOption(profileOption) as string;
|
||||
|
||||
var outputOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--output"));
|
||||
if (outputOption is not null && context.ParseResult.GetValueForOption(outputOption) is OutputFormat format)
|
||||
options.OutputFormat = format;
|
||||
|
||||
var verboseOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--verbose"));
|
||||
if (verboseOption is not null && context.ParseResult.GetValueForOption(verboseOption) is bool verbose)
|
||||
options.Verbose = verbose;
|
||||
|
||||
var quietOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--quiet"));
|
||||
if (quietOption is not null && context.ParseResult.GetValueForOption(quietOption) is bool quiet)
|
||||
options.Quiet = quiet;
|
||||
|
||||
var noColorOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--no-color"));
|
||||
if (noColorOption is not null && context.ParseResult.GetValueForOption(noColorOption) is bool noColor)
|
||||
options.NoColor = noColor;
|
||||
|
||||
var backendOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--backend-url"));
|
||||
if (backendOption is not null)
|
||||
options.BackendUrl = context.ParseResult.GetValueForOption(backendOption) as string;
|
||||
|
||||
var tenantOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--tenant-id"));
|
||||
if (tenantOption is not null)
|
||||
options.TenantId = context.ParseResult.GetValueForOption(tenantOption) as string;
|
||||
|
||||
var dryRunOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--dry-run"));
|
||||
if (dryRunOption is not null && context.ParseResult.GetValueForOption(dryRunOption) is bool dryRun)
|
||||
options.DryRun = dryRun;
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
353
src/Cli/StellaOps.Cli/Output/CliError.cs
Normal file
353
src/Cli/StellaOps.Cli/Output/CliError.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Structured CLI error with code, message, and optional details.
|
||||
/// Per CLI-CORE-41-001, provides error mapping for standardized API error envelopes.
|
||||
/// CLI-SDK-62-002: Enhanced to surface error.code and trace_id from API responses.
|
||||
/// </summary>
|
||||
public sealed record CliError(
|
||||
string Code,
|
||||
string Message,
|
||||
string? TraceId = null,
|
||||
string? Detail = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null,
|
||||
string? RequestId = null,
|
||||
string? HelpUrl = null,
|
||||
int? RetryAfter = null,
|
||||
string? Target = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Exit code to use when this error occurs.
|
||||
/// </summary>
|
||||
public int ExitCode => GetExitCode(Code);
|
||||
|
||||
/// <summary>
|
||||
/// Maps error code prefixes to exit codes.
|
||||
/// </summary>
|
||||
private static int GetExitCode(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return 1;
|
||||
|
||||
// Authentication/authorization errors
|
||||
if (code.StartsWith("AUTH_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase))
|
||||
return 2;
|
||||
|
||||
// Invalid scope errors
|
||||
if (code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase))
|
||||
return 3;
|
||||
|
||||
// Not found errors
|
||||
if (code.StartsWith("NOT_FOUND", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_NOT_FOUND", StringComparison.OrdinalIgnoreCase))
|
||||
return 4;
|
||||
|
||||
// Validation errors
|
||||
if (code.StartsWith("VALIDATION_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_VALIDATION_", StringComparison.OrdinalIgnoreCase))
|
||||
return 5;
|
||||
|
||||
// Rate limit errors
|
||||
if (code.StartsWith("RATE_LIMIT", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_RATE_LIMIT", StringComparison.OrdinalIgnoreCase))
|
||||
return 6;
|
||||
|
||||
// Air-gap errors
|
||||
if (code.StartsWith("AIRGAP_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_AIRGAP_", StringComparison.OrdinalIgnoreCase))
|
||||
return 7;
|
||||
|
||||
// AOC errors
|
||||
if (code.StartsWith("ERR_AOC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 8;
|
||||
|
||||
// Aggregation errors
|
||||
if (code.StartsWith("ERR_AGG_", StringComparison.OrdinalIgnoreCase))
|
||||
return 9;
|
||||
|
||||
// Forensic verification errors
|
||||
if (code.StartsWith("ERR_FORENSIC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 12;
|
||||
|
||||
// Determinism errors
|
||||
if (code.StartsWith("ERR_DETER_", StringComparison.OrdinalIgnoreCase))
|
||||
return 13;
|
||||
|
||||
// Observability errors
|
||||
if (code.StartsWith("ERR_OBS_", StringComparison.OrdinalIgnoreCase))
|
||||
return 14;
|
||||
|
||||
// Pack errors
|
||||
if (code.StartsWith("ERR_PACK_", StringComparison.OrdinalIgnoreCase))
|
||||
return 15;
|
||||
|
||||
// Exception governance errors
|
||||
if (code.StartsWith("ERR_EXC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 16;
|
||||
|
||||
// Orchestrator errors
|
||||
if (code.StartsWith("ERR_ORCH_", StringComparison.OrdinalIgnoreCase))
|
||||
return 17;
|
||||
|
||||
// SBOM errors
|
||||
if (code.StartsWith("ERR_SBOM_", StringComparison.OrdinalIgnoreCase))
|
||||
return 18;
|
||||
|
||||
// Notify errors
|
||||
if (code.StartsWith("ERR_NOTIFY_", StringComparison.OrdinalIgnoreCase))
|
||||
return 19;
|
||||
|
||||
// Sbomer errors
|
||||
if (code.StartsWith("ERR_SBOMER_", StringComparison.OrdinalIgnoreCase))
|
||||
return 20;
|
||||
|
||||
// Network/connectivity errors
|
||||
if (code.StartsWith("NETWORK_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_NETWORK_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("CONNECTION_", StringComparison.OrdinalIgnoreCase))
|
||||
return 10;
|
||||
|
||||
// Timeout errors
|
||||
if (code.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase))
|
||||
return 11;
|
||||
|
||||
// Generic errors
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an exception.
|
||||
/// </summary>
|
||||
public static CliError FromException(Exception ex, string? traceId = null)
|
||||
{
|
||||
var code = ex switch
|
||||
{
|
||||
UnauthorizedAccessException => "ERR_AUTH_UNAUTHORIZED",
|
||||
TimeoutException => "ERR_TIMEOUT",
|
||||
OperationCanceledException => "ERR_CANCELLED",
|
||||
InvalidOperationException => "ERR_INVALID_OPERATION",
|
||||
ArgumentException => "ERR_VALIDATION_ARGUMENT",
|
||||
_ => "ERR_UNKNOWN"
|
||||
};
|
||||
|
||||
return new CliError(code, ex.Message, traceId, ex.InnerException?.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an HTTP status code.
|
||||
/// </summary>
|
||||
public static CliError FromHttpStatus(int statusCode, string? message = null, string? traceId = null)
|
||||
{
|
||||
var (code, defaultMessage) = statusCode switch
|
||||
{
|
||||
400 => ("ERR_VALIDATION_BAD_REQUEST", "Bad request"),
|
||||
401 => ("ERR_AUTH_UNAUTHORIZED", "Unauthorized"),
|
||||
403 => ("ERR_AUTH_FORBIDDEN", "Forbidden"),
|
||||
404 => ("ERR_NOT_FOUND", "Resource not found"),
|
||||
409 => ("ERR_CONFLICT", "Resource conflict"),
|
||||
422 => ("ERR_VALIDATION_UNPROCESSABLE", "Unprocessable entity"),
|
||||
429 => ("ERR_RATE_LIMIT", "Rate limit exceeded"),
|
||||
500 => ("ERR_SERVER_INTERNAL", "Internal server error"),
|
||||
502 => ("ERR_SERVER_BAD_GATEWAY", "Bad gateway"),
|
||||
503 => ("ERR_SERVER_UNAVAILABLE", "Service unavailable"),
|
||||
504 => ("ERR_SERVER_TIMEOUT", "Gateway timeout"),
|
||||
_ => ($"ERR_HTTP_{statusCode}", $"HTTP error {statusCode}")
|
||||
};
|
||||
|
||||
return new CliError(code, message ?? defaultMessage, traceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from a parsed API error.
|
||||
/// CLI-SDK-62-002: Surfaces standardized API error envelope fields.
|
||||
/// </summary>
|
||||
public static CliError FromParsedApiError(ParsedApiError error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
|
||||
Dictionary<string, string>? metadata = null;
|
||||
if (error.Metadata is not null && error.Metadata.Count > 0)
|
||||
{
|
||||
metadata = error.Metadata
|
||||
.Where(kvp => kvp.Value is not null)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "");
|
||||
}
|
||||
|
||||
return new CliError(
|
||||
Code: error.Code,
|
||||
Message: error.Message,
|
||||
TraceId: error.TraceId,
|
||||
Detail: error.Detail,
|
||||
Metadata: metadata,
|
||||
RequestId: error.RequestId,
|
||||
HelpUrl: error.HelpUrl,
|
||||
RetryAfter: error.RetryAfter,
|
||||
Target: error.Target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an API error envelope.
|
||||
/// CLI-SDK-62-002: Direct conversion from envelope format.
|
||||
/// </summary>
|
||||
public static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
var errorDetail = envelope.Error;
|
||||
var code = errorDetail?.Code ?? $"ERR_HTTP_{httpStatus}";
|
||||
var message = errorDetail?.Message ?? $"HTTP error {httpStatus}";
|
||||
|
||||
Dictionary<string, string>? metadata = null;
|
||||
if (errorDetail?.Metadata is not null && errorDetail.Metadata.Count > 0)
|
||||
{
|
||||
metadata = errorDetail.Metadata
|
||||
.Where(kvp => kvp.Value is not null)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "");
|
||||
}
|
||||
|
||||
return new CliError(
|
||||
Code: code,
|
||||
Message: message,
|
||||
TraceId: envelope.TraceId,
|
||||
Detail: errorDetail?.Detail,
|
||||
Metadata: metadata,
|
||||
RequestId: envelope.RequestId,
|
||||
HelpUrl: errorDetail?.HelpUrl,
|
||||
RetryAfter: errorDetail?.RetryAfter,
|
||||
Target: errorDetail?.Target);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known CLI error codes.
|
||||
/// </summary>
|
||||
public static class CliErrorCodes
|
||||
{
|
||||
public const string Unauthorized = "ERR_AUTH_UNAUTHORIZED";
|
||||
public const string Forbidden = "ERR_AUTH_FORBIDDEN";
|
||||
public const string InvalidScope = "ERR_AUTH_INVALID_SCOPE";
|
||||
public const string NotFound = "ERR_NOT_FOUND";
|
||||
public const string ValidationFailed = "ERR_VALIDATION_FAILED";
|
||||
public const string RateLimited = "ERR_RATE_LIMIT";
|
||||
public const string AirGapBlocked = "ERR_AIRGAP_EGRESS_BLOCKED";
|
||||
public const string AocViolation = "ERR_AOC_001";
|
||||
public const string NetworkError = "ERR_NETWORK_FAILED";
|
||||
public const string Timeout = "ERR_TIMEOUT";
|
||||
public const string Cancelled = "ERR_CANCELLED";
|
||||
public const string ConfigurationMissing = "ERR_CONFIG_MISSING";
|
||||
public const string ProfileNotFound = "ERR_PROFILE_NOT_FOUND";
|
||||
|
||||
// CLI-LNM-22-001: Aggregation error codes (exit code 9)
|
||||
public const string AggNoObservations = "ERR_AGG_NO_OBSERVATIONS";
|
||||
public const string AggConflictDetected = "ERR_AGG_CONFLICT_DETECTED";
|
||||
public const string AggLinksetEmpty = "ERR_AGG_LINKSET_EMPTY";
|
||||
public const string AggSourceMissing = "ERR_AGG_SOURCE_MISSING";
|
||||
public const string AggExportFailed = "ERR_AGG_EXPORT_FAILED";
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic verification error codes (exit code 12)
|
||||
public const string ForensicBundleNotFound = "ERR_FORENSIC_BUNDLE_NOT_FOUND";
|
||||
public const string ForensicBundleInvalid = "ERR_FORENSIC_BUNDLE_INVALID";
|
||||
public const string ForensicChecksumMismatch = "ERR_FORENSIC_CHECKSUM_MISMATCH";
|
||||
public const string ForensicSignatureInvalid = "ERR_FORENSIC_SIGNATURE_INVALID";
|
||||
public const string ForensicSignatureUntrusted = "ERR_FORENSIC_SIGNATURE_UNTRUSTED";
|
||||
public const string ForensicChainOfCustodyBroken = "ERR_FORENSIC_CHAIN_BROKEN";
|
||||
public const string ForensicTimelineInvalid = "ERR_FORENSIC_TIMELINE_INVALID";
|
||||
public const string ForensicTrustRootMissing = "ERR_FORENSIC_TRUST_ROOT_MISSING";
|
||||
|
||||
// CLI-DETER-70-003: Determinism error codes (exit code 13)
|
||||
public const string DeterminismDockerUnavailable = "ERR_DETER_DOCKER_UNAVAILABLE";
|
||||
public const string DeterminismNoImages = "ERR_DETER_NO_IMAGES";
|
||||
public const string DeterminismScannerMissing = "ERR_DETER_SCANNER_MISSING";
|
||||
public const string DeterminismThresholdFailed = "ERR_DETER_THRESHOLD_FAILED";
|
||||
public const string DeterminismRunFailed = "ERR_DETER_RUN_FAILED";
|
||||
public const string DeterminismManifestInvalid = "ERR_DETER_MANIFEST_INVALID";
|
||||
|
||||
// CLI-OBS-51-001: Observability error codes (exit code 14)
|
||||
public const string ObsConnectionFailed = "ERR_OBS_CONNECTION_FAILED";
|
||||
public const string ObsServiceUnavailable = "ERR_OBS_SERVICE_UNAVAILABLE";
|
||||
public const string ObsNoData = "ERR_OBS_NO_DATA";
|
||||
public const string ObsInvalidFilter = "ERR_OBS_INVALID_FILTER";
|
||||
public const string ObsOfflineViolation = "ERR_OBS_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-PACKS-42-001: Pack error codes (exit code 15)
|
||||
public const string PackNotFound = "ERR_PACK_NOT_FOUND";
|
||||
public const string PackValidationFailed = "ERR_PACK_VALIDATION_FAILED";
|
||||
public const string PackPlanFailed = "ERR_PACK_PLAN_FAILED";
|
||||
public const string PackRunFailed = "ERR_PACK_RUN_FAILED";
|
||||
public const string PackPushFailed = "ERR_PACK_PUSH_FAILED";
|
||||
public const string PackPullFailed = "ERR_PACK_PULL_FAILED";
|
||||
public const string PackVerifyFailed = "ERR_PACK_VERIFY_FAILED";
|
||||
public const string PackSignatureInvalid = "ERR_PACK_SIGNATURE_INVALID";
|
||||
public const string PackApprovalRequired = "ERR_PACK_APPROVAL_REQUIRED";
|
||||
public const string PackOfflineViolation = "ERR_PACK_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-EXC-25-001: Exception governance error codes (exit code 16)
|
||||
public const string ExcNotFound = "ERR_EXC_NOT_FOUND";
|
||||
public const string ExcValidationFailed = "ERR_EXC_VALIDATION_FAILED";
|
||||
public const string ExcCreateFailed = "ERR_EXC_CREATE_FAILED";
|
||||
public const string ExcPromoteFailed = "ERR_EXC_PROMOTE_FAILED";
|
||||
public const string ExcRevokeFailed = "ERR_EXC_REVOKE_FAILED";
|
||||
public const string ExcImportFailed = "ERR_EXC_IMPORT_FAILED";
|
||||
public const string ExcExportFailed = "ERR_EXC_EXPORT_FAILED";
|
||||
public const string ExcApprovalRequired = "ERR_EXC_APPROVAL_REQUIRED";
|
||||
public const string ExcExpired = "ERR_EXC_EXPIRED";
|
||||
public const string ExcConflict = "ERR_EXC_CONFLICT";
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator error codes (exit code 17)
|
||||
public const string OrchSourceNotFound = "ERR_ORCH_SOURCE_NOT_FOUND";
|
||||
public const string OrchSourcePaused = "ERR_ORCH_SOURCE_PAUSED";
|
||||
public const string OrchSourceThrottled = "ERR_ORCH_SOURCE_THROTTLED";
|
||||
public const string OrchTestFailed = "ERR_ORCH_TEST_FAILED";
|
||||
public const string OrchQuotaExceeded = "ERR_ORCH_QUOTA_EXCEEDED";
|
||||
public const string OrchConnectionFailed = "ERR_ORCH_CONNECTION_FAILED";
|
||||
|
||||
// CLI-PARITY-41-001: SBOM error codes (exit code 18)
|
||||
public const string SbomNotFound = "ERR_SBOM_NOT_FOUND";
|
||||
public const string SbomConnectionFailed = "ERR_SBOM_CONNECTION_FAILED";
|
||||
public const string SbomExportFailed = "ERR_SBOM_EXPORT_FAILED";
|
||||
public const string SbomCompareFailed = "ERR_SBOM_COMPARE_FAILED";
|
||||
public const string SbomInvalidFormat = "ERR_SBOM_INVALID_FORMAT";
|
||||
public const string SbomOfflineViolation = "ERR_SBOM_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-PARITY-41-002: Notify error codes (exit code 19)
|
||||
public const string NotifyChannelNotFound = "ERR_NOTIFY_CHANNEL_NOT_FOUND";
|
||||
public const string NotifyDeliveryNotFound = "ERR_NOTIFY_DELIVERY_NOT_FOUND";
|
||||
public const string NotifyConnectionFailed = "ERR_NOTIFY_CONNECTION_FAILED";
|
||||
public const string NotifySendFailed = "ERR_NOTIFY_SEND_FAILED";
|
||||
public const string NotifyTestFailed = "ERR_NOTIFY_TEST_FAILED";
|
||||
public const string NotifyRetryFailed = "ERR_NOTIFY_RETRY_FAILED";
|
||||
public const string NotifyOfflineViolation = "ERR_NOTIFY_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer error codes (exit code 20)
|
||||
public const string SbomerLayerNotFound = "ERR_SBOMER_LAYER_NOT_FOUND";
|
||||
public const string SbomerCompositionNotFound = "ERR_SBOMER_COMPOSITION_NOT_FOUND";
|
||||
public const string SbomerDsseInvalid = "ERR_SBOMER_DSSE_INVALID";
|
||||
public const string SbomerContentHashMismatch = "ERR_SBOMER_CONTENT_HASH_MISMATCH";
|
||||
public const string SbomerMerkleProofInvalid = "ERR_SBOMER_MERKLE_PROOF_INVALID";
|
||||
public const string SbomerComposeFailed = "ERR_SBOMER_COMPOSE_FAILED";
|
||||
public const string SbomerVerifyFailed = "ERR_SBOMER_VERIFY_FAILED";
|
||||
public const string SbomerNonDeterministic = "ERR_SBOMER_NON_DETERMINISTIC";
|
||||
public const string SbomerOfflineViolation = "ERR_SBOMER_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-POLICY-27-006: Policy Studio scope error codes (exit code 3)
|
||||
public const string PolicyStudioScopeRequired = "ERR_SCOPE_POLICY_STUDIO_REQUIRED";
|
||||
public const string PolicyStudioScopeInvalid = "ERR_SCOPE_POLICY_STUDIO_INVALID";
|
||||
public const string PolicyStudioScopeWorkflowRequired = "ERR_SCOPE_POLICY_WORKFLOW_REQUIRED";
|
||||
public const string PolicyStudioScopePublishRequired = "ERR_SCOPE_POLICY_PUBLISH_REQUIRED";
|
||||
public const string PolicyStudioScopeSignRequired = "ERR_SCOPE_POLICY_SIGN_REQUIRED";
|
||||
|
||||
// CLI-RISK-66-001: Risk scope error codes (exit code 3)
|
||||
public const string RiskScopeRequired = "ERR_SCOPE_RISK_REQUIRED";
|
||||
public const string RiskScopeProfileRequired = "ERR_SCOPE_RISK_PROFILE_REQUIRED";
|
||||
public const string RiskScopeSimulateRequired = "ERR_SCOPE_RISK_SIMULATE_REQUIRED";
|
||||
|
||||
// CLI-SIG-26-001: Reachability scope error codes (exit code 3)
|
||||
public const string ReachabilityScopeRequired = "ERR_SCOPE_REACHABILITY_REQUIRED";
|
||||
public const string ReachabilityScopeUploadRequired = "ERR_SCOPE_REACHABILITY_UPLOAD_REQUIRED";
|
||||
}
|
||||
211
src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
Normal file
211
src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for rendering CLI errors consistently.
|
||||
/// CLI-SDK-62-002: Provides standardized error output with error.code and trace_id.
|
||||
/// </summary>
|
||||
internal static class CliErrorRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error to the console.
|
||||
/// </summary>
|
||||
public static void Render(CliError error, bool verbose = false, bool asJson = false)
|
||||
{
|
||||
if (asJson)
|
||||
{
|
||||
RenderJson(error);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderConsole(error, verbose);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error as JSON.
|
||||
/// </summary>
|
||||
public static void RenderJson(CliError error)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = error.Code,
|
||||
message = error.Message,
|
||||
detail = error.Detail,
|
||||
target = error.Target,
|
||||
help_url = error.HelpUrl,
|
||||
retry_after = error.RetryAfter,
|
||||
metadata = error.Metadata
|
||||
},
|
||||
trace_id = error.TraceId,
|
||||
request_id = error.RequestId,
|
||||
exit_code = error.ExitCode
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(output, JsonOptions);
|
||||
AnsiConsole.WriteLine(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error to the console with formatting.
|
||||
/// </summary>
|
||||
public static void RenderConsole(CliError error, bool verbose = false)
|
||||
{
|
||||
// Main error message
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
||||
|
||||
// Error code
|
||||
AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(error.Code)}");
|
||||
|
||||
// Detail (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.Detail))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Detail:[/] {Markup.Escape(error.Detail)}");
|
||||
}
|
||||
|
||||
// Target (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.Target))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Target:[/] {Markup.Escape(error.Target)}");
|
||||
}
|
||||
|
||||
// Help URL (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.HelpUrl))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Help:[/] [link]{Markup.Escape(error.HelpUrl)}[/]");
|
||||
}
|
||||
|
||||
// Retry-after (if present)
|
||||
if (error.RetryAfter.HasValue)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Retry after:[/] {error.RetryAfter} seconds");
|
||||
}
|
||||
|
||||
// Trace/Request IDs (shown in verbose mode or always for debugging)
|
||||
if (verbose || !string.IsNullOrWhiteSpace(error.TraceId))
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
if (!string.IsNullOrWhiteSpace(error.TraceId))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Trace ID:[/] {Markup.Escape(error.TraceId)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(error.RequestId))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Request ID:[/] {Markup.Escape(error.RequestId)}");
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata (shown in verbose mode)
|
||||
if (verbose && error.Metadata is not null && error.Metadata.Count > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[grey]Metadata:[/]");
|
||||
foreach (var (key, value) in error.Metadata)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(key)}:[/] {Markup.Escape(value)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a simple error message.
|
||||
/// </summary>
|
||||
public static void RenderSimple(string message, string? code = null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
|
||||
if (!string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(code)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a warning message.
|
||||
/// </summary>
|
||||
public static void RenderWarning(string message)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(message)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders scope guidance for invalid_scope errors.
|
||||
/// </summary>
|
||||
public static void RenderScopeGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[yellow]The requested operation requires additional OAuth scopes.[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("To resolve this issue:");
|
||||
AnsiConsole.MarkupLine(" 1. Check your CLI profile configuration: [cyan]stella profile show[/]");
|
||||
AnsiConsole.MarkupLine(" 2. Update your profile with required scopes: [cyan]stella profile edit <name>[/]");
|
||||
AnsiConsole.MarkupLine(" 3. Request additional scopes from your administrator");
|
||||
AnsiConsole.MarkupLine(" 4. Re-authenticate with the updated scopes: [cyan]stella auth login[/]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders rate limit guidance.
|
||||
/// </summary>
|
||||
public static void RenderRateLimitGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.Contains("RATE_LIMIT", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[yellow]Rate limit exceeded.[/]");
|
||||
|
||||
if (error.RetryAfter.HasValue)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"Please wait [cyan]{error.RetryAfter}[/] seconds before retrying.");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("Please wait before retrying your request.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders authentication guidance.
|
||||
/// </summary>
|
||||
public static void RenderAuthGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (error.Code == CliErrorCodes.Unauthorized)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Authentication required.[/]");
|
||||
AnsiConsole.MarkupLine("Please authenticate using: [cyan]stella auth login[/]");
|
||||
}
|
||||
else if (error.Code == CliErrorCodes.Forbidden)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Access denied.[/]");
|
||||
AnsiConsole.MarkupLine("You do not have permission to perform this operation.");
|
||||
AnsiConsole.MarkupLine("Contact your administrator to request access.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders contextual guidance based on error code.
|
||||
/// </summary>
|
||||
public static void RenderGuidance(CliError error)
|
||||
{
|
||||
RenderScopeGuidance(error);
|
||||
RenderRateLimitGuidance(error);
|
||||
RenderAuthGuidance(error);
|
||||
}
|
||||
}
|
||||
98
src/Cli/StellaOps.Cli/Output/IOutputRenderer.cs
Normal file
98
src/Cli/StellaOps.Cli/Output/IOutputRenderer.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for rendering CLI output in multiple formats.
|
||||
/// Per CLI-CORE-41-001, supports json/yaml/table rendering.
|
||||
/// </summary>
|
||||
public interface IOutputRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current output format.
|
||||
/// </summary>
|
||||
OutputFormat Format { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Renders a single object to the output stream.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the object to render.</typeparam>
|
||||
/// <param name="value">The value to render.</param>
|
||||
/// <param name="writer">The text writer to output to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RenderAsync<T>(T value, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a collection as a table or list.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of items in the collection.</typeparam>
|
||||
/// <param name="items">The items to render.</param>
|
||||
/// <param name="writer">The text writer to output to.</param>
|
||||
/// <param name="columns">Optional column definitions for table format.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RenderTableAsync<T>(
|
||||
IEnumerable<T> items,
|
||||
TextWriter writer,
|
||||
IReadOnlyList<ColumnDefinition<T>>? columns = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a success message.
|
||||
/// </summary>
|
||||
Task RenderSuccessAsync(string message, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error message.
|
||||
/// </summary>
|
||||
Task RenderErrorAsync(string message, TextWriter writer, string? errorCode = null, string? traceId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a warning message.
|
||||
/// </summary>
|
||||
Task RenderWarningAsync(string message, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Column definition for table rendering.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the row item.</typeparam>
|
||||
public sealed class ColumnDefinition<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Column header text.
|
||||
/// </summary>
|
||||
public required string Header { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function to extract the column value from an item.
|
||||
/// </summary>
|
||||
public required Func<T, string?> ValueSelector { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional minimum width for the column.
|
||||
/// </summary>
|
||||
public int? MinWidth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum width for the column (truncates with ellipsis).
|
||||
/// </summary>
|
||||
public int? MaxWidth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Alignment for the column content.
|
||||
/// </summary>
|
||||
public ColumnAlignment Alignment { get; init; } = ColumnAlignment.Left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Column alignment for table rendering.
|
||||
/// </summary>
|
||||
public enum ColumnAlignment
|
||||
{
|
||||
Left,
|
||||
Right,
|
||||
Center
|
||||
}
|
||||
17
src/Cli/StellaOps.Cli/Output/OutputFormat.cs
Normal file
17
src/Cli/StellaOps.Cli/Output/OutputFormat.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Output format for CLI commands.
|
||||
/// Per CLI-CORE-41-001, supports json/yaml/table formats.
|
||||
/// </summary>
|
||||
public enum OutputFormat
|
||||
{
|
||||
/// <summary>Human-readable table format (default).</summary>
|
||||
Table,
|
||||
|
||||
/// <summary>JSON format for automation/scripting.</summary>
|
||||
Json,
|
||||
|
||||
/// <summary>YAML format for configuration/scripting.</summary>
|
||||
Yaml
|
||||
}
|
||||
396
src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
Normal file
396
src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IOutputRenderer"/>.
|
||||
/// Per CLI-CORE-41-001, renders output in json/yaml/table formats.
|
||||
/// </summary>
|
||||
public sealed class OutputRenderer : IOutputRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
||||
};
|
||||
|
||||
public OutputFormat Format { get; }
|
||||
|
||||
public OutputRenderer(OutputFormat format = OutputFormat.Table)
|
||||
{
|
||||
Format = format;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderAsync<T>(T value, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var output = Format switch
|
||||
{
|
||||
OutputFormat.Json => RenderJson(value),
|
||||
OutputFormat.Yaml => RenderYaml(value),
|
||||
OutputFormat.Table => RenderObject(value),
|
||||
_ => RenderObject(value)
|
||||
};
|
||||
|
||||
await writer.WriteLineAsync(output.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderTableAsync<T>(
|
||||
IEnumerable<T> items,
|
||||
TextWriter writer,
|
||||
IReadOnlyList<ColumnDefinition<T>>? columns = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var itemList = items.ToList();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var json = RenderJson(itemList);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
var yaml = RenderYaml(itemList);
|
||||
await writer.WriteLineAsync(yaml.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Table format
|
||||
if (itemList.Count == 0)
|
||||
{
|
||||
await writer.WriteLineAsync("(no results)".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveColumns = columns ?? InferColumns<T>();
|
||||
var table = BuildTable(itemList, effectiveColumns);
|
||||
await writer.WriteLineAsync(table.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderSuccessAsync(string message, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "success", message };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
await writer.WriteLineAsync($"status: success\nmessage: {EscapeYamlString(message)}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync($"✓ {message}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderErrorAsync(string message, TextWriter writer, string? errorCode = null, string? traceId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "error", error_code = errorCode, message, trace_id = traceId };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("status: error");
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
sb.AppendLine($"error_code: {errorCode}");
|
||||
sb.AppendLine($"message: {EscapeYamlString(message)}");
|
||||
if (!string.IsNullOrWhiteSpace(traceId))
|
||||
sb.AppendLine($"trace_id: {traceId}");
|
||||
await writer.WriteLineAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var output = new StringBuilder();
|
||||
output.Append("✗ ");
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
output.Append($"[{errorCode}] ");
|
||||
output.Append(message);
|
||||
if (!string.IsNullOrWhiteSpace(traceId))
|
||||
output.Append($" (trace: {traceId})");
|
||||
|
||||
await writer.WriteLineAsync(output.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderWarningAsync(string message, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "warning", message };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
await writer.WriteLineAsync($"status: warning\nmessage: {EscapeYamlString(message)}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync($"⚠ {message}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string RenderJson<T>(T value)
|
||||
{
|
||||
return JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
private static string RenderYaml<T>(T value)
|
||||
{
|
||||
// Simple YAML rendering via JSON conversion for now
|
||||
// A full YAML library would be used in production
|
||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return ConvertJsonElementToYaml(doc.RootElement, 0);
|
||||
}
|
||||
|
||||
private static string ConvertJsonElementToYaml(JsonElement element, int indent)
|
||||
{
|
||||
var indentStr = new string(' ', indent * 2);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine($"{indentStr}{prop.Name}:");
|
||||
sb.Append(ConvertJsonElementToYaml(prop.Value, indent + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{indentStr}{prop.Name}: {FormatYamlValue(prop.Value)}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Object || item.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine($"{indentStr}-");
|
||||
sb.Append(ConvertJsonElementToYaml(item, indent + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{indentStr}- {FormatYamlValue(item)}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
sb.AppendLine($"{indentStr}{FormatYamlValue(element)}");
|
||||
break;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatYamlValue(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => EscapeYamlString(element.GetString() ?? ""),
|
||||
JsonValueKind.Number => element.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static string EscapeYamlString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return "\"\"";
|
||||
|
||||
if (value.Contains('\n') || value.Contains(':') || value.Contains('#') ||
|
||||
value.StartsWith(' ') || value.EndsWith(' ') ||
|
||||
value.StartsWith('"') || value.StartsWith('\''))
|
||||
{
|
||||
return $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string RenderObject<T>(T value)
|
||||
{
|
||||
if (value is null)
|
||||
return "(null)";
|
||||
|
||||
var type = typeof(T);
|
||||
var properties = type.GetProperties()
|
||||
.Where(p => p.CanRead)
|
||||
.ToList();
|
||||
|
||||
if (properties.Count == 0)
|
||||
return value.ToString() ?? "(empty)";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var maxNameLength = properties.Max(p => p.Name.Length);
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
var propValue = prop.GetValue(value);
|
||||
var displayValue = propValue?.ToString() ?? "(null)";
|
||||
sb.AppendLine($"{prop.Name.PadRight(maxNameLength)} : {displayValue}");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ColumnDefinition<T>> InferColumns<T>()
|
||||
{
|
||||
var properties = typeof(T).GetProperties()
|
||||
.Where(p => p.CanRead && IsSimpleType(p.PropertyType))
|
||||
.Take(8) // Limit to 8 columns for readability
|
||||
.ToList();
|
||||
|
||||
return properties.Select(p => new ColumnDefinition<T>
|
||||
{
|
||||
Header = ToHeaderCase(p.Name),
|
||||
ValueSelector = item => p.GetValue(item)?.ToString()
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static bool IsSimpleType(Type type)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return underlying.IsPrimitive ||
|
||||
underlying == typeof(string) ||
|
||||
underlying == typeof(DateTime) ||
|
||||
underlying == typeof(DateTimeOffset) ||
|
||||
underlying == typeof(Guid) ||
|
||||
underlying == typeof(decimal) ||
|
||||
underlying.IsEnum;
|
||||
}
|
||||
|
||||
private static string ToHeaderCase(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return name;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
var c = name[i];
|
||||
if (i > 0 && char.IsUpper(c))
|
||||
{
|
||||
sb.Append(' ');
|
||||
}
|
||||
sb.Append(i == 0 ? char.ToUpperInvariant(c) : c);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildTable<T>(IReadOnlyList<T> items, IReadOnlyList<ColumnDefinition<T>> columns)
|
||||
{
|
||||
if (columns.Count == 0 || items.Count == 0)
|
||||
return "(no data)";
|
||||
|
||||
// Calculate column widths
|
||||
var widths = new int[columns.Count];
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
widths[i] = columns[i].Header.Length;
|
||||
if (columns[i].MinWidth.HasValue)
|
||||
widths[i] = Math.Max(widths[i], columns[i].MinWidth.Value);
|
||||
}
|
||||
|
||||
// Get all values and update widths
|
||||
var rows = new List<string[]>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var row = new string[columns.Count];
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
var value = columns[i].ValueSelector(item) ?? "";
|
||||
if (columns[i].MaxWidth.HasValue && value.Length > columns[i].MaxWidth.Value)
|
||||
{
|
||||
value = value[..(columns[i].MaxWidth.Value - 3)] + "...";
|
||||
}
|
||||
row[i] = value;
|
||||
widths[i] = Math.Max(widths[i], value.Length);
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
// Build output
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(PadColumn(columns[i].Header.ToUpperInvariant(), widths[i], columns[i].Alignment));
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Separator
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(new string('-', widths[i]));
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Rows
|
||||
foreach (var row in rows)
|
||||
{
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(PadColumn(row[i], widths[i], columns[i].Alignment));
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string PadColumn(string value, int width, ColumnAlignment alignment)
|
||||
{
|
||||
return alignment switch
|
||||
{
|
||||
ColumnAlignment.Right => value.PadLeft(width),
|
||||
ColumnAlignment.Center => value.PadLeft((width + value.Length) / 2).PadRight(width),
|
||||
_ => value.PadRight(width)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,85 @@ internal static class Program
|
||||
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
|
||||
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
|
||||
|
||||
// CLI-FORENSICS-53-001: Forensic snapshot client
|
||||
services.AddHttpClient<IForensicSnapshotClient, ForensicSnapshotClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "forensic-api");
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic verifier (local only, no HTTP)
|
||||
services.AddSingleton<IForensicVerifier, ForensicVerifier>();
|
||||
|
||||
// CLI-FORENSICS-54-002: Attestation reader (local only, no HTTP)
|
||||
services.AddSingleton<IAttestationReader, AttestationReader>();
|
||||
|
||||
// CLI-LNM-22-002: VEX observations client
|
||||
services.AddHttpClient<IVexObservationsClient, VexObservationsClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(2);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "vex-api");
|
||||
|
||||
// CLI-PROMO-70-001: Promotion assembler (local, may call crane/cosign)
|
||||
services.AddHttpClient<IPromotionAssembler, PromotionAssembler>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
|
||||
// CLI-DETER-70-003: Determinism harness (local only, executes docker)
|
||||
services.AddSingleton<IDeterminismHarness, DeterminismHarness>();
|
||||
|
||||
// CLI-OBS-51-001: Observability client for health metrics
|
||||
services.AddHttpClient<IObservabilityClient, ObservabilityClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "observability-api");
|
||||
|
||||
// CLI-PACKS-42-001: Pack client for Task Pack operations
|
||||
services.AddHttpClient<IPackClient, PackClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(10); // Pack operations may take longer
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "packs-api");
|
||||
|
||||
// CLI-EXC-25-001: Exception client for exception governance operations
|
||||
services.AddHttpClient<IExceptionClient, ExceptionClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "exceptions-api");
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator client for source/job management
|
||||
services.AddHttpClient<IOrchestratorClient, OrchestratorClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "orchestrator-api");
|
||||
|
||||
// CLI-PARITY-41-001: SBOM client for SBOM explorer
|
||||
services.AddHttpClient<ISbomClient, SbomClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbom-api");
|
||||
|
||||
// CLI-PARITY-41-002: Notify client for notification management
|
||||
services.AddHttpClient<INotifyClient, NotifyClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "notify-api");
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer client for layer/compose operations
|
||||
services.AddHttpClient<ISbomerClient, SbomerClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5); // Composition may take longer
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbomer-api");
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
|
||||
|
||||
385
src/Cli/StellaOps.Cli/Services/AttestationReader.cs
Normal file
385
src/Cli/StellaOps.Cli/Services/AttestationReader.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reader for attestation files (DSSE envelopes with in-toto statements).
|
||||
/// Per CLI-FORENSICS-54-002.
|
||||
/// </summary>
|
||||
internal sealed class AttestationReader : IAttestationReader
|
||||
{
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
private const string InTotoStatementType = "https://in-toto.io/Statement/v0.1";
|
||||
private const string InTotoStatementV1Type = "https://in-toto.io/Statement/v1";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ILogger<AttestationReader> _logger;
|
||||
private readonly IForensicVerifier _verifier;
|
||||
|
||||
public AttestationReader(ILogger<AttestationReader> logger, IForensicVerifier verifier)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
|
||||
}
|
||||
|
||||
public async Task<AttestationShowResult> ReadAttestationAsync(
|
||||
string filePath,
|
||||
AttestationShowOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_logger.LogDebug("Reading attestation from {FilePath}", filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Attestation file not found: {filePath}", filePath);
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AttestationEnvelope envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<AttestationEnvelope>(json, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid attestation JSON");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse attestation envelope from {FilePath}", filePath);
|
||||
throw new InvalidDataException($"Failed to parse attestation envelope: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid base64 payload: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
InTotoStatement statement;
|
||||
try
|
||||
{
|
||||
statement = JsonSerializer.Deserialize<InTotoStatement>(payloadJson, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid in-toto statement JSON");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse in-toto statement from payload");
|
||||
throw new InvalidDataException($"Failed to parse in-toto statement: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// Extract subjects
|
||||
var subjects = statement.Subject
|
||||
.Select(s => new AttestationSubjectInfo
|
||||
{
|
||||
Name = s.Name,
|
||||
DigestAlgorithm = s.Digest.Keys.FirstOrDefault() ?? "unknown",
|
||||
DigestValue = s.Digest.Values.FirstOrDefault() ?? string.Empty
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Extract signatures
|
||||
var signatures = new List<AttestationSignatureInfo>();
|
||||
var trustRoots = options.TrustRoots.ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.TrustRootPath))
|
||||
{
|
||||
var loadedRoots = await _verifier.LoadTrustRootsAsync(options.TrustRootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
trustRoots.AddRange(loadedRoots);
|
||||
}
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
var sigInfo = new AttestationSignatureInfo
|
||||
{
|
||||
KeyId = sig.KeyId ?? "(no key id)",
|
||||
Algorithm = "unknown" // Would need certificate parsing for actual algorithm
|
||||
};
|
||||
|
||||
if (options.VerifySignatures && trustRoots.Count > 0)
|
||||
{
|
||||
var matchingRoot = trustRoots.FirstOrDefault(tr =>
|
||||
string.Equals(tr.KeyId, sig.KeyId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingRoot is not null)
|
||||
{
|
||||
var isValid = VerifySignature(envelope, sig, matchingRoot);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
|
||||
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
|
||||
|
||||
sigInfo = sigInfo with
|
||||
{
|
||||
Algorithm = matchingRoot.Algorithm,
|
||||
IsValid = isValid,
|
||||
IsTrusted = isValid && timeValid,
|
||||
SignerInfo = new AttestationSignerInfo
|
||||
{
|
||||
Fingerprint = matchingRoot.Fingerprint,
|
||||
NotBefore = matchingRoot.NotBefore,
|
||||
NotAfter = matchingRoot.NotAfter
|
||||
},
|
||||
Reason = !isValid ? "Signature verification failed" :
|
||||
!timeValid ? "Key outside validity period" : null
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
sigInfo = sigInfo with
|
||||
{
|
||||
IsValid = null,
|
||||
IsTrusted = false,
|
||||
Reason = "No matching trust root found"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
signatures.Add(sigInfo);
|
||||
}
|
||||
|
||||
// Extract predicate summary
|
||||
var predicateSummary = ExtractPredicateSummary(statement);
|
||||
|
||||
// Build verification result
|
||||
AttestationVerificationResult? verificationResult = null;
|
||||
if (options.VerifySignatures)
|
||||
{
|
||||
var validCount = signatures.Count(s => s.IsValid == true);
|
||||
var trustedCount = signatures.Count(s => s.IsTrusted == true);
|
||||
var errors = signatures
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Reason))
|
||||
.Select(s => $"{s.KeyId}: {s.Reason}")
|
||||
.ToList();
|
||||
|
||||
verificationResult = new AttestationVerificationResult
|
||||
{
|
||||
IsValid = validCount > 0,
|
||||
SignatureCount = signatures.Count,
|
||||
ValidSignatures = validCount,
|
||||
TrustedSignatures = trustedCount,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
return new AttestationShowResult
|
||||
{
|
||||
FilePath = filePath,
|
||||
PayloadType = envelope.PayloadType,
|
||||
StatementType = statement.Type,
|
||||
PredicateType = statement.PredicateType,
|
||||
Subjects = subjects,
|
||||
Signatures = signatures,
|
||||
PredicateSummary = predicateSummary,
|
||||
VerificationResult = verificationResult
|
||||
};
|
||||
}
|
||||
|
||||
private static bool VerifySignature(
|
||||
AttestationEnvelope envelope,
|
||||
AttestationSignature sig,
|
||||
ForensicTrustRoot trustRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
var pae = BuildPreAuthEncoding(envelope.PayloadType, payloadBytes);
|
||||
var signatureBytes = Convert.FromBase64String(sig.Signature);
|
||||
var publicKeyBytes = Convert.FromBase64String(trustRoot.PublicKey);
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
|
||||
|
||||
return rsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
// DSSE PAE format: "DSSEv1" + len(payloadType) + payloadType + len(payload) + payload
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
// Write "DSSEv1" prefix length and value
|
||||
writer.Write((long)PaePrefix.Length);
|
||||
writer.Write(Encoding.UTF8.GetBytes(PaePrefix));
|
||||
|
||||
// Write payload type length and value
|
||||
writer.Write((long)payloadTypeBytes.Length);
|
||||
writer.Write(payloadTypeBytes);
|
||||
|
||||
// Write payload length and value
|
||||
writer.Write((long)payload.Length);
|
||||
writer.Write(payload);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static AttestationPredicateSummary? ExtractPredicateSummary(InTotoStatement statement)
|
||||
{
|
||||
if (statement.Predicate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var summary = new AttestationPredicateSummary
|
||||
{
|
||||
Type = statement.PredicateType
|
||||
};
|
||||
|
||||
// Try to extract common fields from predicate
|
||||
if (statement.Predicate is JsonElement element)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
var materials = new List<AttestationMaterial>();
|
||||
|
||||
// Extract buildType (SLSA)
|
||||
if (element.TryGetProperty("buildType", out var buildTypeProp) &&
|
||||
buildTypeProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { BuildType = buildTypeProp.GetString() };
|
||||
}
|
||||
|
||||
// Extract builder (SLSA)
|
||||
if (element.TryGetProperty("builder", out var builderProp))
|
||||
{
|
||||
if (builderProp.TryGetProperty("id", out var builderIdProp) &&
|
||||
builderIdProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { Builder = builderIdProp.GetString() };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract invocation ID (SLSA)
|
||||
if (element.TryGetProperty("invocation", out var invocationProp))
|
||||
{
|
||||
if (invocationProp.TryGetProperty("configSource", out var configSourceProp) &&
|
||||
configSourceProp.TryGetProperty("digest", out var digestProp))
|
||||
{
|
||||
foreach (var d in digestProp.EnumerateObject())
|
||||
{
|
||||
metadata[$"invocation.digest.{d.Name}"] = d.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract materials
|
||||
if (element.TryGetProperty("materials", out var materialsProp) &&
|
||||
materialsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var material in materialsProp.EnumerateArray())
|
||||
{
|
||||
var uri = string.Empty;
|
||||
var digest = new Dictionary<string, string>();
|
||||
|
||||
if (material.TryGetProperty("uri", out var uriProp) &&
|
||||
uriProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
uri = uriProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (material.TryGetProperty("digest", out var matDigestProp))
|
||||
{
|
||||
foreach (var d in matDigestProp.EnumerateObject())
|
||||
{
|
||||
digest[d.Name] = d.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
materials.Add(new AttestationMaterial { Uri = uri, Digest = digest });
|
||||
}
|
||||
|
||||
summary = summary with { Materials = materials };
|
||||
}
|
||||
|
||||
// Extract timestamp
|
||||
if (element.TryGetProperty("metadata", out var metaProp))
|
||||
{
|
||||
if (metaProp.TryGetProperty("buildStartedOn", out var startedProp) &&
|
||||
startedProp.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(startedProp.GetString(), out var started))
|
||||
{
|
||||
summary = summary with { Timestamp = started };
|
||||
}
|
||||
else if (metaProp.TryGetProperty("buildFinishedOn", out var finishedProp) &&
|
||||
finishedProp.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(finishedProp.GetString(), out var finished))
|
||||
{
|
||||
summary = summary with { Timestamp = finished };
|
||||
}
|
||||
|
||||
if (metaProp.TryGetProperty("invocationId", out var invIdProp) &&
|
||||
invIdProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { InvocationId = invIdProp.GetString() };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract VEX-specific fields
|
||||
if (statement.PredicateType.Contains("vex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (element.TryGetProperty("author", out var authorProp) &&
|
||||
authorProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
metadata["author"] = authorProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("timestamp", out var tsProp) &&
|
||||
tsProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(tsProp.GetString(), out var ts))
|
||||
{
|
||||
summary = summary with { Timestamp = ts };
|
||||
}
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("version", out var versionProp))
|
||||
{
|
||||
if (versionProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
metadata["version"] = versionProp.GetString() ?? string.Empty;
|
||||
}
|
||||
else if (versionProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
metadata["version"] = versionProp.GetInt32().ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.Count > 0)
|
||||
{
|
||||
summary = summary with { Metadata = metadata };
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,83 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
return result ?? new AdvisoryObservationsResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets advisory linkset with conflict information.
|
||||
/// Per CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
||||
AdvisoryLinksetQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildLinksetRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to query linkset (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryLinksetResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new AdvisoryLinksetResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single observation by ID.
|
||||
/// Per CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/concelier/observations/{Uri.EscapeDataString(observationId)}?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get observation (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryLinksetObservation>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildRequestUri(AdvisoryObservationsQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/observations?tenant=");
|
||||
@@ -130,6 +207,71 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLinksetRequestUri(AdvisoryLinksetQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/linkset?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
AppendValues(builder, "observationId", query.ObservationIds);
|
||||
AppendValues(builder, "alias", query.Aliases);
|
||||
AppendValues(builder, "purl", query.Purls);
|
||||
AppendValues(builder, "cpe", query.Cpes);
|
||||
AppendValues(builder, "source", query.Sources);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Severity))
|
||||
{
|
||||
builder.Append("&severity=");
|
||||
builder.Append(Uri.EscapeDataString(query.Severity));
|
||||
}
|
||||
|
||||
if (query.KevOnly.HasValue)
|
||||
{
|
||||
builder.Append("&kevOnly=");
|
||||
builder.Append(query.KevOnly.Value ? "true" : "false");
|
||||
}
|
||||
|
||||
if (query.HasFix.HasValue)
|
||||
{
|
||||
builder.Append("&hasFix=");
|
||||
builder.Append(query.HasFix.Value ? "true" : "false");
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append("&limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
builder.Append("&cursor=");
|
||||
builder.Append(Uri.EscapeDataString(query.Cursor));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
|
||||
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('&');
|
||||
builder.Append(name);
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
|
||||
|
||||
850
src/Cli/StellaOps.Cli/Services/DeterminismHarness.cs
Normal file
850
src/Cli/StellaOps.Cli/Services/DeterminismHarness.cs
Normal file
@@ -0,0 +1,850 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism harness for running scanner with frozen conditions.
|
||||
/// Per CLI-DETER-70-003.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismHarness : IDeterminismHarness
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly string[] ArtifactPatterns = new[]
|
||||
{
|
||||
"sbom.json", "sbom.spdx.json", "sbom.cdx.json",
|
||||
"vex.json", "findings.json", "scan.json",
|
||||
"layers.json", "metadata.json"
|
||||
};
|
||||
|
||||
private readonly ILogger<DeterminismHarness> _logger;
|
||||
|
||||
public DeterminismHarness(ILogger<DeterminismHarness> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DeterminismRunResult> RunAsync(
|
||||
DeterminismRunRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
var imageResults = new List<DeterminismImageResult>();
|
||||
var failedImages = new List<string>();
|
||||
|
||||
_logger.LogDebug("Starting determinism harness with {ImageCount} images, {Runs} runs each",
|
||||
request.Images.Count, request.Runs);
|
||||
|
||||
// Validate prerequisites
|
||||
if (!await IsDockerAvailableAsync(cancellationToken))
|
||||
{
|
||||
errors.Add("Docker is not available or not running");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
if (request.Images.Count == 0)
|
||||
{
|
||||
errors.Add("No images specified for determinism testing");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Scanner))
|
||||
{
|
||||
errors.Add("Scanner image reference is required");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
var outputDir = request.OutputDir ?? Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"stella-detscore-{DateTime.UtcNow:yyyyMMdd-HHmmss}");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
// Resolve SHAs
|
||||
var scannerSha = await ResolveImageDigestAsync(request.Scanner, cancellationToken) ?? "unknown";
|
||||
var policySha = await ComputeBundleShaAsync(request.PolicyBundle, cancellationToken) ?? "none";
|
||||
var feedsSha = await ComputeBundleShaAsync(request.FeedsBundle, cancellationToken) ?? "none";
|
||||
|
||||
var fixedClock = request.FixedClock ?? DateTimeOffset.UtcNow;
|
||||
|
||||
// Run determinism tests for each image
|
||||
foreach (var imageRef in request.Images)
|
||||
{
|
||||
_logger.LogInformation("Testing determinism for image: {Image}", imageRef);
|
||||
|
||||
try
|
||||
{
|
||||
var imageResult = await RunImageDeterminismAsync(
|
||||
imageRef,
|
||||
request,
|
||||
fixedClock,
|
||||
outputDir,
|
||||
cancellationToken);
|
||||
|
||||
imageResults.Add(imageResult);
|
||||
|
||||
if (imageResult.Score < request.ImageThreshold)
|
||||
{
|
||||
failedImages.Add(imageRef);
|
||||
warnings.Add($"Image {imageRef} score {imageResult.Score:P0} below threshold {request.ImageThreshold:P0}");
|
||||
}
|
||||
|
||||
if (imageResult.NonDeterministic.Count > 0)
|
||||
{
|
||||
warnings.Add($"Image {imageRef} has non-deterministic artifacts: {string.Join(", ", imageResult.NonDeterministic)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test determinism for image {Image}", imageRef);
|
||||
errors.Add($"Failed to test image {imageRef}: {ex.Message}");
|
||||
imageResults.Add(new DeterminismImageResult
|
||||
{
|
||||
Digest = imageRef,
|
||||
Runs = 0,
|
||||
Identical = 0,
|
||||
Score = 0,
|
||||
Notes = $"Error: {ex.Message}"
|
||||
});
|
||||
failedImages.Add(imageRef);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall score
|
||||
var overallScore = imageResults.Count > 0
|
||||
? imageResults.Average(r => r.Score)
|
||||
: 0;
|
||||
|
||||
var passedThreshold = overallScore >= request.OverallThreshold &&
|
||||
failedImages.Count == 0;
|
||||
|
||||
if (overallScore < request.OverallThreshold)
|
||||
{
|
||||
warnings.Add($"Overall score {overallScore:P0} below threshold {request.OverallThreshold:P0}");
|
||||
}
|
||||
|
||||
// Build manifest
|
||||
var manifest = new DeterminismManifest
|
||||
{
|
||||
Version = "1",
|
||||
Release = request.Release ?? $"local-{DateTime.UtcNow:yyyyMMdd}",
|
||||
Platform = request.Platform,
|
||||
PolicySha = policySha,
|
||||
FeedsSha = feedsSha,
|
||||
ScannerSha = scannerSha,
|
||||
Images = imageResults,
|
||||
OverallScore = overallScore,
|
||||
Thresholds = new DeterminismThresholds
|
||||
{
|
||||
ImageMin = request.ImageThreshold,
|
||||
OverallMin = request.OverallThreshold
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Execution = new DeterminismExecutionInfo
|
||||
{
|
||||
FixedClock = fixedClock,
|
||||
RngSeed = request.RngSeed,
|
||||
MaxConcurrency = request.MaxConcurrency,
|
||||
MemoryLimit = request.MemoryLimit,
|
||||
CpuSet = request.CpuSet,
|
||||
NetworkMode = "none"
|
||||
}
|
||||
};
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = Path.Combine(outputDir, "determinism.json");
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
|
||||
_logger.LogInformation("Wrote determinism manifest to {Path}", manifestPath);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
Manifest = manifest,
|
||||
OutputPath = manifestPath,
|
||||
PassedThreshold = passedThreshold,
|
||||
FailedImages = failedImages,
|
||||
Errors = errors,
|
||||
Warnings = warnings,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
public DeterminismVerificationResult VerifyManifest(
|
||||
DeterminismManifest manifest,
|
||||
double imageThreshold,
|
||||
double overallThreshold)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var failedImages = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var image in manifest.Images)
|
||||
{
|
||||
if (image.Score < imageThreshold)
|
||||
{
|
||||
failedImages.Add(image.Digest);
|
||||
}
|
||||
|
||||
if (image.NonDeterministic.Count > 0)
|
||||
{
|
||||
warnings.Add($"Image {image.Digest} has non-deterministic artifacts: {string.Join(", ", image.NonDeterministic)}");
|
||||
}
|
||||
}
|
||||
|
||||
var passed = manifest.OverallScore >= overallThreshold && failedImages.Count == 0;
|
||||
|
||||
if (manifest.OverallScore < overallThreshold)
|
||||
{
|
||||
warnings.Add($"Overall score {manifest.OverallScore:P0} below threshold {overallThreshold:P0}");
|
||||
}
|
||||
|
||||
return new DeterminismVerificationResult
|
||||
{
|
||||
Passed = passed,
|
||||
OverallScore = manifest.OverallScore,
|
||||
FailedImages = failedImages.ToArray(),
|
||||
Warnings = warnings.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<DeterminismImageResult> RunImageDeterminismAsync(
|
||||
string imageRef,
|
||||
DeterminismRunRequest request,
|
||||
DateTimeOffset fixedClock,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imageDigest = await ResolveImageDigestAsync(imageRef, cancellationToken) ?? imageRef;
|
||||
var imageOutputDir = Path.Combine(outputDir, SanitizeForPath(imageDigest));
|
||||
Directory.CreateDirectory(imageOutputDir);
|
||||
|
||||
var runDetails = new List<DeterminismRunDetail>();
|
||||
Dictionary<string, string>? baselineHashes = null;
|
||||
var identicalCount = 0;
|
||||
var nonDeterministic = new HashSet<string>();
|
||||
|
||||
for (var runNum = 1; runNum <= request.Runs; runNum++)
|
||||
{
|
||||
_logger.LogDebug("Running determinism test {Run}/{Total} for {Image}",
|
||||
runNum, request.Runs, imageRef);
|
||||
|
||||
var runDir = Path.Combine(imageOutputDir, $"run_{runNum}");
|
||||
Directory.CreateDirectory(runDir);
|
||||
|
||||
var runSw = Stopwatch.StartNew();
|
||||
var exitCode = await RunScannerAsync(
|
||||
imageRef,
|
||||
request.Scanner,
|
||||
request.PolicyBundle,
|
||||
request.FeedsBundle,
|
||||
fixedClock,
|
||||
request.RngSeed,
|
||||
request.MaxConcurrency,
|
||||
request.MemoryLimit,
|
||||
request.CpuSet,
|
||||
runDir,
|
||||
cancellationToken);
|
||||
runSw.Stop();
|
||||
|
||||
// Compute hashes for artifacts
|
||||
var artifactHashes = await ComputeArtifactHashesAsync(runDir, cancellationToken);
|
||||
|
||||
// Compare with baseline
|
||||
var isIdentical = true;
|
||||
if (baselineHashes == null)
|
||||
{
|
||||
baselineHashes = artifactHashes;
|
||||
isIdentical = true; // First run is always "identical" (it's the baseline)
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (artifact, hash) in artifactHashes)
|
||||
{
|
||||
if (baselineHashes.TryGetValue(artifact, out var baselineHash))
|
||||
{
|
||||
if (hash != baselineHash)
|
||||
{
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
_logger.LogWarning("Non-deterministic artifact {Artifact} in run {Run}: {Hash} != {Baseline}",
|
||||
artifact, runNum, hash[..16], baselineHash[..16]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New artifact not in baseline
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing artifacts
|
||||
foreach (var artifact in baselineHashes.Keys)
|
||||
{
|
||||
if (!artifactHashes.ContainsKey(artifact))
|
||||
{
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isIdentical)
|
||||
{
|
||||
identicalCount++;
|
||||
}
|
||||
|
||||
runDetails.Add(new DeterminismRunDetail
|
||||
{
|
||||
RunNumber = runNum,
|
||||
Identical = isIdentical,
|
||||
ArtifactHashes = artifactHashes,
|
||||
DurationMs = runSw.ElapsedMilliseconds,
|
||||
ExitCode = exitCode
|
||||
});
|
||||
}
|
||||
|
||||
var score = request.Runs > 0 ? (double)identicalCount / request.Runs : 0;
|
||||
|
||||
return new DeterminismImageResult
|
||||
{
|
||||
Digest = imageDigest,
|
||||
Runs = request.Runs,
|
||||
Identical = identicalCount,
|
||||
Score = score,
|
||||
ArtifactHashes = baselineHashes ?? new Dictionary<string, string>(),
|
||||
NonDeterministic = nonDeterministic.ToArray(),
|
||||
RunDetails = runDetails
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<int> RunScannerAsync(
|
||||
string imageRef,
|
||||
string scannerImage,
|
||||
string? policyBundle,
|
||||
string? feedsBundle,
|
||||
DateTimeOffset fixedClock,
|
||||
int rngSeed,
|
||||
int maxConcurrency,
|
||||
string memoryLimit,
|
||||
string cpuSet,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Build docker run command with determinism constraints
|
||||
var args = new StringBuilder();
|
||||
args.Append("run --rm ");
|
||||
args.Append($"--network=none ");
|
||||
args.Append($"--cpuset-cpus={cpuSet} ");
|
||||
args.Append($"--memory={memoryLimit} ");
|
||||
args.Append($"-e RNG_SEED={rngSeed} ");
|
||||
args.Append($"-e SCANNER_MAX_CONCURRENCY={maxConcurrency} ");
|
||||
args.Append($"-v \"{outputDir}:/output\" ");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyBundle) && File.Exists(policyBundle))
|
||||
{
|
||||
args.Append($"-v \"{Path.GetFullPath(policyBundle)}:/policy:ro\" ");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(feedsBundle) && File.Exists(feedsBundle))
|
||||
{
|
||||
args.Append($"-v \"{Path.GetFullPath(feedsBundle)}:/feeds:ro\" ");
|
||||
}
|
||||
|
||||
args.Append($"{scannerImage} ");
|
||||
args.Append($"scan --image {imageRef} ");
|
||||
args.Append($"--fixed-clock {fixedClock:yyyy-MM-ddTHH:mm:ssZ} ");
|
||||
args.Append("--output /output ");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = args.ToString(),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogDebug("Executing: docker {Args}", args);
|
||||
|
||||
process.Start();
|
||||
|
||||
// Read output asynchronously
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
_logger.LogDebug("Scanner stderr: {Stderr}", stderr);
|
||||
}
|
||||
|
||||
// Save stdout/stderr for diagnostics
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "stdout.log"),
|
||||
stdout,
|
||||
cancellationToken);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "stderr.log"),
|
||||
stderr,
|
||||
cancellationToken);
|
||||
|
||||
return process.ExitCode;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ComputeArtifactHashesAsync(
|
||||
string directory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hashes = new Dictionary<string, string>();
|
||||
|
||||
foreach (var pattern in ArtifactPatterns)
|
||||
{
|
||||
var filePath = Path.Combine(directory, pattern);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var hash = await ComputeFileHashAsync(filePath, cancellationToken);
|
||||
hashes[pattern] = hash;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for any .json files in output
|
||||
foreach (var file in Directory.GetFiles(directory, "*.json"))
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
if (!hashes.ContainsKey(fileName))
|
||||
{
|
||||
var hash = await ComputeFileHashAsync(file, cancellationToken);
|
||||
hashes[fileName] = hash;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static async Task<string?> ComputeBundleShaAsync(string? bundlePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
return null;
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
return null;
|
||||
|
||||
return await ComputeFileHashAsync(bundlePath, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveImageDigestAsync(string imageRef, CancellationToken cancellationToken)
|
||||
{
|
||||
// If already contains digest, extract it
|
||||
if (imageRef.Contains("@sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var atIndex = imageRef.IndexOf('@');
|
||||
if (atIndex >= 0)
|
||||
{
|
||||
return imageRef[(atIndex + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
// Try using crane/docker to resolve
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = $"inspect --format='{{{{.RepoDigests}}}}' {imageRef}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode == 0 && stdout.Contains("sha256:"))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(stdout, @"sha256:[a-f0-9]{64}");
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors, return null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<bool> IsDockerAvailableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = "version --format '{{.Server.Version}}'",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeForPath(string input)
|
||||
{
|
||||
// Replace invalid path characters
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var result = new StringBuilder(input);
|
||||
foreach (var c in invalid)
|
||||
{
|
||||
result.Replace(c, '_');
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
// CLI-DETER-70-004: Generate report implementation
|
||||
public async Task<DeterminismReportResult> GenerateReportAsync(
|
||||
DeterminismReportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
var manifests = new List<(string path, DeterminismManifest manifest)>();
|
||||
|
||||
_logger.LogDebug("Generating determinism report from {Count} manifests", request.ManifestPaths.Count);
|
||||
|
||||
if (request.ManifestPaths.Count == 0)
|
||||
{
|
||||
errors.Add("No manifest paths provided");
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
// Load all manifests
|
||||
foreach (var path in request.ManifestPaths)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
warnings.Add($"Manifest not found: {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<DeterminismManifest>(json, SerializerOptions);
|
||||
if (manifest != null)
|
||||
{
|
||||
manifests.Add((path, manifest));
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add($"Failed to parse manifest: {path}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"Error reading manifest {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (manifests.Count == 0)
|
||||
{
|
||||
errors.Add("No valid manifests found");
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
// Build release entries
|
||||
var releases = manifests.Select(m => new DeterminismReleaseEntry
|
||||
{
|
||||
Release = m.manifest.Release,
|
||||
Platform = m.manifest.Platform,
|
||||
OverallScore = m.manifest.OverallScore,
|
||||
Passed = m.manifest.OverallScore >= m.manifest.Thresholds.OverallMin,
|
||||
ImageCount = m.manifest.Images.Count,
|
||||
GeneratedAt = m.manifest.GeneratedAt,
|
||||
ScannerSha = m.manifest.ScannerSha,
|
||||
ManifestPath = m.path
|
||||
}).OrderByDescending(r => r.GeneratedAt).ToList();
|
||||
|
||||
// Build image matrix
|
||||
var imageScores = new Dictionary<string, Dictionary<string, double>>();
|
||||
var imageNonDet = new Dictionary<string, HashSet<string>>();
|
||||
|
||||
foreach (var (path, manifest) in manifests)
|
||||
{
|
||||
foreach (var img in manifest.Images)
|
||||
{
|
||||
if (!imageScores.ContainsKey(img.Digest))
|
||||
{
|
||||
imageScores[img.Digest] = new Dictionary<string, double>();
|
||||
imageNonDet[img.Digest] = new HashSet<string>();
|
||||
}
|
||||
|
||||
imageScores[img.Digest][manifest.Release] = img.Score;
|
||||
|
||||
foreach (var nd in img.NonDeterministic)
|
||||
{
|
||||
imageNonDet[img.Digest].Add(nd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var imageMatrix = imageScores.Select(kvp => new DeterminismImageMatrixEntry
|
||||
{
|
||||
ImageDigest = kvp.Key,
|
||||
Scores = kvp.Value,
|
||||
AverageScore = kvp.Value.Values.Average(),
|
||||
NonDeterministicArtifacts = imageNonDet[kvp.Key].ToArray()
|
||||
}).OrderBy(e => e.AverageScore).ToList();
|
||||
|
||||
// Compute summary
|
||||
var allScores = manifests.Select(m => m.manifest.OverallScore).ToList();
|
||||
var allNonDet = manifests
|
||||
.SelectMany(m => m.manifest.Images)
|
||||
.SelectMany(i => i.NonDeterministic)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var summary = new DeterminismReportSummary
|
||||
{
|
||||
TotalReleases = manifests.Count,
|
||||
TotalImages = imageMatrix.Count,
|
||||
AverageScore = allScores.Count > 0 ? allScores.Average() : 0,
|
||||
MinScore = allScores.Count > 0 ? allScores.Min() : 0,
|
||||
MaxScore = allScores.Count > 0 ? allScores.Max() : 0,
|
||||
PassedCount = releases.Count(r => r.Passed),
|
||||
FailedCount = releases.Count(r => !r.Passed),
|
||||
NonDeterministicArtifacts = allNonDet
|
||||
};
|
||||
|
||||
var report = new DeterminismReport
|
||||
{
|
||||
Title = request.Title ?? "Determinism Score Report",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = summary,
|
||||
Releases = releases,
|
||||
ImageMatrix = imageMatrix
|
||||
};
|
||||
|
||||
// Write output
|
||||
string? outputPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.OutputPath))
|
||||
{
|
||||
outputPath = request.OutputPath;
|
||||
var content = request.Format.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => JsonSerializer.Serialize(report, SerializerOptions),
|
||||
"csv" => GenerateCsvReport(report),
|
||||
_ => GenerateMarkdownReport(report, request.IncludeDetails)
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Wrote determinism report to {Path}", outputPath);
|
||||
}
|
||||
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = true,
|
||||
Report = report,
|
||||
OutputPath = outputPath,
|
||||
Format = request.Format,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateMarkdownReport(DeterminismReport report, bool includeDetails)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine($"# {report.Title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Generated: {report.GeneratedAt:u}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary
|
||||
sb.AppendLine("## Summary");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"| Metric | Value |");
|
||||
sb.AppendLine($"|--------|-------|");
|
||||
sb.AppendLine($"| Total Releases | {report.Summary.TotalReleases} |");
|
||||
sb.AppendLine($"| Total Images | {report.Summary.TotalImages} |");
|
||||
sb.AppendLine($"| Average Score | {report.Summary.AverageScore:P1} |");
|
||||
sb.AppendLine($"| Min Score | {report.Summary.MinScore:P1} |");
|
||||
sb.AppendLine($"| Max Score | {report.Summary.MaxScore:P1} |");
|
||||
sb.AppendLine($"| Passed | {report.Summary.PassedCount} |");
|
||||
sb.AppendLine($"| Failed | {report.Summary.FailedCount} |");
|
||||
sb.AppendLine();
|
||||
|
||||
if (report.Summary.NonDeterministicArtifacts.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Non-Deterministic Artifacts");
|
||||
sb.AppendLine();
|
||||
foreach (var artifact in report.Summary.NonDeterministicArtifacts)
|
||||
{
|
||||
sb.AppendLine($"- `{artifact}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Releases table
|
||||
sb.AppendLine("## Releases");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Release | Platform | Score | Status | Images | Generated |");
|
||||
sb.AppendLine("|---------|----------|-------|--------|--------|-----------|");
|
||||
|
||||
foreach (var release in report.Releases)
|
||||
{
|
||||
var status = release.Passed ? "✅ PASS" : "❌ FAIL";
|
||||
sb.AppendLine($"| {release.Release} | {release.Platform} | {release.OverallScore:P1} | {status} | {release.ImageCount} | {release.GeneratedAt:yyyy-MM-dd} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Image matrix
|
||||
if (includeDetails && report.ImageMatrix.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Per-Image Matrix");
|
||||
sb.AppendLine();
|
||||
|
||||
var releaseNames = report.Releases.Select(r => r.Release).ToList();
|
||||
|
||||
// Header
|
||||
sb.Append("| Image |");
|
||||
foreach (var rel in releaseNames)
|
||||
{
|
||||
sb.Append($" {rel} |");
|
||||
}
|
||||
sb.AppendLine(" Avg |");
|
||||
|
||||
// Separator
|
||||
sb.Append("|-------|");
|
||||
foreach (var _ in releaseNames)
|
||||
{
|
||||
sb.Append("------|");
|
||||
}
|
||||
sb.AppendLine("-----|");
|
||||
|
||||
// Rows
|
||||
foreach (var img in report.ImageMatrix)
|
||||
{
|
||||
var digest = img.ImageDigest.Length > 16 ? img.ImageDigest[..16] + "..." : img.ImageDigest;
|
||||
sb.Append($"| `{digest}` |");
|
||||
|
||||
foreach (var rel in releaseNames)
|
||||
{
|
||||
if (img.Scores.TryGetValue(rel, out var score))
|
||||
{
|
||||
sb.Append($" {score:P0} |");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" - |");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($" {img.AverageScore:P0} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateCsvReport(DeterminismReport report)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("Release,Platform,Score,Passed,ImageCount,GeneratedAt,ScannerSha");
|
||||
|
||||
// Rows
|
||||
foreach (var release in report.Releases)
|
||||
{
|
||||
sb.AppendLine($"{release.Release},{release.Platform},{release.OverallScore:F4},{release.Passed},{release.ImageCount},{release.GeneratedAt:o},{release.ScannerSha}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
596
src/Cli/StellaOps.Cli/Services/ExceptionClient.cs
Normal file
596
src/Cli/StellaOps.Cli/Services/ExceptionClient.cs
Normal file
@@ -0,0 +1,596 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for exception governance API operations.
|
||||
/// Per CLI-EXC-25-001.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionClient : IExceptionClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<ExceptionClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ExceptionClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ExceptionClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionListResponse> ListAsync(
|
||||
ExceptionListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing exceptions");
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing exceptions");
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionInstance?> GetAsync(
|
||||
string exceptionId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/exceptions/{Uri.EscapeDataString(exceptionId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<ExceptionInstance>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting exception");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting exception");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> CreateAsync(
|
||||
ExceptionCreateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to create exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while creating exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while creating exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> PromoteAsync(
|
||||
ExceptionPromoteRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/promote")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.approve", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to promote exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while promoting exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while promoting exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> RevokeAsync(
|
||||
ExceptionRevokeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/revoke")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to revoke exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while revoking exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while revoking exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionImportResult> ImportAsync(
|
||||
ExceptionImportRequest request,
|
||||
Stream ndjsonStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(ndjsonStream);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var streamContent = new StreamContent(ndjsonStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson");
|
||||
content.Add(streamContent, "file", "exceptions.ndjson");
|
||||
content.Add(new StringContent(request.Tenant), "tenant");
|
||||
content.Add(new StringContent(request.Stage.ToString().ToLowerInvariant()), "stage");
|
||||
if (!string.IsNullOrWhiteSpace(request.Source))
|
||||
{
|
||||
content.Add(new StringContent(request.Source), "source");
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions/import")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to import exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}" }]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionImportResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = "Empty response" }] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while importing exceptions");
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = $"Connection error: {ex.Message}" }]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while importing exceptions");
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = "Request timed out" }]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
|
||||
ExceptionExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.Statuses is { Count: > 0 })
|
||||
{
|
||||
foreach (var status in request.Statuses)
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(status)}");
|
||||
}
|
||||
}
|
||||
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
|
||||
queryParams.Add($"includeManifest={request.IncludeManifest.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/exceptions/export{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to export exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
|
||||
// Parse manifest from header if present
|
||||
ExceptionExportManifest? manifest = null;
|
||||
if (response.Headers.TryGetValues("X-Export-Manifest", out var manifestValues))
|
||||
{
|
||||
var manifestJson = string.Join("", manifestValues);
|
||||
if (!string.IsNullOrWhiteSpace(manifestJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<ExceptionExportManifest>(manifestJson, SerializerOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore parse errors for optional header
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (contentStream, manifest);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while exporting exceptions");
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while exporting exceptions");
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildListUri(ExceptionListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Vuln))
|
||||
{
|
||||
queryParams.Add($"vuln={Uri.EscapeDataString(request.Vuln)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ScopeType))
|
||||
{
|
||||
queryParams.Add($"scopeType={Uri.EscapeDataString(request.ScopeType)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ScopeValue))
|
||||
{
|
||||
queryParams.Add($"scopeValue={Uri.EscapeDataString(request.ScopeValue)}");
|
||||
}
|
||||
if (request.Statuses is { Count: > 0 })
|
||||
{
|
||||
foreach (var status in request.Statuses)
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(status)}");
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Owner))
|
||||
{
|
||||
queryParams.Add($"owner={Uri.EscapeDataString(request.Owner)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EffectType))
|
||||
{
|
||||
queryParams.Add($"effectType={Uri.EscapeDataString(request.EffectType)}");
|
||||
}
|
||||
if (request.ExpiringBefore.HasValue)
|
||||
{
|
||||
queryParams.Add($"expiringBefore={Uri.EscapeDataString(request.ExpiringBefore.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.IncludeExpired)
|
||||
{
|
||||
queryParams.Add("includeExpired=true");
|
||||
}
|
||||
queryParams.Add($"pageSize={request.PageSize}");
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
{
|
||||
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/exceptions{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
372
src/Cli/StellaOps.Cli/Services/ForensicSnapshotClient.cs
Normal file
372
src/Cli/StellaOps.Cli/Services/ForensicSnapshotClient.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for forensic snapshot and evidence locker APIs.
|
||||
/// Per CLI-FORENSICS-53-001.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotClient : IForensicSnapshotClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<ForensicSnapshotClient> _logger;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly object _tokenSync = new();
|
||||
|
||||
private string? _cachedAccessToken;
|
||||
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ForensicSnapshotClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ForensicSnapshotClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotDocument> CreateSnapshotAsync(
|
||||
string tenant,
|
||||
ForensicSnapshotCreateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to create forensic snapshot (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ForensicSnapshotDocument>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Invalid response from forensic API.");
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotListResponse> ListSnapshotsAsync(
|
||||
ForensicSnapshotListQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildListRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to list forensic snapshots (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ForensicSnapshotListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ForensicSnapshotListResponse();
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotDocument?> GetSnapshotAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to get forensic snapshot (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<ForensicSnapshotDocument>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotManifest?> GetSnapshotManifestAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}/manifest?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to get forensic snapshot manifest (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<ForensicSnapshotManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildListRequestUri(ForensicSnapshotListQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/forensic/snapshots?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.CaseId))
|
||||
{
|
||||
builder.Append("&caseId=");
|
||||
builder.Append(Uri.EscapeDataString(query.CaseId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
builder.Append("&status=");
|
||||
builder.Append(Uri.EscapeDataString(query.Status));
|
||||
}
|
||||
|
||||
if (query.Tags is { Count: > 0 })
|
||||
{
|
||||
foreach (var tag in query.Tags)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
builder.Append("&tag=");
|
||||
builder.Append(Uri.EscapeDataString(tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
builder.Append("&createdAfter=");
|
||||
builder.Append(Uri.EscapeDataString(query.CreatedAfter.Value.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
builder.Append("&createdBefore=");
|
||||
builder.Append(Uri.EscapeDataString(query.CreatedBefore.Value.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append("&limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (query.Offset.HasValue && query.Offset.Value > 0)
|
||||
{
|
||||
builder.Append("&offset=");
|
||||
builder.Append(query.Offset.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"BackendUrl is not configured. Set StellaOps:BackendUrl or STELLAOPS_BACKEND_URL.");
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
return _options.ApiKey;
|
||||
}
|
||||
|
||||
if (_tokenClient is null || string.IsNullOrWhiteSpace(_options.Authority.Url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_cachedAccessToken) && now < _cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var (scope, cacheKey) = BuildScopeAndCacheKey(_options);
|
||||
var cachedEntry = await _tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
|
||||
{
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = cachedEntry.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
StellaOpsTokenResult token;
|
||||
if (!string.IsNullOrWhiteSpace(_options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured when username is provided.");
|
||||
}
|
||||
|
||||
token = await _tokenClient.RequestPasswordTokenAsync(
|
||||
_options.Authority.Username,
|
||||
_options.Authority.Password!,
|
||||
scope,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = token.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var finalScope = EnsureScope(baseScope, StellaOpsScopes.EvidenceRead);
|
||||
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
: $"client:{options.Authority.ClientId}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
|
||||
return (finalScope, cacheKey);
|
||||
}
|
||||
|
||||
private static string EnsureScope(string scopes, string required)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopes))
|
||||
{
|
||||
return required;
|
||||
}
|
||||
|
||||
var parts = scopes
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static scope => scope.ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (!parts.Contains(required, StringComparer.Ordinal))
|
||||
{
|
||||
parts.Add(required);
|
||||
}
|
||||
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
592
src/Cli/StellaOps.Cli/Services/ForensicVerifier.cs
Normal file
592
src/Cli/StellaOps.Cli/Services/ForensicVerifier.cs
Normal file
@@ -0,0 +1,592 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifier for forensic bundles including checksums, DSSE signatures, and chain-of-custody.
|
||||
/// Per CLI-FORENSICS-54-001.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerifier : IForensicVerifier
|
||||
{
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ILogger<ForensicVerifier> _logger;
|
||||
|
||||
public ForensicVerifier(ILogger<ForensicVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ForensicVerificationResult> VerifyBundleAsync(
|
||||
string bundlePath,
|
||||
ForensicVerificationOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var errors = new List<ForensicVerificationError>();
|
||||
var warnings = new List<string>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogDebug("Verifying forensic bundle at {BundlePath}", bundlePath);
|
||||
|
||||
// Check bundle exists
|
||||
if (!File.Exists(bundlePath) && !Directory.Exists(bundlePath))
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleNotFound,
|
||||
Message = "Bundle path not found",
|
||||
Detail = bundlePath
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = ResolveManifestPath(bundlePath);
|
||||
if (manifestPath is null || !File.Exists(manifestPath))
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleInvalid,
|
||||
Message = "Manifest not found in bundle",
|
||||
Detail = "Expected manifest.json in bundle root"
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
ForensicSnapshotManifest manifest;
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
manifest = JsonSerializer.Deserialize<ForensicSnapshotManifest>(manifestJson, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid manifest JSON");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse manifest at {ManifestPath}", manifestPath);
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleInvalid,
|
||||
Message = "Failed to parse manifest",
|
||||
Detail = ex.Message
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
var bundleDir = Path.GetDirectoryName(manifestPath) ?? bundlePath;
|
||||
|
||||
// Verify manifest
|
||||
var manifestVerification = await VerifyManifestAsync(manifest, manifestPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!manifestVerification.IsValid)
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicChecksumMismatch,
|
||||
Message = "Manifest digest verification failed",
|
||||
Detail = $"Expected: {manifestVerification.Digest}, Computed: {manifestVerification.ComputedDigest}"
|
||||
});
|
||||
}
|
||||
|
||||
// Verify checksums
|
||||
ForensicChecksumVerification? checksumVerification = null;
|
||||
if (options.VerifyChecksums)
|
||||
{
|
||||
checksumVerification = await VerifyChecksumsAsync(manifest, bundleDir, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var failure in checksumVerification.FailedArtifacts)
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicChecksumMismatch,
|
||||
Message = $"Checksum mismatch for artifact {failure.ArtifactId}",
|
||||
Detail = failure.Reason,
|
||||
ArtifactId = failure.ArtifactId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signatures
|
||||
ForensicSignatureVerification? signatureVerification = null;
|
||||
if (options.VerifySignatures && manifest.Signature is not null)
|
||||
{
|
||||
var trustRoots = options.TrustRoots.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(options.TrustRootPath))
|
||||
{
|
||||
var loadedRoots = await LoadTrustRootsAsync(options.TrustRootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
trustRoots.AddRange(loadedRoots);
|
||||
}
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
warnings.Add("No trust roots configured; signature verification skipped");
|
||||
}
|
||||
else
|
||||
{
|
||||
signatureVerification = VerifySignature(manifest, trustRoots);
|
||||
|
||||
if (!signatureVerification.IsValid)
|
||||
{
|
||||
var untrusted = signatureVerification.Signatures
|
||||
.Where(s => !s.IsTrusted)
|
||||
.Select(s => s.KeyId);
|
||||
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = signatureVerification.VerifiedSignatures == 0
|
||||
? CliErrorCodes.ForensicSignatureInvalid
|
||||
: CliErrorCodes.ForensicSignatureUntrusted,
|
||||
Message = "Signature verification failed",
|
||||
Detail = string.Join(", ", signatureVerification.Signatures.Select(s => s.Reason).Where(r => r is not null))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify chain of custody
|
||||
ForensicChainOfCustodyVerification? chainVerification = null;
|
||||
if (options.VerifyChainOfCustody && manifest.Metadata?.ChainOfCustody is { Count: > 0 })
|
||||
{
|
||||
chainVerification = VerifyChainOfCustody(manifest.Metadata.ChainOfCustody, options.StrictTimeline);
|
||||
|
||||
if (!chainVerification.IsValid)
|
||||
{
|
||||
var errorCode = !chainVerification.TimelineValid
|
||||
? CliErrorCodes.ForensicTimelineInvalid
|
||||
: CliErrorCodes.ForensicChainOfCustodyBroken;
|
||||
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = errorCode,
|
||||
Message = "Chain of custody verification failed",
|
||||
Detail = chainVerification.Gaps.Count > 0
|
||||
? $"Found {chainVerification.Gaps.Count} timeline gap(s)"
|
||||
: "Invalid entry signatures"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var isValid = errors.Count == 0 &&
|
||||
manifestVerification.IsValid &&
|
||||
(checksumVerification?.IsValid ?? true) &&
|
||||
(signatureVerification?.IsValid ?? true) &&
|
||||
(chainVerification?.IsValid ?? true);
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = isValid,
|
||||
VerifiedAt = verifiedAt,
|
||||
ManifestVerification = manifestVerification,
|
||||
ChecksumVerification = checksumVerification,
|
||||
SignatureVerification = signatureVerification,
|
||||
ChainOfCustodyVerification = chainVerification,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
|
||||
string trustRootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(trustRootPath);
|
||||
|
||||
if (!File.Exists(trustRootPath))
|
||||
{
|
||||
_logger.LogWarning("Trust root file not found: {Path}", trustRootPath);
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(trustRootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Try array format first
|
||||
var roots = JsonSerializer.Deserialize<List<ForensicTrustRoot>>(json, SerializerOptions);
|
||||
if (roots is not null)
|
||||
{
|
||||
return roots;
|
||||
}
|
||||
|
||||
// Try single object
|
||||
var singleRoot = JsonSerializer.Deserialize<ForensicTrustRoot>(json, SerializerOptions);
|
||||
if (singleRoot is not null)
|
||||
{
|
||||
return new[] { singleRoot };
|
||||
}
|
||||
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse trust roots from {Path}", trustRootPath);
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveManifestPath(string bundlePath)
|
||||
{
|
||||
if (File.Exists(bundlePath))
|
||||
{
|
||||
// If bundlePath is a file, check if it's the manifest
|
||||
var fileName = Path.GetFileName(bundlePath);
|
||||
if (fileName.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
// Otherwise look for manifest in same directory
|
||||
var dir = Path.GetDirectoryName(bundlePath);
|
||||
if (dir is not null)
|
||||
{
|
||||
var manifestInDir = Path.Combine(dir, "manifest.json");
|
||||
if (File.Exists(manifestInDir))
|
||||
{
|
||||
return manifestInDir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Directory.Exists(bundlePath))
|
||||
{
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
return File.Exists(manifestPath) ? manifestPath : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<ForensicManifestVerification> VerifyManifestAsync(
|
||||
ForensicSnapshotManifest manifest,
|
||||
string manifestPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
var computedDigest = ComputeDigest(manifestBytes, manifest.DigestAlgorithm);
|
||||
|
||||
var isValid = string.Equals(manifest.Digest, computedDigest, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.IsNullOrEmpty(manifest.Digest); // Allow empty digest for unsigned manifests
|
||||
|
||||
return new ForensicManifestVerification
|
||||
{
|
||||
IsValid = isValid,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Version = manifest.Version,
|
||||
Digest = manifest.Digest,
|
||||
DigestAlgorithm = manifest.DigestAlgorithm,
|
||||
ComputedDigest = computedDigest,
|
||||
ArtifactCount = manifest.Artifacts.Count
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ForensicChecksumVerification> VerifyChecksumsAsync(
|
||||
ForensicSnapshotManifest manifest,
|
||||
string bundleDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = new List<ForensicArtifactChecksumFailure>();
|
||||
var verified = 0;
|
||||
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
{
|
||||
var artifactPath = Path.Combine(bundleDir, artifact.Path);
|
||||
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = string.Empty,
|
||||
Reason = "Artifact file not found"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileBytes = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
|
||||
var actualDigest = ComputeDigest(fileBytes, artifact.DigestAlgorithm);
|
||||
|
||||
if (!string.Equals(artifact.Digest, actualDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = actualDigest,
|
||||
Reason = "Digest mismatch"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
verified++;
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = string.Empty,
|
||||
Reason = $"IO error: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ForensicChecksumVerification
|
||||
{
|
||||
IsValid = failures.Count == 0,
|
||||
TotalArtifacts = manifest.Artifacts.Count,
|
||||
VerifiedArtifacts = verified,
|
||||
FailedArtifacts = failures
|
||||
};
|
||||
}
|
||||
|
||||
private ForensicSignatureVerification VerifySignature(
|
||||
ForensicSnapshotManifest manifest,
|
||||
IReadOnlyList<ForensicTrustRoot> trustRoots)
|
||||
{
|
||||
if (manifest.Signature is null)
|
||||
{
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = false,
|
||||
SignatureCount = 0,
|
||||
VerifiedSignatures = 0,
|
||||
Signatures = Array.Empty<ForensicSignatureDetail>()
|
||||
};
|
||||
}
|
||||
|
||||
var signatures = new List<ForensicSignatureDetail>();
|
||||
var verifiedCount = 0;
|
||||
|
||||
// Find matching trust root
|
||||
var matchingRoot = trustRoots.FirstOrDefault(tr =>
|
||||
string.Equals(tr.KeyId, manifest.Signature.KeyId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingRoot is null)
|
||||
{
|
||||
signatures.Add(new ForensicSignatureDetail
|
||||
{
|
||||
KeyId = manifest.Signature.KeyId ?? "unknown",
|
||||
Algorithm = manifest.Signature.Algorithm,
|
||||
IsValid = false,
|
||||
IsTrusted = false,
|
||||
SignedAt = manifest.Signature.SignedAt,
|
||||
Reason = "No matching trust root found"
|
||||
});
|
||||
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = false,
|
||||
SignatureCount = 1,
|
||||
VerifiedSignatures = 0,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
var isValid = VerifyRsaPssSignature(
|
||||
manifest.Digest,
|
||||
manifest.Signature.Value,
|
||||
matchingRoot.PublicKey);
|
||||
|
||||
// Check time validity
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
|
||||
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
|
||||
|
||||
if (isValid && timeValid)
|
||||
{
|
||||
verifiedCount++;
|
||||
}
|
||||
|
||||
signatures.Add(new ForensicSignatureDetail
|
||||
{
|
||||
KeyId = manifest.Signature.KeyId ?? "unknown",
|
||||
Algorithm = manifest.Signature.Algorithm,
|
||||
IsValid = isValid,
|
||||
IsTrusted = isValid && timeValid,
|
||||
SignedAt = manifest.Signature.SignedAt,
|
||||
Fingerprint = matchingRoot.Fingerprint,
|
||||
Reason = !isValid ? "Signature verification failed" :
|
||||
!timeValid ? "Key outside validity period" : null
|
||||
});
|
||||
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = verifiedCount > 0,
|
||||
SignatureCount = 1,
|
||||
VerifiedSignatures = verifiedCount,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private ForensicChainOfCustodyVerification VerifyChainOfCustody(
|
||||
IReadOnlyList<ForensicChainOfCustodyEntry> entries,
|
||||
bool strictTimeline)
|
||||
{
|
||||
var entryVerifications = new List<ForensicChainOfCustodyEntryVerification>();
|
||||
var gaps = new List<ForensicTimelineGap>();
|
||||
var timelineValid = true;
|
||||
var signaturesValid = true;
|
||||
|
||||
DateTimeOffset? lastTimestamp = null;
|
||||
var index = 0;
|
||||
|
||||
foreach (var entry in entries.OrderBy(e => e.Timestamp))
|
||||
{
|
||||
// Check timeline progression
|
||||
if (lastTimestamp.HasValue && entry.Timestamp < lastTimestamp.Value)
|
||||
{
|
||||
timelineValid = false;
|
||||
gaps.Add(new ForensicTimelineGap
|
||||
{
|
||||
FromIndex = index - 1,
|
||||
ToIndex = index,
|
||||
FromTimestamp = lastTimestamp.Value,
|
||||
ToTimestamp = entry.Timestamp,
|
||||
GapDuration = lastTimestamp.Value - entry.Timestamp,
|
||||
Description = "Timestamp out of order"
|
||||
});
|
||||
}
|
||||
else if (strictTimeline && lastTimestamp.HasValue)
|
||||
{
|
||||
var gap = entry.Timestamp - lastTimestamp.Value;
|
||||
if (gap > TimeSpan.FromDays(1))
|
||||
{
|
||||
gaps.Add(new ForensicTimelineGap
|
||||
{
|
||||
FromIndex = index - 1,
|
||||
ToIndex = index,
|
||||
FromTimestamp = lastTimestamp.Value,
|
||||
ToTimestamp = entry.Timestamp,
|
||||
GapDuration = gap,
|
||||
Description = $"Large gap of {gap.TotalHours:F1} hours"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Signature verification (if present)
|
||||
bool? signatureValid = null;
|
||||
if (!string.IsNullOrWhiteSpace(entry.Signature))
|
||||
{
|
||||
// For now, just check signature is present
|
||||
// Full verification would require the signing key
|
||||
signatureValid = true;
|
||||
}
|
||||
|
||||
entryVerifications.Add(new ForensicChainOfCustodyEntryVerification
|
||||
{
|
||||
Index = index,
|
||||
Action = entry.Action,
|
||||
Actor = entry.Actor,
|
||||
Timestamp = entry.Timestamp,
|
||||
SignatureValid = signatureValid,
|
||||
Notes = entry.Notes
|
||||
});
|
||||
|
||||
lastTimestamp = entry.Timestamp;
|
||||
index++;
|
||||
}
|
||||
|
||||
return new ForensicChainOfCustodyVerification
|
||||
{
|
||||
IsValid = timelineValid && signaturesValid && (gaps.Count == 0 || !strictTimeline),
|
||||
EntryCount = entries.Count,
|
||||
TimelineValid = timelineValid,
|
||||
SignaturesValid = signaturesValid,
|
||||
Entries = entryVerifications,
|
||||
Gaps = gaps
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] data, string algorithm)
|
||||
{
|
||||
byte[] hash;
|
||||
switch (algorithm.ToLowerInvariant())
|
||||
{
|
||||
case "sha256":
|
||||
hash = SHA256.HashData(data);
|
||||
break;
|
||||
case "sha384":
|
||||
hash = SHA384.HashData(data);
|
||||
break;
|
||||
case "sha512":
|
||||
hash = SHA512.HashData(data);
|
||||
break;
|
||||
default:
|
||||
hash = SHA256.HashData(data);
|
||||
break;
|
||||
}
|
||||
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool VerifyRsaPssSignature(string digest, string signatureBase64, string publicKeyBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
var publicKeyBytes = Convert.FromBase64String(publicKeyBase64);
|
||||
var signatureBytes = Convert.FromBase64String(signatureBase64);
|
||||
var digestBytes = Convert.FromHexString(digest);
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
|
||||
|
||||
return rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Cli/StellaOps.Cli/Services/IAttestationReader.cs
Normal file
20
src/Cli/StellaOps.Cli/Services/IAttestationReader.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reader for attestation files.
|
||||
/// Per CLI-FORENSICS-54-002.
|
||||
/// </summary>
|
||||
internal interface IAttestationReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads and parses an attestation file.
|
||||
/// </summary>
|
||||
Task<AttestationShowResult> ReadAttestationAsync(
|
||||
string filePath,
|
||||
AttestationShowOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -83,4 +83,48 @@ internal interface IBackendOperationsClient
|
||||
// CLI-VULN-29-005: Vulnerability export
|
||||
Task<VulnExportResponse> ExportVulnerabilitiesAsync(VulnExportRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
Task<Stream> DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-23-006: Policy history and explain
|
||||
Task<PolicyHistoryResponse> GetPolicyHistoryAsync(PolicyHistoryRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyExplainResult> GetPolicyExplainAsync(PolicyExplainRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-27-002: Policy submission/review workflow
|
||||
Task<PolicyVersionBumpResult> BumpPolicyVersionAsync(PolicyVersionBumpRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicySubmitResult> SubmitPolicyForReviewAsync(PolicySubmitRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyReviewCommentResult> AddPolicyReviewCommentAsync(PolicyReviewCommentRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyApproveResult> ApprovePolicyReviewAsync(PolicyApproveRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyRejectResult> RejectPolicyReviewAsync(PolicyRejectRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyReviewSummary?> GetPolicyReviewStatusAsync(PolicyReviewStatusRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-27-004: Policy lifecycle (publish/promote/rollback/sign)
|
||||
Task<PolicyPublishResult> PublishPolicyAsync(PolicyPublishRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyPromoteResult> PromotePolicyAsync(PolicyPromoteRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyRollbackResult> RollbackPolicyAsync(PolicyRollbackRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicySignResult> SignPolicyAsync(PolicySignRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyVerifySignatureResult> VerifyPolicySignatureAsync(PolicyVerifySignatureRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-66-001: Risk profile list
|
||||
Task<RiskProfileListResponse> ListRiskProfilesAsync(RiskProfileListRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-66-002: Risk simulate
|
||||
Task<RiskSimulateResult> SimulateRiskAsync(RiskSimulateRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-67-001: Risk results
|
||||
Task<RiskResultsResponse> GetRiskResultsAsync(RiskResultsRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-68-001: Risk bundle verify
|
||||
Task<RiskBundleVerifyResult> VerifyRiskBundleAsync(RiskBundleVerifyRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SIG-26-001: Reachability operations
|
||||
Task<ReachabilityUploadCallGraphResult> UploadCallGraphAsync(ReachabilityUploadCallGraphRequest request, Stream callGraphStream, CancellationToken cancellationToken);
|
||||
Task<ReachabilityListResponse> ListReachabilityAnalysesAsync(ReachabilityListRequest request, CancellationToken cancellationToken);
|
||||
Task<ReachabilityExplainResult> ExplainReachabilityAsync(ReachabilityExplainRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SDK-63-001: API spec download
|
||||
Task<ApiSpecListResponse> ListApiSpecsAsync(string? tenant, CancellationToken cancellationToken);
|
||||
Task<ApiSpecDownloadResult> DownloadApiSpecAsync(ApiSpecDownloadRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SDK-64-001: SDK update
|
||||
Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken);
|
||||
Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,32 @@ using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Concelier advisory observations API.
|
||||
/// Per CLI-LNM-22-001, supports obs get, linkset show, and export operations.
|
||||
/// </summary>
|
||||
internal interface IConcelierObservationsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets advisory observations matching the query.
|
||||
/// </summary>
|
||||
Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets advisory linkset with conflict information.
|
||||
/// Per CLI-LNM-22-001, includes conflict display.
|
||||
/// </summary>
|
||||
Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
||||
AdvisoryLinksetQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single observation by ID.
|
||||
/// </summary>
|
||||
Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
46
src/Cli/StellaOps.Cli/Services/IDeterminismHarness.cs
Normal file
46
src/Cli/StellaOps.Cli/Services/IDeterminismHarness.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism harness for running scanner with frozen conditions.
|
||||
/// Per CLI-DETER-70-003/004.
|
||||
/// </summary>
|
||||
internal interface IDeterminismHarness
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the determinism harness with the specified configuration.
|
||||
/// </summary>
|
||||
Task<DeterminismRunResult> RunAsync(
|
||||
DeterminismRunRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a determinism manifest against thresholds.
|
||||
/// </summary>
|
||||
DeterminismVerificationResult VerifyManifest(
|
||||
DeterminismManifest manifest,
|
||||
double imageThreshold,
|
||||
double overallThreshold);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a determinism report from multiple manifest files.
|
||||
/// Per CLI-DETER-70-004.
|
||||
/// </summary>
|
||||
Task<DeterminismReportResult> GenerateReportAsync(
|
||||
DeterminismReportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a determinism manifest.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismVerificationResult
|
||||
{
|
||||
public bool Passed { get; init; }
|
||||
public double OverallScore { get; init; }
|
||||
public string[] FailedImages { get; init; } = System.Array.Empty<string>();
|
||||
public string[] Warnings { get; init; } = System.Array.Empty<string>();
|
||||
}
|
||||
64
src/Cli/StellaOps.Cli/Services/IExceptionClient.cs
Normal file
64
src/Cli/StellaOps.Cli/Services/IExceptionClient.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for exception governance API operations.
|
||||
/// Per CLI-EXC-25-001.
|
||||
/// </summary>
|
||||
internal interface IExceptionClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists exceptions matching the query.
|
||||
/// </summary>
|
||||
Task<ExceptionListResponse> ListAsync(
|
||||
ExceptionListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an exception by ID.
|
||||
/// </summary>
|
||||
Task<ExceptionInstance?> GetAsync(
|
||||
string exceptionId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new exception.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> CreateAsync(
|
||||
ExceptionCreateRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Promotes an exception to the next lifecycle stage.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> PromoteAsync(
|
||||
ExceptionPromoteRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an exception.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> RevokeAsync(
|
||||
ExceptionRevokeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Imports exceptions from NDJSON stream.
|
||||
/// </summary>
|
||||
Task<ExceptionImportResult> ImportAsync(
|
||||
ExceptionImportRequest request,
|
||||
Stream ndjsonStream,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Exports exceptions to a stream.
|
||||
/// </summary>
|
||||
Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
|
||||
ExceptionExportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
43
src/Cli/StellaOps.Cli/Services/IForensicSnapshotClient.cs
Normal file
43
src/Cli/StellaOps.Cli/Services/IForensicSnapshotClient.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for forensic snapshot and evidence locker APIs.
|
||||
/// Per CLI-FORENSICS-53-001.
|
||||
/// </summary>
|
||||
internal interface IForensicSnapshotClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new forensic snapshot.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotDocument> CreateSnapshotAsync(
|
||||
string tenant,
|
||||
ForensicSnapshotCreateRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists forensic snapshots matching the query.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotListResponse> ListSnapshotsAsync(
|
||||
ForensicSnapshotListQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a forensic snapshot by ID.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotDocument?> GetSnapshotAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the manifest for a forensic snapshot.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotManifest?> GetSnapshotManifestAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
27
src/Cli/StellaOps.Cli/Services/IForensicVerifier.cs
Normal file
27
src/Cli/StellaOps.Cli/Services/IForensicVerifier.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifier for forensic bundles.
|
||||
/// Per CLI-FORENSICS-54-001.
|
||||
/// </summary>
|
||||
internal interface IForensicVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a forensic bundle at the specified path.
|
||||
/// </summary>
|
||||
Task<ForensicVerificationResult> VerifyBundleAsync(
|
||||
string bundlePath,
|
||||
ForensicVerificationOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Loads trust roots from a file path.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
|
||||
string trustRootPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
70
src/Cli/StellaOps.Cli/Services/INotifyClient.cs
Normal file
70
src/Cli/StellaOps.Cli/Services/INotifyClient.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Notify API operations.
|
||||
/// Per CLI-PARITY-41-002.
|
||||
/// </summary>
|
||||
internal interface INotifyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists notification channels.
|
||||
/// </summary>
|
||||
Task<NotifyChannelListResponse> ListChannelsAsync(
|
||||
NotifyChannelListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a notification channel by ID.
|
||||
/// </summary>
|
||||
Task<NotifyChannelDetail?> GetChannelAsync(
|
||||
string channelId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Tests a notification channel.
|
||||
/// </summary>
|
||||
Task<NotifyChannelTestResult> TestChannelAsync(
|
||||
NotifyChannelTestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists notification rules.
|
||||
/// </summary>
|
||||
Task<NotifyRuleListResponse> ListRulesAsync(
|
||||
NotifyRuleListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists notification deliveries.
|
||||
/// </summary>
|
||||
Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
|
||||
NotifyDeliveryListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a delivery by ID.
|
||||
/// </summary>
|
||||
Task<NotifyDeliveryDetail?> GetDeliveryAsync(
|
||||
string deliveryId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retries a failed delivery.
|
||||
/// </summary>
|
||||
Task<NotifyRetryResult> RetryDeliveryAsync(
|
||||
NotifyRetryRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification.
|
||||
/// </summary>
|
||||
Task<NotifySendResult> SendAsync(
|
||||
NotifySendRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
59
src/Cli/StellaOps.Cli/Services/IObservabilityClient.cs
Normal file
59
src/Cli/StellaOps.Cli/Services/IObservabilityClient.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for observability API operations.
|
||||
/// Per CLI-OBS-51-001/52-001.
|
||||
/// </summary>
|
||||
internal interface IObservabilityClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets platform health summary for obs top command.
|
||||
/// </summary>
|
||||
Task<ObsTopResult> GetHealthSummaryAsync(
|
||||
ObsTopRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a distributed trace by ID.
|
||||
/// Per CLI-OBS-52-001.
|
||||
/// </summary>
|
||||
Task<ObsTraceResult> GetTraceAsync(
|
||||
ObsTraceRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets logs within a time window.
|
||||
/// Per CLI-OBS-52-001.
|
||||
/// </summary>
|
||||
Task<ObsLogsResult> GetLogsAsync(
|
||||
ObsLogsRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets current incident mode status.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> GetIncidentModeStatusAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Enables incident mode.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> EnableIncidentModeAsync(
|
||||
IncidentModeEnableRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Disables incident mode.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> DisableIncidentModeAsync(
|
||||
IncidentModeDisableRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
102
src/Cli/StellaOps.Cli/Services/IOrchestratorClient.cs
Normal file
102
src/Cli/StellaOps.Cli/Services/IOrchestratorClient.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for orchestrator API operations.
|
||||
/// Per CLI-ORCH-32-001.
|
||||
/// </summary>
|
||||
internal interface IOrchestratorClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists sources matching the query.
|
||||
/// </summary>
|
||||
Task<SourceListResponse> ListSourcesAsync(
|
||||
SourceListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a source by ID.
|
||||
/// </summary>
|
||||
Task<OrchestratorSource?> GetSourceAsync(
|
||||
string sourceId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a source.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> PauseSourceAsync(
|
||||
SourcePauseRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused source.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> ResumeSourceAsync(
|
||||
SourceResumeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Tests a source connection.
|
||||
/// </summary>
|
||||
Task<SourceTestResult> TestSourceAsync(
|
||||
SourceTestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-ORCH-34-001: Backfill operations
|
||||
|
||||
/// <summary>
|
||||
/// Starts a backfill operation for a source.
|
||||
/// </summary>
|
||||
Task<BackfillResult> StartBackfillAsync(
|
||||
BackfillRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a backfill operation.
|
||||
/// </summary>
|
||||
Task<BackfillResult?> GetBackfillAsync(
|
||||
string backfillId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists backfill operations.
|
||||
/// </summary>
|
||||
Task<BackfillListResponse> ListBackfillsAsync(
|
||||
BackfillListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running backfill operation.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> CancelBackfillAsync(
|
||||
BackfillCancelRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-ORCH-34-001: Quota management
|
||||
|
||||
/// <summary>
|
||||
/// Gets quotas for a tenant/source.
|
||||
/// </summary>
|
||||
Task<QuotaGetResponse> GetQuotasAsync(
|
||||
QuotaGetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a quota limit.
|
||||
/// </summary>
|
||||
Task<QuotaOperationResult> SetQuotaAsync(
|
||||
QuotaSetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resets a quota's usage counter.
|
||||
/// </summary>
|
||||
Task<QuotaOperationResult> ResetQuotaAsync(
|
||||
QuotaResetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
124
src/Cli/StellaOps.Cli/Services/IPackClient.cs
Normal file
124
src/Cli/StellaOps.Cli/Services/IPackClient.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Task Pack registry and runner API operations.
|
||||
/// Per CLI-PACKS-42-001.
|
||||
/// </summary>
|
||||
internal interface IPackClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Plans a pack execution without running it.
|
||||
/// Returns the execution graph, validation errors, and approval requirements.
|
||||
/// </summary>
|
||||
Task<PackPlanResult> PlanAsync(
|
||||
PackPlanRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a pack with the specified inputs.
|
||||
/// Can optionally wait for completion.
|
||||
/// </summary>
|
||||
Task<PackRunResult> RunAsync(
|
||||
PackRunRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a pack run.
|
||||
/// </summary>
|
||||
Task<PackRunStatus?> GetRunStatusAsync(
|
||||
string runId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a pack to the registry.
|
||||
/// </summary>
|
||||
Task<PackPushResult> PushAsync(
|
||||
PackPushRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pulls a pack from the registry.
|
||||
/// </summary>
|
||||
Task<PackPullResult> PullAsync(
|
||||
PackPullRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a pack's signature, digest, and schema.
|
||||
/// </summary>
|
||||
Task<PackVerifyResult> VerifyAsync(
|
||||
PackVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets pack info from the registry.
|
||||
/// </summary>
|
||||
Task<TaskPackInfo?> GetPackInfoAsync(
|
||||
string packId,
|
||||
string? version,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-PACKS-43-001: Advanced pack features
|
||||
|
||||
/// <summary>
|
||||
/// Lists pack runs with optional filters.
|
||||
/// </summary>
|
||||
Task<PackRunListResponse> ListRunsAsync(
|
||||
PackRunListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running pack.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> CancelRunAsync(
|
||||
PackCancelRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a pack run waiting for approval.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> PauseForApprovalAsync(
|
||||
PackApprovalPauseRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused pack run with approval decision.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> ResumeWithApprovalAsync(
|
||||
PackApprovalResumeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Injects a secret into a pack run.
|
||||
/// </summary>
|
||||
Task<PackSecretInjectResult> InjectSecretAsync(
|
||||
PackSecretInjectRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets logs for a pack run.
|
||||
/// </summary>
|
||||
Task<PackLogsResult> GetLogsAsync(
|
||||
PackLogsRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an artifact from a pack run.
|
||||
/// </summary>
|
||||
Task<PackArtifactDownloadResult> DownloadArtifactAsync(
|
||||
PackArtifactDownloadRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Manages the offline pack cache.
|
||||
/// </summary>
|
||||
Task<PackCacheResult> ManageCacheAsync(
|
||||
PackCacheRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
42
src/Cli/StellaOps.Cli/Services/IPromotionAssembler.cs
Normal file
42
src/Cli/StellaOps.Cli/Services/IPromotionAssembler.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Assembler for promotion attestations.
|
||||
/// Per CLI-PROMO-70-001/002.
|
||||
/// </summary>
|
||||
internal interface IPromotionAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembles a promotion attestation from the provided request.
|
||||
/// </summary>
|
||||
Task<PromotionAssembleResult> AssembleAsync(
|
||||
PromotionAssembleRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves image digest from registry.
|
||||
/// </summary>
|
||||
Task<string?> ResolveImageDigestAsync(
|
||||
string imageRef,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Signs a promotion predicate and produces a DSSE bundle.
|
||||
/// Per CLI-PROMO-70-002.
|
||||
/// </summary>
|
||||
Task<PromotionAttestResult> AttestAsync(
|
||||
PromotionAttestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a promotion attestation bundle offline.
|
||||
/// Per CLI-PROMO-70-002.
|
||||
/// </summary>
|
||||
Task<PromotionVerifyResult> VerifyAsync(
|
||||
PromotionVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
53
src/Cli/StellaOps.Cli/Services/ISbomClient.cs
Normal file
53
src/Cli/StellaOps.Cli/Services/ISbomClient.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for SBOM API operations.
|
||||
/// Per CLI-PARITY-41-001.
|
||||
/// </summary>
|
||||
internal interface ISbomClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists SBOMs matching the query.
|
||||
/// </summary>
|
||||
Task<SbomListResponse> ListAsync(
|
||||
SbomListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an SBOM by ID with optional explain information.
|
||||
/// </summary>
|
||||
Task<SbomDetailResponse?> GetAsync(
|
||||
string sbomId,
|
||||
string? tenant,
|
||||
bool includeComponents,
|
||||
bool includeVulnerabilities,
|
||||
bool includeLicenses,
|
||||
bool explain,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Compares two SBOMs.
|
||||
/// </summary>
|
||||
Task<SbomCompareResponse?> CompareAsync(
|
||||
SbomCompareRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Exports an SBOM in the specified format.
|
||||
/// </summary>
|
||||
Task<(Stream Content, SbomExportResult? Result)> ExportAsync(
|
||||
SbomExportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parity matrix showing CLI command coverage.
|
||||
/// </summary>
|
||||
Task<ParityMatrixResponse> GetParityMatrixAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
78
src/Cli/StellaOps.Cli/Services/ISbomerClient.cs
Normal file
78
src/Cli/StellaOps.Cli/Services/ISbomerClient.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Sbomer API operations (layer fragments and composition).
|
||||
/// Per CLI-SBOM-60-001.
|
||||
/// </summary>
|
||||
internal interface ISbomerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists layer fragments for a scan.
|
||||
/// </summary>
|
||||
Task<SbomerLayerListResponse> ListLayersAsync(
|
||||
SbomerLayerListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets layer fragment details.
|
||||
/// </summary>
|
||||
Task<SbomerLayerDetail?> GetLayerAsync(
|
||||
SbomerLayerShowRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a layer fragment DSSE signature.
|
||||
/// </summary>
|
||||
Task<SbomerLayerVerifyResult> VerifyLayerAsync(
|
||||
SbomerLayerVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the composition manifest for a scan.
|
||||
/// </summary>
|
||||
Task<CompositionManifest?> GetCompositionManifestAsync(
|
||||
SbomerCompositionShowRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Composes SBOM from layer fragments.
|
||||
/// </summary>
|
||||
Task<SbomerComposeResult> ComposeAsync(
|
||||
SbomerComposeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies composition against manifest and fragments.
|
||||
/// </summary>
|
||||
Task<SbomerCompositionVerifyResult> VerifyCompositionAsync(
|
||||
SbomerCompositionVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets Merkle diagnostics for a composition.
|
||||
/// </summary>
|
||||
Task<MerkleDiagnostics?> GetMerkleDiagnosticsAsync(
|
||||
string scanId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection methods
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes drift between current SBOM and baseline.
|
||||
/// </summary>
|
||||
Task<SbomerDriftResult> AnalyzeDriftAsync(
|
||||
SbomerDriftRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies SBOM with local recomposition and drift detection.
|
||||
/// </summary>
|
||||
Task<SbomerDriftVerifyResult> VerifyDriftAsync(
|
||||
SbomerDriftVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
34
src/Cli/StellaOps.Cli/Services/IVexObservationsClient.cs
Normal file
34
src/Cli/StellaOps.Cli/Services/IVexObservationsClient.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for VEX observation queries.
|
||||
/// Per CLI-LNM-22-002.
|
||||
/// </summary>
|
||||
internal interface IVexObservationsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets VEX observations matching the query.
|
||||
/// </summary>
|
||||
Task<VexObservationResponse> GetObservationsAsync(
|
||||
VexObservationQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a VEX linkset for a vulnerability ID.
|
||||
/// </summary>
|
||||
Task<VexLinksetResponse> GetLinksetAsync(
|
||||
VexLinksetQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single VEX observation by ID.
|
||||
/// </summary>
|
||||
Task<VexObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
503
src/Cli/StellaOps.Cli/Services/Models/AdvisoryLinksetModels.cs
Normal file
503
src/Cli/StellaOps.Cli/Services/Models/AdvisoryLinksetModels.cs
Normal file
@@ -0,0 +1,503 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-LNM-22-001: Advisory linkset models for obs get/linkset show/export commands
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory linkset query with additional filters for CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
internal sealed record AdvisoryLinksetQuery(
|
||||
string Tenant,
|
||||
IReadOnlyList<string> ObservationIds,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> Purls,
|
||||
IReadOnlyList<string> Cpes,
|
||||
IReadOnlyList<string> Sources,
|
||||
string? Severity,
|
||||
bool? KevOnly,
|
||||
bool? HasFix,
|
||||
int? Limit,
|
||||
string? Cursor)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates from a basic AdvisoryObservationsQuery.
|
||||
/// </summary>
|
||||
public static AdvisoryLinksetQuery FromBasicQuery(AdvisoryObservationsQuery query)
|
||||
{
|
||||
return new AdvisoryLinksetQuery(
|
||||
query.Tenant,
|
||||
query.ObservationIds,
|
||||
query.Aliases,
|
||||
query.Purls,
|
||||
query.Cpes,
|
||||
Sources: Array.Empty<string>(),
|
||||
Severity: null,
|
||||
KevOnly: null,
|
||||
HasFix: null,
|
||||
query.Limit,
|
||||
query.Cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory linkset response with conflict information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<AdvisoryLinksetObservation> Observations { get; init; } =
|
||||
Array.Empty<AdvisoryLinksetObservation>();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryLinksetAggregate Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<AdvisoryLinksetConflict> Conflicts { get; init; } =
|
||||
Array.Empty<AdvisoryLinksetConflict>();
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public int? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory observation with severity, KEV, and fix information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetObservation
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public AdvisoryObservationSource Source { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("upstream")]
|
||||
public AdvisoryObservationUpstream Upstream { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinkset Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public AdvisoryLinksetSeverity? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("kev")]
|
||||
public AdvisoryLinksetKev? Kev { get; init; }
|
||||
|
||||
[JsonPropertyName("fix")]
|
||||
public AdvisoryLinksetFix? Fix { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory severity information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetSeverity
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cvssV3")]
|
||||
public AdvisoryLinksetCvss? CvssV3 { get; init; }
|
||||
|
||||
[JsonPropertyName("cvssV2")]
|
||||
public AdvisoryLinksetCvss? CvssV2 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score details.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetCvss
|
||||
{
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("vector")]
|
||||
public string? Vector { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KEV (Known Exploited Vulnerabilities) information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetKev
|
||||
{
|
||||
[JsonPropertyName("listed")]
|
||||
public bool Listed { get; init; }
|
||||
|
||||
[JsonPropertyName("addedDate")]
|
||||
public DateTimeOffset? AddedDate { get; init; }
|
||||
|
||||
[JsonPropertyName("dueDate")]
|
||||
public DateTimeOffset? DueDate { get; init; }
|
||||
|
||||
[JsonPropertyName("knownRansomwareCampaignUse")]
|
||||
public bool? KnownRansomwareCampaignUse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix availability information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetFix
|
||||
{
|
||||
[JsonPropertyName("available")]
|
||||
public bool Available { get; init; }
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public IReadOnlyList<string> Versions { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("advisoryLinks")]
|
||||
public IReadOnlyList<string> AdvisoryLinks { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated linkset with conflict summary.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetAggregate
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
|
||||
[JsonPropertyName("sourceCoverage")]
|
||||
public AdvisorySourceCoverageSummary? SourceCoverage { get; init; }
|
||||
|
||||
[JsonPropertyName("conflictSummary")]
|
||||
public AdvisoryConflictSummary? ConflictSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source coverage summary across observations.
|
||||
/// </summary>
|
||||
internal sealed class AdvisorySourceCoverageSummary
|
||||
{
|
||||
[JsonPropertyName("totalSources")]
|
||||
public int TotalSources { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("coveragePercent")]
|
||||
public double CoveragePercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict summary for the linkset.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryConflictSummary
|
||||
{
|
||||
[JsonPropertyName("hasConflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("totalConflicts")]
|
||||
public int TotalConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("severityConflicts")]
|
||||
public int SeverityConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("kevConflicts")]
|
||||
public int KevConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("fixConflicts")]
|
||||
public int FixConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed conflict information between observations.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetConflict
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("field")]
|
||||
public string Field { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<AdvisoryConflictSource> Sources { get; init; } =
|
||||
Array.Empty<AdvisoryConflictSource>();
|
||||
|
||||
[JsonPropertyName("resolution")]
|
||||
public string? Resolution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source contribution to a conflict.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryConflictSource
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV (Open Source Vulnerability) format output.
|
||||
/// Per CLI-LNM-22-001, supports JSON/OSV output format.
|
||||
/// </summary>
|
||||
internal sealed class OsvVulnerability
|
||||
{
|
||||
[JsonPropertyName("schema_version")]
|
||||
public string SchemaVersion { get; init; } = "1.6.0";
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("modified")]
|
||||
public string Modified { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("published")]
|
||||
public string? Published { get; init; }
|
||||
|
||||
[JsonPropertyName("withdrawn")]
|
||||
public string? Withdrawn { get; init; }
|
||||
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("related")]
|
||||
public IReadOnlyList<string> Related { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public IReadOnlyList<OsvSeverity> Severity { get; init; } = Array.Empty<OsvSeverity>();
|
||||
|
||||
[JsonPropertyName("affected")]
|
||||
public IReadOnlyList<OsvAffected> Affected { get; init; } = Array.Empty<OsvAffected>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<OsvReference> References { get; init; } = Array.Empty<OsvReference>();
|
||||
|
||||
[JsonPropertyName("credits")]
|
||||
public IReadOnlyList<OsvCredit> Credits { get; init; } = Array.Empty<OsvCredit>();
|
||||
|
||||
[JsonPropertyName("database_specific")]
|
||||
public OsvDatabaseSpecific? DatabaseSpecific { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV severity entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvSeverity
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public string Score { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV affected package entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvAffected
|
||||
{
|
||||
[JsonPropertyName("package")]
|
||||
public OsvPackage Package { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public IReadOnlyList<OsvSeverity> Severity { get; init; } = Array.Empty<OsvSeverity>();
|
||||
|
||||
[JsonPropertyName("ranges")]
|
||||
public IReadOnlyList<OsvRange> Ranges { get; init; } = Array.Empty<OsvRange>();
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public IReadOnlyList<string> Versions { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("ecosystem_specific")]
|
||||
public Dictionary<string, object>? EcosystemSpecific { get; init; }
|
||||
|
||||
[JsonPropertyName("database_specific")]
|
||||
public Dictionary<string, object>? DatabaseSpecific { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV package identifier.
|
||||
/// </summary>
|
||||
internal sealed class OsvPackage
|
||||
{
|
||||
[JsonPropertyName("ecosystem")]
|
||||
public string Ecosystem { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV version range.
|
||||
/// </summary>
|
||||
internal sealed class OsvRange
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("repo")]
|
||||
public string? Repo { get; init; }
|
||||
|
||||
[JsonPropertyName("events")]
|
||||
public IReadOnlyList<OsvEvent> Events { get; init; } = Array.Empty<OsvEvent>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV range event.
|
||||
/// </summary>
|
||||
internal sealed class OsvEvent
|
||||
{
|
||||
[JsonPropertyName("introduced")]
|
||||
public string? Introduced { get; init; }
|
||||
|
||||
[JsonPropertyName("fixed")]
|
||||
public string? Fixed { get; init; }
|
||||
|
||||
[JsonPropertyName("last_affected")]
|
||||
public string? LastAffected { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public string? Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV reference entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvReference
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV credit entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvCredit
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("contact")]
|
||||
public IReadOnlyList<string> Contact { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV database-specific metadata.
|
||||
/// </summary>
|
||||
internal sealed class OsvDatabaseSpecific
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("kev")]
|
||||
public OsvKevInfo? Kev { get; init; }
|
||||
|
||||
[JsonPropertyName("stellaops")]
|
||||
public OsvStellaOpsInfo? StellaOps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV KEV information.
|
||||
/// </summary>
|
||||
internal sealed class OsvKevInfo
|
||||
{
|
||||
[JsonPropertyName("listed")]
|
||||
public bool Listed { get; init; }
|
||||
|
||||
[JsonPropertyName("added_date")]
|
||||
public string? AddedDate { get; init; }
|
||||
|
||||
[JsonPropertyName("due_date")]
|
||||
public string? DueDate { get; init; }
|
||||
|
||||
[JsonPropertyName("ransomware")]
|
||||
public bool? Ransomware { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps-specific OSV metadata.
|
||||
/// </summary>
|
||||
internal sealed class OsvStellaOpsInfo
|
||||
{
|
||||
[JsonPropertyName("observation_ids")]
|
||||
public IReadOnlyList<string> ObservationIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("has_conflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export format enumeration for advisory exports.
|
||||
/// </summary>
|
||||
internal enum AdvisoryExportFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON format (native StellaOps).
|
||||
/// </summary>
|
||||
Json,
|
||||
|
||||
/// <summary>
|
||||
/// OSV (Open Source Vulnerability) format.
|
||||
/// </summary>
|
||||
Osv,
|
||||
|
||||
/// <summary>
|
||||
/// NDJSON (newline-delimited JSON) format.
|
||||
/// </summary>
|
||||
Ndjson,
|
||||
|
||||
/// <summary>
|
||||
/// CSV format for spreadsheet imports.
|
||||
/// </summary>
|
||||
Csv
|
||||
}
|
||||
223
src/Cli/StellaOps.Cli/Services/Models/ApiSpecModels.cs
Normal file
223
src/Cli/StellaOps.Cli/Services/Models/ApiSpecModels.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request for downloading API specification.
|
||||
/// CLI-SDK-63-001: Exposes stella api spec download command.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecDownloadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant context for the operation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public required string OutputPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Spec format to download (openapi-json, openapi-yaml).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "openapi-json";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to overwrite existing files.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool Overwrite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional service filter (e.g., "concelier", "scanner", "policy").
|
||||
/// When null, downloads the aggregate/combined spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("service")]
|
||||
public string? Service { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected ETag for conditional download (If-None-Match).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? ExpectedETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected checksum for verification after download.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? ExpectedChecksum { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checksum algorithm (sha256, sha384, sha512).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string ChecksumAlgorithm { get; init; } = "sha256";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of API spec download operation.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecDownloadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path where the spec was downloaded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the downloaded spec in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the result was served from cache (304 Not Modified).
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromCache")]
|
||||
public bool FromCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ETag of the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("etag")]
|
||||
public string? ETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed checksum of the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksum")]
|
||||
public string? Checksum { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checksum algorithm used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksumAlgorithm")]
|
||||
public string? ChecksumAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether checksum verification passed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksumVerified")]
|
||||
public bool? ChecksumVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// API version extracted from the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("apiVersion")]
|
||||
public string? ApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of when the spec was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset? GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errorCode")]
|
||||
public string? ErrorCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about available API specifications.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Service name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("service")]
|
||||
public required string Service { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI spec version (e.g., "3.1.0").
|
||||
/// </summary>
|
||||
[JsonPropertyName("openApiVersion")]
|
||||
public string? OpenApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available formats.
|
||||
/// </summary>
|
||||
[JsonPropertyName("formats")]
|
||||
public IReadOnlyList<string> Formats { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// ETag for the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("etag")]
|
||||
public string? ETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 checksum of the JSON format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public string? Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last modified timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastModified")]
|
||||
public DateTimeOffset? LastModified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL for the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing available API specifications.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available API specifications.
|
||||
/// </summary>
|
||||
[JsonPropertyName("specs")]
|
||||
public IReadOnlyList<ApiSpecInfo> Specs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate spec info (combined all services).
|
||||
/// </summary>
|
||||
[JsonPropertyName("aggregate")]
|
||||
public ApiSpecInfo? Aggregate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
230
src/Cli/StellaOps.Cli/Services/Models/AttestationModels.cs
Normal file
230
src/Cli/StellaOps.Cli/Services/Models/AttestationModels.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-54-002: Attestation models for forensic attest show command
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope from attestation file.
|
||||
/// </summary>
|
||||
internal sealed class AttestationEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<AttestationSignature> Signatures { get; init; } = Array.Empty<AttestationSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature in attestation envelope.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Signature { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement from attestation payload.
|
||||
/// </summary>
|
||||
internal sealed class InTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public IReadOnlyList<InTotoSubject> Subject { get; init; } = Array.Empty<InTotoSubject>();
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public object? Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in in-toto statement.
|
||||
/// </summary>
|
||||
internal sealed class InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation show operation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationShowResult
|
||||
{
|
||||
[JsonPropertyName("filePath")]
|
||||
public string FilePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("statementType")]
|
||||
public string StatementType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subjects")]
|
||||
public IReadOnlyList<AttestationSubjectInfo> Subjects { get; init; } = Array.Empty<AttestationSubjectInfo>();
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<AttestationSignatureInfo> Signatures { get; init; } = Array.Empty<AttestationSignatureInfo>();
|
||||
|
||||
[JsonPropertyName("predicateSummary")]
|
||||
public AttestationPredicateSummary? PredicateSummary { get; init; }
|
||||
|
||||
[JsonPropertyName("verificationResult")]
|
||||
public AttestationVerificationResult? VerificationResult { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject information for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSubjectInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestValue")]
|
||||
public string DigestValue { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature information for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignatureInfo
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool? IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("isTrusted")]
|
||||
public bool? IsTrusted { get; init; }
|
||||
|
||||
[JsonPropertyName("signerInfo")]
|
||||
public AttestationSignerInfo? SignerInfo { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signer information extracted from signature or certificate.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignerInfo
|
||||
{
|
||||
[JsonPropertyName("commonName")]
|
||||
public string? CommonName { get; init; }
|
||||
|
||||
[JsonPropertyName("organization")]
|
||||
public string? Organization { get; init; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("notBefore")]
|
||||
public DateTimeOffset? NotBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("notAfter")]
|
||||
public DateTimeOffset? NotAfter { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the predicate for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationPredicateSummary
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("buildType")]
|
||||
public string? BuildType { get; init; }
|
||||
|
||||
[JsonPropertyName("builder")]
|
||||
public string? Builder { get; init; }
|
||||
|
||||
[JsonPropertyName("invocationId")]
|
||||
public string? InvocationId { get; init; }
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<AttestationMaterial>? Materials { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material in attestation predicate.
|
||||
/// </summary>
|
||||
internal sealed class AttestationMaterial
|
||||
{
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string>? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification result for attestation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationVerificationResult
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureCount")]
|
||||
public int SignatureCount { get; init; }
|
||||
|
||||
[JsonPropertyName("validSignatures")]
|
||||
public int ValidSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("trustedSignatures")]
|
||||
public int TrustedSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation show operation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationShowOptions
|
||||
{
|
||||
public bool VerifySignatures { get; init; } = true;
|
||||
public string? TrustRootPath { get; init; }
|
||||
public IReadOnlyList<ForensicTrustRoot> TrustRoots { get; init; } = Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
420
src/Cli/StellaOps.Cli/Services/Models/DeterminismModels.cs
Normal file
420
src/Cli/StellaOps.Cli/Services/Models/DeterminismModels.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-DETER-70-003: Determinism score models (docs/modules/scanner/determinism-score.md)
|
||||
|
||||
/// <summary>
|
||||
/// Request for running determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Image digests to test.
|
||||
/// </summary>
|
||||
[JsonPropertyName("images")]
|
||||
public IReadOnlyList<string> Images { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Scanner container image reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scanner")]
|
||||
public string Scanner { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle path or SHA.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyBundle")]
|
||||
public string? PolicyBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feeds bundle path or SHA.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedsBundle")]
|
||||
public string? FeedsBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of runs per image (default 10).
|
||||
/// </summary>
|
||||
[JsonPropertyName("runs")]
|
||||
public int Runs { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed clock timestamp for deterministic execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixedClock")]
|
||||
public DateTimeOffset? FixedClock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// RNG seed for deterministic execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rngSeed")]
|
||||
public int RngSeed { get; init; } = 1337;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum concurrency (default 1 for determinism).
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxConcurrency")]
|
||||
public int MaxConcurrency { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Memory limit for container (default 2G).
|
||||
/// </summary>
|
||||
[JsonPropertyName("memoryLimit")]
|
||||
public string MemoryLimit { get; init; } = "2G";
|
||||
|
||||
/// <summary>
|
||||
/// CPU set for container (default 0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("cpuSet")]
|
||||
public string CpuSet { get; init; } = "0";
|
||||
|
||||
/// <summary>
|
||||
/// Platform (default linux/amd64).
|
||||
/// </summary>
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = "linux/amd64";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum threshold for individual image scores.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageThreshold")]
|
||||
public double ImageThreshold { get; init; } = 0.90;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum threshold for overall score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overallThreshold")]
|
||||
public double OverallThreshold { get; init; } = 0.95;
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for determinism.json and run artifacts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputDir")]
|
||||
public string? OutputDir { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release version string for the manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("release")]
|
||||
public string? Release { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinism score manifest (determinism.json schema per SCAN-DETER-186-010).
|
||||
/// </summary>
|
||||
internal sealed class DeterminismManifest
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1";
|
||||
|
||||
[JsonPropertyName("release")]
|
||||
public string Release { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = "linux/amd64";
|
||||
|
||||
[JsonPropertyName("policy_sha")]
|
||||
public string PolicySha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("feeds_sha")]
|
||||
public string FeedsSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanner_sha")]
|
||||
public string ScannerSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public IReadOnlyList<DeterminismImageResult> Images { get; init; } = Array.Empty<DeterminismImageResult>();
|
||||
|
||||
[JsonPropertyName("overall_score")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("thresholds")]
|
||||
public DeterminismThresholds Thresholds { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("generated_at")]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("execution")]
|
||||
public DeterminismExecutionInfo? Execution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-image determinism result.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismImageResult
|
||||
{
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("runs")]
|
||||
public int Runs { get; init; }
|
||||
|
||||
[JsonPropertyName("identical")]
|
||||
public int Identical { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_hashes")]
|
||||
public IReadOnlyDictionary<string, string> ArtifactHashes { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("non_deterministic")]
|
||||
public IReadOnlyList<string> NonDeterministic { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("run_details")]
|
||||
public IReadOnlyList<DeterminismRunDetail>? RunDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a single determinism run.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunDetail
|
||||
{
|
||||
[JsonPropertyName("run_number")]
|
||||
public int RunNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("identical")]
|
||||
public bool Identical { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_hashes")]
|
||||
public IReadOnlyDictionary<string, string> ArtifactHashes { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("exit_code")]
|
||||
public int ExitCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thresholds for determinism scoring.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismThresholds
|
||||
{
|
||||
[JsonPropertyName("image_min")]
|
||||
public double ImageMin { get; init; } = 0.90;
|
||||
|
||||
[JsonPropertyName("overall_min")]
|
||||
public double OverallMin { get; init; } = 0.95;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution information for determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismExecutionInfo
|
||||
{
|
||||
[JsonPropertyName("fixed_clock")]
|
||||
public DateTimeOffset? FixedClock { get; init; }
|
||||
|
||||
[JsonPropertyName("rng_seed")]
|
||||
public int RngSeed { get; init; }
|
||||
|
||||
[JsonPropertyName("max_concurrency")]
|
||||
public int MaxConcurrency { get; init; }
|
||||
|
||||
[JsonPropertyName("memory_limit")]
|
||||
public string MemoryLimit { get; init; } = "2G";
|
||||
|
||||
[JsonPropertyName("cpu_set")]
|
||||
public string CpuSet { get; init; } = "0";
|
||||
|
||||
[JsonPropertyName("network_mode")]
|
||||
public string NetworkMode { get; init; } = "none";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of running the determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public DeterminismManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("passedThreshold")]
|
||||
public bool PassedThreshold { get; init; }
|
||||
|
||||
[JsonPropertyName("failedImages")]
|
||||
public IReadOnlyList<string> FailedImages { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
// CLI-DETER-70-004: Determinism report models
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating a determinism report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Paths to determinism.json files to include in report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("manifestPaths")]
|
||||
public IReadOnlyList<string> ManifestPaths { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Output format (markdown, json, csv).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "markdown";
|
||||
|
||||
/// <summary>
|
||||
/// Output path for the report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include detailed per-run information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeDetails")]
|
||||
public bool IncludeDetails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Title for the report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated determinism report across multiple manifests.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReport
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "Determinism Score Report";
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public DeterminismReportSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("releases")]
|
||||
public IReadOnlyList<DeterminismReleaseEntry> Releases { get; init; } = Array.Empty<DeterminismReleaseEntry>();
|
||||
|
||||
[JsonPropertyName("imageMatrix")]
|
||||
public IReadOnlyList<DeterminismImageMatrixEntry> ImageMatrix { get; init; } = Array.Empty<DeterminismImageMatrixEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for the report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportSummary
|
||||
{
|
||||
[JsonPropertyName("totalReleases")]
|
||||
public int TotalReleases { get; init; }
|
||||
|
||||
[JsonPropertyName("totalImages")]
|
||||
public int TotalImages { get; init; }
|
||||
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("minScore")]
|
||||
public double MinScore { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("passedCount")]
|
||||
public int PassedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("failedCount")]
|
||||
public int FailedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("nonDeterministicArtifacts")]
|
||||
public IReadOnlyList<string> NonDeterministicArtifacts { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a single release in the report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReleaseEntry
|
||||
{
|
||||
[JsonPropertyName("release")]
|
||||
public string Release { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("overallScore")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("passed")]
|
||||
public bool Passed { get; init; }
|
||||
|
||||
[JsonPropertyName("imageCount")]
|
||||
public int ImageCount { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("scannerSha")]
|
||||
public string ScannerSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifestPath")]
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-image matrix entry showing scores across releases.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismImageMatrixEntry
|
||||
{
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scores")]
|
||||
public IReadOnlyDictionary<string, double> Scores { get; init; } = new Dictionary<string, double>();
|
||||
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("nonDeterministicArtifacts")]
|
||||
public IReadOnlyList<string> NonDeterministicArtifacts { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating a determinism report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("report")]
|
||||
public DeterminismReport? Report { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "markdown";
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
422
src/Cli/StellaOps.Cli/Services/Models/ExceptionModels.cs
Normal file
422
src/Cli/StellaOps.Cli/Services/Models/ExceptionModels.cs
Normal file
@@ -0,0 +1,422 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-EXC-25-001: Exception governance models for stella exceptions commands
|
||||
|
||||
/// <summary>
|
||||
/// Exception scope types.
|
||||
/// </summary>
|
||||
internal static class ExceptionScopeTypes
|
||||
{
|
||||
public const string Purl = "purl";
|
||||
public const string Image = "image";
|
||||
public const string Component = "component";
|
||||
public const string TenantWide = "tenant";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception status values following lifecycle: draft -> staged -> active -> expired.
|
||||
/// </summary>
|
||||
internal static class ExceptionStatuses
|
||||
{
|
||||
public const string Draft = "draft";
|
||||
public const string Staged = "staged";
|
||||
public const string Active = "active";
|
||||
public const string Expired = "expired";
|
||||
public const string Revoked = "revoked";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception effect types.
|
||||
/// </summary>
|
||||
internal static class ExceptionEffectTypes
|
||||
{
|
||||
public const string Suppress = "suppress";
|
||||
public const string Defer = "defer";
|
||||
public const string Downgrade = "downgrade";
|
||||
public const string RequireControl = "requireControl";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception scope definition.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionScope
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = ExceptionScopeTypes.Purl;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ruleNames")]
|
||||
public IReadOnlyList<string>? RuleNames { get; init; }
|
||||
|
||||
[JsonPropertyName("severities")]
|
||||
public IReadOnlyList<string>? Severities { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string>? Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence reference for an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionEvidenceRef
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // ticket, vex_claim, scan_report, attestation
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception effect definition.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionEffect
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("effectType")]
|
||||
public string EffectType { get; init; } = ExceptionEffectTypes.Suppress;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("downgradeSeverity")]
|
||||
public string? DowngradeSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("requiredControlId")]
|
||||
public string? RequiredControlId { get; init; }
|
||||
|
||||
[JsonPropertyName("routingTemplate")]
|
||||
public string? RoutingTemplate { get; init; }
|
||||
|
||||
[JsonPropertyName("maxDurationDays")]
|
||||
public int? MaxDurationDays { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception instance representing a governed waiver.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionInstance
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string Vuln { get; init; } = string.Empty; // CVE ID or alias
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ExceptionScope Scope { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("effectId")]
|
||||
public string EffectId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("effect")]
|
||||
public ExceptionEffect? Effect { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string Justification { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = ExceptionStatuses.Draft;
|
||||
|
||||
[JsonPropertyName("expiration")]
|
||||
public DateTimeOffset? Expiration { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("approvedBy")]
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("approvedAt")]
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<ExceptionEvidenceRef> EvidenceRefs { get; init; } = Array.Empty<ExceptionEvidenceRef>();
|
||||
|
||||
[JsonPropertyName("policyBinding")]
|
||||
public string? PolicyBinding { get; init; }
|
||||
|
||||
[JsonPropertyName("supersedes")]
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string? Vuln { get; init; }
|
||||
|
||||
[JsonPropertyName("scopeType")]
|
||||
public string? ScopeType { get; init; }
|
||||
|
||||
[JsonPropertyName("scopeValue")]
|
||||
public string? ScopeValue { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public IReadOnlyList<string>? Statuses { get; init; }
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string? Owner { get; init; }
|
||||
|
||||
[JsonPropertyName("effectType")]
|
||||
public string? EffectType { get; init; }
|
||||
|
||||
[JsonPropertyName("expiringBefore")]
|
||||
public DateTimeOffset? ExpiringBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("includeExpired")]
|
||||
public bool IncludeExpired { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionListResponse
|
||||
{
|
||||
[JsonPropertyName("exceptions")]
|
||||
public IReadOnlyList<ExceptionInstance> Exceptions { get; init; } = Array.Empty<ExceptionInstance>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionCreateRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string Vuln { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ExceptionScope Scope { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("effectId")]
|
||||
public string EffectId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string Justification { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expiration")]
|
||||
public DateTimeOffset? Expiration { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<ExceptionEvidenceRef>? EvidenceRefs { get; init; }
|
||||
|
||||
[JsonPropertyName("policyBinding")]
|
||||
public string? PolicyBinding { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public bool Stage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to promote an exception (draft -> staged -> active).
|
||||
/// </summary>
|
||||
internal sealed class ExceptionPromoteRequest
|
||||
{
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public string ExceptionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("targetStatus")]
|
||||
public string TargetStatus { get; init; } = ExceptionStatuses.Active;
|
||||
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionRevokeRequest
|
||||
{
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public string ExceptionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to import exceptions from NDJSON.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public bool Stage { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of exception import.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("imported")]
|
||||
public int Imported { get; init; }
|
||||
|
||||
[JsonPropertyName("skipped")]
|
||||
public int Skipped { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<ExceptionImportError> Errors { get; init; } = Array.Empty<ExceptionImportError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import error detail.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportError
|
||||
{
|
||||
[JsonPropertyName("line")]
|
||||
public int Line { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("field")]
|
||||
public string? Field { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to export exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionExportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string>? Statuses { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "ndjson"; // ndjson, json
|
||||
|
||||
[JsonPropertyName("includeManifest")]
|
||||
public bool IncludeManifest { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export manifest for exception bundle.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionExportManifest
|
||||
{
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("aocEnforced")]
|
||||
public bool AocEnforced { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureUri")]
|
||||
public string? SignatureUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of exception operation.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("exception")]
|
||||
public ExceptionInstance? Exception { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
279
src/Cli/StellaOps.Cli/Services/Models/ForensicSnapshotModels.cs
Normal file
279
src/Cli/StellaOps.Cli/Services/Models/ForensicSnapshotModels.cs
Normal file
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-53-001: Forensic snapshot models for evidence locker integration
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotCreateRequest(
|
||||
[property: JsonPropertyName("caseId")] string CaseId,
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null,
|
||||
[property: JsonPropertyName("scope")] ForensicSnapshotScope? Scope = null,
|
||||
[property: JsonPropertyName("retentionDays")] int? RetentionDays = null);
|
||||
|
||||
/// <summary>
|
||||
/// Scope configuration for forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotScope(
|
||||
[property: JsonPropertyName("sbomIds")] IReadOnlyList<string>? SbomIds = null,
|
||||
[property: JsonPropertyName("scanIds")] IReadOnlyList<string>? ScanIds = null,
|
||||
[property: JsonPropertyName("policyIds")] IReadOnlyList<string>? PolicyIds = null,
|
||||
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
|
||||
[property: JsonPropertyName("timeRange")] ForensicTimeRange? TimeRange = null);
|
||||
|
||||
/// <summary>
|
||||
/// Time range for forensic snapshot scope.
|
||||
/// </summary>
|
||||
internal sealed record ForensicTimeRange(
|
||||
[property: JsonPropertyName("from")] DateTimeOffset? From = null,
|
||||
[property: JsonPropertyName("to")] DateTimeOffset? To = null);
|
||||
|
||||
/// <summary>
|
||||
/// Forensic snapshot document from the evidence locker.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotDocument
|
||||
{
|
||||
[JsonPropertyName("snapshotId")]
|
||||
public string SnapshotId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("caseId")]
|
||||
public string CaseId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public ForensicSnapshotManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ForensicSnapshotScope? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactCount")]
|
||||
public int? ArtifactCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest for a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotManifest
|
||||
{
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string ManifestId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public ForensicManifestSignature? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<ForensicSnapshotArtifact> Artifacts { get; init; } =
|
||||
Array.Empty<ForensicSnapshotArtifact>();
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public ForensicManifestMetadata? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature information for the manifest.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestSignature
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("certificate")]
|
||||
public string? Certificate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual artifact in a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotArtifact
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for the forensic manifest.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestMetadata
|
||||
{
|
||||
[JsonPropertyName("capturedAt")]
|
||||
public DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("capturedBy")]
|
||||
public string? CapturedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("toolVersion")]
|
||||
public string? ToolVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("stellaOpsVersion")]
|
||||
public string? StellaOpsVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("chainOfCustody")]
|
||||
public IReadOnlyList<ForensicChainOfCustodyEntry> ChainOfCustody { get; init; } =
|
||||
Array.Empty<ForensicChainOfCustodyEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chain of custody entry.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyEntry
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotListResponse
|
||||
{
|
||||
[JsonPropertyName("snapshots")]
|
||||
public IReadOnlyList<ForensicSnapshotDocument> Snapshots { get; init; } =
|
||||
Array.Empty<ForensicSnapshotDocument>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotListQuery(
|
||||
string Tenant,
|
||||
string? CaseId = null,
|
||||
string? Status = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
int? Limit = null,
|
||||
int? Offset = null);
|
||||
|
||||
/// <summary>
|
||||
/// Local cache metadata for forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotCacheEntry
|
||||
{
|
||||
[JsonPropertyName("snapshotId")]
|
||||
public string SnapshotId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("caseId")]
|
||||
public string CaseId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("localPath")]
|
||||
public string LocalPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifestDigest")]
|
||||
public string ManifestDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("downloadedAt")]
|
||||
public DateTimeOffset DownloadedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot status enumeration.
|
||||
/// </summary>
|
||||
internal static class ForensicSnapshotStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Creating = "creating";
|
||||
public const string Ready = "ready";
|
||||
public const string Failed = "failed";
|
||||
public const string Expired = "expired";
|
||||
public const string Archived = "archived";
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic bundle verification models
|
||||
|
||||
/// <summary>
|
||||
/// Represents a forensic bundle for local verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicBundle
|
||||
{
|
||||
[JsonPropertyName("manifestPath")]
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public ForensicSnapshotManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<ForensicBundleArtifact> Artifacts { get; init; } = Array.Empty<ForensicBundleArtifact>();
|
||||
|
||||
[JsonPropertyName("dsseEnvelopes")]
|
||||
public IReadOnlyList<ForensicDsseEnvelope> DsseEnvelopes { get; init; } = Array.Empty<ForensicDsseEnvelope>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact in a forensic bundle with local file reference.
|
||||
/// </summary>
|
||||
internal sealed class ForensicBundleArtifact
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("localPath")]
|
||||
public string LocalPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for signature verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<ForensicDsseSignature> Signatures { get; init; } = Array.Empty<ForensicDsseSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature entry.
|
||||
/// </summary>
|
||||
internal sealed class ForensicDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Signature { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of forensic bundle verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationResult
|
||||
{
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("manifestVerification")]
|
||||
public ForensicManifestVerification? ManifestVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("checksumVerification")]
|
||||
public ForensicChecksumVerification? ChecksumVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureVerification")]
|
||||
public ForensicSignatureVerification? SignatureVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("chainOfCustodyVerification")]
|
||||
public ForensicChainOfCustodyVerification? ChainOfCustodyVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<ForensicVerificationError> Errors { get; init; } = Array.Empty<ForensicVerificationError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string ManifestId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("computedDigest")]
|
||||
public string ComputedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactCount")]
|
||||
public int ArtifactCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checksum verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChecksumVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("totalArtifacts")]
|
||||
public int TotalArtifacts { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedArtifacts")]
|
||||
public int VerifiedArtifacts { get; init; }
|
||||
|
||||
[JsonPropertyName("failedArtifacts")]
|
||||
public IReadOnlyList<ForensicArtifactChecksumFailure> FailedArtifacts { get; init; } =
|
||||
Array.Empty<ForensicArtifactChecksumFailure>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual artifact checksum failure.
|
||||
/// </summary>
|
||||
internal sealed class ForensicArtifactChecksumFailure
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actualDigest")]
|
||||
public string ActualDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSignatureVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureCount")]
|
||||
public int SignatureCount { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedSignatures")]
|
||||
public int VerifiedSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<ForensicSignatureDetail> Signatures { get; init; } =
|
||||
Array.Empty<ForensicSignatureDetail>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual signature verification detail.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSignatureDetail
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("isTrusted")]
|
||||
public bool IsTrusted { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chain of custody verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("entryCount")]
|
||||
public int EntryCount { get; init; }
|
||||
|
||||
[JsonPropertyName("timelineValid")]
|
||||
public bool TimelineValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signaturesValid")]
|
||||
public bool SignaturesValid { get; init; }
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<ForensicChainOfCustodyEntryVerification> Entries { get; init; } =
|
||||
Array.Empty<ForensicChainOfCustodyEntryVerification>();
|
||||
|
||||
[JsonPropertyName("gaps")]
|
||||
public IReadOnlyList<ForensicTimelineGap> Gaps { get; init; } = Array.Empty<ForensicTimelineGap>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual chain of custody entry verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyEntryVerification
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline gap in chain of custody.
|
||||
/// </summary>
|
||||
internal sealed class ForensicTimelineGap
|
||||
{
|
||||
[JsonPropertyName("fromIndex")]
|
||||
public int FromIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("toIndex")]
|
||||
public int ToIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("fromTimestamp")]
|
||||
public DateTimeOffset FromTimestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("toTimestamp")]
|
||||
public DateTimeOffset ToTimestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("gapDuration")]
|
||||
public TimeSpan GapDuration { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification error detail.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationError
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string? ArtifactId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust root configuration for forensic verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicTrustRoot
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string Fingerprint { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("publicKey")]
|
||||
public string PublicKey { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = "rsa-pss-sha256";
|
||||
|
||||
[JsonPropertyName("notBefore")]
|
||||
public DateTimeOffset? NotBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("notAfter")]
|
||||
public DateTimeOffset? NotAfter { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification options for forensic bundle.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationOptions
|
||||
{
|
||||
public bool VerifyChecksums { get; init; } = true;
|
||||
public bool VerifySignatures { get; init; } = true;
|
||||
public bool VerifyChainOfCustody { get; init; } = true;
|
||||
public bool StrictTimeline { get; init; } = false;
|
||||
public IReadOnlyList<ForensicTrustRoot> TrustRoots { get; init; } = Array.Empty<ForensicTrustRoot>();
|
||||
public string? TrustRootPath { get; init; }
|
||||
}
|
||||
612
src/Cli/StellaOps.Cli/Services/Models/NotifyModels.cs
Normal file
612
src/Cli/StellaOps.Cli/Services/Models/NotifyModels.cs
Normal file
@@ -0,0 +1,612 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PARITY-41-002: Notify command models for CLI
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
internal enum NotifyChannelType
|
||||
{
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Custom,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
InApp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery status.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
internal enum NotifyDeliveryStatus
|
||||
{
|
||||
Pending,
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
Digested,
|
||||
Dropped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyChannelSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel summary for list view.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelSummary
|
||||
{
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryCount")]
|
||||
public int DeliveryCount { get; init; }
|
||||
|
||||
[JsonPropertyName("failureRate")]
|
||||
public double? FailureRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed notify channel response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelDetail
|
||||
{
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public NotifyChannelConfigInfo? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedBy")]
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("stats")]
|
||||
public NotifyChannelStats? Stats { get; init; }
|
||||
|
||||
[JsonPropertyName("health")]
|
||||
public NotifyChannelHealth? Health { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel configuration info (redacted secrets).
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelConfigInfo
|
||||
{
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; init; }
|
||||
|
||||
[JsonPropertyName("endpoint")]
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public IReadOnlyDictionary<string, string>? Properties { get; init; }
|
||||
|
||||
[JsonPropertyName("limits")]
|
||||
public NotifyChannelLimitsInfo? Limits { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel limits.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelLimitsInfo
|
||||
{
|
||||
[JsonPropertyName("concurrency")]
|
||||
public int? Concurrency { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerMinute")]
|
||||
public int? RequestsPerMinute { get; init; }
|
||||
|
||||
[JsonPropertyName("timeoutSeconds")]
|
||||
public int? TimeoutSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("maxBatchSize")]
|
||||
public int? MaxBatchSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel statistics.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelStats
|
||||
{
|
||||
[JsonPropertyName("totalDeliveries")]
|
||||
public long TotalDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("successfulDeliveries")]
|
||||
public long SuccessfulDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("failedDeliveries")]
|
||||
public long FailedDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("throttledDeliveries")]
|
||||
public long ThrottledDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("lastDeliveryAt")]
|
||||
public DateTimeOffset? LastDeliveryAt { get; init; }
|
||||
|
||||
[JsonPropertyName("avgLatencyMs")]
|
||||
public double? AvgLatencyMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel health status.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelHealth
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("lastCheckAt")]
|
||||
public DateTimeOffset? LastCheckAt { get; init; }
|
||||
|
||||
[JsonPropertyName("consecutiveFailures")]
|
||||
public int ConsecutiveFailures { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel test request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelTestRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel test result.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelTestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("responseCode")]
|
||||
public int? ResponseCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string? DeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string? EventType { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyRuleSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule summary.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleSummary
|
||||
{
|
||||
[JsonPropertyName("ruleId")]
|
||||
public string RuleId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("eventTypes")]
|
||||
public IReadOnlyList<string> EventTypes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("channelIds")]
|
||||
public IReadOnlyList<string> ChannelIds { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("matchCount")]
|
||||
public long MatchCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string? EventType { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("until")]
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyDeliverySummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery summary.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliverySummary
|
||||
{
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelName")]
|
||||
public string? ChannelName { get; init; }
|
||||
|
||||
[JsonPropertyName("channelType")]
|
||||
public string ChannelType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attemptCount")]
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sentAt")]
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery detail.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryDetail
|
||||
{
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelName")]
|
||||
public string? ChannelName { get; init; }
|
||||
|
||||
[JsonPropertyName("channelType")]
|
||||
public string ChannelType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ruleId")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public string? EventId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("attemptCount")]
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
[JsonPropertyName("attempts")]
|
||||
public IReadOnlyList<NotifyDeliveryAttempt>? Attempts { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sentAt")]
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
|
||||
[JsonPropertyName("failedAt")]
|
||||
public DateTimeOffset? FailedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery attempt.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryAttempt
|
||||
{
|
||||
[JsonPropertyName("attemptNumber")]
|
||||
public int AttemptNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attemptedAt")]
|
||||
public DateTimeOffset AttemptedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("responseCode")]
|
||||
public int? ResponseCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry delivery request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRetryRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry delivery result.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRetryResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("newStatus")]
|
||||
public string? NewStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send notification request.
|
||||
/// </summary>
|
||||
internal sealed class NotifySendRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string Body { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send notification result.
|
||||
/// </summary>
|
||||
internal sealed class NotifySendResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public string? EventId { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryIds")]
|
||||
public IReadOnlyList<string>? DeliveryIds { get; init; }
|
||||
|
||||
[JsonPropertyName("channelsMatched")]
|
||||
public int ChannelsMatched { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
542
src/Cli/StellaOps.Cli/Services/Models/ObservabilityModels.cs
Normal file
542
src/Cli/StellaOps.Cli/Services/Models/ObservabilityModels.cs
Normal file
@@ -0,0 +1,542 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-OBS-51-001: Observability models for stella obs commands
|
||||
|
||||
/// <summary>
|
||||
/// Service health status from the platform.
|
||||
/// </summary>
|
||||
internal sealed class ServiceHealthStatus
|
||||
{
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "unknown"; // healthy, degraded, unhealthy, unknown
|
||||
|
||||
[JsonPropertyName("availability")]
|
||||
public double Availability { get; init; }
|
||||
|
||||
[JsonPropertyName("sloTarget")]
|
||||
public double SloTarget { get; init; } = 0.999;
|
||||
|
||||
[JsonPropertyName("errorBudgetRemaining")]
|
||||
public double ErrorBudgetRemaining { get; init; }
|
||||
|
||||
[JsonPropertyName("burnRate")]
|
||||
public BurnRateInfo? BurnRate { get; init; }
|
||||
|
||||
[JsonPropertyName("latency")]
|
||||
public LatencyInfo? Latency { get; init; }
|
||||
|
||||
[JsonPropertyName("traffic")]
|
||||
public TrafficInfo? Traffic { get; init; }
|
||||
|
||||
[JsonPropertyName("queues")]
|
||||
public IReadOnlyList<QueueHealth> Queues { get; init; } = Array.Empty<QueueHealth>();
|
||||
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTimeOffset LastUpdated { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Burn rate alert information.
|
||||
/// </summary>
|
||||
internal sealed class BurnRateInfo
|
||||
{
|
||||
[JsonPropertyName("current")]
|
||||
public double Current { get; init; }
|
||||
|
||||
[JsonPropertyName("shortWindow")]
|
||||
public double ShortWindow { get; init; } // 5m or 1h window
|
||||
|
||||
[JsonPropertyName("longWindow")]
|
||||
public double LongWindow { get; init; } // 6h or 3d window
|
||||
|
||||
[JsonPropertyName("alertLevel")]
|
||||
public string AlertLevel { get; init; } = "none"; // none, warning, critical
|
||||
|
||||
[JsonPropertyName("threshold2x")]
|
||||
public double Threshold2x { get; init; } = 2.0;
|
||||
|
||||
[JsonPropertyName("threshold14x")]
|
||||
public double Threshold14x { get; init; } = 14.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Latency percentile information.
|
||||
/// </summary>
|
||||
internal sealed class LatencyInfo
|
||||
{
|
||||
[JsonPropertyName("p50")]
|
||||
public double P50Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p95")]
|
||||
public double P95Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p99")]
|
||||
public double P99Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p95Target")]
|
||||
public double P95TargetMs { get; init; } = 300;
|
||||
|
||||
[JsonPropertyName("breaching")]
|
||||
public bool Breaching { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traffic/throughput information.
|
||||
/// </summary>
|
||||
internal sealed class TrafficInfo
|
||||
{
|
||||
[JsonPropertyName("requestsPerSecond")]
|
||||
public double RequestsPerSecond { get; init; }
|
||||
|
||||
[JsonPropertyName("successRate")]
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
[JsonPropertyName("errorRate")]
|
||||
public double ErrorRate { get; init; }
|
||||
|
||||
[JsonPropertyName("totalRequests")]
|
||||
public long TotalRequests { get; init; }
|
||||
|
||||
[JsonPropertyName("totalErrors")]
|
||||
public long TotalErrors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue health information.
|
||||
/// </summary>
|
||||
internal sealed class QueueHealth
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("depth")]
|
||||
public long Depth { get; init; }
|
||||
|
||||
[JsonPropertyName("depthThreshold")]
|
||||
public long DepthThreshold { get; init; } = 1000;
|
||||
|
||||
[JsonPropertyName("oldestMessageAge")]
|
||||
public TimeSpan OldestMessageAge { get; init; }
|
||||
|
||||
[JsonPropertyName("throughput")]
|
||||
public double Throughput { get; init; }
|
||||
|
||||
[JsonPropertyName("successRate")]
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
[JsonPropertyName("alerting")]
|
||||
public bool Alerting { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide health summary.
|
||||
/// </summary>
|
||||
internal sealed class PlatformHealthSummary
|
||||
{
|
||||
[JsonPropertyName("overallStatus")]
|
||||
public string OverallStatus { get; init; } = "unknown";
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<ServiceHealthStatus> Services { get; init; } = Array.Empty<ServiceHealthStatus>();
|
||||
|
||||
[JsonPropertyName("activeAlerts")]
|
||||
public IReadOnlyList<ActiveAlert> ActiveAlerts { get; init; } = Array.Empty<ActiveAlert>();
|
||||
|
||||
[JsonPropertyName("globalErrorBudget")]
|
||||
public double GlobalErrorBudget { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Active alert information.
|
||||
/// </summary>
|
||||
internal sealed class ActiveAlert
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // burn_rate, latency, error_rate, queue_depth
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "warning"; // warning, critical
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public double Value { get; init; }
|
||||
|
||||
[JsonPropertyName("threshold")]
|
||||
public double Threshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for obs top command.
|
||||
/// </summary>
|
||||
internal sealed class ObsTopRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by service names.
|
||||
/// </summary>
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Filter by tenant.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include queue details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeQueues")]
|
||||
public bool IncludeQueues { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh interval in seconds for streaming mode (0 = single fetch).
|
||||
/// </summary>
|
||||
[JsonPropertyName("refreshInterval")]
|
||||
public int RefreshInterval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum alerts to return.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxAlerts")]
|
||||
public int MaxAlerts { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of obs top command.
|
||||
/// </summary>
|
||||
internal sealed class ObsTopResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public PlatformHealthSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Trace and logs models
|
||||
|
||||
/// <summary>
|
||||
/// Request for fetching a trace by ID.
|
||||
/// </summary>
|
||||
internal sealed class ObsTraceRequest
|
||||
{
|
||||
[JsonPropertyName("traceId")]
|
||||
public string TraceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("includeEvidence")]
|
||||
public bool IncludeEvidence { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distributed trace with spans.
|
||||
/// </summary>
|
||||
internal sealed class DistributedTrace
|
||||
{
|
||||
[JsonPropertyName("traceId")]
|
||||
public string TraceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootSpan")]
|
||||
public TraceSpan? RootSpan { get; init; }
|
||||
|
||||
[JsonPropertyName("spans")]
|
||||
public IReadOnlyList<TraceSpan> Spans { get; init; } = Array.Empty<TraceSpan>();
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("startTime")]
|
||||
public DateTimeOffset StartTime { get; init; }
|
||||
|
||||
[JsonPropertyName("endTime")]
|
||||
public DateTimeOffset EndTime { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "ok"; // ok, error
|
||||
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public IReadOnlyList<EvidenceLink> EvidenceLinks { get; init; } = Array.Empty<EvidenceLink>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual span within a trace.
|
||||
/// </summary>
|
||||
internal sealed class TraceSpan
|
||||
{
|
||||
[JsonPropertyName("spanId")]
|
||||
public string SpanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("parentSpanId")]
|
||||
public string? ParentSpanId { get; init; }
|
||||
|
||||
[JsonPropertyName("operationName")]
|
||||
public string OperationName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("serviceName")]
|
||||
public string ServiceName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("startTime")]
|
||||
public DateTimeOffset StartTime { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "ok"; // ok, error
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyDictionary<string, string> Tags { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<SpanLog> Logs { get; init; } = Array.Empty<SpanLog>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log entry within a span.
|
||||
/// </summary>
|
||||
internal sealed class SpanLog
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info"; // debug, info, warn, error
|
||||
|
||||
[JsonPropertyName("fields")]
|
||||
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link to evidence artifact (SBOM, VEX, attestation, etc.).
|
||||
/// </summary>
|
||||
internal sealed class EvidenceLink
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // sbom, vex, attestation, scan_result
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching a trace.
|
||||
/// </summary>
|
||||
internal sealed class ObsTraceResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("trace")]
|
||||
public DistributedTrace? Trace { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for fetching logs.
|
||||
/// </summary>
|
||||
internal sealed class ObsLogsRequest
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("levels")]
|
||||
public IReadOnlyList<string> Levels { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("query")]
|
||||
public string? Query { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 100;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log entry from the platform.
|
||||
/// </summary>
|
||||
internal sealed class LogEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info";
|
||||
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
[JsonPropertyName("spanId")]
|
||||
public string? SpanId { get; init; }
|
||||
|
||||
[JsonPropertyName("fields")]
|
||||
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public IReadOnlyList<EvidenceLink> EvidenceLinks { get; init; } = Array.Empty<EvidenceLink>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching logs.
|
||||
/// </summary>
|
||||
internal sealed class ObsLogsResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<LogEntry> Logs { get; init; } = Array.Empty<LogEntry>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-OBS-55-001: Incident mode models
|
||||
|
||||
/// <summary>
|
||||
/// Incident mode state.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeState
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("setAt")]
|
||||
public DateTimeOffset? SetAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("retentionExtensionDays")]
|
||||
public int RetentionExtensionDays { get; init; } = 60;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = "cli"; // cli, config, api
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to enable incident mode.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeEnableRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("ttlMinutes")]
|
||||
public int TtlMinutes { get; init; } = 30;
|
||||
|
||||
[JsonPropertyName("retentionExtensionDays")]
|
||||
public int RetentionExtensionDays { get; init; } = 60;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to disable incident mode.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeDisableRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of incident mode operation.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public IncidentModeState? State { get; init; }
|
||||
|
||||
[JsonPropertyName("previousState")]
|
||||
public IncidentModeState? PreviousState { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
671
src/Cli/StellaOps.Cli/Services/Models/OrchestratorModels.cs
Normal file
671
src/Cli/StellaOps.Cli/Services/Models/OrchestratorModels.cs
Normal file
@@ -0,0 +1,671 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator source and job models for stella orch commands
|
||||
|
||||
/// <summary>
|
||||
/// Source status values.
|
||||
/// </summary>
|
||||
internal static class SourceStatuses
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Paused = "paused";
|
||||
public const string Disabled = "disabled";
|
||||
public const string Throttled = "throttled";
|
||||
public const string Error = "error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source type values representing data feed categories.
|
||||
/// </summary>
|
||||
internal static class SourceTypes
|
||||
{
|
||||
public const string Advisory = "advisory";
|
||||
public const string Vex = "vex";
|
||||
public const string Sbom = "sbom";
|
||||
public const string Package = "package";
|
||||
public const string Registry = "registry";
|
||||
public const string Custom = "custom";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrator source definition representing a data feed.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorSource
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = SourceTypes.Advisory;
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = SourceStatuses.Active;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("schedule")]
|
||||
public SourceSchedule? Schedule { get; init; }
|
||||
|
||||
[JsonPropertyName("rateLimit")]
|
||||
public SourceRateLimit? RateLimit { get; init; }
|
||||
|
||||
[JsonPropertyName("lastRun")]
|
||||
public SourceLastRun? LastRun { get; init; }
|
||||
|
||||
[JsonPropertyName("metrics")]
|
||||
public SourceMetrics? Metrics { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("pausedAt")]
|
||||
public DateTimeOffset? PausedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("pausedBy")]
|
||||
public string? PausedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("pauseReason")]
|
||||
public string? PauseReason { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source schedule configuration.
|
||||
/// </summary>
|
||||
internal sealed class SourceSchedule
|
||||
{
|
||||
[JsonPropertyName("cron")]
|
||||
public string? Cron { get; init; }
|
||||
|
||||
[JsonPropertyName("intervalMinutes")]
|
||||
public int? IntervalMinutes { get; init; }
|
||||
|
||||
[JsonPropertyName("nextRunAt")]
|
||||
public DateTimeOffset? NextRunAt { get; init; }
|
||||
|
||||
[JsonPropertyName("timezone")]
|
||||
public string Timezone { get; init; } = "UTC";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source rate limit configuration.
|
||||
/// </summary>
|
||||
internal sealed class SourceRateLimit
|
||||
{
|
||||
[JsonPropertyName("maxRequestsPerMinute")]
|
||||
public int MaxRequestsPerMinute { get; init; }
|
||||
|
||||
[JsonPropertyName("maxRequestsPerHour")]
|
||||
public int? MaxRequestsPerHour { get; init; }
|
||||
|
||||
[JsonPropertyName("burstSize")]
|
||||
public int BurstSize { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("currentTokens")]
|
||||
public double? CurrentTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("refillRatePerSecond")]
|
||||
public double? RefillRatePerSecond { get; init; }
|
||||
|
||||
[JsonPropertyName("throttledUntil")]
|
||||
public DateTimeOffset? ThrottledUntil { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source last run information.
|
||||
/// </summary>
|
||||
internal sealed class SourceLastRun
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string? RunId { get; init; }
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("itemsProcessed")]
|
||||
public long? ItemsProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("itemsFailed")]
|
||||
public long? ItemsFailed { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long? DurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source metrics summary.
|
||||
/// </summary>
|
||||
internal sealed class SourceMetrics
|
||||
{
|
||||
[JsonPropertyName("totalRuns")]
|
||||
public long TotalRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("successfulRuns")]
|
||||
public long SuccessfulRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("failedRuns")]
|
||||
public long FailedRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("averageDurationMs")]
|
||||
public double? AverageDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("totalItemsProcessed")]
|
||||
public long TotalItemsProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("totalItemsFailed")]
|
||||
public long TotalItemsFailed { get; init; }
|
||||
|
||||
[JsonPropertyName("lastSuccessAt")]
|
||||
public DateTimeOffset? LastSuccessAt { get; init; }
|
||||
|
||||
[JsonPropertyName("lastFailureAt")]
|
||||
public DateTimeOffset? LastFailureAt { get; init; }
|
||||
|
||||
[JsonPropertyName("uptimePercent")]
|
||||
public double? UptimePercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list sources.
|
||||
/// </summary>
|
||||
internal sealed class SourceListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string? Host { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing sources.
|
||||
/// </summary>
|
||||
internal sealed class SourceListResponse
|
||||
{
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<OrchestratorSource> Sources { get; init; } = Array.Empty<OrchestratorSource>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to pause a source.
|
||||
/// </summary>
|
||||
internal sealed class SourcePauseRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMinutes")]
|
||||
public int? DurationMinutes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resume a source.
|
||||
/// </summary>
|
||||
internal sealed class SourceResumeRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to test a source connection.
|
||||
/// </summary>
|
||||
internal sealed class SourceTestRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("timeout")]
|
||||
public int TimeoutSeconds { get; init; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of source operation.
|
||||
/// </summary>
|
||||
internal sealed class SourceOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public OrchestratorSource? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of source test operation.
|
||||
/// </summary>
|
||||
internal sealed class SourceTestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachable")]
|
||||
public bool Reachable { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("statusCode")]
|
||||
public int? StatusCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("tlsValid")]
|
||||
public bool? TlsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("tlsExpiry")]
|
||||
public DateTimeOffset? TlsExpiry { get; init; }
|
||||
|
||||
[JsonPropertyName("testedAt")]
|
||||
public DateTimeOffset TestedAt { get; init; }
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Backfill wizard and quota management models
|
||||
|
||||
/// <summary>
|
||||
/// Request to start a backfill operation for a source.
|
||||
/// </summary>
|
||||
internal sealed class BackfillRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; } = 5;
|
||||
|
||||
[JsonPropertyName("concurrency")]
|
||||
public int Concurrency { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("batchSize")]
|
||||
public int BatchSize { get; init; } = 100;
|
||||
|
||||
[JsonPropertyName("resume")]
|
||||
public bool Resume { get; init; }
|
||||
|
||||
[JsonPropertyName("filter")]
|
||||
public string? Filter { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a backfill operation.
|
||||
/// </summary>
|
||||
internal sealed class BackfillResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("backfillId")]
|
||||
public string? BackfillId { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("estimatedItems")]
|
||||
public long? EstimatedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("processedItems")]
|
||||
public long ProcessedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("failedItems")]
|
||||
public long FailedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("skippedItems")]
|
||||
public long SkippedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("estimatedDurationMs")]
|
||||
public long? EstimatedDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("actualDurationMs")]
|
||||
public long? ActualDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status values for backfill operations.
|
||||
/// </summary>
|
||||
internal static class BackfillStatuses
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Running = "running";
|
||||
public const string Completed = "completed";
|
||||
public const string Failed = "failed";
|
||||
public const string Cancelled = "cancelled";
|
||||
public const string DryRun = "dry_run";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list backfill operations.
|
||||
/// </summary>
|
||||
internal sealed class BackfillListRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing backfill operations.
|
||||
/// </summary>
|
||||
internal sealed class BackfillListResponse
|
||||
{
|
||||
[JsonPropertyName("backfills")]
|
||||
public IReadOnlyList<BackfillResult> Backfills { get; init; } = Array.Empty<BackfillResult>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to cancel a backfill operation.
|
||||
/// </summary>
|
||||
internal sealed class BackfillCancelRequest
|
||||
{
|
||||
[JsonPropertyName("backfillId")]
|
||||
public string BackfillId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota resource representing usage limits for a tenant/source.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorQuota
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public long Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("used")]
|
||||
public long Used { get; init; }
|
||||
|
||||
[JsonPropertyName("remaining")]
|
||||
public long Remaining { get; init; }
|
||||
|
||||
[JsonPropertyName("period")]
|
||||
public string Period { get; init; } = "monthly";
|
||||
|
||||
[JsonPropertyName("periodStart")]
|
||||
public DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
[JsonPropertyName("periodEnd")]
|
||||
public DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
[JsonPropertyName("resetAt")]
|
||||
public DateTimeOffset ResetAt { get; init; }
|
||||
|
||||
[JsonPropertyName("warningThreshold")]
|
||||
public double WarningThreshold { get; init; } = 0.8;
|
||||
|
||||
[JsonPropertyName("isWarning")]
|
||||
public bool IsWarning { get; init; }
|
||||
|
||||
[JsonPropertyName("isExceeded")]
|
||||
public bool IsExceeded { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota resource types.
|
||||
/// </summary>
|
||||
internal static class QuotaResourceTypes
|
||||
{
|
||||
public const string ApiCalls = "api_calls";
|
||||
public const string DataIngested = "data_ingested_bytes";
|
||||
public const string ItemsProcessed = "items_processed";
|
||||
public const string Backfills = "backfills";
|
||||
public const string ConcurrentJobs = "concurrent_jobs";
|
||||
public const string Storage = "storage_bytes";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota period types.
|
||||
/// </summary>
|
||||
internal static class QuotaPeriods
|
||||
{
|
||||
public const string Hourly = "hourly";
|
||||
public const string Daily = "daily";
|
||||
public const string Weekly = "weekly";
|
||||
public const string Monthly = "monthly";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to get quotas.
|
||||
/// </summary>
|
||||
internal sealed class QuotaGetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string? ResourceType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from getting quotas.
|
||||
/// </summary>
|
||||
internal sealed class QuotaGetResponse
|
||||
{
|
||||
[JsonPropertyName("quotas")]
|
||||
public IReadOnlyList<OrchestratorQuota> Quotas { get; init; } = Array.Empty<OrchestratorQuota>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set a quota limit.
|
||||
/// </summary>
|
||||
internal sealed class QuotaSetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public long Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("period")]
|
||||
public string Period { get; init; } = QuotaPeriods.Monthly;
|
||||
|
||||
[JsonPropertyName("warningThreshold")]
|
||||
public double WarningThreshold { get; init; } = 0.8;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a quota operation.
|
||||
/// </summary>
|
||||
internal sealed class QuotaOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("quota")]
|
||||
public OrchestratorQuota? Quota { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to reset a quota's usage counter.
|
||||
/// </summary>
|
||||
internal sealed class QuotaResetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
915
src/Cli/StellaOps.Cli/Services/Models/PackModels.cs
Normal file
915
src/Cli/StellaOps.Cli/Services/Models/PackModels.cs
Normal file
@@ -0,0 +1,915 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PACKS-42-001: Task Pack models for stella pack commands
|
||||
|
||||
/// <summary>
|
||||
/// Task pack metadata from the registry.
|
||||
/// </summary>
|
||||
internal sealed class TaskPackInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("author")]
|
||||
public string? Author { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public PackSignature? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string> Labels { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyList<PackInputSchema> Inputs { get; init; } = Array.Empty<PackInputSchema>();
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyList<PackOutputSchema> Outputs { get; init; } = Array.Empty<PackOutputSchema>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack signature information.
|
||||
/// </summary>
|
||||
internal sealed class PackSignature
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty; // ecdsa-p256, rsa-pkcs1-sha256, etc.
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("certificate")]
|
||||
public string? Certificate { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack input parameter schema.
|
||||
/// </summary>
|
||||
internal sealed class PackInputSchema
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "string"; // string, number, boolean, array, object
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; init; }
|
||||
|
||||
[JsonPropertyName("default")]
|
||||
public object? Default { get; init; }
|
||||
|
||||
[JsonPropertyName("enum")]
|
||||
public IReadOnlyList<string>? Enum { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack output schema.
|
||||
/// </summary>
|
||||
internal sealed class PackOutputSchema
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "string";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to plan a pack execution.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("validateOnly")]
|
||||
public bool ValidateOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution plan step.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanStep
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dependsOn")]
|
||||
public IReadOnlyList<string> DependsOn { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("condition")]
|
||||
public string? Condition { get; init; }
|
||||
|
||||
[JsonPropertyName("timeout")]
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
|
||||
[JsonPropertyName("retryPolicy")]
|
||||
public PackRetryPolicy? RetryPolicy { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresApproval")]
|
||||
public bool RequiresApproval { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry policy for pack steps.
|
||||
/// </summary>
|
||||
internal sealed class PackRetryPolicy
|
||||
{
|
||||
[JsonPropertyName("maxAttempts")]
|
||||
public int MaxAttempts { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("backoffMultiplier")]
|
||||
public double BackoffMultiplier { get; init; } = 2.0;
|
||||
|
||||
[JsonPropertyName("initialDelayMs")]
|
||||
public int InitialDelayMs { get; init; } = 1000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack plan operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("planId")]
|
||||
public string? PlanId { get; init; }
|
||||
|
||||
[JsonPropertyName("planHash")]
|
||||
public string? PlanHash { get; init; }
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public IReadOnlyList<PackPlanStep> Steps { get; init; } = Array.Empty<PackPlanStep>();
|
||||
|
||||
[JsonPropertyName("requiresApproval")]
|
||||
public bool RequiresApproval { get; init; }
|
||||
|
||||
[JsonPropertyName("approvalGates")]
|
||||
public IReadOnlyList<string> ApprovalGates { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("estimatedDuration")]
|
||||
public TimeSpan? EstimatedDuration { get; init; }
|
||||
|
||||
[JsonPropertyName("validationErrors")]
|
||||
public IReadOnlyList<PackValidationError> ValidationErrors { get; init; } = Array.Empty<PackValidationError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation error from pack plan/verify.
|
||||
/// </summary>
|
||||
internal sealed class PackValidationError
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "error"; // error, warning
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to run a pack.
|
||||
/// </summary>
|
||||
internal sealed class PackRunRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("planId")]
|
||||
public string? PlanId { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("waitForCompletion")]
|
||||
public bool WaitForCompletion { get; init; }
|
||||
|
||||
[JsonPropertyName("timeoutMinutes")]
|
||||
public int TimeoutMinutes { get; init; } = 60;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack run status.
|
||||
/// </summary>
|
||||
internal sealed class PackRunStatus
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "pending"; // pending, running, succeeded, failed, cancelled, waiting_approval
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("currentStep")]
|
||||
public string? CurrentStep { get; init; }
|
||||
|
||||
[JsonPropertyName("stepStatuses")]
|
||||
public IReadOnlyList<PackStepStatus> StepStatuses { get; init; } = Array.Empty<PackStepStatus>();
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyDictionary<string, object>? Outputs { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<PackArtifact> Artifacts { get; init; } = Array.Empty<PackArtifact>();
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of individual pack step.
|
||||
/// </summary>
|
||||
internal sealed class PackStepStatus
|
||||
{
|
||||
[JsonPropertyName("stepId")]
|
||||
public string StepId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "pending"; // pending, running, succeeded, failed, skipped
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("attempt")]
|
||||
public int Attempt { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyDictionary<string, object>? Outputs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact produced by pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifact
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // log, sbom, report, attestation
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack run operation.
|
||||
/// </summary>
|
||||
internal sealed class PackRunResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("run")]
|
||||
public PackRunStatus? Run { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to push a pack to the registry.
|
||||
/// </summary>
|
||||
internal sealed class PackPushRequest
|
||||
{
|
||||
[JsonPropertyName("packPath")]
|
||||
public string PackPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sign")]
|
||||
public bool Sign { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack push operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPushResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to pull a pack from the registry.
|
||||
/// </summary>
|
||||
internal sealed class PackPullRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("verify")]
|
||||
public bool Verify { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack pull operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPullResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a pack.
|
||||
/// </summary>
|
||||
internal sealed class PackVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("packPath")]
|
||||
public string? PackPath { get; init; }
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("checkRekor")]
|
||||
public bool CheckRekor { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("checkExpiry")]
|
||||
public bool CheckExpiry { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack verify operation.
|
||||
/// </summary>
|
||||
internal sealed class PackVerifyResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("digestMatch")]
|
||||
public bool DigestMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerified")]
|
||||
public bool? RekorVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateValid")]
|
||||
public bool? CertificateValid { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateExpiry")]
|
||||
public DateTimeOffset? CertificateExpiry { get; init; }
|
||||
|
||||
[JsonPropertyName("schemaValid")]
|
||||
public bool SchemaValid { get; init; }
|
||||
|
||||
[JsonPropertyName("validationErrors")]
|
||||
public IReadOnlyList<PackValidationError> ValidationErrors { get; init; } = Array.Empty<PackValidationError>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-PACKS-43-001: Advanced pack features
|
||||
|
||||
/// <summary>
|
||||
/// Request to pause a pack run waiting for approval.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalPauseRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resume a paused pack run with approval.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalResumeRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("approved")]
|
||||
public bool Approved { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an approval operation.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("run")]
|
||||
public PackRunStatus? Run { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to inject a secret into a pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackSecretInjectRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("secretProvider")]
|
||||
public string SecretProvider { get; init; } = "vault"; // vault, aws-ssm, azure-keyvault, k8s-secret
|
||||
|
||||
[JsonPropertyName("targetEnvVar")]
|
||||
public string? TargetEnvVar { get; init; }
|
||||
|
||||
[JsonPropertyName("targetPath")]
|
||||
public string? TargetPath { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a secret injection operation.
|
||||
/// </summary>
|
||||
internal sealed class PackSecretInjectResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("injectedAt")]
|
||||
public DateTimeOffset InjectedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list pack runs.
|
||||
/// </summary>
|
||||
internal sealed class PackRunListRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("until")]
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing pack runs.
|
||||
/// </summary>
|
||||
internal sealed class PackRunListResponse
|
||||
{
|
||||
[JsonPropertyName("runs")]
|
||||
public IReadOnlyList<PackRunStatus> Runs { get; init; } = Array.Empty<PackRunStatus>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to cancel a pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackCancelRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to get pack run logs.
|
||||
/// </summary>
|
||||
internal sealed class PackLogsRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
|
||||
[JsonPropertyName("follow")]
|
||||
public bool Follow { get; init; }
|
||||
|
||||
[JsonPropertyName("tail")]
|
||||
public int? Tail { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack log entry.
|
||||
/// </summary>
|
||||
internal sealed class PackLogEntry
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info"; // debug, info, warn, error
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; init; } = "stdout"; // stdout, stderr
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack logs request.
|
||||
/// </summary>
|
||||
internal sealed class PackLogsResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<PackLogEntry> Logs { get; init; } = Array.Empty<PackLogEntry>();
|
||||
|
||||
[JsonPropertyName("nextToken")]
|
||||
public string? NextToken { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to download a pack artifact.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifactDownloadRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactName")]
|
||||
public string ArtifactName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of artifact download.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifactDownloadResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact")]
|
||||
public PackArtifact? Artifact { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offline cache entry for packs.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheEntry
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cachedAt")]
|
||||
public DateTimeOffset CachedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to manage offline cache.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheRequest
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = "list"; // list, add, remove, sync, prune
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("cacheDir")]
|
||||
public string? CacheDir { get; init; }
|
||||
|
||||
[JsonPropertyName("maxAge")]
|
||||
public TimeSpan? MaxAge { get; init; }
|
||||
|
||||
[JsonPropertyName("maxSize")]
|
||||
public long? MaxSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of cache operation.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<PackCacheEntry> Entries { get; init; } = Array.Empty<PackCacheEntry>();
|
||||
|
||||
[JsonPropertyName("totalSize")]
|
||||
public long TotalSize { get; init; }
|
||||
|
||||
[JsonPropertyName("prunedCount")]
|
||||
public int PrunedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("prunedSize")]
|
||||
public long PrunedSize { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -2,16 +2,39 @@ using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced simulation modes
|
||||
internal enum PolicySimulationMode
|
||||
{
|
||||
Quick,
|
||||
Batch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for policy simulation.
|
||||
/// Per CLI-EXC-25-002, supports exception preview via WithExceptions/WithoutExceptions.
|
||||
/// Per CLI-POLICY-27-003, supports mode (quick/batch), SBOM selectors, heatmap, and manifest download.
|
||||
/// Per CLI-SIG-26-002, supports reachability overrides for vulnerability/package state and score.
|
||||
/// </summary>
|
||||
internal sealed record PolicySimulationInput(
|
||||
int? BaseVersion,
|
||||
int? CandidateVersion,
|
||||
IReadOnlyList<string> SbomSet,
|
||||
IReadOnlyDictionary<string, object?> Environment,
|
||||
bool Explain);
|
||||
bool Explain,
|
||||
IReadOnlyList<string>? WithExceptions = null,
|
||||
IReadOnlyList<string>? WithoutExceptions = null,
|
||||
PolicySimulationMode? Mode = null,
|
||||
IReadOnlyList<string>? SbomSelectors = null,
|
||||
bool IncludeHeatmap = false,
|
||||
bool IncludeManifest = false,
|
||||
IReadOnlyList<ReachabilityOverride>? ReachabilityOverrides = null);
|
||||
|
||||
internal sealed record PolicySimulationResult(
|
||||
PolicySimulationDiff Diff,
|
||||
string? ExplainUri);
|
||||
string? ExplainUri,
|
||||
PolicySimulationHeatmap? Heatmap = null,
|
||||
string? ManifestDownloadUri = null,
|
||||
string? ManifestDigest = null);
|
||||
|
||||
internal sealed record PolicySimulationDiff(
|
||||
string? SchemaVersion,
|
||||
@@ -24,3 +47,17 @@ internal sealed record PolicySimulationDiff(
|
||||
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
|
||||
|
||||
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);
|
||||
|
||||
// CLI-POLICY-27-003: Heatmap summary for quick severity visualization
|
||||
internal sealed record PolicySimulationHeatmap(
|
||||
int Critical,
|
||||
int High,
|
||||
int Medium,
|
||||
int Low,
|
||||
int Info,
|
||||
IReadOnlyList<PolicySimulationHeatmapBucket> Buckets);
|
||||
|
||||
internal sealed record PolicySimulationHeatmapBucket(
|
||||
string Label,
|
||||
int Count,
|
||||
string? Color);
|
||||
|
||||
1211
src/Cli/StellaOps.Cli/Services/Models/PolicyWorkspaceModels.cs
Normal file
1211
src/Cli/StellaOps.Cli/Services/Models/PolicyWorkspaceModels.cs
Normal file
File diff suppressed because it is too large
Load Diff
468
src/Cli/StellaOps.Cli/Services/Models/PromotionModels.cs
Normal file
468
src/Cli/StellaOps.Cli/Services/Models/PromotionModels.cs
Normal file
@@ -0,0 +1,468 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PROMO-70-001: Promotion attestation models
|
||||
|
||||
/// <summary>
|
||||
/// Request for assembling a promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAssembleRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string Image { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("vexPath")]
|
||||
public string? VexPath { get; init; }
|
||||
|
||||
[JsonPropertyName("fromEnvironment")]
|
||||
public string FromEnvironment { get; init; } = "staging";
|
||||
|
||||
[JsonPropertyName("toEnvironment")]
|
||||
public string ToEnvironment { get; init; } = "prod";
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("pipeline")]
|
||||
public string? Pipeline { get; init; }
|
||||
|
||||
[JsonPropertyName("ticket")]
|
||||
public string? Ticket { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("skipRekor")]
|
||||
public bool SkipRekor { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion attestation predicate following stella.ops/promotion@v1 schema.
|
||||
/// </summary>
|
||||
internal sealed class PromotionPredicate
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = "stella.ops/promotion@v1";
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public IReadOnlyList<PromotionSubject> Subject { get; init; } = Array.Empty<PromotionSubject>();
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterial> Materials { get; init; } = Array.Empty<PromotionMaterial>();
|
||||
|
||||
[JsonPropertyName("promotion")]
|
||||
public PromotionMetadata Promotion { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public PromotionRekorEntry? Rekor { get; init; }
|
||||
|
||||
[JsonPropertyName("attestation")]
|
||||
public PromotionAttestationMetadata? Attestation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in promotion attestation (image reference).
|
||||
/// </summary>
|
||||
internal sealed class PromotionSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material in promotion attestation (SBOM, VEX, etc.).
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterial
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algo")]
|
||||
public string Algo { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion metadata.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMetadata
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public string From { get; init; } = "staging";
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public string To { get; init; } = "prod";
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("pipeline")]
|
||||
public string? Pipeline { get; init; }
|
||||
|
||||
[JsonPropertyName("ticket")]
|
||||
public string? Ticket { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry in promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionRekorEntry
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string Uuid { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("logIndex")]
|
||||
public long LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
public PromotionInclusionProof? InclusionProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle inclusion proof.
|
||||
/// </summary>
|
||||
internal sealed class PromotionInclusionProof
|
||||
{
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyList<string> Hashes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public long TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public PromotionCheckpoint? Checkpoint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint.
|
||||
/// </summary>
|
||||
internal sealed class PromotionCheckpoint
|
||||
{
|
||||
[JsonPropertyName("origin")]
|
||||
public string Origin { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signedNote")]
|
||||
public string? SignedNote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation metadata.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestationMetadata
|
||||
{
|
||||
[JsonPropertyName("bundle_sha256")]
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("witness")]
|
||||
public string? Witness { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion assemble operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAssembleResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterial> Materials { get; init; } = Array.Empty<PromotionMaterial>();
|
||||
|
||||
[JsonPropertyName("rekorEntry")]
|
||||
public PromotionRekorEntry? RekorEntry { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-PROMO-70-002: Promotion attest/verify models
|
||||
|
||||
/// <summary>
|
||||
/// Request for attesting a promotion predicate.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicatePath")]
|
||||
public string? PredicatePath { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("useKeyless")]
|
||||
public bool UseKeyless { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("uploadToRekor")]
|
||||
public bool UploadToRekor { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion attest operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string? BundlePath { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseEnvelope")]
|
||||
public DsseEnvelope? DsseEnvelope { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorEntry")]
|
||||
public PromotionRekorEntry? RekorEntry { get; init; }
|
||||
|
||||
[JsonPropertyName("auditId")]
|
||||
public string? AuditId { get; init; }
|
||||
|
||||
[JsonPropertyName("signerKeyId")]
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class DsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = "application/vnd.in-toto+json";
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<DsseSignature> Signatures { get; init; } = Array.Empty<DsseSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature.
|
||||
/// </summary>
|
||||
internal sealed class DsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Sig { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cert")]
|
||||
public string? Cert { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for verifying a promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string? BundlePath { get; init; }
|
||||
|
||||
[JsonPropertyName("predicatePath")]
|
||||
public string? PredicatePath { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("vexPath")]
|
||||
public string? VexPath { get; init; }
|
||||
|
||||
[JsonPropertyName("trustRootPath")]
|
||||
public string? TrustRootPath { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpointPath")]
|
||||
public string? CheckpointPath { get; init; }
|
||||
|
||||
[JsonPropertyName("skipRekorVerification")]
|
||||
public bool SkipRekorVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("skipSignatureVerification")]
|
||||
public bool SkipSignatureVerification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion verify operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionVerifyResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureVerification")]
|
||||
public PromotionSignatureVerification? SignatureVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("materialVerification")]
|
||||
public PromotionMaterialVerification? MaterialVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerification")]
|
||||
public PromotionRekorVerification? RekorVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionSignatureVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("certSubject")]
|
||||
public string? CertSubject { get; init; }
|
||||
|
||||
[JsonPropertyName("certIssuer")]
|
||||
public string? CertIssuer { get; init; }
|
||||
|
||||
[JsonPropertyName("validFrom")]
|
||||
public DateTimeOffset? ValidFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("validTo")]
|
||||
public DateTimeOffset? ValidTo { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterialVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterialVerificationEntry> Materials { get; init; } = Array.Empty<PromotionMaterialVerificationEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual material verification entry.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterialVerificationEntry
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actualDigest")]
|
||||
public string? ActualDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionRekorVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
[JsonPropertyName("logIndex")]
|
||||
public long? LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("inclusionProofVerified")]
|
||||
public bool InclusionProofVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpointVerified")]
|
||||
public bool CheckpointVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
252
src/Cli/StellaOps.Cli/Services/Models/ReachabilityModels.cs
Normal file
252
src/Cli/StellaOps.Cli/Services/Models/ReachabilityModels.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-SIG-26-001: Reachability command models
|
||||
|
||||
/// <summary>
|
||||
/// Request to upload a call graph for reachability analysis.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityUploadCallGraphRequest
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphPath")]
|
||||
public string CallGraphPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of uploading a call graph.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityUploadCallGraphResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphId")]
|
||||
public string CallGraphId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("entriesProcessed")]
|
||||
public int EntriesProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("errorsCount")]
|
||||
public int ErrorsCount { get; init; }
|
||||
|
||||
[JsonPropertyName("uploadedAt")]
|
||||
public DateTimeOffset UploadedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list reachability analyses.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityListRequest
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing reachability analyses.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityListResponse
|
||||
{
|
||||
[JsonPropertyName("analyses")]
|
||||
public IReadOnlyList<ReachabilityAnalysisSummary> Analyses { get; init; } = Array.Empty<ReachabilityAnalysisSummary>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a reachability analysis.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityAnalysisSummary
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetName")]
|
||||
public string? AssetName { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphId")]
|
||||
public string CallGraphId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachableCount")]
|
||||
public int ReachableCount { get; init; }
|
||||
|
||||
[JsonPropertyName("unreachableCount")]
|
||||
public int UnreachableCount { get; init; }
|
||||
|
||||
[JsonPropertyName("unknownCount")]
|
||||
public int UnknownCount { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to explain reachability for a specific vulnerability or package.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityExplainRequest
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("includeCallPaths")]
|
||||
public bool IncludeCallPaths { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of reachability explanation.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityExplainResult
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("reachabilityState")]
|
||||
public string ReachabilityState { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachabilityScore")]
|
||||
public double? ReachabilityScore { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public string Confidence { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reasoning")]
|
||||
public string? Reasoning { get; init; }
|
||||
|
||||
[JsonPropertyName("callPaths")]
|
||||
public IReadOnlyList<ReachabilityCallPath> CallPaths { get; init; } = Array.Empty<ReachabilityCallPath>();
|
||||
|
||||
[JsonPropertyName("affectedFunctions")]
|
||||
public IReadOnlyList<ReachabilityFunction> AffectedFunctions { get; init; } = Array.Empty<ReachabilityFunction>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call path demonstrating reachability.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityCallPath
|
||||
{
|
||||
[JsonPropertyName("pathId")]
|
||||
public string PathId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("depth")]
|
||||
public int Depth { get; init; }
|
||||
|
||||
[JsonPropertyName("entryPoint")]
|
||||
public ReachabilityFunction EntryPoint { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("frames")]
|
||||
public IReadOnlyList<ReachabilityFunction> Frames { get; init; } = Array.Empty<ReachabilityFunction>();
|
||||
|
||||
[JsonPropertyName("vulnerableFunction")]
|
||||
public ReachabilityFunction VulnerableFunction { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function in the call graph.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityFunction
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("className")]
|
||||
public string? ClassName { get; init; }
|
||||
|
||||
[JsonPropertyName("packageName")]
|
||||
public string? PackageName { get; init; }
|
||||
|
||||
[JsonPropertyName("filePath")]
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
[JsonPropertyName("lineNumber")]
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
|
||||
// CLI-SIG-26-002: Policy simulate reachability override models (extends PolicySimulationInput)
|
||||
|
||||
/// <summary>
|
||||
/// Reachability override for policy simulation.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityOverride
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string State { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
}
|
||||
448
src/Cli/StellaOps.Cli/Services/Models/RiskModels.cs
Normal file
448
src/Cli/StellaOps.Cli/Services/Models/RiskModels.cs
Normal file
@@ -0,0 +1,448 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-RISK-66-001: Risk profile list models
|
||||
|
||||
/// <summary>
|
||||
/// Request to list risk profiles.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileListRequest
|
||||
{
|
||||
[JsonPropertyName("includeDisabled")]
|
||||
public bool IncludeDisabled { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public string? Category { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of risk profiles.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileListResponse
|
||||
{
|
||||
[JsonPropertyName("profiles")]
|
||||
public IReadOnlyList<RiskProfileSummary> Profiles { get; init; } = Array.Empty<RiskProfileSummary>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a risk profile.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileSummary
|
||||
{
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("builtIn")]
|
||||
public bool BuiltIn { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleCount")]
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
[JsonPropertyName("severityWeights")]
|
||||
public RiskSeverityWeights? SeverityWeights { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity weights for risk scoring.
|
||||
/// </summary>
|
||||
internal sealed class RiskSeverityWeights
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
public double Critical { get; init; }
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
public double High { get; init; }
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
public double Medium { get; init; }
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
public double Low { get; init; }
|
||||
|
||||
[JsonPropertyName("info")]
|
||||
public double Info { get; init; }
|
||||
}
|
||||
|
||||
// CLI-RISK-66-002: Risk simulate models
|
||||
|
||||
/// <summary>
|
||||
/// Request to simulate risk scoring.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateRequest
|
||||
{
|
||||
[JsonPropertyName("profileId")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("diffMode")]
|
||||
public bool DiffMode { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineProfileId")]
|
||||
public string? BaselineProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of risk simulation.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileName")]
|
||||
public string ProfileName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("overallScore")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findings")]
|
||||
public RiskSimulateFindingsSummary Findings { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("componentScores")]
|
||||
public IReadOnlyList<RiskComponentScore> ComponentScores { get; init; } = Array.Empty<RiskComponentScore>();
|
||||
|
||||
[JsonPropertyName("diff")]
|
||||
public RiskSimulateDiff? Diff { get; init; }
|
||||
|
||||
[JsonPropertyName("simulatedAt")]
|
||||
public DateTimeOffset SimulatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of findings from risk simulation.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateFindingsSummary
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
public int Critical { get; init; }
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
public int High { get; init; }
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
public int Medium { get; init; }
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
public int Low { get; init; }
|
||||
|
||||
[JsonPropertyName("info")]
|
||||
public int Info { get; init; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component-level risk score.
|
||||
/// </summary>
|
||||
internal sealed class RiskComponentScore
|
||||
{
|
||||
[JsonPropertyName("componentId")]
|
||||
public string ComponentId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentName")]
|
||||
public string ComponentName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findingCount")]
|
||||
public int FindingCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff between baseline and candidate risk scores.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateDiff
|
||||
{
|
||||
[JsonPropertyName("baselineScore")]
|
||||
public double BaselineScore { get; init; }
|
||||
|
||||
[JsonPropertyName("candidateScore")]
|
||||
public double CandidateScore { get; init; }
|
||||
|
||||
[JsonPropertyName("delta")]
|
||||
public double Delta { get; init; }
|
||||
|
||||
[JsonPropertyName("improved")]
|
||||
public bool Improved { get; init; }
|
||||
|
||||
[JsonPropertyName("findingsAdded")]
|
||||
public int FindingsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("findingsRemoved")]
|
||||
public int FindingsRemoved { get; init; }
|
||||
}
|
||||
|
||||
// CLI-RISK-67-001: Risk results models
|
||||
|
||||
/// <summary>
|
||||
/// Request to get risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsRequest
|
||||
{
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("minSeverity")]
|
||||
public string? MinSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double? MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("includeExplain")]
|
||||
public bool IncludeExplain { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsResponse
|
||||
{
|
||||
[JsonPropertyName("results")]
|
||||
public IReadOnlyList<RiskResult> Results { get; init; } = Array.Empty<RiskResult>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public RiskResultsSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual risk result.
|
||||
/// </summary>
|
||||
internal sealed class RiskResult
|
||||
{
|
||||
[JsonPropertyName("resultId")]
|
||||
public string ResultId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string AssetId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("assetName")]
|
||||
public string? AssetName { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileName")]
|
||||
public string? ProfileName { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findingCount")]
|
||||
public int FindingCount { get; init; }
|
||||
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public RiskResultExplain? Explain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explanation for a risk result.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultExplain
|
||||
{
|
||||
[JsonPropertyName("factors")]
|
||||
public IReadOnlyList<RiskFactor> Factors { get; init; } = Array.Empty<RiskFactor>();
|
||||
|
||||
[JsonPropertyName("recommendations")]
|
||||
public IReadOnlyList<string> Recommendations { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factor contributing to risk score.
|
||||
/// </summary>
|
||||
internal sealed class RiskFactor
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("weight")]
|
||||
public double Weight { get; init; }
|
||||
|
||||
[JsonPropertyName("contribution")]
|
||||
public double Contribution { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsSummary
|
||||
{
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("minScore")]
|
||||
public double MinScore { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("assetCount")]
|
||||
public int AssetCount { get; init; }
|
||||
|
||||
[JsonPropertyName("bySeverity")]
|
||||
public RiskSimulateFindingsSummary BySeverity { get; init; } = new();
|
||||
}
|
||||
|
||||
// CLI-RISK-68-001: Risk bundle verify models
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a risk bundle.
|
||||
/// </summary>
|
||||
internal sealed class RiskBundleVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signaturePath")]
|
||||
public string? SignaturePath { get; init; }
|
||||
|
||||
[JsonPropertyName("checkRekor")]
|
||||
public bool CheckRekor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a risk bundle.
|
||||
/// </summary>
|
||||
internal sealed class RiskBundleVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string BundleId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundleVersion")]
|
||||
public string BundleVersion { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileCount")]
|
||||
public int ProfileCount { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleCount")]
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("signedBy")]
|
||||
public string? SignedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerified")]
|
||||
public bool? RekorVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public string? RekorLogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
633
src/Cli/StellaOps.Cli/Services/Models/SbomModels.cs
Normal file
633
src/Cli/StellaOps.Cli/Services/Models/SbomModels.cs
Normal file
@@ -0,0 +1,633 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PARITY-41-001: SBOM Explorer models for CLI
|
||||
|
||||
/// <summary>
|
||||
/// SBOM list request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAfter")]
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBefore")]
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("hasVulnerabilities")]
|
||||
public bool? HasVulnerabilities { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated SBOM list response.
|
||||
/// </summary>
|
||||
internal sealed class SbomListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<SbomSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary view of an SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomSummary
|
||||
{
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityCount")]
|
||||
public int VulnerabilityCount { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesDetected")]
|
||||
public int LicensesDetected { get; init; }
|
||||
|
||||
[JsonPropertyName("determinismScore")]
|
||||
public double? DeterminismScore { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed SBOM response including components, vulnerabilities, and metadata.
|
||||
/// </summary>
|
||||
internal sealed class SbomDetailResponse
|
||||
{
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityCount")]
|
||||
public int VulnerabilityCount { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesDetected")]
|
||||
public int LicensesDetected { get; init; }
|
||||
|
||||
[JsonPropertyName("determinismScore")]
|
||||
public double? DeterminismScore { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<SbomComponent>? Components { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public SbomMetadata? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilities")]
|
||||
public IReadOnlyList<SbomVulnerability>? Vulnerabilities { get; init; }
|
||||
|
||||
[JsonPropertyName("licenses")]
|
||||
public IReadOnlyList<SbomLicense>? Licenses { get; init; }
|
||||
|
||||
[JsonPropertyName("attestation")]
|
||||
public SbomAttestation? Attestation { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public SbomExplainInfo? Explain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component information.
|
||||
/// </summary>
|
||||
internal sealed class SbomComponent
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cpe")]
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("supplier")]
|
||||
public string? Supplier { get; init; }
|
||||
|
||||
[JsonPropertyName("licenses")]
|
||||
public IReadOnlyList<string>? Licenses { get; init; }
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyDictionary<string, string>? Hashes { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("dependencies")]
|
||||
public IReadOnlyList<string>? Dependencies { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM creation metadata.
|
||||
/// </summary>
|
||||
internal sealed class SbomMetadata
|
||||
{
|
||||
[JsonPropertyName("toolName")]
|
||||
public string? ToolName { get; init; }
|
||||
|
||||
[JsonPropertyName("toolVersion")]
|
||||
public string? ToolVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("scannerVersion")]
|
||||
public string? ScannerVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("serialNumber")]
|
||||
public string? SerialNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("documentNamespace")]
|
||||
public string? DocumentNamespace { get; init; }
|
||||
|
||||
[JsonPropertyName("creators")]
|
||||
public IReadOnlyList<string>? Creators { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability found in SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomVulnerability
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedComponent")]
|
||||
public string? AffectedComponent { get; init; }
|
||||
|
||||
[JsonPropertyName("fixedIn")]
|
||||
public string? FixedIn { get; init; }
|
||||
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License information in SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomLicense
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<string>? Components { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation information for SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomAttestation
|
||||
{
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateIssuer")]
|
||||
public string? CertificateIssuer { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateSubject")]
|
||||
public string? CertificateSubject { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explain information for SBOM generation (determinism debugging).
|
||||
/// </summary>
|
||||
internal sealed class SbomExplainInfo
|
||||
{
|
||||
[JsonPropertyName("determinismFactors")]
|
||||
public IReadOnlyList<SbomDeterminismFactor>? DeterminismFactors { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public IReadOnlyList<SbomCompositionStep>? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factor affecting SBOM determinism score.
|
||||
/// </summary>
|
||||
internal sealed class SbomDeterminismFactor
|
||||
{
|
||||
[JsonPropertyName("factor")]
|
||||
public string Factor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("impact")]
|
||||
public string Impact { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step in SBOM composition chain.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompositionStep
|
||||
{
|
||||
[JsonPropertyName("step")]
|
||||
public int Step { get; init; }
|
||||
|
||||
[JsonPropertyName("operation")]
|
||||
public string Operation { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("input")]
|
||||
public string? Input { get; init; }
|
||||
|
||||
[JsonPropertyName("output")]
|
||||
public string? Output { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM compare request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("baseSbomId")]
|
||||
public string BaseSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("targetSbomId")]
|
||||
public string TargetSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("includeUnchanged")]
|
||||
public bool IncludeUnchanged { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM comparison result.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareResponse
|
||||
{
|
||||
[JsonPropertyName("baseSbomId")]
|
||||
public string BaseSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("targetSbomId")]
|
||||
public string TargetSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public SbomCompareSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("componentChanges")]
|
||||
public IReadOnlyList<SbomComponentChange>? ComponentChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityChanges")]
|
||||
public IReadOnlyList<SbomVulnerabilityChange>? VulnerabilityChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("licenseChanges")]
|
||||
public IReadOnlyList<SbomLicenseChange>? LicenseChanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of SBOM comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareSummary
|
||||
{
|
||||
[JsonPropertyName("componentsAdded")]
|
||||
public int ComponentsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsRemoved")]
|
||||
public int ComponentsRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsModified")]
|
||||
public int ComponentsModified { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsUnchanged")]
|
||||
public int ComponentsUnchanged { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilitiesAdded")]
|
||||
public int VulnerabilitiesAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilitiesRemoved")]
|
||||
public int VulnerabilitiesRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesAdded")]
|
||||
public int LicensesAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesRemoved")]
|
||||
public int LicensesRemoved { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomComponentChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentName")]
|
||||
public string ComponentName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("baseVersion")]
|
||||
public string? BaseVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("targetVersion")]
|
||||
public string? TargetVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("basePurl")]
|
||||
public string? BasePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("targetPurl")]
|
||||
public string? TargetPurl { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public IReadOnlyList<string>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomVulnerabilityChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedComponent")]
|
||||
public string? AffectedComponent { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomLicenseChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("licenseId")]
|
||||
public string? LicenseId { get; init; }
|
||||
|
||||
[JsonPropertyName("licenseName")]
|
||||
public string LicenseName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM export request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomExportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "spdx";
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("includeVex")]
|
||||
public bool IncludeVex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM export result.
|
||||
/// </summary>
|
||||
internal sealed class SbomExportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("exportId")]
|
||||
public string? ExportId { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string? DigestAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
}
|
||||
|
||||
// CLI-PARITY-41-001: Parity matrix models
|
||||
|
||||
/// <summary>
|
||||
/// Parity matrix entry showing CLI command coverage.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixEntry
|
||||
{
|
||||
[JsonPropertyName("commandGroup")]
|
||||
public string CommandGroup { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("command")]
|
||||
public string Command { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cliSupport")]
|
||||
public string CliSupport { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("apiEndpoint")]
|
||||
public string? ApiEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("uiEquivalent")]
|
||||
public string? UiEquivalent { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("explainSupport")]
|
||||
public bool ExplainSupport { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineSupport")]
|
||||
public bool OfflineSupport { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parity matrix summary response.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixResponse
|
||||
{
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<ParityMatrixEntry> Entries { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public ParityMatrixSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("cliVersion")]
|
||||
public string? CliVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of parity matrix coverage.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixSummary
|
||||
{
|
||||
[JsonPropertyName("totalCommands")]
|
||||
public int TotalCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("fullParity")]
|
||||
public int FullParity { get; init; }
|
||||
|
||||
[JsonPropertyName("partialParity")]
|
||||
public int PartialParity { get; init; }
|
||||
|
||||
[JsonPropertyName("noParity")]
|
||||
public int NoParity { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministicCommands")]
|
||||
public int DeterministicCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("explainEnabledCommands")]
|
||||
public int ExplainEnabledCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineCapableCommands")]
|
||||
public int OfflineCapableCommands { get; init; }
|
||||
}
|
||||
834
src/Cli/StellaOps.Cli/Services/Models/SbomerModels.cs
Normal file
834
src/Cli/StellaOps.Cli/Services/Models/SbomerModels.cs
Normal file
@@ -0,0 +1,834 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer command models for layer/compose operations
|
||||
|
||||
/// <summary>
|
||||
/// SBOM fragment from a container layer.
|
||||
/// </summary>
|
||||
internal sealed class SbomFragment
|
||||
{
|
||||
[JsonPropertyName("fragmentId")]
|
||||
public string FragmentId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeSha256")]
|
||||
public string? DsseEnvelopeSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeUri")]
|
||||
public string? DsseEnvelopeUri { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<SbomFragmentComponent>? Components { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component within an SBOM fragment.
|
||||
/// </summary>
|
||||
internal sealed class SbomFragmentComponent
|
||||
{
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("identityKey")]
|
||||
public string? IdentityKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer list request for sbomer layer list.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer list response.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<SbomFragment> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer show request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerShowRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("includeComponents")]
|
||||
public bool IncludeComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("includeDsse")]
|
||||
public bool IncludeDsse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer detail response.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerDetail
|
||||
{
|
||||
[JsonPropertyName("fragment")]
|
||||
public SbomFragment Fragment { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("dsseEnvelope")]
|
||||
public DsseEnvelopeInfo? DsseEnvelope { get; init; }
|
||||
|
||||
[JsonPropertyName("canonicalJson")]
|
||||
public string? CanonicalJson { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProof")]
|
||||
public MerkleProofInfo? MerkleProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope information.
|
||||
/// </summary>
|
||||
internal sealed class DsseEnvelopeInfo
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadSha256")]
|
||||
public string PayloadSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<DsseSignatureInfo> Signatures { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("envelopeSha256")]
|
||||
public string EnvelopeSha256 { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature information.
|
||||
/// </summary>
|
||||
internal sealed class DsseSignatureInfo
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatureSha256")]
|
||||
public string SignatureSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool? Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateSubject")]
|
||||
public string? CertificateSubject { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateExpiry")]
|
||||
public DateTimeOffset? CertificateExpiry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle proof information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleProofInfo
|
||||
{
|
||||
[JsonPropertyName("leafHash")]
|
||||
public string LeafHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("proofHashes")]
|
||||
public IReadOnlyList<string> ProofHashes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("leafIndex")]
|
||||
public int LeafIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public int TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool? Valid { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer verify request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerVerifyResult
|
||||
{
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseValid")]
|
||||
public bool DsseValid { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHashMatch")]
|
||||
public bool ContentHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProofValid")]
|
||||
public bool? MerkleProofValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition manifest (_composition.json).
|
||||
/// </summary>
|
||||
internal sealed class CompositionManifest
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public string MerkleRoot { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("composedSha256")]
|
||||
public string ComposedSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragments")]
|
||||
public IReadOnlyList<CompositionFragmentEntry> Fragments { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("canonicalOrder")]
|
||||
public IReadOnlyList<string> CanonicalOrder { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public IReadOnlyDictionary<string, string>? Properties { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragment entry in composition manifest.
|
||||
/// </summary>
|
||||
internal sealed class CompositionFragmentEntry
|
||||
{
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeSha256")]
|
||||
public string? DsseEnvelopeSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose request for sbomer compose.
|
||||
/// </summary>
|
||||
internal sealed class SbomerComposeRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("verifyFragments")]
|
||||
public bool VerifyFragments { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("emitCompositionManifest")]
|
||||
public bool EmitCompositionManifest { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("emitMerkleDiagnostics")]
|
||||
public bool EmitMerkleDiagnostics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerComposeResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("composedSha256")]
|
||||
public string? ComposedSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public string? MerkleRoot { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("totalComponents")]
|
||||
public int TotalComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionManifestPath")]
|
||||
public string? CompositionManifestPath { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleDiagnosticsPath")]
|
||||
public string? MerkleDiagnosticsPath { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition show request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionShowRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle diagnostics for composition.
|
||||
/// </summary>
|
||||
internal sealed class MerkleDiagnostics
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public int TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("leaves")]
|
||||
public IReadOnlyList<MerkleLeafInfo> Leaves { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("intermediateNodes")]
|
||||
public IReadOnlyList<MerkleNodeInfo>? IntermediateNodes { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle leaf information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleLeafInfo
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle intermediate node information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleNodeInfo
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public int Level { get; init; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("leftChild")]
|
||||
public string? LeftChild { get; init; }
|
||||
|
||||
[JsonPropertyName("rightChild")]
|
||||
public string? RightChild { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition verify request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("recompose")]
|
||||
public bool Recompose { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("merkleRootMatch")]
|
||||
public bool MerkleRootMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("composedHashMatch")]
|
||||
public bool ComposedHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("allFragmentsValid")]
|
||||
public bool AllFragmentsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHash")]
|
||||
public string? RecomposedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedHash")]
|
||||
public string? ExpectedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection and explain models
|
||||
|
||||
/// <summary>
|
||||
/// Drift analysis request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineScanId")]
|
||||
public string? BaselineScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("baselinePath")]
|
||||
public string? BaselinePath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public bool Explain { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitPath")]
|
||||
public string? OfflineKitPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift analysis result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftResult
|
||||
{
|
||||
[JsonPropertyName("hasDrift")]
|
||||
public bool HasDrift { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("baselineScanId")]
|
||||
public string? BaselineScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("currentHash")]
|
||||
public string? CurrentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineHash")]
|
||||
public string? BaselineHash { get; init; }
|
||||
|
||||
[JsonPropertyName("driftSummary")]
|
||||
public DriftSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("driftDetails")]
|
||||
public IReadOnlyList<DriftDetail>? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("explanations")]
|
||||
public IReadOnlyList<DriftExplanation>? Explanations { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of drift between two SBOMs.
|
||||
/// </summary>
|
||||
internal sealed class DriftSummary
|
||||
{
|
||||
[JsonPropertyName("componentsAdded")]
|
||||
public int ComponentsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsRemoved")]
|
||||
public int ComponentsRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsModified")]
|
||||
public int ComponentsModified { get; init; }
|
||||
|
||||
[JsonPropertyName("arrayOrderingDrifts")]
|
||||
public int ArrayOrderingDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("timestampDrifts")]
|
||||
public int TimestampDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("keyOrderingDrifts")]
|
||||
public int KeyOrderingDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("whitespaceDrifts")]
|
||||
public int WhitespaceDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("totalDrifts")]
|
||||
public int TotalDrifts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed drift information.
|
||||
/// </summary>
|
||||
internal sealed class DriftDetail
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("currentValue")]
|
||||
public string? CurrentValue { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineValue")]
|
||||
public string? BaselineValue { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string? LayerDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "info";
|
||||
|
||||
[JsonPropertyName("breaksDeterminism")]
|
||||
public bool BreaksDeterminism { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explanation for drift occurrence.
|
||||
/// </summary>
|
||||
internal sealed class DriftExplanation
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedBehavior")]
|
||||
public string? ExpectedBehavior { get; init; }
|
||||
|
||||
[JsonPropertyName("actualBehavior")]
|
||||
public string? ActualBehavior { get; init; }
|
||||
|
||||
[JsonPropertyName("rootCause")]
|
||||
public string? RootCause { get; init; }
|
||||
|
||||
[JsonPropertyName("remediation")]
|
||||
public string? Remediation { get; init; }
|
||||
|
||||
[JsonPropertyName("documentationUrl")]
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedLayers")]
|
||||
public IReadOnlyList<string>? AffectedLayers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift verify request for offline verification.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitPath")]
|
||||
public string? OfflineKitPath { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposeLocally")]
|
||||
public bool RecomposeLocally { get; init; }
|
||||
|
||||
[JsonPropertyName("validateFragments")]
|
||||
public bool ValidateFragments { get; init; }
|
||||
|
||||
[JsonPropertyName("checkMerkleProofs")]
|
||||
public bool CheckMerkleProofs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("compositionValid")]
|
||||
public bool CompositionValid { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentsValid")]
|
||||
public bool FragmentsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProofsValid")]
|
||||
public bool MerkleProofsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHashMatch")]
|
||||
public bool? RecomposedHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("currentHash")]
|
||||
public string? CurrentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHash")]
|
||||
public string? RecomposedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("driftResult")]
|
||||
public SbomerDriftResult? DriftResult { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitInfo")]
|
||||
public OfflineKitInfo? OfflineKitInfo { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about offline kit used for verification.
|
||||
/// </summary>
|
||||
internal sealed class OfflineKitInfo
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPresent")]
|
||||
public bool VerifiersPresent { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionManifestPresent")]
|
||||
public bool CompositionManifestPresent { get; init; }
|
||||
}
|
||||
282
src/Cli/StellaOps.Cli/Services/Models/SdkModels.cs
Normal file
282
src/Cli/StellaOps.Cli/Services/Models/SdkModels.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request for checking SDK updates.
|
||||
/// CLI-SDK-64-001: Supports stella sdk update command.
|
||||
/// </summary>
|
||||
internal sealed class SdkUpdateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant context for the operation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Language filter (typescript, go, csharp, python, java).
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only check for updates without downloading.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool CheckOnly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include changelog information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeChangelog")]
|
||||
public bool IncludeChangelog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include deprecation notices.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeDeprecations")]
|
||||
public bool IncludeDeprecations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for SDK update check.
|
||||
/// </summary>
|
||||
internal sealed class SdkUpdateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available SDK updates.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updates")]
|
||||
public IReadOnlyList<SdkVersionInfo> Updates { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation notices.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deprecations")]
|
||||
public IReadOnlyList<SdkDeprecation> Deprecations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when updates were last checked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkedAt")]
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK version information.
|
||||
/// </summary>
|
||||
internal sealed class SdkVersionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK language (typescript, go, csharp, python, java).
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the SDK.
|
||||
/// </summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name (e.g., @stellaops/sdk, stellaops-sdk).
|
||||
/// </summary>
|
||||
[JsonPropertyName("packageName")]
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current installed version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("installedVersion")]
|
||||
public string? InstalledVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest available version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("latestVersion")]
|
||||
public required string LatestVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an update is available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updateAvailable")]
|
||||
public bool UpdateAvailable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum supported API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minApiVersion")]
|
||||
public string? MinApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum supported API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxApiVersion")]
|
||||
public string? MaxApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release date of the latest version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("releaseDate")]
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Changelog for recent versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changelog")]
|
||||
public IReadOnlyList<SdkChangelogEntry>? Changelog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL for the package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package registry URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("registryUrl")]
|
||||
public string? RegistryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Documentation URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("docsUrl")]
|
||||
public string? DocsUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK changelog entry.
|
||||
/// </summary>
|
||||
internal sealed class SdkChangelogEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Version number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("releaseDate")]
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change type (feature, fix, breaking, deprecation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a breaking change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isBreaking")]
|
||||
public bool IsBreaking { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to more details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("link")]
|
||||
public string? Link { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK deprecation notice.
|
||||
/// </summary>
|
||||
internal sealed class SdkDeprecation
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK language affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deprecated feature or API.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feature")]
|
||||
public required string Feature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version when deprecation was introduced.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deprecatedInVersion")]
|
||||
public string? DeprecatedInVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version when feature will be removed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removedInVersion")]
|
||||
public string? RemovedInVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replacement or migration path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replacement")]
|
||||
public string? Replacement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to migration guide.
|
||||
/// </summary>
|
||||
[JsonPropertyName("migrationGuide")]
|
||||
public string? MigrationGuide { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the deprecation (info, warning, critical).
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "warning";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing installed SDKs.
|
||||
/// </summary>
|
||||
internal sealed class SdkListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Installed SDK versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sdks")]
|
||||
public IReadOnlyList<SdkVersionInfo> Sdks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -14,6 +14,15 @@ internal sealed class PolicySimulationRequestDocument
|
||||
public Dictionary<string, JsonElement>? Env { get; set; }
|
||||
|
||||
public bool? Explain { get; set; }
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced simulation options
|
||||
public string? Mode { get; set; }
|
||||
|
||||
public IReadOnlyList<string>? SbomSelectors { get; set; }
|
||||
|
||||
public bool? IncludeHeatmap { get; set; }
|
||||
|
||||
public bool? IncludeManifest { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationResponseDocument
|
||||
@@ -21,6 +30,13 @@ internal sealed class PolicySimulationResponseDocument
|
||||
public PolicySimulationDiffDocument? Diff { get; set; }
|
||||
|
||||
public string? ExplainUri { get; set; }
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced response fields
|
||||
public PolicySimulationHeatmapDocument? Heatmap { get; set; }
|
||||
|
||||
public string? ManifestDownloadUri { get; set; }
|
||||
|
||||
public string? ManifestDigest { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationDiffDocument
|
||||
@@ -55,3 +71,28 @@ internal sealed class PolicySimulationRuleDeltaDocument
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
|
||||
// CLI-POLICY-27-003: Heatmap response documents
|
||||
internal sealed class PolicySimulationHeatmapDocument
|
||||
{
|
||||
public int? Critical { get; set; }
|
||||
|
||||
public int? High { get; set; }
|
||||
|
||||
public int? Medium { get; set; }
|
||||
|
||||
public int? Low { get; set; }
|
||||
|
||||
public int? Info { get; set; }
|
||||
|
||||
public List<PolicySimulationHeatmapBucketDocument>? Buckets { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationHeatmapBucketDocument
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
|
||||
public int? Count { get; set; }
|
||||
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,184 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 7807 Problem Details response.
|
||||
/// </summary>
|
||||
internal sealed class ProblemDocument
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public int? Status { get; set; }
|
||||
|
||||
[JsonPropertyName("instance")]
|
||||
public string? Instance { get; set; }
|
||||
|
||||
[JsonPropertyName("extensions")]
|
||||
public Dictionary<string, object?>? Extensions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standardized API error envelope with error.code and trace_id.
|
||||
/// CLI-SDK-62-002: Supports surfacing structured error information.
|
||||
/// </summary>
|
||||
internal sealed class ApiErrorEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public ApiErrorDetail? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Distributed trace identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trace_id")]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("request_id")]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error detail within the standardized envelope.
|
||||
/// </summary>
|
||||
internal sealed class ApiErrorDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine-readable error code (e.g., "ERR_AUTH_INVALID_SCOPE").
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed description of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error (field name, resource identifier).
|
||||
/// </summary>
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner errors for nested error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inner_errors")]
|
||||
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, object?>? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Help URL for more information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("help_url")]
|
||||
public string? HelpUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds (for rate limiting).
|
||||
/// </summary>
|
||||
[JsonPropertyName("retry_after")]
|
||||
public int? RetryAfter { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed API error result combining multiple error formats.
|
||||
/// </summary>
|
||||
internal sealed class ParsedApiError
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code (from envelope, problem, or HTTP status).
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error description.
|
||||
/// </summary>
|
||||
public string? Detail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for distributed tracing.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request ID.
|
||||
/// </summary>
|
||||
public string? RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code.
|
||||
/// </summary>
|
||||
public int HttpStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error.
|
||||
/// </summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Help URL for more information.
|
||||
/// </summary>
|
||||
public string? HelpUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds.
|
||||
/// </summary>
|
||||
public int? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original problem document if parsed.
|
||||
/// </summary>
|
||||
public ProblemDocument? ProblemDocument { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original error envelope if parsed.
|
||||
/// </summary>
|
||||
public ApiErrorEnvelope? ErrorEnvelope { get; init; }
|
||||
}
|
||||
|
||||
292
src/Cli/StellaOps.Cli/Services/Models/VexObservationModels.cs
Normal file
292
src/Cli/StellaOps.Cli/Services/Models/VexObservationModels.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-LNM-22-002: VEX observation models for CLI commands
|
||||
|
||||
/// <summary>
|
||||
/// Query options for VEX observations.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationQuery
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityIds")]
|
||||
public IReadOnlyList<string> VulnerabilityIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("providerIds")]
|
||||
public IReadOnlyList<string> ProviderIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from VEX observation query.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<VexObservation> Observations { get; init; } = Array.Empty<VexObservation>();
|
||||
|
||||
[JsonPropertyName("aggregate")]
|
||||
public VexObservationAggregate? Aggregate { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX observation document.
|
||||
/// </summary>
|
||||
internal sealed class VexObservation
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("providerId")]
|
||||
public string ProviderId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("product")]
|
||||
public VexObservationProduct? Product { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public VexObservationDocument? Document { get; init; }
|
||||
|
||||
[JsonPropertyName("firstSeen")]
|
||||
public DateTimeOffset FirstSeen { get; init; }
|
||||
|
||||
[JsonPropertyName("lastSeen")]
|
||||
public DateTimeOffset LastSeen { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public VexObservationConfidence? Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product information in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationProduct
|
||||
{
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cpe")]
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
[JsonPropertyName("componentIdentifiers")]
|
||||
public IReadOnlyList<string> ComponentIdentifiers { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Document reference in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationDocument
|
||||
{
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceUri")]
|
||||
public string SourceUri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("revision")]
|
||||
public string? Revision { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public VexObservationSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature metadata for VEX document.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationSignature
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("transparencyLogReference")]
|
||||
public string? TransparencyLogReference { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationConfidence
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
public string? Method { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate data from VEX observation query.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationAggregate
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityIds")]
|
||||
public IReadOnlyList<string> VulnerabilityIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("providerIds")]
|
||||
public IReadOnlyList<string> ProviderIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statusCounts")]
|
||||
public IReadOnlyDictionary<string, int> StatusCounts { get; init; } = new Dictionary<string, int>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX linkset query options.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetQuery
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX linkset response showing linked observations.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetResponse
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<VexObservation> Observations { get; init; } = Array.Empty<VexObservation>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public VexLinksetSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<VexLinksetConflict> Conflicts { get; init; } = Array.Empty<VexLinksetConflict>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of VEX linkset.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetSummary
|
||||
{
|
||||
[JsonPropertyName("totalObservations")]
|
||||
public int TotalObservations { get; init; }
|
||||
|
||||
[JsonPropertyName("providers")]
|
||||
public IReadOnlyList<string> Providers { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("products")]
|
||||
public IReadOnlyList<string> Products { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statusCounts")]
|
||||
public IReadOnlyDictionary<string, int> StatusCounts { get; init; } = new Dictionary<string, int>();
|
||||
|
||||
[JsonPropertyName("hasConflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict between VEX observations.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetConflict
|
||||
{
|
||||
[JsonPropertyName("productKey")]
|
||||
public string ProductKey { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("conflictingStatuses")]
|
||||
public IReadOnlyList<string> ConflictingStatuses { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<string> ObservationIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
654
src/Cli/StellaOps.Cli/Services/NotifyClient.cs
Normal file
654
src/Cli/StellaOps.Cli/Services/NotifyClient.cs
Normal file
@@ -0,0 +1,654 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Notify API operations.
|
||||
/// Per CLI-PARITY-41-002.
|
||||
/// </summary>
|
||||
internal sealed class NotifyClient : INotifyClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<NotifyClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public NotifyClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<NotifyClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelListResponse> ListChannelsAsync(
|
||||
NotifyChannelListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildChannelListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify channels (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyChannelListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify channels");
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify channels");
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelDetail?> GetChannelAsync(
|
||||
string channelId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/notify/channels/{Uri.EscapeDataString(channelId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get notify channel (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelDetail>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting notify channel");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting notify channel");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelTestResult> TestChannelAsync(
|
||||
NotifyChannelTestRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/channels/{Uri.EscapeDataString(request.ChannelId)}/test")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to test notify channel (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelTestResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = "Empty response" };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while testing notify channel");
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = $"Connection error: {ex.Message}"
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while testing notify channel");
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = "Request timed out"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyRuleListResponse> ListRulesAsync(
|
||||
NotifyRuleListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.Enabled.HasValue)
|
||||
{
|
||||
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EventType))
|
||||
{
|
||||
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ChannelId))
|
||||
{
|
||||
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/notify/rules{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify rules (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyRuleListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyRuleListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify rules");
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify rules");
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
|
||||
NotifyDeliveryListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ChannelId))
|
||||
{
|
||||
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(request.Status)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EventType))
|
||||
{
|
||||
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
|
||||
}
|
||||
if (request.Since.HasValue)
|
||||
{
|
||||
queryParams.Add($"since={Uri.EscapeDataString(request.Since.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.Until.HasValue)
|
||||
{
|
||||
queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/notify/deliveries{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify deliveries (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyDeliveryListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyDeliveryListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify deliveries");
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify deliveries");
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyDeliveryDetail?> GetDeliveryAsync(
|
||||
string deliveryId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/notify/deliveries/{Uri.EscapeDataString(deliveryId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get notify delivery (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<NotifyDeliveryDetail>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting notify delivery");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting notify delivery");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyRetryResult> RetryDeliveryAsync(
|
||||
NotifyRetryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/deliveries/{Uri.EscapeDataString(request.DeliveryId)}/retry")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
// Add idempotency key header if present
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
|
||||
}
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to retry notify delivery (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyRetryResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while retrying notify delivery");
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while retrying notify delivery");
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifySendResult> SendAsync(
|
||||
NotifySendRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/send")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
// Add idempotency key header if present
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
|
||||
}
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to send notification (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifySendResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifySendResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while sending notification");
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while sending notification");
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildChannelListUri(NotifyChannelListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
{
|
||||
queryParams.Add($"type={Uri.EscapeDataString(request.Type)}");
|
||||
}
|
||||
if (request.Enabled.HasValue)
|
||||
{
|
||||
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/notify/channels{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
557
src/Cli/StellaOps.Cli/Services/ObservabilityClient.cs
Normal file
557
src/Cli/StellaOps.Cli/Services/ObservabilityClient.cs
Normal file
@@ -0,0 +1,557 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for observability API operations.
|
||||
/// Per CLI-OBS-51-001.
|
||||
/// </summary>
|
||||
internal sealed class ObservabilityClient : IObservabilityClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<ObservabilityClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ObservabilityClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ObservabilityClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ObsTopResult> GetHealthSummaryAsync(
|
||||
ObsTopRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildHealthSummaryUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get health summary (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summary = await JsonSerializer
|
||||
.DeserializeAsync<PlatformHealthSummary>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = true,
|
||||
Summary = summary ?? new PlatformHealthSummary()
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching health summary");
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching health summary");
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildHealthSummaryUri(ObsTopRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (request.Services.Count > 0)
|
||||
{
|
||||
foreach (var service in request.Services)
|
||||
{
|
||||
queryParams.Add($"service={Uri.EscapeDataString(service)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"includeQueues={request.IncludeQueues.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"maxAlerts={request.MaxAlerts}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/observability/health{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = ["obs:read"] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Trace retrieval
|
||||
public async Task<ObsTraceResult> GetTraceAsync(
|
||||
ObsTraceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildTraceUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Trace not found: {request.TraceId}"]
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get trace (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var trace = await JsonSerializer
|
||||
.DeserializeAsync<DistributedTrace>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = true,
|
||||
Trace = trace
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching trace");
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching trace");
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildTraceUri(ObsTraceRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"includeEvidence={request.IncludeEvidence.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/observability/traces/{Uri.EscapeDataString(request.TraceId)}{query}";
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Logs retrieval
|
||||
public async Task<ObsLogsResult> GetLogsAsync(
|
||||
ObsLogsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildLogsUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get logs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ObsLogsResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ObsLogsResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching logs");
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching logs");
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLogsUri(ObsLogsRequest request)
|
||||
{
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"from={Uri.EscapeDataString(request.From.ToString("o"))}",
|
||||
$"to={Uri.EscapeDataString(request.To.ToString("o"))}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
foreach (var service in request.Services)
|
||||
{
|
||||
queryParams.Add($"service={Uri.EscapeDataString(service)}");
|
||||
}
|
||||
|
||||
foreach (var level in request.Levels)
|
||||
{
|
||||
queryParams.Add($"level={Uri.EscapeDataString(level)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
queryParams.Add($"q={Uri.EscapeDataString(request.Query)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"pageSize={request.PageSize}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
{
|
||||
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
|
||||
}
|
||||
|
||||
var query = "?" + string.Join("&", queryParams);
|
||||
return $"/api/v1/observability/logs{query}";
|
||||
}
|
||||
|
||||
// CLI-OBS-55-001: Incident mode operations
|
||||
public async Task<IncidentModeResult> GetIncidentModeStatusAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var query = !string.IsNullOrWhiteSpace(tenant)
|
||||
? $"?tenant={Uri.EscapeDataString(tenant)}"
|
||||
: string.Empty;
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/observability/incident-mode{query}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get incident mode status (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var state = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeState>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = true,
|
||||
State = state
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching incident mode status");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching incident mode status");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IncidentModeResult> EnableIncidentModeAsync(
|
||||
IncidentModeEnableRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/enable");
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to enable incident mode (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new IncidentModeResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while enabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while enabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IncidentModeResult> DisableIncidentModeAsync(
|
||||
IncidentModeDisableRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/disable");
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to disable incident mode (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new IncidentModeResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while disabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while disabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
463
src/Cli/StellaOps.Cli/Services/OrchestratorClient.cs
Normal file
463
src/Cli/StellaOps.Cli/Services/OrchestratorClient.cs
Normal file
@@ -0,0 +1,463 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.Client.Scopes;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for orchestrator API operations.
|
||||
/// Per CLI-ORCH-32-001.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IStellaOpsTokenClient _tokenClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<OrchestratorClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public OrchestratorClient(
|
||||
HttpClient httpClient,
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptions<StellaOpsCliOptions> options,
|
||||
ILogger<OrchestratorClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_tokenClient = tokenClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SourceListResponse> ListSourcesAsync(
|
||||
SourceListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildSourcesListUrl(request);
|
||||
|
||||
_logger.LogDebug("Listing orchestrator sources: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to list sources: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to list sources: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceListResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceListResponse();
|
||||
}
|
||||
|
||||
public async Task<OrchestratorSource?> GetSourceAsync(
|
||||
string sourceId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(sourceId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
url += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Getting orchestrator source: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get source: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<OrchestratorSource>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> PauseSourceAsync(
|
||||
SourcePauseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:pause";
|
||||
|
||||
_logger.LogDebug("Pausing orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to pause source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to pause source: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> ResumeSourceAsync(
|
||||
SourceResumeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:resume";
|
||||
|
||||
_logger.LogDebug("Resuming orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to resume source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to resume source: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<SourceTestResult> TestSourceAsync(
|
||||
SourceTestRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:test";
|
||||
|
||||
_logger.LogDebug("Testing orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to test source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceTestResult
|
||||
{
|
||||
Success = false,
|
||||
SourceId = request.SourceId,
|
||||
Reachable = false,
|
||||
ErrorMessage = $"Failed to test source: {response.StatusCode} - {errorContent}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceTestResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceTestResult
|
||||
{
|
||||
Success = true,
|
||||
SourceId = request.SourceId,
|
||||
Reachable = true,
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Backfill operations
|
||||
|
||||
public async Task<BackfillResult> StartBackfillAsync(
|
||||
BackfillRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills";
|
||||
|
||||
_logger.LogDebug("Starting backfill for source: {SourceId} from {From} to {To}",
|
||||
request.SourceId, request.From, request.To);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to start backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new BackfillResult
|
||||
{
|
||||
Success = false,
|
||||
SourceId = request.SourceId,
|
||||
Status = BackfillStatuses.Failed,
|
||||
From = request.From,
|
||||
To = request.To,
|
||||
DryRun = request.DryRun,
|
||||
Errors = new[] { $"Failed to start backfill: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BackfillResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new BackfillResult
|
||||
{
|
||||
Success = true,
|
||||
SourceId = request.SourceId,
|
||||
Status = request.DryRun ? BackfillStatuses.DryRun : BackfillStatuses.Pending,
|
||||
From = request.From,
|
||||
To = request.To,
|
||||
DryRun = request.DryRun
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BackfillResult?> GetBackfillAsync(
|
||||
string backfillId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills/{Uri.EscapeDataString(backfillId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
url += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Getting backfill status: {BackfillId}", backfillId);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get backfill: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<BackfillResult>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<BackfillListResponse> ListBackfillsAsync(
|
||||
BackfillListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildBackfillsListUrl(request);
|
||||
|
||||
_logger.LogDebug("Listing backfills: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to list backfills: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to list backfills: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BackfillListResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new BackfillListResponse();
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> CancelBackfillAsync(
|
||||
BackfillCancelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills/{Uri.EscapeDataString(request.BackfillId)}:cancel";
|
||||
|
||||
_logger.LogDebug("Cancelling backfill: {BackfillId}", request.BackfillId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to cancel backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to cancel backfill: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Quota management
|
||||
|
||||
public async Task<QuotaGetResponse> GetQuotasAsync(
|
||||
QuotaGetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildQuotasGetUrl(request);
|
||||
|
||||
_logger.LogDebug("Getting quotas: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get quotas: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get quotas: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaGetResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaGetResponse();
|
||||
}
|
||||
|
||||
public async Task<QuotaOperationResult> SetQuotaAsync(
|
||||
QuotaSetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/quotas";
|
||||
|
||||
_logger.LogDebug("Setting quota for tenant: {Tenant}, resource: {ResourceType}",
|
||||
request.Tenant, request.ResourceType);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to set quota: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new QuotaOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to set quota: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<QuotaOperationResult> ResetQuotaAsync(
|
||||
QuotaResetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/quotas:reset";
|
||||
|
||||
_logger.LogDebug("Resetting quota for tenant: {Tenant}, resource: {ResourceType}",
|
||||
request.Tenant, request.ResourceType);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to reset quota: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new QuotaOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to reset quota: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaOperationResult { Success = true };
|
||||
}
|
||||
|
||||
private async Task ConfigureAuthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _tokenClient.GetCachedAccessTokenAsync(
|
||||
new[] { StellaOpsScope.OrchRead },
|
||||
cancellationToken);
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.AccessToken);
|
||||
}
|
||||
|
||||
private string GetBaseUrl()
|
||||
{
|
||||
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "https://api.stellaops.local";
|
||||
return $"{baseUrl}/api/v1/orchestrator";
|
||||
}
|
||||
|
||||
private string BuildSourcesListUrl(SourceListRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/sources");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
query["type"] = request.Type;
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
query["status"] = request.Status;
|
||||
if (request.Enabled.HasValue)
|
||||
query["enabled"] = request.Enabled.Value.ToString().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(request.Host))
|
||||
query["host"] = request.Host;
|
||||
if (!string.IsNullOrWhiteSpace(request.Tag))
|
||||
query["tag"] = request.Tag;
|
||||
if (request.PageSize != 50)
|
||||
query["page_size"] = request.PageSize.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
query["page_token"] = request.PageToken;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private string BuildBackfillsListUrl(BackfillListRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/backfills");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SourceId))
|
||||
query["source_id"] = request.SourceId;
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
query["status"] = request.Status;
|
||||
if (request.PageSize != 20)
|
||||
query["page_size"] = request.PageSize.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
query["page_token"] = request.PageToken;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private string BuildQuotasGetUrl(QuotaGetRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/quotas");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.SourceId))
|
||||
query["source_id"] = request.SourceId;
|
||||
if (!string.IsNullOrWhiteSpace(request.ResourceType))
|
||||
query["resource_type"] = request.ResourceType;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
1017
src/Cli/StellaOps.Cli/Services/PackClient.cs
Normal file
1017
src/Cli/StellaOps.Cli/Services/PackClient.cs
Normal file
File diff suppressed because it is too large
Load Diff
1118
src/Cli/StellaOps.Cli/Services/PromotionAssembler.cs
Normal file
1118
src/Cli/StellaOps.Cli/Services/PromotionAssembler.cs
Normal file
File diff suppressed because it is too large
Load Diff
483
src/Cli/StellaOps.Cli/Services/SbomClient.cs
Normal file
483
src/Cli/StellaOps.Cli/Services/SbomClient.cs
Normal file
@@ -0,0 +1,483 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for SBOM API operations.
|
||||
/// Per CLI-PARITY-41-001.
|
||||
/// </summary>
|
||||
internal sealed class SbomClient : ISbomClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<SbomClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public SbomClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<SbomClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomListResponse> ListAsync(
|
||||
SbomListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list SBOMs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new SbomListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<SbomListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new SbomListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing SBOMs");
|
||||
return new SbomListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing SBOMs");
|
||||
return new SbomListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomDetailResponse?> GetAsync(
|
||||
string sbomId,
|
||||
string? tenant,
|
||||
bool includeComponents,
|
||||
bool includeVulnerabilities,
|
||||
bool includeLicenses,
|
||||
bool explain,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sbomId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(tenant)}");
|
||||
}
|
||||
if (includeComponents)
|
||||
{
|
||||
queryParams.Add("includeComponents=true");
|
||||
}
|
||||
if (includeVulnerabilities)
|
||||
{
|
||||
queryParams.Add("includeVulnerabilities=true");
|
||||
}
|
||||
if (includeLicenses)
|
||||
{
|
||||
queryParams.Add("includeLicenses=true");
|
||||
}
|
||||
if (explain)
|
||||
{
|
||||
queryParams.Add("explain=true");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/sboms/{Uri.EscapeDataString(sbomId)}{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get SBOM (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<SbomDetailResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting SBOM");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting SBOM");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomCompareResponse?> CompareAsync(
|
||||
SbomCompareRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"base={Uri.EscapeDataString(request.BaseSbomId)}",
|
||||
$"target={Uri.EscapeDataString(request.TargetSbomId)}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.IncludeUnchanged)
|
||||
{
|
||||
queryParams.Add("includeUnchanged=true");
|
||||
}
|
||||
|
||||
var query = string.Join("&", queryParams);
|
||||
var uri = $"/api/v1/sboms/compare?{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to compare SBOMs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<SbomCompareResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while comparing SBOMs");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while comparing SBOMs");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, SbomExportResult? Result)> ExportAsync(
|
||||
SbomExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"format={Uri.EscapeDataString(request.Format)}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.FormatVersion))
|
||||
{
|
||||
queryParams.Add($"formatVersion={Uri.EscapeDataString(request.FormatVersion)}");
|
||||
}
|
||||
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"includeVex={request.IncludeVex.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = string.Join("&", queryParams);
|
||||
var uri = $"/api/v1/sboms/{Uri.EscapeDataString(request.SbomId)}/export?{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to export SBOM (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
});
|
||||
}
|
||||
|
||||
// Parse export metadata from headers if present
|
||||
SbomExportResult? result = null;
|
||||
if (response.Headers.TryGetValues("X-Export-Metadata", out var metadataValues))
|
||||
{
|
||||
var metadataJson = string.Join("", metadataValues);
|
||||
if (!string.IsNullOrWhiteSpace(metadataJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
result = JsonSerializer.Deserialize<SbomExportResult>(metadataJson, SerializerOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore parse errors for optional header
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result ??= new SbomExportResult
|
||||
{
|
||||
Success = true,
|
||||
Format = request.Format,
|
||||
Signed = request.Signed
|
||||
};
|
||||
|
||||
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (contentStream, result);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while exporting SBOM");
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
});
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while exporting SBOM");
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ParityMatrixResponse> GetParityMatrixAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = "/api/v1/cli/parity-matrix";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "cli.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get parity matrix (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ParityMatrixResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ParityMatrixResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting parity matrix");
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting parity matrix");
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildListUri(SbomListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ImageRef))
|
||||
{
|
||||
queryParams.Add($"imageRef={Uri.EscapeDataString(request.ImageRef)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Digest))
|
||||
{
|
||||
queryParams.Add($"digest={Uri.EscapeDataString(request.Digest)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Format))
|
||||
{
|
||||
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
|
||||
}
|
||||
if (request.CreatedAfter.HasValue)
|
||||
{
|
||||
queryParams.Add($"createdAfter={Uri.EscapeDataString(request.CreatedAfter.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.CreatedBefore.HasValue)
|
||||
{
|
||||
queryParams.Add($"createdBefore={Uri.EscapeDataString(request.CreatedBefore.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.HasVulnerabilities.HasValue)
|
||||
{
|
||||
queryParams.Add($"hasVulnerabilities={request.HasVulnerabilities.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/sboms{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
254
src/Cli/StellaOps.Cli/Services/SbomerClient.cs
Normal file
254
src/Cli/StellaOps.Cli/Services/SbomerClient.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for Sbomer API operations.
|
||||
/// Per CLI-SBOM-60-001.
|
||||
/// </summary>
|
||||
internal sealed class SbomerClient : ISbomerClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly ILogger<SbomerClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public SbomerClient(
|
||||
HttpClient httpClient,
|
||||
IStellaOpsTokenClient? tokenClient,
|
||||
ILogger<SbomerClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_tokenClient = tokenClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerListResponse> ListLayersAsync(
|
||||
SbomerLayerListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildQueryString(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/layers{query}", cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerListResponse>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerLayerListResponse();
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerDetail?> GetLayerAsync(
|
||||
SbomerLayerShowRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildLayerShowQuery(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/layers/{Uri.EscapeDataString(request.LayerDigest)}{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerDetail>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerVerifyResult> VerifyLayerAsync(
|
||||
SbomerLayerVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
$"/api/v1/sbomer/layers/{Uri.EscapeDataString(request.LayerDigest)}/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerLayerVerifyResult { LayerDigest = request.LayerDigest };
|
||||
}
|
||||
|
||||
public async Task<CompositionManifest?> GetCompositionManifestAsync(
|
||||
SbomerCompositionShowRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildCompositionShowQuery(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/composition{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<CompositionManifest>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<SbomerComposeResult> ComposeAsync(
|
||||
SbomerComposeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/compose",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerComposeResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerComposeResult();
|
||||
}
|
||||
|
||||
public async Task<SbomerCompositionVerifyResult> VerifyCompositionAsync(
|
||||
SbomerCompositionVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/composition/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerCompositionVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerCompositionVerifyResult();
|
||||
}
|
||||
|
||||
public async Task<MerkleDiagnostics?> GetMerkleDiagnosticsAsync(
|
||||
string scanId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = string.IsNullOrWhiteSpace(tenant) ? "" : $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/composition/{Uri.EscapeDataString(scanId)}/merkle{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<MerkleDiagnostics>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection methods
|
||||
|
||||
public async Task<SbomerDriftResult> AnalyzeDriftAsync(
|
||||
SbomerDriftRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/drift/analyze",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerDriftResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerDriftResult();
|
||||
}
|
||||
|
||||
public async Task<SbomerDriftVerifyResult> VerifyDriftAsync(
|
||||
SbomerDriftVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/drift/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerDriftVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerDriftVerifyResult();
|
||||
}
|
||||
|
||||
private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tokenClient == null)
|
||||
return;
|
||||
|
||||
var token = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildQueryString(SbomerLayerListRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ImageRef))
|
||||
parts.Add($"imageRef={Uri.EscapeDataString(request.ImageRef)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Digest))
|
||||
parts.Add($"digest={Uri.EscapeDataString(request.Digest)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (request.Limit.HasValue)
|
||||
parts.Add($"limit={request.Limit.Value}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
parts.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
|
||||
private static string BuildLayerShowQuery(SbomerLayerShowRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (request.IncludeComponents)
|
||||
parts.Add("includeComponents=true");
|
||||
if (request.IncludeDsse)
|
||||
parts.Add("includeDsse=true");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
|
||||
private static string BuildCompositionShowQuery(SbomerCompositionShowRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.CompositionPath))
|
||||
parts.Add($"compositionPath={Uri.EscapeDataString(request.CompositionPath)}");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
}
|
||||
164
src/Cli/StellaOps.Cli/Services/Transport/HttpTransport.cs
Normal file
164
src/Cli/StellaOps.Cli/Services/Transport/HttpTransport.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP transport implementation for online mode.
|
||||
/// CLI-SDK-62-001: Provides HTTP transport for online API operations.
|
||||
/// </summary>
|
||||
public sealed class HttpTransport : IStellaOpsTransport
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<HttpTransport> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
_httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient.Timeout = _options.Timeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffline => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string TransportMode => "http";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.MaxRetries);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Sending {Method} request to {Uri} (attempt {Attempt}/{MaxAttempts})",
|
||||
request.Method, request.RequestUri, attempt, maxAttempts);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Received response {StatusCode} from {Uri}",
|
||||
(int)response.StatusCode, request.RequestUri);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (HttpRequestException ex) when (attempt < maxAttempts && IsRetryableException(ex))
|
||||
{
|
||||
var delay = GetRetryDelay(attempt);
|
||||
_logger.LogWarning(ex, "Request failed (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s...",
|
||||
attempt, maxAttempts, delay.TotalSeconds);
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Clone the request for retry
|
||||
request = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// For HTTP transport, we return a memory stream that will be uploaded
|
||||
// The caller is responsible for writing to the stream and then calling upload
|
||||
return await Task.FromResult<Stream>(new MemoryStream()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||
var response = await SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsRetryableException(HttpRequestException ex)
|
||||
{
|
||||
// Retry on connection errors, timeouts, and server errors
|
||||
return ex.InnerException is IOException
|
||||
|| ex.InnerException is OperationCanceledException
|
||||
|| (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 500);
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
// Exponential backoff with jitter
|
||||
var baseDelay = Math.Pow(2, attempt);
|
||||
var jitter = Random.Shared.NextDouble() * 0.5;
|
||||
return TimeSpan.FromSeconds(baseDelay + jitter);
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
|
||||
|
||||
// Copy headers
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
// Copy content if present
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var content = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
clone.Content = new ByteArrayContent(content);
|
||||
|
||||
foreach (var header in request.Content.Headers)
|
||||
{
|
||||
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy options
|
||||
foreach (var option in request.Options)
|
||||
{
|
||||
clone.Options.TryAdd(option.Key, option.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
// Note: We don't dispose _httpClient as it's typically managed by DI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Transport abstraction for CLI operations.
|
||||
/// CLI-SDK-62-001: Supports modular transport for online and air-gapped modes.
|
||||
/// </summary>
|
||||
public interface IStellaOpsTransport : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this transport is operating in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
bool IsOffline { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transport mode identifier.
|
||||
/// </summary>
|
||||
string TransportMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response with streaming content.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stream for uploading content.
|
||||
/// </summary>
|
||||
Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stream for downloading content.
|
||||
/// </summary>
|
||||
Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transport configuration options.
|
||||
/// </summary>
|
||||
public sealed class TransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL for the backend API.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to operate in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
public bool IsOffline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory for offline kit data.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate SSL certificates.
|
||||
/// </summary>
|
||||
public bool ValidateSsl { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom CA certificate path for SSL validation.
|
||||
/// </summary>
|
||||
public string? CaCertificatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Proxy URL if required.
|
||||
/// </summary>
|
||||
public string? ProxyUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User agent string.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps-CLI/1.0";
|
||||
}
|
||||
186
src/Cli/StellaOps.Cli/Services/Transport/OfflineTransport.cs
Normal file
186
src/Cli/StellaOps.Cli/Services/Transport/OfflineTransport.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Offline transport implementation for air-gapped mode.
|
||||
/// CLI-SDK-62-001: Provides offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
public sealed class OfflineTransport : IStellaOpsTransport
|
||||
{
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<OfflineTransport> _logger;
|
||||
private readonly string _offlineKitDirectory;
|
||||
private bool _disposed;
|
||||
|
||||
public OfflineTransport(TransportOptions options, ILogger<OfflineTransport> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
|
||||
{
|
||||
throw new ArgumentException("OfflineKitDirectory must be specified for offline transport.", nameof(options));
|
||||
}
|
||||
|
||||
_offlineKitDirectory = options.OfflineKitDirectory;
|
||||
|
||||
if (!Directory.Exists(_offlineKitDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Offline kit directory not found: {_offlineKitDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffline => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string TransportMode => "offline";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_logger.LogDebug("Offline transport handling {Method} {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
// Map the request to an offline resource
|
||||
var (found, content) = await TryGetOfflineContentAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (found)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = content,
|
||||
RequestMessage = request
|
||||
};
|
||||
}
|
||||
|
||||
// Return a 503 Service Unavailable for operations that require online access
|
||||
_logger.LogWarning("Operation not available in offline mode: {Method} {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "ERR_AIRGAP_EGRESS_BLOCKED",
|
||||
message = "This operation is not available in offline/air-gapped mode."
|
||||
}
|
||||
})),
|
||||
RequestMessage = request
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Create a staging area for uploads in offline mode
|
||||
var stagingDir = Path.Combine(_offlineKitDirectory, "staging", "uploads");
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
|
||||
var stagingFile = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.dat");
|
||||
_logger.LogDebug("Creating offline upload staging file: {Path}", stagingFile);
|
||||
|
||||
return Task.FromResult<Stream>(File.Create(stagingFile));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Map endpoint to offline file
|
||||
var localPath = MapEndpointToLocalPath(endpoint);
|
||||
|
||||
if (!File.Exists(localPath))
|
||||
{
|
||||
_logger.LogWarning("Offline resource not found: {Path}", localPath);
|
||||
throw new FileNotFoundException($"Offline resource not found: {endpoint}", localPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Opening offline resource: {Path}", localPath);
|
||||
return Task.FromResult<Stream>(File.OpenRead(localPath));
|
||||
}
|
||||
|
||||
private async Task<(bool Found, HttpContent? Content)> TryGetOfflineContentAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = request.RequestUri;
|
||||
if (uri is null)
|
||||
return (false, null);
|
||||
|
||||
var path = uri.PathAndQuery.TrimStart('/');
|
||||
|
||||
// Check for cached API responses
|
||||
var cachePath = Path.Combine(_offlineKitDirectory, "cache", "api", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
// Try with .json extension
|
||||
var jsonPath = cachePath + ".json";
|
||||
if (File.Exists(jsonPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new StringContent(content, System.Text.Encoding.UTF8, "application/json"));
|
||||
}
|
||||
|
||||
// Try exact path
|
||||
if (File.Exists(cachePath))
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new ByteArrayContent(content));
|
||||
}
|
||||
|
||||
// Check for bundled data
|
||||
var bundlePath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(bundlePath))
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new ByteArrayContent(content));
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private string MapEndpointToLocalPath(string endpoint)
|
||||
{
|
||||
var path = endpoint.TrimStart('/');
|
||||
|
||||
// Check data directory first
|
||||
var dataPath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(dataPath))
|
||||
return dataPath;
|
||||
|
||||
// Check cache directory
|
||||
var cachePath = Path.Combine(_offlineKitDirectory, "cache", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(cachePath))
|
||||
return cachePath;
|
||||
|
||||
// Return data path as default (will throw FileNotFoundException if not found)
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
264
src/Cli/StellaOps.Cli/Services/Transport/StellaOpsClientBase.cs
Normal file
264
src/Cli/StellaOps.Cli/Services/Transport/StellaOpsClientBase.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for SDK-generated clients.
|
||||
/// CLI-SDK-62-001: Provides common functionality for SDK clients with modular transport.
|
||||
/// </summary>
|
||||
public abstract class StellaOpsClientBase : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly IStellaOpsTransport _transport;
|
||||
private readonly ILogger _logger;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization token for API requests.
|
||||
/// </summary>
|
||||
protected string? AccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current tenant context.
|
||||
/// </summary>
|
||||
protected string? TenantId { get; set; }
|
||||
|
||||
protected StellaOpsClientBase(IStellaOpsTransport transport, ILogger logger)
|
||||
{
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the client is operating in offline mode.
|
||||
/// </summary>
|
||||
public bool IsOffline => _transport.IsOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the access token for authenticated requests.
|
||||
/// </summary>
|
||||
public void SetAccessToken(string? token)
|
||||
{
|
||||
AccessToken = token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the tenant context.
|
||||
/// </summary>
|
||||
public void SetTenant(string? tenantId)
|
||||
{
|
||||
TenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the operation requires online connectivity and transport is offline.
|
||||
/// </summary>
|
||||
protected void ThrowIfOffline(string operation)
|
||||
{
|
||||
if (_transport.IsOffline)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Operation '{operation}' is not available in offline/air-gapped mode. " +
|
||||
"Please use online mode or import the required data to the offline kit.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GET request and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> GetAsync<TResponse>(
|
||||
string relativeUrl,
|
||||
CancellationToken cancellationToken) where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, relativeUrl);
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a POST request with JSON body and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PostAsync<TRequest, TResponse>(
|
||||
string relativeUrl,
|
||||
TRequest body,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Post, relativeUrl);
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a PUT request with JSON body and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PutAsync<TRequest, TResponse>(
|
||||
string relativeUrl,
|
||||
TRequest body,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Put, relativeUrl);
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a DELETE request.
|
||||
/// </summary>
|
||||
protected async Task DeleteAsync(string relativeUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Delete, relativeUrl);
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request and parses any error response.
|
||||
/// </summary>
|
||||
protected async Task<(TResponse? Result, CliError? Error)> TrySendAsync<TResponse>(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) where TResponse : class
|
||||
{
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return (result, null);
|
||||
}
|
||||
|
||||
var error = await ParseErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return (null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP request with standard headers.
|
||||
/// </summary>
|
||||
protected HttpRequestMessage CreateRequest(HttpMethod method, string relativeUrl)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, relativeUrl);
|
||||
|
||||
// Add authorization header
|
||||
if (!string.IsNullOrWhiteSpace(AccessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
|
||||
}
|
||||
|
||||
// Add tenant header
|
||||
if (!string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", TenantId);
|
||||
}
|
||||
|
||||
// Add standard headers
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request through the transport.
|
||||
/// </summary>
|
||||
protected async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_logger.LogDebug("Sending {Method} request to {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
return await _transport.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an error response into a CliError.
|
||||
/// </summary>
|
||||
protected async Task<CliError> ParseErrorAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
string? content = null;
|
||||
|
||||
try
|
||||
{
|
||||
content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore content read errors
|
||||
}
|
||||
|
||||
// Try to parse as error envelope
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = JsonSerializer.Deserialize<ApiErrorEnvelope>(content, JsonOptions);
|
||||
if (envelope?.Error is not null)
|
||||
{
|
||||
return CliError.FromApiErrorEnvelope(envelope, statusCode);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not an error envelope
|
||||
}
|
||||
|
||||
// Try to parse as problem details
|
||||
try
|
||||
{
|
||||
var problem = JsonSerializer.Deserialize<ProblemDocument>(content, JsonOptions);
|
||||
if (problem is not null)
|
||||
{
|
||||
return new CliError(
|
||||
Code: problem.Type ?? $"ERR_HTTP_{statusCode}",
|
||||
Message: problem.Title ?? $"HTTP error {statusCode}",
|
||||
Detail: problem.Detail);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not a problem document
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to HTTP status-based error
|
||||
return CliError.FromHttpStatus(statusCode, content);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_transport.Dispose();
|
||||
}
|
||||
}
|
||||
126
src/Cli/StellaOps.Cli/Services/Transport/TransportFactory.cs
Normal file
126
src/Cli/StellaOps.Cli/Services/Transport/TransportFactory.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating transport instances based on configuration.
|
||||
/// CLI-SDK-62-001: Provides modular transport selection for online/offline modes.
|
||||
/// </summary>
|
||||
public sealed class TransportFactory : ITransportFactory
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly CliProfileManager _profileManager;
|
||||
|
||||
public TransportFactory(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILoggerFactory loggerFactory,
|
||||
StellaOpsCliOptions options,
|
||||
CliProfileManager profileManager)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_profileManager = profileManager ?? throw new ArgumentNullException(nameof(profileManager));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a transport instance based on current configuration.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateTransport()
|
||||
{
|
||||
var transportOptions = CreateTransportOptions();
|
||||
|
||||
if (transportOptions.IsOffline)
|
||||
{
|
||||
return CreateOfflineTransport(transportOptions);
|
||||
}
|
||||
|
||||
return CreateHttpTransport(transportOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP transport for online operations.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null)
|
||||
{
|
||||
options ??= CreateTransportOptions();
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient("StellaOps");
|
||||
var logger = _loggerFactory.CreateLogger<HttpTransport>();
|
||||
|
||||
return new HttpTransport(httpClient, options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null)
|
||||
{
|
||||
options ??= CreateTransportOptions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("Offline kit directory must be specified for offline transport.");
|
||||
}
|
||||
|
||||
var logger = _loggerFactory.CreateLogger<OfflineTransport>();
|
||||
return new OfflineTransport(options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates transport options from current configuration.
|
||||
/// </summary>
|
||||
public TransportOptions CreateTransportOptions()
|
||||
{
|
||||
var profile = _profileManager.GetCurrentProfileAsync().GetAwaiter().GetResult();
|
||||
|
||||
return new TransportOptions
|
||||
{
|
||||
BackendUrl = profile?.BackendUrl ?? _options.BackendUrl,
|
||||
IsOffline = profile?.IsOffline ?? _options.IsOffline,
|
||||
OfflineKitDirectory = profile?.OfflineKitDirectory ?? _options.OfflineKitDirectory,
|
||||
Timeout = TimeSpan.FromMinutes(5),
|
||||
MaxRetries = 3,
|
||||
ValidateSsl = true,
|
||||
UserAgent = $"StellaOps-CLI/{GetVersion()}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetVersion()
|
||||
{
|
||||
var assembly = typeof(TransportFactory).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "1.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory interface for creating transport instances.
|
||||
/// </summary>
|
||||
public interface ITransportFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a transport instance based on current configuration.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateTransport();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP transport for online operations.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates transport options from current configuration.
|
||||
/// </summary>
|
||||
TransportOptions CreateTransportOptions();
|
||||
}
|
||||
228
src/Cli/StellaOps.Cli/Services/VexObservationsClient.cs
Normal file
228
src/Cli/StellaOps.Cli/Services/VexObservationsClient.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for VEX observation queries.
|
||||
/// Per CLI-LNM-22-002.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationsClient : IVexObservationsClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ITokenClient? _tokenClient;
|
||||
private readonly ILogger<VexObservationsClient> _logger;
|
||||
private string? _cachedToken;
|
||||
private DateTimeOffset _tokenExpiry;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public VexObservationsClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<VexObservationsClient> logger,
|
||||
ITokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
}
|
||||
|
||||
public async Task<VexObservationResponse> GetObservationsAsync(
|
||||
VexObservationQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = BuildObservationRequestUri(query);
|
||||
_logger.LogDebug("Fetching VEX observations from {Uri}", requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX observations request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX observations: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexObservationResponse>(content, SerializerOptions)
|
||||
?? new VexObservationResponse();
|
||||
}
|
||||
|
||||
public async Task<VexLinksetResponse> GetLinksetAsync(
|
||||
VexLinksetQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = BuildLinksetRequestUri(query);
|
||||
_logger.LogDebug("Fetching VEX linkset from {Uri}", requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX linkset request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX linkset: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexLinksetResponse>(content, SerializerOptions)
|
||||
?? new VexLinksetResponse();
|
||||
}
|
||||
|
||||
public async Task<VexObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = $"api/v1/tenants/{Uri.EscapeDataString(tenant)}/vex/observations/{Uri.EscapeDataString(observationId)}";
|
||||
_logger.LogDebug("Fetching VEX observation {ObservationId} from {Uri}", observationId, requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX observation request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX observation: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexObservation>(content, SerializerOptions);
|
||||
}
|
||||
|
||||
private async Task EnsureAuthorizationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tokenClient is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_cachedToken) && DateTimeOffset.UtcNow < _tokenExpiry)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenResult = await _tokenClient.GetAccessTokenAsync(
|
||||
new[] { StellaOpsScopes.VexRead },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (tokenResult.IsSuccess && !string.IsNullOrWhiteSpace(tokenResult.AccessToken))
|
||||
{
|
||||
_cachedToken = tokenResult.AccessToken;
|
||||
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to acquire token for VEX API access.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildObservationRequestUri(VexObservationQuery query)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"api/v1/tenants/{Uri.EscapeDataString(query.Tenant)}/vex/observations?");
|
||||
|
||||
foreach (var vulnId in query.VulnerabilityIds)
|
||||
{
|
||||
sb.Append($"vulnerabilityId={Uri.EscapeDataString(vulnId)}&");
|
||||
}
|
||||
|
||||
foreach (var productKey in query.ProductKeys)
|
||||
{
|
||||
sb.Append($"productKey={Uri.EscapeDataString(productKey)}&");
|
||||
}
|
||||
|
||||
foreach (var purl in query.Purls)
|
||||
{
|
||||
sb.Append($"purl={Uri.EscapeDataString(purl)}&");
|
||||
}
|
||||
|
||||
foreach (var cpe in query.Cpes)
|
||||
{
|
||||
sb.Append($"cpe={Uri.EscapeDataString(cpe)}&");
|
||||
}
|
||||
|
||||
foreach (var status in query.Statuses)
|
||||
{
|
||||
sb.Append($"status={Uri.EscapeDataString(status)}&");
|
||||
}
|
||||
|
||||
foreach (var providerId in query.ProviderIds)
|
||||
{
|
||||
sb.Append($"providerId={Uri.EscapeDataString(providerId)}&");
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
sb.Append($"limit={query.Limit.Value}&");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
sb.Append($"cursor={Uri.EscapeDataString(query.Cursor)}&");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd('&', '?');
|
||||
}
|
||||
|
||||
private static string BuildLinksetRequestUri(VexLinksetQuery query)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"api/v1/tenants/{Uri.EscapeDataString(query.Tenant)}/vex/linkset/{Uri.EscapeDataString(query.VulnerabilityId)}?");
|
||||
|
||||
foreach (var productKey in query.ProductKeys)
|
||||
{
|
||||
sb.Append($"productKey={Uri.EscapeDataString(productKey)}&");
|
||||
}
|
||||
|
||||
foreach (var purl in query.Purls)
|
||||
{
|
||||
sb.Append($"purl={Uri.EscapeDataString(purl)}&");
|
||||
}
|
||||
|
||||
foreach (var status in query.Statuses)
|
||||
{
|
||||
sb.Append($"status={Uri.EscapeDataString(status)}&");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd('&', '?');
|
||||
}
|
||||
}
|
||||
192
src/Cli/StellaOps.Cli/Telemetry/TraceparentHttpMessageHandler.cs
Normal file
192
src/Cli/StellaOps.Cli/Telemetry/TraceparentHttpMessageHandler.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP message handler that propagates W3C Trace Context (traceparent) headers.
|
||||
/// Per CLI-OBS-50-001, ensures CLI HTTP client propagates traceparent headers for all commands,
|
||||
/// prints correlation IDs on failure, and records trace IDs in verbose logs.
|
||||
/// </summary>
|
||||
public sealed class TraceparentHttpMessageHandler : DelegatingHandler
|
||||
{
|
||||
private const string TraceparentHeader = "traceparent";
|
||||
private const string TracestateHeader = "tracestate";
|
||||
private const string RequestIdHeader = "x-request-id";
|
||||
private const string CorrelationIdHeader = "x-correlation-id";
|
||||
|
||||
private readonly ILogger<TraceparentHttpMessageHandler> _logger;
|
||||
private readonly bool _verbose;
|
||||
|
||||
public TraceparentHttpMessageHandler(
|
||||
ILogger<TraceparentHttpMessageHandler> logger,
|
||||
bool verbose = false)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verbose = verbose;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
string? traceId = null;
|
||||
string? spanId = null;
|
||||
|
||||
// Generate or use existing trace context
|
||||
if (activity is not null)
|
||||
{
|
||||
traceId = activity.TraceId.ToString();
|
||||
spanId = activity.SpanId.ToString();
|
||||
|
||||
// Add W3C traceparent header if not already present
|
||||
if (!request.Headers.Contains(TraceparentHeader))
|
||||
{
|
||||
var traceparent = $"00-{traceId}-{spanId}-{(activity.Recorded ? "01" : "00")}";
|
||||
request.Headers.TryAddWithoutValidation(TraceparentHeader, traceparent);
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug("Added traceparent header: {Traceparent}", traceparent);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tracestate if present
|
||||
if (!string.IsNullOrWhiteSpace(activity.TraceStateString) &&
|
||||
!request.Headers.Contains(TracestateHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(TracestateHeader, activity.TraceStateString);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate a new trace ID if no activity exists
|
||||
traceId = Guid.NewGuid().ToString("N");
|
||||
spanId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
if (!request.Headers.Contains(TraceparentHeader))
|
||||
{
|
||||
var traceparent = $"00-{traceId}-{spanId}-00";
|
||||
request.Headers.TryAddWithoutValidation(TraceparentHeader, traceparent);
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug("Generated new traceparent header: {Traceparent}", traceparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also add x-request-id for legacy compatibility
|
||||
if (!request.Headers.Contains(RequestIdHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(RequestIdHeader, traceId);
|
||||
}
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Sending {Method} {Uri} with trace_id={TraceId}",
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId);
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Request failed: {Method} {Uri} trace_id={TraceId} error={Error}",
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId,
|
||||
ex.Message);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Extract correlation ID from response if present
|
||||
var responseTraceId = GetResponseTraceId(response);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Request returned {StatusCode}: {Method} {Uri} trace_id={TraceId} response_trace_id={ResponseTraceId}",
|
||||
(int)response.StatusCode,
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId,
|
||||
responseTraceId ?? "(not provided)");
|
||||
}
|
||||
else if (_verbose)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Request completed {StatusCode}: {Method} {Uri} trace_id={TraceId}",
|
||||
(int)response.StatusCode,
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string? GetResponseTraceId(HttpResponseMessage response)
|
||||
{
|
||||
if (response.Headers.TryGetValues(CorrelationIdHeader, out var correlationValues))
|
||||
{
|
||||
return string.Join(",", correlationValues);
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues(RequestIdHeader, out var requestIdValues))
|
||||
{
|
||||
return string.Join(",", requestIdValues);
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues("x-trace-id", out var traceIdValues))
|
||||
{
|
||||
return string.Join(",", traceIdValues);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ScrubUrl(Uri? uri)
|
||||
{
|
||||
if (uri is null)
|
||||
return "(null)";
|
||||
|
||||
// Remove query string to avoid logging sensitive parameters
|
||||
return $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension to add traceparent propagation to HTTP client.
|
||||
/// </summary>
|
||||
public static class TraceparentHttpClientBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds W3C Trace Context (traceparent) header propagation to the HTTP client.
|
||||
/// Per CLI-OBS-50-001.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddTraceparentPropagation(
|
||||
this IHttpClientBuilder builder,
|
||||
bool verbose = false)
|
||||
{
|
||||
return builder.AddHttpMessageHandler(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
return new TraceparentHttpMessageHandler(
|
||||
loggerFactory.CreateLogger<TraceparentHttpMessageHandler>(),
|
||||
verbose);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IAdvisoryFieldChangeEmitter"/>.
|
||||
/// Per CONCELIER-RISK-69-001, emits notifications on upstream advisory field changes
|
||||
/// with observation IDs and provenance; no severity inference.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryFieldChangeEmitter : IAdvisoryFieldChangeEmitter
|
||||
{
|
||||
private readonly IAdvisoryFieldChangeNotificationPublisher _publisher;
|
||||
private readonly ILogger<AdvisoryFieldChangeEmitter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AdvisoryFieldChangeEmitter(
|
||||
IAdvisoryFieldChangeNotificationPublisher publisher,
|
||||
ILogger<AdvisoryFieldChangeEmitter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AdvisoryFieldChangeNotification?> EmitChangesAsync(
|
||||
string tenantId,
|
||||
string observationId,
|
||||
VendorRiskSignal? previousSignal,
|
||||
VendorRiskSignal currentSignal,
|
||||
string? linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
ArgumentNullException.ThrowIfNull(currentSignal);
|
||||
|
||||
var changes = DetectChanges(previousSignal, currentSignal);
|
||||
|
||||
if (changes.IsDefaultOrEmpty)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No field changes detected for observation {ObservationId}",
|
||||
observationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var changeType = DetermineChangeType(changes);
|
||||
var provenance = BuildProvenance(previousSignal, currentSignal);
|
||||
|
||||
var notification = new AdvisoryFieldChangeNotification(
|
||||
NotificationId: Guid.NewGuid(),
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: currentSignal.AdvisoryId,
|
||||
ObservationId: observationId,
|
||||
LinksetId: linksetId,
|
||||
ChangeType: changeType,
|
||||
Changes: changes,
|
||||
Provenance: provenance,
|
||||
DetectedAt: now,
|
||||
EmittedAt: now);
|
||||
|
||||
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted field change notification for observation {ObservationId}: type={ChangeType}, fields=[{Fields}]",
|
||||
observationId, changeType, string.Join(", ", notification.ChangedFields));
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AdvisoryFieldChangeNotification>> EmitLinksetChangesAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
IReadOnlyList<VendorRiskSignal> previousSignals,
|
||||
IReadOnlyList<VendorRiskSignal> currentSignals,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
|
||||
ArgumentNullException.ThrowIfNull(currentSignals);
|
||||
|
||||
var previousByObservation = (previousSignals ?? [])
|
||||
.ToDictionary(s => s.ObservationId, StringComparer.Ordinal);
|
||||
|
||||
var notifications = new List<AdvisoryFieldChangeNotification>();
|
||||
|
||||
foreach (var currentSignal in currentSignals)
|
||||
{
|
||||
previousByObservation.TryGetValue(currentSignal.ObservationId, out var previousSignal);
|
||||
|
||||
var notification = await EmitChangesAsync(
|
||||
tenantId,
|
||||
currentSignal.ObservationId,
|
||||
previousSignal,
|
||||
currentSignal,
|
||||
linksetId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (notification is not null)
|
||||
{
|
||||
notifications.Add(notification);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for withdrawn observations
|
||||
var currentObservationIds = currentSignals
|
||||
.Select(s => s.ObservationId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var previousSignal in previousSignals ?? [])
|
||||
{
|
||||
if (!currentObservationIds.Contains(previousSignal.ObservationId))
|
||||
{
|
||||
var withdrawnNotification = await EmitWithdrawnAsync(
|
||||
tenantId,
|
||||
previousSignal,
|
||||
linksetId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (withdrawnNotification is not null)
|
||||
{
|
||||
notifications.Add(withdrawnNotification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
private async Task<AdvisoryFieldChangeNotification?> EmitWithdrawnAsync(
|
||||
string tenantId,
|
||||
VendorRiskSignal previousSignal,
|
||||
string? linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var provenance = new AdvisoryFieldChangeProvenance(
|
||||
Vendor: previousSignal.Provenance.Vendor,
|
||||
Source: previousSignal.Provenance.Source,
|
||||
ObservationHash: previousSignal.Provenance.ObservationHash,
|
||||
FetchedAt: previousSignal.Provenance.FetchedAt,
|
||||
IngestJobId: previousSignal.Provenance.IngestJobId,
|
||||
UpstreamId: previousSignal.Provenance.UpstreamId,
|
||||
PreviousObservationHash: null);
|
||||
|
||||
var change = new AdvisoryFieldChange(
|
||||
Field: "observation_status",
|
||||
PreviousValue: "active",
|
||||
CurrentValue: "withdrawn",
|
||||
Category: AdvisoryFieldChangeCategory.Metadata,
|
||||
Provenance: provenance);
|
||||
|
||||
var notification = new AdvisoryFieldChangeNotification(
|
||||
NotificationId: Guid.NewGuid(),
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: previousSignal.AdvisoryId,
|
||||
ObservationId: previousSignal.ObservationId,
|
||||
LinksetId: linksetId,
|
||||
ChangeType: AdvisoryFieldChangeType.ObservationWithdrawn,
|
||||
Changes: [change],
|
||||
Provenance: provenance,
|
||||
DetectedAt: now,
|
||||
EmittedAt: now);
|
||||
|
||||
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted withdrawn observation notification for {ObservationId}",
|
||||
previousSignal.ObservationId);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
private static ImmutableArray<AdvisoryFieldChange> DetectChanges(
|
||||
VendorRiskSignal? previousSignal,
|
||||
VendorRiskSignal currentSignal)
|
||||
{
|
||||
var changes = ImmutableArray.CreateBuilder<AdvisoryFieldChange>();
|
||||
var currentProvenance = MapProvenance(currentSignal.Provenance, previousSignal?.Provenance.ObservationHash);
|
||||
|
||||
// New observation
|
||||
if (previousSignal is null)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "observation_status",
|
||||
PreviousValue: null,
|
||||
CurrentValue: "active",
|
||||
Category: AdvisoryFieldChangeCategory.Metadata,
|
||||
Provenance: currentProvenance));
|
||||
|
||||
// Report initial fix availability if present
|
||||
if (currentSignal.HasFixAvailable)
|
||||
{
|
||||
var fixVersion = currentSignal.FixAvailability.FirstOrDefault(f => f.Status == FixStatus.Available)?.FixedVersion;
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "fix_availability",
|
||||
PreviousValue: null,
|
||||
CurrentValue: fixVersion ?? "available",
|
||||
Category: AdvisoryFieldChangeCategory.Remediation,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
// Report initial KEV status if present
|
||||
if (currentSignal.IsKnownExploited)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "kev_status",
|
||||
PreviousValue: null,
|
||||
CurrentValue: "in_kev",
|
||||
Category: AdvisoryFieldChangeCategory.Threat,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
// Report initial severity if present
|
||||
if (currentSignal.HighestCvssScore is not null)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "severity",
|
||||
PreviousValue: null,
|
||||
CurrentValue: currentSignal.HighestCvssScore.EffectiveSeverity,
|
||||
Category: AdvisoryFieldChangeCategory.Risk,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
return changes.ToImmutable();
|
||||
}
|
||||
|
||||
// Compare fix availability
|
||||
var previousHasFix = previousSignal.HasFixAvailable;
|
||||
var currentHasFix = currentSignal.HasFixAvailable;
|
||||
|
||||
if (previousHasFix != currentHasFix)
|
||||
{
|
||||
var previousValue = previousHasFix ? GetFixVersion(previousSignal) ?? "available" : "not_available";
|
||||
var currentValue = currentHasFix ? GetFixVersion(currentSignal) ?? "available" : "not_available";
|
||||
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "fix_availability",
|
||||
PreviousValue: previousValue,
|
||||
CurrentValue: currentValue,
|
||||
Category: AdvisoryFieldChangeCategory.Remediation,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
else if (currentHasFix)
|
||||
{
|
||||
// Both have fixes - check if version changed
|
||||
var previousVersion = GetFixVersion(previousSignal);
|
||||
var currentVersion = GetFixVersion(currentSignal);
|
||||
|
||||
if (!string.Equals(previousVersion, currentVersion, StringComparison.Ordinal))
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "fix_version",
|
||||
PreviousValue: previousVersion,
|
||||
CurrentValue: currentVersion,
|
||||
Category: AdvisoryFieldChangeCategory.Remediation,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
}
|
||||
|
||||
// Compare KEV status
|
||||
var previousInKev = previousSignal.IsKnownExploited;
|
||||
var currentInKev = currentSignal.IsKnownExploited;
|
||||
|
||||
if (previousInKev != currentInKev)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "kev_status",
|
||||
PreviousValue: previousInKev ? "in_kev" : "not_in_kev",
|
||||
CurrentValue: currentInKev ? "in_kev" : "not_in_kev",
|
||||
Category: AdvisoryFieldChangeCategory.Threat,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
// Compare severity
|
||||
var previousSeverity = previousSignal.HighestCvssScore?.EffectiveSeverity;
|
||||
var currentSeverity = currentSignal.HighestCvssScore?.EffectiveSeverity;
|
||||
|
||||
if (!string.Equals(previousSeverity, currentSeverity, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "severity",
|
||||
PreviousValue: previousSeverity,
|
||||
CurrentValue: currentSeverity,
|
||||
Category: AdvisoryFieldChangeCategory.Risk,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
// Compare CVSS score (if both have scores)
|
||||
var previousScore = previousSignal.HighestCvssScore?.Score;
|
||||
var currentScore = currentSignal.HighestCvssScore?.Score;
|
||||
|
||||
if (previousScore.HasValue && currentScore.HasValue &&
|
||||
Math.Abs(previousScore.Value - currentScore.Value) >= 0.1)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "cvss_score",
|
||||
PreviousValue: previousScore.Value.ToString("F1"),
|
||||
CurrentValue: currentScore.Value.ToString("F1"),
|
||||
Category: AdvisoryFieldChangeCategory.Risk,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
return changes.ToImmutable();
|
||||
}
|
||||
|
||||
private static string? GetFixVersion(VendorRiskSignal signal)
|
||||
{
|
||||
return signal.FixAvailability
|
||||
.Where(f => f.Status == FixStatus.Available)
|
||||
.Select(f => f.FixedVersion)
|
||||
.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
private static AdvisoryFieldChangeType DetermineChangeType(ImmutableArray<AdvisoryFieldChange> changes)
|
||||
{
|
||||
if (changes.Length == 0)
|
||||
{
|
||||
return AdvisoryFieldChangeType.Unknown;
|
||||
}
|
||||
|
||||
if (changes.Length > 1)
|
||||
{
|
||||
return AdvisoryFieldChangeType.MultipleChanges;
|
||||
}
|
||||
|
||||
var change = changes[0];
|
||||
|
||||
return change.Field switch
|
||||
{
|
||||
"observation_status" when change.CurrentValue == "active" => AdvisoryFieldChangeType.NewObservation,
|
||||
"observation_status" when change.CurrentValue == "withdrawn" => AdvisoryFieldChangeType.ObservationWithdrawn,
|
||||
"fix_availability" or "fix_version" => AdvisoryFieldChangeType.FixAvailabilityChanged,
|
||||
"kev_status" => AdvisoryFieldChangeType.KevStatusChanged,
|
||||
"severity" or "cvss_score" => AdvisoryFieldChangeType.SeverityChanged,
|
||||
_ => AdvisoryFieldChangeType.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvisoryFieldChangeProvenance BuildProvenance(
|
||||
VendorRiskSignal? previousSignal,
|
||||
VendorRiskSignal currentSignal)
|
||||
{
|
||||
return new AdvisoryFieldChangeProvenance(
|
||||
Vendor: currentSignal.Provenance.Vendor,
|
||||
Source: currentSignal.Provenance.Source,
|
||||
ObservationHash: currentSignal.Provenance.ObservationHash,
|
||||
FetchedAt: currentSignal.Provenance.FetchedAt,
|
||||
IngestJobId: currentSignal.Provenance.IngestJobId,
|
||||
UpstreamId: currentSignal.Provenance.UpstreamId,
|
||||
PreviousObservationHash: previousSignal?.Provenance.ObservationHash);
|
||||
}
|
||||
|
||||
private static AdvisoryFieldChangeProvenance MapProvenance(
|
||||
VendorRiskProvenance provenance,
|
||||
string? previousHash)
|
||||
{
|
||||
return new AdvisoryFieldChangeProvenance(
|
||||
Vendor: provenance.Vendor,
|
||||
Source: provenance.Source,
|
||||
ObservationHash: provenance.ObservationHash,
|
||||
FetchedAt: provenance.FetchedAt,
|
||||
IngestJobId: provenance.IngestJobId,
|
||||
UpstreamId: provenance.UpstreamId,
|
||||
PreviousObservationHash: previousHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Notification for upstream advisory field changes.
|
||||
/// Per CONCELIER-RISK-69-001, emits notifications on field changes (e.g., fix availability)
|
||||
/// with observation IDs and provenance; no severity inference.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This notification is fact-only: surfaces vendor-published changes with provenance.
|
||||
/// No inference, weighting, or exploitability assumptions.
|
||||
/// </remarks>
|
||||
public sealed record AdvisoryFieldChangeNotification(
|
||||
Guid NotificationId,
|
||||
string TenantId,
|
||||
string AdvisoryId,
|
||||
string ObservationId,
|
||||
string? LinksetId,
|
||||
AdvisoryFieldChangeType ChangeType,
|
||||
ImmutableArray<AdvisoryFieldChange> Changes,
|
||||
AdvisoryFieldChangeProvenance Provenance,
|
||||
DateTimeOffset DetectedAt,
|
||||
DateTimeOffset EmittedAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Event kind for notification routing.
|
||||
/// </summary>
|
||||
public const string EventKind = "advisory.field.changed";
|
||||
|
||||
/// <summary>
|
||||
/// Event version.
|
||||
/// </summary>
|
||||
public const string EventVersion = "1";
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if any critical field changed (fix availability, KEV status).
|
||||
/// </summary>
|
||||
public bool HasCriticalChange => Changes.Any(c =>
|
||||
c.Field is "fix_availability" or "kev_status" or "severity");
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique fields that changed.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ChangedFields =>
|
||||
Changes.Select(c => c.Field).Distinct(StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of field change notification.
|
||||
/// </summary>
|
||||
public enum AdvisoryFieldChangeType
|
||||
{
|
||||
/// <summary>Unknown change type.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Fix availability changed (became available, version updated, etc.).</summary>
|
||||
FixAvailabilityChanged,
|
||||
|
||||
/// <summary>KEV status changed (added to or removed from KEV list).</summary>
|
||||
KevStatusChanged,
|
||||
|
||||
/// <summary>Severity score changed.</summary>
|
||||
SeverityChanged,
|
||||
|
||||
/// <summary>New observation added from upstream.</summary>
|
||||
NewObservation,
|
||||
|
||||
/// <summary>Observation withdrawn by upstream.</summary>
|
||||
ObservationWithdrawn,
|
||||
|
||||
/// <summary>Advisory link/reference added.</summary>
|
||||
ReferenceAdded,
|
||||
|
||||
/// <summary>Multiple fields changed.</summary>
|
||||
MultipleChanges
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A specific field change in an advisory.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryFieldChange(
|
||||
string Field,
|
||||
string? PreviousValue,
|
||||
string? CurrentValue,
|
||||
AdvisoryFieldChangeCategory Category,
|
||||
AdvisoryFieldChangeProvenance Provenance)
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates if the value transitioned from null/empty to having a value.
|
||||
/// </summary>
|
||||
public bool IsNewValue => string.IsNullOrWhiteSpace(PreviousValue) && !string.IsNullOrWhiteSpace(CurrentValue);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the value was removed (non-null to null/empty).
|
||||
/// </summary>
|
||||
public bool IsValueRemoved => !string.IsNullOrWhiteSpace(PreviousValue) && string.IsNullOrWhiteSpace(CurrentValue);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the value changed between two non-null values.
|
||||
/// </summary>
|
||||
public bool IsValueUpdated => !string.IsNullOrWhiteSpace(PreviousValue) &&
|
||||
!string.IsNullOrWhiteSpace(CurrentValue) &&
|
||||
!string.Equals(PreviousValue, CurrentValue, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Category of field change for filtering/routing.
|
||||
/// </summary>
|
||||
public enum AdvisoryFieldChangeCategory
|
||||
{
|
||||
/// <summary>Unknown category.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Remediation-related (fix version, patch URL, etc.).</summary>
|
||||
Remediation,
|
||||
|
||||
/// <summary>Threat-related (KEV, exploitation evidence).</summary>
|
||||
Threat,
|
||||
|
||||
/// <summary>Risk-related (severity, CVSS score).</summary>
|
||||
Risk,
|
||||
|
||||
/// <summary>Metadata-related (references, aliases, description).</summary>
|
||||
Metadata,
|
||||
|
||||
/// <summary>Scope-related (affected packages, versions).</summary>
|
||||
Scope
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provenance anchor for field change notifications.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryFieldChangeProvenance(
|
||||
string Vendor,
|
||||
string Source,
|
||||
string ObservationHash,
|
||||
DateTimeOffset FetchedAt,
|
||||
string? IngestJobId,
|
||||
string? UpstreamId,
|
||||
string? PreviousObservationHash);
|
||||
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IFixAvailabilityEmitter"/>.
|
||||
/// Per CONCELIER-RISK-66-002, emits structured fix-availability metadata per observation/linkset
|
||||
/// without guessing exploitability.
|
||||
/// </summary>
|
||||
public sealed class FixAvailabilityEmitter : IFixAvailabilityEmitter
|
||||
{
|
||||
private readonly IVendorRiskSignalProvider _riskSignalProvider;
|
||||
private readonly ILogger<FixAvailabilityEmitter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public FixAvailabilityEmitter(
|
||||
IVendorRiskSignalProvider riskSignalProvider,
|
||||
ILogger<FixAvailabilityEmitter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_riskSignalProvider = riskSignalProvider ?? throw new ArgumentNullException(nameof(riskSignalProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FixAvailabilityMetadata?> EmitByObservationAsync(
|
||||
string tenantId,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
var riskSignal = await _riskSignalProvider.GetByObservationAsync(
|
||||
tenantId, observationId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (riskSignal is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No risk signal found for observation {ObservationId} in tenant {TenantId}",
|
||||
observationId, tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return EmitFromRiskSignal(riskSignal, linksetId: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
|
||||
|
||||
var riskSignals = await _riskSignalProvider.GetByLinksetAsync(
|
||||
tenantId, linksetId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (riskSignals.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No risk signals found for linkset {LinksetId} in tenant {TenantId}",
|
||||
linksetId, tenantId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<FixAvailabilityMetadata>(riskSignals.Count);
|
||||
foreach (var signal in riskSignals)
|
||||
{
|
||||
results.Add(EmitFromRiskSignal(signal, linksetId));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
|
||||
|
||||
var riskSignals = await _riskSignalProvider.GetByAdvisoryAsync(
|
||||
tenantId, advisoryId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (riskSignals.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No risk signals found for advisory {AdvisoryId} in tenant {TenantId}",
|
||||
advisoryId, tenantId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<FixAvailabilityMetadata>(riskSignals.Count);
|
||||
foreach (var signal in riskSignals)
|
||||
{
|
||||
results.Add(EmitFromRiskSignal(signal, linksetId: null));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private FixAvailabilityMetadata EmitFromRiskSignal(VendorRiskSignal signal, string? linksetId)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var provenance = MapProvenance(signal.Provenance);
|
||||
|
||||
if (signal.FixAvailability.IsDefaultOrEmpty)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Emitting empty fix-availability for observation {ObservationId} (no fix data in signal)",
|
||||
signal.ObservationId);
|
||||
|
||||
return FixAvailabilityMetadata.Empty(
|
||||
signal.TenantId,
|
||||
signal.AdvisoryId,
|
||||
signal.ObservationId,
|
||||
linksetId,
|
||||
provenance,
|
||||
now);
|
||||
}
|
||||
|
||||
var releases = MapReleases(signal.FixAvailability, provenance);
|
||||
var advisoryLinks = ExtractAdvisoryLinks(signal.FixAvailability, provenance);
|
||||
var status = DetermineOverallStatus(signal.FixAvailability);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Emitting fix-availability for observation {ObservationId}: status={Status}, releases={ReleaseCount}, links={LinkCount}",
|
||||
signal.ObservationId, status, releases.Length, advisoryLinks.Length);
|
||||
|
||||
return new FixAvailabilityMetadata(
|
||||
TenantId: signal.TenantId,
|
||||
AdvisoryId: signal.AdvisoryId,
|
||||
ObservationId: signal.ObservationId,
|
||||
LinksetId: linksetId,
|
||||
Status: status,
|
||||
Releases: releases,
|
||||
AdvisoryLinks: advisoryLinks,
|
||||
Provenance: provenance,
|
||||
EmittedAt: now);
|
||||
}
|
||||
|
||||
private static FixAvailabilityProvenance MapProvenance(VendorRiskProvenance vendorProvenance)
|
||||
{
|
||||
return new FixAvailabilityProvenance(
|
||||
Vendor: vendorProvenance.Vendor,
|
||||
Source: vendorProvenance.Source,
|
||||
ObservationHash: vendorProvenance.ObservationHash,
|
||||
FetchedAt: vendorProvenance.FetchedAt,
|
||||
IngestJobId: vendorProvenance.IngestJobId,
|
||||
UpstreamId: vendorProvenance.UpstreamId);
|
||||
}
|
||||
|
||||
private static ImmutableArray<FixRelease> MapReleases(
|
||||
ImmutableArray<VendorFixAvailability> vendorFixes,
|
||||
FixAvailabilityProvenance provenance)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<FixRelease>();
|
||||
|
||||
foreach (var vendorFix in vendorFixes)
|
||||
{
|
||||
if (vendorFix.Status != FixStatus.Available || string.IsNullOrWhiteSpace(vendorFix.FixedVersion))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fixProvenance = MapProvenance(vendorFix.Provenance);
|
||||
var releaseType = DetermineReleaseType(vendorFix.FixedVersion);
|
||||
|
||||
builder.Add(new FixRelease(
|
||||
FixedVersion: vendorFix.FixedVersion,
|
||||
Package: vendorFix.Package,
|
||||
Ecosystem: vendorFix.Ecosystem,
|
||||
ReleasedAt: vendorFix.FixReleasedAt,
|
||||
Type: releaseType,
|
||||
Provenance: fixProvenance));
|
||||
}
|
||||
|
||||
// Sort by release date, then by version for deterministic ordering
|
||||
return builder
|
||||
.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(r => r.FixedVersion, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<FixAdvisoryLink> ExtractAdvisoryLinks(
|
||||
ImmutableArray<VendorFixAvailability> vendorFixes,
|
||||
FixAvailabilityProvenance provenance)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<FixAdvisoryLink>();
|
||||
var seenUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var vendorFix in vendorFixes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vendorFix.AdvisoryUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenUrls.Add(vendorFix.AdvisoryUrl))
|
||||
{
|
||||
continue; // Deduplicate
|
||||
}
|
||||
|
||||
var fixProvenance = MapProvenance(vendorFix.Provenance);
|
||||
var linkType = DetermineLinkType(vendorFix.AdvisoryUrl);
|
||||
|
||||
builder.Add(new FixAdvisoryLink(
|
||||
Url: vendorFix.AdvisoryUrl,
|
||||
Title: null, // Not available from VendorFixAvailability
|
||||
Type: linkType,
|
||||
PublishedAt: vendorFix.FixReleasedAt,
|
||||
Provenance: fixProvenance));
|
||||
}
|
||||
|
||||
// Sort by published date for deterministic ordering
|
||||
return builder
|
||||
.OrderBy(l => l.PublishedAt ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(l => l.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static FixAvailabilityStatus DetermineOverallStatus(
|
||||
ImmutableArray<VendorFixAvailability> vendorFixes)
|
||||
{
|
||||
if (vendorFixes.IsDefaultOrEmpty)
|
||||
{
|
||||
return FixAvailabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
// Check for available fixes first
|
||||
if (vendorFixes.Any(f => f.Status == FixStatus.Available))
|
||||
{
|
||||
return FixAvailabilityStatus.Available;
|
||||
}
|
||||
|
||||
// Check for in-progress fixes
|
||||
if (vendorFixes.Any(f => f.Status == FixStatus.InProgress))
|
||||
{
|
||||
return FixAvailabilityStatus.InProgress;
|
||||
}
|
||||
|
||||
// Check for will-not-fix
|
||||
if (vendorFixes.Any(f => f.Status == FixStatus.WillNotFix))
|
||||
{
|
||||
return FixAvailabilityStatus.WillNotFix;
|
||||
}
|
||||
|
||||
// Check for not-available
|
||||
if (vendorFixes.Any(f => f.Status == FixStatus.NotAvailable))
|
||||
{
|
||||
return FixAvailabilityStatus.NotAvailable;
|
||||
}
|
||||
|
||||
return FixAvailabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
private static FixReleaseType DetermineReleaseType(string version)
|
||||
{
|
||||
// Basic heuristics for release type
|
||||
var lowerVersion = version.ToLowerInvariant();
|
||||
|
||||
if (lowerVersion.Contains("hotfix") || lowerVersion.Contains("patch"))
|
||||
{
|
||||
return FixReleaseType.Hotfix;
|
||||
}
|
||||
|
||||
if (lowerVersion.Contains("backport"))
|
||||
{
|
||||
return FixReleaseType.Backport;
|
||||
}
|
||||
|
||||
// Could add SemVer analysis here for minor/major detection
|
||||
// For now, default to Patch as most security fixes are patches
|
||||
return FixReleaseType.Patch;
|
||||
}
|
||||
|
||||
private static FixAdvisoryLinkType DetermineLinkType(string url)
|
||||
{
|
||||
var lowerUrl = url.ToLowerInvariant();
|
||||
|
||||
// Distribution security notices
|
||||
if (lowerUrl.Contains("access.redhat.com/errata") ||
|
||||
lowerUrl.Contains("ubuntu.com/security") ||
|
||||
lowerUrl.Contains("debian.org/security") ||
|
||||
lowerUrl.Contains("suse.com/security"))
|
||||
{
|
||||
return FixAdvisoryLinkType.DistributionNotice;
|
||||
}
|
||||
|
||||
// Commit references
|
||||
if (lowerUrl.Contains("/commit/") ||
|
||||
lowerUrl.Contains("/commits/"))
|
||||
{
|
||||
return FixAdvisoryLinkType.Commit;
|
||||
}
|
||||
|
||||
// Patch URLs
|
||||
if (lowerUrl.EndsWith(".patch") ||
|
||||
lowerUrl.Contains("/patches/") ||
|
||||
lowerUrl.Contains("/diff/"))
|
||||
{
|
||||
return FixAdvisoryLinkType.PatchUrl;
|
||||
}
|
||||
|
||||
// Release notes
|
||||
if (lowerUrl.Contains("/releases/") ||
|
||||
lowerUrl.Contains("/release-notes") ||
|
||||
lowerUrl.Contains("/changelog"))
|
||||
{
|
||||
return FixAdvisoryLinkType.ReleaseNotes;
|
||||
}
|
||||
|
||||
// Vendor advisories (common patterns)
|
||||
if (lowerUrl.Contains("/security/") ||
|
||||
lowerUrl.Contains("/advisory/") ||
|
||||
lowerUrl.Contains("/cve-") ||
|
||||
lowerUrl.Contains("/vuln"))
|
||||
{
|
||||
return FixAdvisoryLinkType.VendorAdvisory;
|
||||
}
|
||||
|
||||
// GitHub Security Advisories
|
||||
if (lowerUrl.Contains("github.com") && lowerUrl.Contains("/advisories/"))
|
||||
{
|
||||
return FixAdvisoryLinkType.UpstreamAdvisory;
|
||||
}
|
||||
|
||||
return FixAdvisoryLinkType.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Structured fix-availability metadata per observation/linkset.
|
||||
/// Per CONCELIER-RISK-66-002, emits release version, advisory link, and evidence timestamp
|
||||
/// without guessing exploitability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This model is fact-only: surfaces vendor-published fix information with provenance.
|
||||
/// No inference, weighting, or exploitability assumptions.
|
||||
/// </remarks>
|
||||
public sealed record FixAvailabilityMetadata(
|
||||
string TenantId,
|
||||
string AdvisoryId,
|
||||
string ObservationId,
|
||||
string? LinksetId,
|
||||
FixAvailabilityStatus Status,
|
||||
ImmutableArray<FixRelease> Releases,
|
||||
ImmutableArray<FixAdvisoryLink> AdvisoryLinks,
|
||||
FixAvailabilityProvenance Provenance,
|
||||
DateTimeOffset EmittedAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an empty fix-availability metadata for observations without fix data.
|
||||
/// </summary>
|
||||
public static FixAvailabilityMetadata Empty(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
string observationId,
|
||||
string? linksetId,
|
||||
FixAvailabilityProvenance provenance,
|
||||
DateTimeOffset emittedAt)
|
||||
{
|
||||
return new FixAvailabilityMetadata(
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: advisoryId,
|
||||
ObservationId: observationId,
|
||||
LinksetId: linksetId,
|
||||
Status: FixAvailabilityStatus.Unknown,
|
||||
Releases: ImmutableArray<FixRelease>.Empty,
|
||||
AdvisoryLinks: ImmutableArray<FixAdvisoryLink>.Empty,
|
||||
Provenance: provenance,
|
||||
EmittedAt: emittedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if any fix release is available.
|
||||
/// </summary>
|
||||
public bool HasFixAvailable => Status == FixAvailabilityStatus.Available && !Releases.IsDefaultOrEmpty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest fix release if available.
|
||||
/// </summary>
|
||||
public FixRelease? EarliestRelease => Releases.IsDefaultOrEmpty
|
||||
? null
|
||||
: Releases.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue).FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provenance anchor for fix-availability metadata.
|
||||
/// </summary>
|
||||
public sealed record FixAvailabilityProvenance(
|
||||
string Vendor,
|
||||
string Source,
|
||||
string ObservationHash,
|
||||
DateTimeOffset FetchedAt,
|
||||
string? IngestJobId,
|
||||
string? UpstreamId);
|
||||
|
||||
/// <summary>
|
||||
/// A fix release with version, timestamp, and provenance.
|
||||
/// </summary>
|
||||
public sealed record FixRelease(
|
||||
string? FixedVersion,
|
||||
string? Package,
|
||||
string? Ecosystem,
|
||||
DateTimeOffset? ReleasedAt,
|
||||
FixReleaseType Type,
|
||||
FixAvailabilityProvenance Provenance)
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalizes the ecosystem name to a standard format.
|
||||
/// </summary>
|
||||
public string? NormalizedEcosystem => Ecosystem?.ToLowerInvariant() switch
|
||||
{
|
||||
"npm" or "node" or "nodejs" => "npm",
|
||||
"pypi" or "pip" or "python" => "pypi",
|
||||
"maven" or "java" => "maven",
|
||||
"nuget" or ".net" or "dotnet" => "nuget",
|
||||
"rubygems" or "gem" or "ruby" => "rubygems",
|
||||
"crates.io" or "cargo" or "rust" => "crates.io",
|
||||
"go" or "golang" => "go",
|
||||
"packagist" or "composer" or "php" => "packagist",
|
||||
var e => e
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of fix release.
|
||||
/// </summary>
|
||||
public enum FixReleaseType
|
||||
{
|
||||
/// <summary>Unknown release type.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Patch release addressing the vulnerability.</summary>
|
||||
Patch,
|
||||
|
||||
/// <summary>Minor version upgrade with fix.</summary>
|
||||
MinorUpgrade,
|
||||
|
||||
/// <summary>Major version upgrade with fix.</summary>
|
||||
MajorUpgrade,
|
||||
|
||||
/// <summary>Backported fix to older version line.</summary>
|
||||
Backport,
|
||||
|
||||
/// <summary>Vendor-specific hotfix.</summary>
|
||||
Hotfix
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory link providing fix guidance.
|
||||
/// </summary>
|
||||
public sealed record FixAdvisoryLink(
|
||||
string Url,
|
||||
string? Title,
|
||||
FixAdvisoryLinkType Type,
|
||||
DateTimeOffset? PublishedAt,
|
||||
FixAvailabilityProvenance Provenance);
|
||||
|
||||
/// <summary>
|
||||
/// Type of advisory link.
|
||||
/// </summary>
|
||||
public enum FixAdvisoryLinkType
|
||||
{
|
||||
/// <summary>Unknown link type.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Vendor security advisory.</summary>
|
||||
VendorAdvisory,
|
||||
|
||||
/// <summary>Upstream project advisory.</summary>
|
||||
UpstreamAdvisory,
|
||||
|
||||
/// <summary>Distribution security notice (e.g., RHSA, DSA).</summary>
|
||||
DistributionNotice,
|
||||
|
||||
/// <summary>Patch URL.</summary>
|
||||
PatchUrl,
|
||||
|
||||
/// <summary>Release notes.</summary>
|
||||
ReleaseNotes,
|
||||
|
||||
/// <summary>Commit reference.</summary>
|
||||
Commit,
|
||||
|
||||
/// <summary>Mitigation/workaround guidance.</summary>
|
||||
Mitigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall fix availability status.
|
||||
/// </summary>
|
||||
public enum FixAvailabilityStatus
|
||||
{
|
||||
/// <summary>Fix status unknown.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Fix is available.</summary>
|
||||
Available,
|
||||
|
||||
/// <summary>No fix available yet.</summary>
|
||||
NotAvailable,
|
||||
|
||||
/// <summary>Will not be fixed (end of life, wontfix, etc.).</summary>
|
||||
WillNotFix,
|
||||
|
||||
/// <summary>Fix is in progress.</summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>Deferred - fix planned for future release.</summary>
|
||||
Deferred,
|
||||
|
||||
/// <summary>Not affected - no fix needed.</summary>
|
||||
NotAffected
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Emitter interface for advisory field change notifications.
|
||||
/// Per CONCELIER-RISK-69-001, emits notifications on upstream advisory field changes
|
||||
/// (e.g., fix availability) with observation IDs and provenance; no severity inference.
|
||||
/// </summary>
|
||||
public interface IAdvisoryFieldChangeEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects and emits field change notifications for an observation.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="observationId">Observation identifier.</param>
|
||||
/// <param name="previousSignal">Previous risk signal state (null if new observation).</param>
|
||||
/// <param name="currentSignal">Current risk signal state.</param>
|
||||
/// <param name="linksetId">Optional linkset identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Emitted notification, or null if no changes detected.</returns>
|
||||
Task<AdvisoryFieldChangeNotification?> EmitChangesAsync(
|
||||
string tenantId,
|
||||
string observationId,
|
||||
VendorRiskSignal? previousSignal,
|
||||
VendorRiskSignal currentSignal,
|
||||
string? linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Detects and emits field change notifications for all observations in a linkset.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="linksetId">Linkset identifier.</param>
|
||||
/// <param name="previousSignals">Previous risk signal states.</param>
|
||||
/// <param name="currentSignals">Current risk signal states.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of emitted notifications.</returns>
|
||||
Task<IReadOnlyList<AdvisoryFieldChangeNotification>> EmitLinksetChangesAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
IReadOnlyList<VendorRiskSignal> previousSignals,
|
||||
IReadOnlyList<VendorRiskSignal> currentSignals,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publisher interface for advisory field change notifications.
|
||||
/// </summary>
|
||||
public interface IAdvisoryFieldChangeNotificationPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes a field change notification to the notification system.
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification to publish.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task PublishAsync(
|
||||
AdvisoryFieldChangeNotification notification,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Emitter interface for structured fix-availability metadata.
|
||||
/// Per CONCELIER-RISK-66-002, emits release version, advisory link, and evidence timestamp
|
||||
/// per observation/linkset without guessing exploitability.
|
||||
/// </summary>
|
||||
public interface IFixAvailabilityEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits fix-availability metadata for a specific observation.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="observationId">Observation identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Emitted fix-availability metadata.</returns>
|
||||
Task<FixAvailabilityMetadata?> EmitByObservationAsync(
|
||||
string tenantId,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Emits fix-availability metadata for all observations in a linkset.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="linksetId">Linkset identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of emitted fix-availability metadata from all linked observations.</returns>
|
||||
Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Emits fix-availability metadata for all observations of an advisory.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="advisoryId">Advisory identifier (e.g., CVE-2024-1234).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of emitted fix-availability metadata.</returns>
|
||||
Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated fix-availability view combining metadata from multiple observations.
|
||||
/// </summary>
|
||||
public sealed record AggregatedFixAvailabilityView(
|
||||
string TenantId,
|
||||
string AdvisoryId,
|
||||
string? LinksetId,
|
||||
IReadOnlyList<FixAvailabilityMetadata> ObservationMetadata)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the overall fix availability status across all observations.
|
||||
/// Returns the most favorable status (Available > InProgress > NotAvailable > Unknown).
|
||||
/// </summary>
|
||||
public FixAvailabilityStatus OverallStatus
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.Available))
|
||||
return FixAvailabilityStatus.Available;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.InProgress))
|
||||
return FixAvailabilityStatus.InProgress;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.Deferred))
|
||||
return FixAvailabilityStatus.Deferred;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.NotAffected))
|
||||
return FixAvailabilityStatus.NotAffected;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.NotAvailable))
|
||||
return FixAvailabilityStatus.NotAvailable;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.WillNotFix))
|
||||
return FixAvailabilityStatus.WillNotFix;
|
||||
|
||||
return FixAvailabilityStatus.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if any observation reports a fix available.
|
||||
/// </summary>
|
||||
public bool HasFixAvailable => OverallStatus == FixAvailabilityStatus.Available;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique fix releases across observations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FixRelease> AllReleases =>
|
||||
ObservationMetadata
|
||||
.SelectMany(m => m.Releases)
|
||||
.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(r => r.FixedVersion, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique advisory links across observations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FixAdvisoryLink> AllAdvisoryLinks =>
|
||||
ObservationMetadata
|
||||
.SelectMany(m => m.AdvisoryLinks)
|
||||
.DistinctBy(l => l.Url)
|
||||
.OrderBy(l => l.PublishedAt ?? DateTimeOffset.MaxValue)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets vendors that contributed fix information.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ContributingVendors =>
|
||||
ObservationMetadata
|
||||
.Select(m => m.Provenance.Vendor)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest available fix release across all observations.
|
||||
/// </summary>
|
||||
public FixRelease? EarliestRelease => AllReleases.FirstOrDefault();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Publisher interface for per-source coverage and conflict metrics.
|
||||
/// Per CONCELIER-RISK-67-001, publishes counts and disagreements so explainers
|
||||
/// cite which upstream statements exist; no weighting applied.
|
||||
/// </summary>
|
||||
public interface ISourceCoverageMetricsPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes and publishes coverage metrics for all observations of an advisory.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="advisoryId">Advisory identifier (e.g., CVE-2024-1234).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Computed coverage metrics.</returns>
|
||||
Task<SourceCoverageMetrics> PublishByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Computes and publishes coverage metrics for all observations in a linkset.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="linksetId">Linkset identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Computed coverage metrics.</returns>
|
||||
Task<SourceCoverageMetrics> PublishByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest published coverage metrics for an advisory.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="advisoryId">Advisory identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Latest coverage metrics, or null if not computed.</returns>
|
||||
Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest published coverage metrics for a linkset.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="linksetId">Linkset identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Latest coverage metrics, or null if not computed.</returns>
|
||||
Task<SourceCoverageMetrics?> GetByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for coverage metrics persistence.
|
||||
/// </summary>
|
||||
public interface ISourceCoverageMetricsStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores coverage metrics.
|
||||
/// </summary>
|
||||
Task StoreAsync(
|
||||
SourceCoverageMetrics metrics,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves coverage metrics by advisory.
|
||||
/// </summary>
|
||||
Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves coverage metrics by linkset.
|
||||
/// </summary>
|
||||
Task<SourceCoverageMetrics?> GetByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IAdvisoryFieldChangeNotificationPublisher"/> for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAdvisoryFieldChangeNotificationPublisher : IAdvisoryFieldChangeNotificationPublisher
|
||||
{
|
||||
private readonly ConcurrentQueue<AdvisoryFieldChangeNotification> _notifications = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync(AdvisoryFieldChangeNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_notifications.Enqueue(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all published notifications (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryFieldChangeNotification> GetAllNotifications()
|
||||
{
|
||||
return _notifications.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets notifications for a specific advisory (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByAdvisory(string advisoryId)
|
||||
{
|
||||
return _notifications
|
||||
.Where(n => string.Equals(n.AdvisoryId, advisoryId, System.StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets notifications for a specific tenant (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByTenant(string tenantId)
|
||||
{
|
||||
return _notifications
|
||||
.Where(n => string.Equals(n.TenantId, tenantId, System.StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets notifications by change type (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByChangeType(AdvisoryFieldChangeType changeType)
|
||||
{
|
||||
return _notifications
|
||||
.Where(n => n.ChangeType == changeType)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all notifications (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
while (_notifications.TryDequeue(out _))
|
||||
{
|
||||
// Clear the queue
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of notifications (for testing).
|
||||
/// </summary>
|
||||
public int Count => _notifications.Count;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISourceCoverageMetricsStore"/> for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemorySourceCoverageMetricsStore : ISourceCoverageMetricsStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SourceCoverageMetrics> _byAdvisory = new();
|
||||
private readonly ConcurrentDictionary<string, SourceCoverageMetrics> _byLinkset = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StoreAsync(SourceCoverageMetrics metrics, CancellationToken cancellationToken)
|
||||
{
|
||||
var advisoryKey = BuildAdvisoryKey(metrics.TenantId, metrics.AdvisoryId);
|
||||
_byAdvisory[advisoryKey] = metrics;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metrics.LinksetId))
|
||||
{
|
||||
var linksetKey = BuildLinksetKey(metrics.TenantId, metrics.LinksetId);
|
||||
_byLinkset[linksetKey] = metrics;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var key = BuildAdvisoryKey(tenantId, advisoryId);
|
||||
_byAdvisory.TryGetValue(key, out var metrics);
|
||||
return Task.FromResult(metrics);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceCoverageMetrics?> GetByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var key = BuildLinksetKey(tenantId, linksetId);
|
||||
_byLinkset.TryGetValue(key, out var metrics);
|
||||
return Task.FromResult(metrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all stored metrics (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_byAdvisory.Clear();
|
||||
_byLinkset.Clear();
|
||||
}
|
||||
|
||||
private static string BuildAdvisoryKey(string tenantId, string advisoryId)
|
||||
=> $"{tenantId}:{advisoryId}";
|
||||
|
||||
private static string BuildLinksetKey(string tenantId, string linksetId)
|
||||
=> $"{tenantId}:linkset:{linksetId}";
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for risk-related services.
|
||||
/// </summary>
|
||||
public static class RiskServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds risk signal and fix-availability services to the service collection.
|
||||
/// Per CONCELIER-RISK-66-002, CONCELIER-RISK-67-001, and CONCELIER-RISK-69-001.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddConcelierRiskServices(this IServiceCollection services)
|
||||
{
|
||||
// Register fix-availability emitter (CONCELIER-RISK-66-002)
|
||||
services.TryAddSingleton<IFixAvailabilityEmitter, FixAvailabilityEmitter>();
|
||||
|
||||
// Register coverage metrics services (CONCELIER-RISK-67-001)
|
||||
services.TryAddSingleton<ISourceCoverageMetricsStore, InMemorySourceCoverageMetricsStore>();
|
||||
services.TryAddSingleton<ISourceCoverageMetricsPublisher, SourceCoverageMetricsPublisher>();
|
||||
|
||||
// Register field change notification services (CONCELIER-RISK-69-001)
|
||||
services.TryAddSingleton<IAdvisoryFieldChangeNotificationPublisher, InMemoryAdvisoryFieldChangeNotificationPublisher>();
|
||||
services.TryAddSingleton<IAdvisoryFieldChangeEmitter, AdvisoryFieldChangeEmitter>();
|
||||
|
||||
// TimeProvider is typically registered elsewhere, but ensure it exists
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="IVendorRiskSignalProvider"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProvider">The provider implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddVendorRiskSignalProvider<TProvider>(this IServiceCollection services)
|
||||
where TProvider : class, IVendorRiskSignalProvider
|
||||
{
|
||||
services.TryAddSingleton<IVendorRiskSignalProvider, TProvider>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="IFixAvailabilityEmitter"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEmitter">The emitter implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFixAvailabilityEmitter<TEmitter>(this IServiceCollection services)
|
||||
where TEmitter : class, IFixAvailabilityEmitter
|
||||
{
|
||||
services.AddSingleton<IFixAvailabilityEmitter, TEmitter>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="ISourceCoverageMetricsStore"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TStore">The store implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSourceCoverageMetricsStore<TStore>(this IServiceCollection services)
|
||||
where TStore : class, ISourceCoverageMetricsStore
|
||||
{
|
||||
services.AddSingleton<ISourceCoverageMetricsStore, TStore>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="ISourceCoverageMetricsPublisher"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPublisher">The publisher implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSourceCoverageMetricsPublisher<TPublisher>(this IServiceCollection services)
|
||||
where TPublisher : class, ISourceCoverageMetricsPublisher
|
||||
{
|
||||
services.AddSingleton<ISourceCoverageMetricsPublisher, TPublisher>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="IAdvisoryFieldChangeNotificationPublisher"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPublisher">The publisher implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAdvisoryFieldChangeNotificationPublisher<TPublisher>(this IServiceCollection services)
|
||||
where TPublisher : class, IAdvisoryFieldChangeNotificationPublisher
|
||||
{
|
||||
services.AddSingleton<IAdvisoryFieldChangeNotificationPublisher, TPublisher>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="IAdvisoryFieldChangeEmitter"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEmitter">The emitter implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAdvisoryFieldChangeEmitter<TEmitter>(this IServiceCollection services)
|
||||
where TEmitter : class, IAdvisoryFieldChangeEmitter
|
||||
{
|
||||
services.AddSingleton<IAdvisoryFieldChangeEmitter, TEmitter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Per-source coverage and conflict metrics for advisory observations.
|
||||
/// Per CONCELIER-RISK-67-001, publishes counts and disagreements so explainers
|
||||
/// cite which upstream statements exist; no weighting applied.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This model is fact-only: no inference, weighting, or prioritization.
|
||||
/// All data traces back to specific vendor observations with provenance.
|
||||
/// </remarks>
|
||||
public sealed record SourceCoverageMetrics(
|
||||
string TenantId,
|
||||
string AdvisoryId,
|
||||
string? LinksetId,
|
||||
ImmutableArray<SourceContribution> Sources,
|
||||
SourceAgreementSummary Agreement,
|
||||
ImmutableArray<SourceConflict> Conflicts,
|
||||
DateTimeOffset ComputedAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of contributing sources.
|
||||
/// </summary>
|
||||
public int SourceCount => Sources.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of observations across all sources.
|
||||
/// </summary>
|
||||
public int TotalObservations => Sources.Sum(s => s.ObservationCount);
|
||||
|
||||
/// <summary>
|
||||
/// Total number of conflicts detected.
|
||||
/// </summary>
|
||||
public int ConflictCount => Conflicts.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if all sources agree (no conflicts).
|
||||
/// </summary>
|
||||
public bool AllSourcesAgree => Conflicts.IsDefaultOrEmpty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates empty coverage metrics when no sources are available.
|
||||
/// </summary>
|
||||
public static SourceCoverageMetrics Empty(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
string? linksetId,
|
||||
DateTimeOffset computedAt)
|
||||
{
|
||||
return new SourceCoverageMetrics(
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: advisoryId,
|
||||
LinksetId: linksetId,
|
||||
Sources: ImmutableArray<SourceContribution>.Empty,
|
||||
Agreement: SourceAgreementSummary.Empty,
|
||||
Conflicts: ImmutableArray<SourceConflict>.Empty,
|
||||
ComputedAt: computedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contribution from a single source/vendor.
|
||||
/// </summary>
|
||||
public sealed record SourceContribution(
|
||||
string SourceId,
|
||||
string Vendor,
|
||||
int ObservationCount,
|
||||
ImmutableArray<string> ObservationIds,
|
||||
SourceCoverageDetail Coverage,
|
||||
DateTimeOffset LatestObservationAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates if this source has full coverage (CVSS, fix info, affected data).
|
||||
/// </summary>
|
||||
public bool HasFullCoverage =>
|
||||
Coverage.HasCvssData &&
|
||||
Coverage.HasFixData &&
|
||||
Coverage.HasAffectedData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detail of what data a source provides.
|
||||
/// </summary>
|
||||
public sealed record SourceCoverageDetail(
|
||||
bool HasCvssData,
|
||||
bool HasKevData,
|
||||
bool HasFixData,
|
||||
bool HasAffectedData,
|
||||
bool HasReferenceData,
|
||||
ImmutableArray<string> CvssVersions,
|
||||
ImmutableArray<string> AffectedEcosystems);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of source agreement/disagreement.
|
||||
/// </summary>
|
||||
public sealed record SourceAgreementSummary(
|
||||
int TotalFields,
|
||||
int AgreeingFields,
|
||||
int DisagreeingFields,
|
||||
ImmutableArray<string> AgreedFieldNames,
|
||||
ImmutableArray<string> DisagreedFieldNames)
|
||||
{
|
||||
/// <summary>
|
||||
/// Agreement ratio (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public double AgreementRatio => TotalFields > 0
|
||||
? (double)AgreeingFields / TotalFields
|
||||
: 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates high agreement (>= 90%).
|
||||
/// </summary>
|
||||
public bool HighAgreement => AgreementRatio >= 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// Empty agreement summary when no fields to compare.
|
||||
/// </summary>
|
||||
public static SourceAgreementSummary Empty => new(
|
||||
TotalFields: 0,
|
||||
AgreeingFields: 0,
|
||||
DisagreeingFields: 0,
|
||||
AgreedFieldNames: ImmutableArray<string>.Empty,
|
||||
DisagreedFieldNames: ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A specific conflict between sources.
|
||||
/// </summary>
|
||||
public sealed record SourceConflict(
|
||||
string Field,
|
||||
ConflictType Type,
|
||||
ImmutableArray<SourceConflictValue> Values,
|
||||
string? Resolution)
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of sources involved in this conflict.
|
||||
/// </summary>
|
||||
public int SourceCount => Values.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of conflict between sources.
|
||||
/// </summary>
|
||||
public enum ConflictType
|
||||
{
|
||||
/// <summary>Sources report different values for the same field.</summary>
|
||||
ValueMismatch,
|
||||
|
||||
/// <summary>One source has data, another does not.</summary>
|
||||
MissingData,
|
||||
|
||||
/// <summary>Severity scores differ significantly.</summary>
|
||||
SeverityDivergence,
|
||||
|
||||
/// <summary>Fix availability status differs.</summary>
|
||||
FixStatusDivergence,
|
||||
|
||||
/// <summary>Affected version ranges conflict.</summary>
|
||||
AffectedRangeConflict,
|
||||
|
||||
/// <summary>KEV status differs between sources.</summary>
|
||||
KevStatusConflict
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A source's value in a conflict.
|
||||
/// </summary>
|
||||
public sealed record SourceConflictValue(
|
||||
string SourceId,
|
||||
string Vendor,
|
||||
string? Value,
|
||||
string? ObservationId,
|
||||
DateTimeOffset? ObservedAt);
|
||||
@@ -0,0 +1,414 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISourceCoverageMetricsPublisher"/>.
|
||||
/// Per CONCELIER-RISK-67-001, publishes per-source coverage/conflict metrics
|
||||
/// so explainers cite which upstream statements exist; no weighting applied.
|
||||
/// </summary>
|
||||
public sealed class SourceCoverageMetricsPublisher : ISourceCoverageMetricsPublisher
|
||||
{
|
||||
private readonly IVendorRiskSignalProvider _riskSignalProvider;
|
||||
private readonly ISourceCoverageMetricsStore _store;
|
||||
private readonly ILogger<SourceCoverageMetricsPublisher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SourceCoverageMetricsPublisher(
|
||||
IVendorRiskSignalProvider riskSignalProvider,
|
||||
ISourceCoverageMetricsStore store,
|
||||
ILogger<SourceCoverageMetricsPublisher> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_riskSignalProvider = riskSignalProvider ?? throw new ArgumentNullException(nameof(riskSignalProvider));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SourceCoverageMetrics> PublishByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
|
||||
|
||||
var signals = await _riskSignalProvider.GetByAdvisoryAsync(
|
||||
tenantId, advisoryId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metrics = ComputeMetrics(tenantId, advisoryId, linksetId: null, signals);
|
||||
|
||||
await _store.StoreAsync(metrics, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published coverage metrics for advisory {AdvisoryId}: {SourceCount} sources, {ConflictCount} conflicts",
|
||||
advisoryId, metrics.SourceCount, metrics.ConflictCount);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SourceCoverageMetrics> PublishByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
|
||||
|
||||
var signals = await _riskSignalProvider.GetByLinksetAsync(
|
||||
tenantId, linksetId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var advisoryId = signals.FirstOrDefault()?.AdvisoryId ?? "unknown";
|
||||
var metrics = ComputeMetrics(tenantId, advisoryId, linksetId, signals);
|
||||
|
||||
await _store.StoreAsync(metrics, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published coverage metrics for linkset {LinksetId}: {SourceCount} sources, {ConflictCount} conflicts",
|
||||
linksetId, metrics.SourceCount, metrics.ConflictCount);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _store.GetByAdvisoryAsync(tenantId, advisoryId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceCoverageMetrics?> GetByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _store.GetByLinksetAsync(tenantId, linksetId, cancellationToken);
|
||||
}
|
||||
|
||||
private SourceCoverageMetrics ComputeMetrics(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
string? linksetId,
|
||||
IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (signals.Count == 0)
|
||||
{
|
||||
return SourceCoverageMetrics.Empty(tenantId, advisoryId, linksetId, now);
|
||||
}
|
||||
|
||||
var sources = ComputeSourceContributions(signals);
|
||||
var agreement = ComputeAgreementSummary(signals);
|
||||
var conflicts = DetectConflicts(signals);
|
||||
|
||||
return new SourceCoverageMetrics(
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: advisoryId,
|
||||
LinksetId: linksetId,
|
||||
Sources: sources,
|
||||
Agreement: agreement,
|
||||
Conflicts: conflicts,
|
||||
ComputedAt: now);
|
||||
}
|
||||
|
||||
private static ImmutableArray<SourceContribution> ComputeSourceContributions(
|
||||
IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var bySource = signals
|
||||
.GroupBy(s => (s.Provenance.Source, s.Provenance.Vendor))
|
||||
.OrderBy(g => g.Key.Source, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(g => g.Key.Vendor, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var contributions = ImmutableArray.CreateBuilder<SourceContribution>();
|
||||
|
||||
foreach (var group in bySource)
|
||||
{
|
||||
var sourceSignals = group.ToList();
|
||||
var observationIds = sourceSignals
|
||||
.Select(s => s.ObservationId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var coverage = ComputeCoverageDetail(sourceSignals);
|
||||
var latestAt = sourceSignals.Max(s => s.ExtractedAt);
|
||||
|
||||
contributions.Add(new SourceContribution(
|
||||
SourceId: group.Key.Source,
|
||||
Vendor: group.Key.Vendor,
|
||||
ObservationCount: observationIds.Length,
|
||||
ObservationIds: observationIds,
|
||||
Coverage: coverage,
|
||||
LatestObservationAt: latestAt));
|
||||
}
|
||||
|
||||
return contributions.ToImmutable();
|
||||
}
|
||||
|
||||
private static SourceCoverageDetail ComputeCoverageDetail(List<VendorRiskSignal> signals)
|
||||
{
|
||||
var hasCvss = signals.Any(s => !s.CvssScores.IsDefaultOrEmpty);
|
||||
var hasKev = signals.Any(s => s.KevStatus is not null);
|
||||
var hasFix = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty);
|
||||
var hasAffected = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty &&
|
||||
s.FixAvailability.Any(f => !string.IsNullOrWhiteSpace(f.Package)));
|
||||
var hasReferences = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty &&
|
||||
s.FixAvailability.Any(f => !string.IsNullOrWhiteSpace(f.AdvisoryUrl)));
|
||||
|
||||
var cvssVersions = signals
|
||||
.SelectMany(s => s.CvssScores)
|
||||
.Select(c => c.NormalizedSystem)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var ecosystems = signals
|
||||
.SelectMany(s => s.FixAvailability)
|
||||
.Where(f => !string.IsNullOrWhiteSpace(f.Ecosystem))
|
||||
.Select(f => f.Ecosystem!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SourceCoverageDetail(
|
||||
HasCvssData: hasCvss,
|
||||
HasKevData: hasKev,
|
||||
HasFixData: hasFix,
|
||||
HasAffectedData: hasAffected,
|
||||
HasReferenceData: hasReferences,
|
||||
CvssVersions: cvssVersions,
|
||||
AffectedEcosystems: ecosystems);
|
||||
}
|
||||
|
||||
private static SourceAgreementSummary ComputeAgreementSummary(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
if (signals.Count <= 1)
|
||||
{
|
||||
return SourceAgreementSummary.Empty;
|
||||
}
|
||||
|
||||
var agreedFields = new List<string>();
|
||||
var disagreedFields = new List<string>();
|
||||
|
||||
// Check CVSS severity agreement
|
||||
var severities = signals
|
||||
.Where(s => s.HighestCvssScore is not null)
|
||||
.Select(s => s.HighestCvssScore!.EffectiveSeverity)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (severities.Count > 0)
|
||||
{
|
||||
if (severities.Count == 1)
|
||||
{
|
||||
agreedFields.Add("severity");
|
||||
}
|
||||
else
|
||||
{
|
||||
disagreedFields.Add("severity");
|
||||
}
|
||||
}
|
||||
|
||||
// Check KEV status agreement
|
||||
var kevStatuses = signals
|
||||
.Where(s => s.KevStatus is not null)
|
||||
.Select(s => s.KevStatus!.InKev)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (kevStatuses.Count > 0)
|
||||
{
|
||||
if (kevStatuses.Count == 1)
|
||||
{
|
||||
agreedFields.Add("kev_status");
|
||||
}
|
||||
else
|
||||
{
|
||||
disagreedFields.Add("kev_status");
|
||||
}
|
||||
}
|
||||
|
||||
// Check fix availability agreement
|
||||
var fixStatuses = signals
|
||||
.Select(s => s.HasFixAvailable)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (fixStatuses.Count == 1)
|
||||
{
|
||||
agreedFields.Add("fix_availability");
|
||||
}
|
||||
else if (fixStatuses.Count > 1)
|
||||
{
|
||||
disagreedFields.Add("fix_availability");
|
||||
}
|
||||
|
||||
var totalFields = agreedFields.Count + disagreedFields.Count;
|
||||
|
||||
return new SourceAgreementSummary(
|
||||
TotalFields: totalFields,
|
||||
AgreeingFields: agreedFields.Count,
|
||||
DisagreeingFields: disagreedFields.Count,
|
||||
AgreedFieldNames: agreedFields.ToImmutableArray(),
|
||||
DisagreedFieldNames: disagreedFields.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static ImmutableArray<SourceConflict> DetectConflicts(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
if (signals.Count <= 1)
|
||||
{
|
||||
return ImmutableArray<SourceConflict>.Empty;
|
||||
}
|
||||
|
||||
var conflicts = ImmutableArray.CreateBuilder<SourceConflict>();
|
||||
|
||||
// Detect severity divergence
|
||||
var severityConflict = DetectSeverityConflict(signals);
|
||||
if (severityConflict is not null)
|
||||
{
|
||||
conflicts.Add(severityConflict);
|
||||
}
|
||||
|
||||
// Detect KEV status conflict
|
||||
var kevConflict = DetectKevConflict(signals);
|
||||
if (kevConflict is not null)
|
||||
{
|
||||
conflicts.Add(kevConflict);
|
||||
}
|
||||
|
||||
// Detect fix status divergence
|
||||
var fixConflict = DetectFixStatusConflict(signals);
|
||||
if (fixConflict is not null)
|
||||
{
|
||||
conflicts.Add(fixConflict);
|
||||
}
|
||||
|
||||
return conflicts.ToImmutable();
|
||||
}
|
||||
|
||||
private static SourceConflict? DetectSeverityConflict(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var signalsWithSeverity = signals
|
||||
.Where(s => s.HighestCvssScore is not null)
|
||||
.ToList();
|
||||
|
||||
if (signalsWithSeverity.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var severities = signalsWithSeverity
|
||||
.Select(s => s.HighestCvssScore!.EffectiveSeverity)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (severities.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = signalsWithSeverity
|
||||
.Select(s => new SourceConflictValue(
|
||||
SourceId: s.Provenance.Source,
|
||||
Vendor: s.Provenance.Vendor,
|
||||
Value: s.HighestCvssScore!.EffectiveSeverity,
|
||||
ObservationId: s.ObservationId,
|
||||
ObservedAt: s.ExtractedAt))
|
||||
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SourceConflict(
|
||||
Field: "severity",
|
||||
Type: ConflictType.SeverityDivergence,
|
||||
Values: values,
|
||||
Resolution: null);
|
||||
}
|
||||
|
||||
private static SourceConflict? DetectKevConflict(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var signalsWithKev = signals
|
||||
.Where(s => s.KevStatus is not null)
|
||||
.ToList();
|
||||
|
||||
if (signalsWithKev.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var kevStatuses = signalsWithKev
|
||||
.Select(s => s.KevStatus!.InKev)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (kevStatuses.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = signalsWithKev
|
||||
.Select(s => new SourceConflictValue(
|
||||
SourceId: s.Provenance.Source,
|
||||
Vendor: s.Provenance.Vendor,
|
||||
Value: s.KevStatus!.InKev ? "in_kev" : "not_in_kev",
|
||||
ObservationId: s.ObservationId,
|
||||
ObservedAt: s.ExtractedAt))
|
||||
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SourceConflict(
|
||||
Field: "kev_status",
|
||||
Type: ConflictType.KevStatusConflict,
|
||||
Values: values,
|
||||
Resolution: null);
|
||||
}
|
||||
|
||||
private static SourceConflict? DetectFixStatusConflict(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var fixStatuses = signals
|
||||
.Select(s => (Signal: s, HasFix: s.HasFixAvailable))
|
||||
.ToList();
|
||||
|
||||
var distinctStatuses = fixStatuses
|
||||
.Select(x => x.HasFix)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (distinctStatuses.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = fixStatuses
|
||||
.Select(x => new SourceConflictValue(
|
||||
SourceId: x.Signal.Provenance.Source,
|
||||
Vendor: x.Signal.Provenance.Vendor,
|
||||
Value: x.HasFix ? "fix_available" : "no_fix",
|
||||
ObservationId: x.Signal.ObservationId,
|
||||
ObservedAt: x.Signal.ExtractedAt))
|
||||
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SourceConflict(
|
||||
Field: "fix_availability",
|
||||
Type: ConflictType.FixStatusDivergence,
|
||||
Values: values,
|
||||
Resolution: null);
|
||||
}
|
||||
}
|
||||
93
src/Policy/StellaOps.Policy.Scoring/AGENTS.md
Normal file
93
src/Policy/StellaOps.Policy.Scoring/AGENTS.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# AGENTS.md - StellaOps.Policy.Scoring
|
||||
|
||||
## Module Summary
|
||||
The CVSS v4.0 Scoring module provides deterministic score computation with full audit trail via receipts. It implements the FIRST CVSS v4.0 specification for vulnerability scoring with policy-driven customization and evidence linkage.
|
||||
|
||||
## Working Directory
|
||||
`src/Policy/StellaOps.Policy.Scoring`
|
||||
|
||||
## Required Reading
|
||||
Before implementing in this module, read:
|
||||
1. `docs/README.md`
|
||||
2. `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
3. `docs/modules/policy/architecture.md`
|
||||
4. FIRST CVSS v4.0 Specification: https://www.first.org/cvss/v4-0/specification-document
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
### This Module Owns
|
||||
- CVSS v4.0 data models (`CvssMetrics.cs`, `CvssScoreReceipt.cs`, `CvssPolicy.cs`)
|
||||
- CVSS v4.0 scoring engine (`Engine/CvssV4Engine.cs`)
|
||||
- Receipt generation and management (`Receipts/ReceiptBuilder.cs`)
|
||||
- Policy loading and validation (`Policies/CvssPolicyLoader.cs`)
|
||||
- JSON schemas for receipts and policies (`Schemas/`)
|
||||
|
||||
### This Module Does NOT Own
|
||||
- Attestation/DSSE envelope creation (use `StellaOps.Attestor.Envelope`)
|
||||
- Vulnerability advisory ingestion (use `StellaOps.Concelier.Core`)
|
||||
- VEX statement handling (use `StellaOps.Excititor.Core`)
|
||||
- General policy evaluation (use `StellaOps.Policy`)
|
||||
|
||||
## Determinism Requirements
|
||||
|
||||
### Input Reproducibility
|
||||
- All score computations must be deterministic: same inputs → same outputs
|
||||
- Receipt `inputHash` field captures SHA-256 of normalized inputs
|
||||
- Use stable JSON serialization with ordered keys for hashing
|
||||
|
||||
### Score Computation
|
||||
- Follow FIRST CVSS v4.0 math exactly (MacroVector lookup tables, EQ formulas)
|
||||
- Use "Round Up" rounding per FIRST spec: `ceil(score * 10) / 10`
|
||||
- Never introduce floating-point non-determinism
|
||||
|
||||
### Timestamp Handling
|
||||
- All timestamps must be UTC in ISO-8601 format
|
||||
- Use `DateTimeOffset.UtcNow` for creation times
|
||||
- Include timestamp in input hash for temporal reproducibility
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Test all CVSS v4.0 metric combinations using FIRST sample vectors
|
||||
- Test edge cases: missing optional metrics, all-None impacts, boundary scores
|
||||
- Test determinism: multiple computations of same input must match
|
||||
|
||||
### Integration Tests
|
||||
- Test policy loading and validation
|
||||
- Test receipt persistence and retrieval
|
||||
- Test amendment workflow with history tracking
|
||||
|
||||
## API Contract
|
||||
|
||||
### Score Computation
|
||||
```csharp
|
||||
// Engine interface
|
||||
public interface ICvssV4Engine
|
||||
{
|
||||
CvssScores ComputeScores(CvssBaseMetrics baseMetrics, CvssThreatMetrics? threatMetrics = null, CvssEnvironmentalMetrics? envMetrics = null);
|
||||
string BuildVectorString(CvssBaseMetrics baseMetrics, CvssThreatMetrics? threatMetrics = null, CvssEnvironmentalMetrics? envMetrics = null, CvssSupplementalMetrics? suppMetrics = null);
|
||||
(CvssBaseMetrics Base, CvssThreatMetrics? Threat, CvssEnvironmentalMetrics? Env, CvssSupplementalMetrics? Supp) ParseVector(string vectorString);
|
||||
}
|
||||
```
|
||||
|
||||
### Receipt Builder
|
||||
```csharp
|
||||
// Receipt builder interface
|
||||
public interface IReceiptBuilder
|
||||
{
|
||||
Task<CvssScoreReceipt> CreateReceiptAsync(CreateReceiptRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CvssScoreReceipt> AmendReceiptAsync(string receiptId, AmendReceiptRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Versioning
|
||||
- Policy schema: `cvss-policy-schema@1.json`
|
||||
- Receipt schema: `cvss-receipt-schema@1.json`
|
||||
- Version increment required for breaking changes
|
||||
- Maintain backward compatibility where possible
|
||||
|
||||
## Security Considerations
|
||||
- Validate all policy inputs against JSON schema
|
||||
- Sanitize vulnerability IDs to prevent injection
|
||||
- Sign receipts via DSSE when attestation is required
|
||||
- Tenant isolation: receipts are tenant-scoped
|
||||
354
src/Policy/StellaOps.Policy.Scoring/CvssMetrics.cs
Normal file
354
src/Policy/StellaOps.Policy.Scoring/CvssMetrics.cs
Normal file
@@ -0,0 +1,354 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 Base metric group - Exploitability and impact metrics.
|
||||
/// Per FIRST CVSS v4.0 Specification Document.
|
||||
/// </summary>
|
||||
public sealed record CvssBaseMetrics
|
||||
{
|
||||
/// <summary>Attack Vector (AV) - Mandatory.</summary>
|
||||
[JsonPropertyName("av")]
|
||||
public required AttackVector AttackVector { get; init; }
|
||||
|
||||
/// <summary>Attack Complexity (AC) - Mandatory.</summary>
|
||||
[JsonPropertyName("ac")]
|
||||
public required AttackComplexity AttackComplexity { get; init; }
|
||||
|
||||
/// <summary>Attack Requirements (AT) - Mandatory.</summary>
|
||||
[JsonPropertyName("at")]
|
||||
public required AttackRequirements AttackRequirements { get; init; }
|
||||
|
||||
/// <summary>Privileges Required (PR) - Mandatory.</summary>
|
||||
[JsonPropertyName("pr")]
|
||||
public required PrivilegesRequired PrivilegesRequired { get; init; }
|
||||
|
||||
/// <summary>User Interaction (UI) - Mandatory.</summary>
|
||||
[JsonPropertyName("ui")]
|
||||
public required UserInteraction UserInteraction { get; init; }
|
||||
|
||||
/// <summary>Vulnerable System Confidentiality (VC) - Mandatory.</summary>
|
||||
[JsonPropertyName("vc")]
|
||||
public required ImpactMetricValue VulnerableSystemConfidentiality { get; init; }
|
||||
|
||||
/// <summary>Vulnerable System Integrity (VI) - Mandatory.</summary>
|
||||
[JsonPropertyName("vi")]
|
||||
public required ImpactMetricValue VulnerableSystemIntegrity { get; init; }
|
||||
|
||||
/// <summary>Vulnerable System Availability (VA) - Mandatory.</summary>
|
||||
[JsonPropertyName("va")]
|
||||
public required ImpactMetricValue VulnerableSystemAvailability { get; init; }
|
||||
|
||||
/// <summary>Subsequent System Confidentiality (SC) - Mandatory.</summary>
|
||||
[JsonPropertyName("sc")]
|
||||
public required ImpactMetricValue SubsequentSystemConfidentiality { get; init; }
|
||||
|
||||
/// <summary>Subsequent System Integrity (SI) - Mandatory.</summary>
|
||||
[JsonPropertyName("si")]
|
||||
public required ImpactMetricValue SubsequentSystemIntegrity { get; init; }
|
||||
|
||||
/// <summary>Subsequent System Availability (SA) - Mandatory.</summary>
|
||||
[JsonPropertyName("sa")]
|
||||
public required ImpactMetricValue SubsequentSystemAvailability { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 Threat metric group.
|
||||
/// </summary>
|
||||
public sealed record CvssThreatMetrics
|
||||
{
|
||||
/// <summary>Exploit Maturity (E) - Optional, defaults to Not Defined.</summary>
|
||||
[JsonPropertyName("e")]
|
||||
public ExploitMaturity ExploitMaturity { get; init; } = ExploitMaturity.NotDefined;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 Environmental metric group - Modified base metrics for specific environments.
|
||||
/// </summary>
|
||||
public sealed record CvssEnvironmentalMetrics
|
||||
{
|
||||
/// <summary>Modified Attack Vector (MAV).</summary>
|
||||
[JsonPropertyName("mav")]
|
||||
public ModifiedAttackVector? ModifiedAttackVector { get; init; }
|
||||
|
||||
/// <summary>Modified Attack Complexity (MAC).</summary>
|
||||
[JsonPropertyName("mac")]
|
||||
public ModifiedAttackComplexity? ModifiedAttackComplexity { get; init; }
|
||||
|
||||
/// <summary>Modified Attack Requirements (MAT).</summary>
|
||||
[JsonPropertyName("mat")]
|
||||
public ModifiedAttackRequirements? ModifiedAttackRequirements { get; init; }
|
||||
|
||||
/// <summary>Modified Privileges Required (MPR).</summary>
|
||||
[JsonPropertyName("mpr")]
|
||||
public ModifiedPrivilegesRequired? ModifiedPrivilegesRequired { get; init; }
|
||||
|
||||
/// <summary>Modified User Interaction (MUI).</summary>
|
||||
[JsonPropertyName("mui")]
|
||||
public ModifiedUserInteraction? ModifiedUserInteraction { get; init; }
|
||||
|
||||
/// <summary>Modified Vulnerable System Confidentiality (MVC).</summary>
|
||||
[JsonPropertyName("mvc")]
|
||||
public ModifiedImpactMetricValue? ModifiedVulnerableSystemConfidentiality { get; init; }
|
||||
|
||||
/// <summary>Modified Vulnerable System Integrity (MVI).</summary>
|
||||
[JsonPropertyName("mvi")]
|
||||
public ModifiedImpactMetricValue? ModifiedVulnerableSystemIntegrity { get; init; }
|
||||
|
||||
/// <summary>Modified Vulnerable System Availability (MVA).</summary>
|
||||
[JsonPropertyName("mva")]
|
||||
public ModifiedImpactMetricValue? ModifiedVulnerableSystemAvailability { get; init; }
|
||||
|
||||
/// <summary>Modified Subsequent System Confidentiality (MSC).</summary>
|
||||
[JsonPropertyName("msc")]
|
||||
public ModifiedImpactMetricValue? ModifiedSubsequentSystemConfidentiality { get; init; }
|
||||
|
||||
/// <summary>Modified Subsequent System Integrity (MSI).</summary>
|
||||
[JsonPropertyName("msi")]
|
||||
public ModifiedSubsequentImpact? ModifiedSubsequentSystemIntegrity { get; init; }
|
||||
|
||||
/// <summary>Modified Subsequent System Availability (MSA).</summary>
|
||||
[JsonPropertyName("msa")]
|
||||
public ModifiedSubsequentImpact? ModifiedSubsequentSystemAvailability { get; init; }
|
||||
|
||||
/// <summary>Confidentiality Requirement (CR).</summary>
|
||||
[JsonPropertyName("cr")]
|
||||
public SecurityRequirement? ConfidentialityRequirement { get; init; }
|
||||
|
||||
/// <summary>Integrity Requirement (IR).</summary>
|
||||
[JsonPropertyName("ir")]
|
||||
public SecurityRequirement? IntegrityRequirement { get; init; }
|
||||
|
||||
/// <summary>Availability Requirement (AR).</summary>
|
||||
[JsonPropertyName("ar")]
|
||||
public SecurityRequirement? AvailabilityRequirement { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 Supplemental metric group - Additional context metrics that do not affect scoring.
|
||||
/// </summary>
|
||||
public sealed record CvssSupplementalMetrics
|
||||
{
|
||||
/// <summary>Safety (S) - Does the vulnerability affect human safety?</summary>
|
||||
[JsonPropertyName("s")]
|
||||
public Safety? Safety { get; init; }
|
||||
|
||||
/// <summary>Automatable (AU) - Can the vulnerability be exploited automatically?</summary>
|
||||
[JsonPropertyName("au")]
|
||||
public Automatable? Automatable { get; init; }
|
||||
|
||||
/// <summary>Recovery (R) - What is the recovery capability?</summary>
|
||||
[JsonPropertyName("r")]
|
||||
public Recovery? Recovery { get; init; }
|
||||
|
||||
/// <summary>Value Density (V) - Resource density of the vulnerable system.</summary>
|
||||
[JsonPropertyName("v")]
|
||||
public ValueDensity? ValueDensity { get; init; }
|
||||
|
||||
/// <summary>Vulnerability Response Effort (RE) - Effort required to respond.</summary>
|
||||
[JsonPropertyName("re")]
|
||||
public ResponseEffort? VulnerabilityResponseEffort { get; init; }
|
||||
|
||||
/// <summary>Provider Urgency (U) - Urgency as assessed by the provider.</summary>
|
||||
[JsonPropertyName("u")]
|
||||
public ProviderUrgency? ProviderUrgency { get; init; }
|
||||
}
|
||||
|
||||
#region Base Metric Enums
|
||||
|
||||
/// <summary>Attack Vector values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttackVector
|
||||
{
|
||||
/// <summary>Network (N) - Remotely exploitable.</summary>
|
||||
Network,
|
||||
/// <summary>Adjacent (A) - Same network segment.</summary>
|
||||
Adjacent,
|
||||
/// <summary>Local (L) - Local access required.</summary>
|
||||
Local,
|
||||
/// <summary>Physical (P) - Physical access required.</summary>
|
||||
Physical
|
||||
}
|
||||
|
||||
/// <summary>Attack Complexity values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttackComplexity
|
||||
{
|
||||
/// <summary>Low (L) - No specialized conditions.</summary>
|
||||
Low,
|
||||
/// <summary>High (H) - Specialized conditions required.</summary>
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>Attack Requirements values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttackRequirements
|
||||
{
|
||||
/// <summary>None (N) - No preconditions required.</summary>
|
||||
None,
|
||||
/// <summary>Present (P) - Preconditions must exist.</summary>
|
||||
Present
|
||||
}
|
||||
|
||||
/// <summary>Privileges Required values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum PrivilegesRequired
|
||||
{
|
||||
/// <summary>None (N) - No privileges needed.</summary>
|
||||
None,
|
||||
/// <summary>Low (L) - Basic user privileges needed.</summary>
|
||||
Low,
|
||||
/// <summary>High (H) - Admin/elevated privileges needed.</summary>
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>User Interaction values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum UserInteraction
|
||||
{
|
||||
/// <summary>None (N) - No user interaction required.</summary>
|
||||
None,
|
||||
/// <summary>Passive (P) - Involuntary user action.</summary>
|
||||
Passive,
|
||||
/// <summary>Active (A) - Conscious user action required.</summary>
|
||||
Active
|
||||
}
|
||||
|
||||
/// <summary>Impact metric values (None/Low/High) per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ImpactMetricValue
|
||||
{
|
||||
/// <summary>None (N) - No impact.</summary>
|
||||
None,
|
||||
/// <summary>Low (L) - Limited impact.</summary>
|
||||
Low,
|
||||
/// <summary>High (H) - Serious impact.</summary>
|
||||
High
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threat Metric Enums
|
||||
|
||||
/// <summary>Exploit Maturity values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ExploitMaturity
|
||||
{
|
||||
/// <summary>Not Defined (X) - Not assessed.</summary>
|
||||
NotDefined,
|
||||
/// <summary>Attacked (A) - Active exploitation observed.</summary>
|
||||
Attacked,
|
||||
/// <summary>Proof of Concept (P) - PoC code exists.</summary>
|
||||
ProofOfConcept,
|
||||
/// <summary>Unreported (U) - No public exploit code.</summary>
|
||||
Unreported
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environmental Metric Enums (Modified versions)
|
||||
|
||||
/// <summary>Modified Attack Vector values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedAttackVector
|
||||
{
|
||||
NotDefined, Network, Adjacent, Local, Physical
|
||||
}
|
||||
|
||||
/// <summary>Modified Attack Complexity values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedAttackComplexity
|
||||
{
|
||||
NotDefined, Low, High
|
||||
}
|
||||
|
||||
/// <summary>Modified Attack Requirements values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedAttackRequirements
|
||||
{
|
||||
NotDefined, None, Present
|
||||
}
|
||||
|
||||
/// <summary>Modified Privileges Required values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedPrivilegesRequired
|
||||
{
|
||||
NotDefined, None, Low, High
|
||||
}
|
||||
|
||||
/// <summary>Modified User Interaction values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedUserInteraction
|
||||
{
|
||||
NotDefined, None, Passive, Active
|
||||
}
|
||||
|
||||
/// <summary>Modified Impact metric values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedImpactMetricValue
|
||||
{
|
||||
NotDefined, None, Low, High
|
||||
}
|
||||
|
||||
/// <summary>Modified Subsequent System Impact values (includes Safety dimension).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedSubsequentImpact
|
||||
{
|
||||
NotDefined, Negligible, Low, High, Safety
|
||||
}
|
||||
|
||||
/// <summary>Security Requirement values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SecurityRequirement
|
||||
{
|
||||
NotDefined, Low, Medium, High
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Supplemental Metric Enums
|
||||
|
||||
/// <summary>Safety values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum Safety
|
||||
{
|
||||
NotDefined, Negligible, Present
|
||||
}
|
||||
|
||||
/// <summary>Automatable values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum Automatable
|
||||
{
|
||||
NotDefined, No, Yes
|
||||
}
|
||||
|
||||
/// <summary>Recovery values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum Recovery
|
||||
{
|
||||
NotDefined, Automatic, User, Irrecoverable
|
||||
}
|
||||
|
||||
/// <summary>Value Density values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ValueDensity
|
||||
{
|
||||
NotDefined, Diffuse, Concentrated
|
||||
}
|
||||
|
||||
/// <summary>Response Effort values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ResponseEffort
|
||||
{
|
||||
NotDefined, Low, Moderate, High
|
||||
}
|
||||
|
||||
/// <summary>Provider Urgency values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ProviderUrgency
|
||||
{
|
||||
NotDefined, Clear, Green, Amber, Red
|
||||
}
|
||||
|
||||
#endregion
|
||||
223
src/Policy/StellaOps.Policy.Scoring/CvssPolicy.cs
Normal file
223
src/Policy/StellaOps.Policy.Scoring/CvssPolicy.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS scoring policy configuration.
|
||||
/// Defines how CVSS scores are computed and what thresholds apply.
|
||||
/// </summary>
|
||||
public sealed record CvssPolicy
|
||||
{
|
||||
/// <summary>Unique policy identifier.</summary>
|
||||
[JsonPropertyName("policyId")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>Policy version (semantic versioning).</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Human-readable policy name.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Policy description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Tenant scope (null for global policy).</summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>When this policy becomes effective.</summary>
|
||||
[JsonPropertyName("effectiveFrom")]
|
||||
public required DateTimeOffset EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>When this policy expires (null for no expiry).</summary>
|
||||
[JsonPropertyName("effectiveUntil")]
|
||||
public DateTimeOffset? EffectiveUntil { get; init; }
|
||||
|
||||
/// <summary>Whether this policy is currently active.</summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>Which score type to use as the effective score by default.</summary>
|
||||
[JsonPropertyName("defaultEffectiveScoreType")]
|
||||
public EffectiveScoreType DefaultEffectiveScoreType { get; init; } = EffectiveScoreType.Full;
|
||||
|
||||
/// <summary>Default environmental metrics to apply when not provided.</summary>
|
||||
[JsonPropertyName("defaultEnvironmentalMetrics")]
|
||||
public CvssEnvironmentalMetrics? DefaultEnvironmentalMetrics { get; init; }
|
||||
|
||||
/// <summary>Severity thresholds (override FIRST defaults if specified).</summary>
|
||||
[JsonPropertyName("severityThresholds")]
|
||||
public CvssSeverityThresholds? SeverityThresholds { get; init; }
|
||||
|
||||
/// <summary>Score rounding configuration.</summary>
|
||||
[JsonPropertyName("rounding")]
|
||||
public CvssRoundingConfig Rounding { get; init; } = new();
|
||||
|
||||
/// <summary>Evidence requirements for receipts.</summary>
|
||||
[JsonPropertyName("evidenceRequirements")]
|
||||
public CvssEvidenceRequirements? EvidenceRequirements { get; init; }
|
||||
|
||||
/// <summary>Attestation requirements.</summary>
|
||||
[JsonPropertyName("attestationRequirements")]
|
||||
public CvssAttestationRequirements? AttestationRequirements { get; init; }
|
||||
|
||||
/// <summary>Metric overrides for specific vulnerability patterns.</summary>
|
||||
[JsonPropertyName("metricOverrides")]
|
||||
public ImmutableList<CvssMetricOverride> MetricOverrides { get; init; } = [];
|
||||
|
||||
/// <summary>SHA-256 hash of this policy for determinism tracking.</summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public string? Hash { get; init; }
|
||||
|
||||
/// <summary>When this policy was created.</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Who created this policy.</summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity threshold configuration.
|
||||
/// </summary>
|
||||
public sealed record CvssSeverityThresholds
|
||||
{
|
||||
/// <summary>Low severity lower bound (default: 0.1).</summary>
|
||||
[JsonPropertyName("lowMin")]
|
||||
public double LowMin { get; init; } = 0.1;
|
||||
|
||||
/// <summary>Medium severity lower bound (default: 4.0).</summary>
|
||||
[JsonPropertyName("mediumMin")]
|
||||
public double MediumMin { get; init; } = 4.0;
|
||||
|
||||
/// <summary>High severity lower bound (default: 7.0).</summary>
|
||||
[JsonPropertyName("highMin")]
|
||||
public double HighMin { get; init; } = 7.0;
|
||||
|
||||
/// <summary>Critical severity lower bound (default: 9.0).</summary>
|
||||
[JsonPropertyName("criticalMin")]
|
||||
public double CriticalMin { get; init; } = 9.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score rounding configuration.
|
||||
/// </summary>
|
||||
public sealed record CvssRoundingConfig
|
||||
{
|
||||
/// <summary>Number of decimal places for scores (default: 1).</summary>
|
||||
[JsonPropertyName("decimalPlaces")]
|
||||
public int DecimalPlaces { get; init; } = 1;
|
||||
|
||||
/// <summary>Rounding mode (default: roundUp per FIRST spec).</summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public CvssRoundingMode Mode { get; init; } = CvssRoundingMode.RoundUp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rounding modes for CVSS scores.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CvssRoundingMode
|
||||
{
|
||||
/// <summary>Round up to nearest tenth (FIRST spec default).</summary>
|
||||
RoundUp,
|
||||
/// <summary>Standard mathematical rounding.</summary>
|
||||
Standard,
|
||||
/// <summary>Always round down.</summary>
|
||||
RoundDown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence requirements configuration.
|
||||
/// </summary>
|
||||
public sealed record CvssEvidenceRequirements
|
||||
{
|
||||
/// <summary>Minimum number of evidence items required.</summary>
|
||||
[JsonPropertyName("minimumCount")]
|
||||
public int MinimumCount { get; init; }
|
||||
|
||||
/// <summary>Whether authoritative evidence is required.</summary>
|
||||
[JsonPropertyName("requireAuthoritative")]
|
||||
public bool RequireAuthoritative { get; init; }
|
||||
|
||||
/// <summary>Required evidence types.</summary>
|
||||
[JsonPropertyName("requiredTypes")]
|
||||
public ImmutableList<string> RequiredTypes { get; init; } = [];
|
||||
|
||||
/// <summary>Maximum age of evidence in days.</summary>
|
||||
[JsonPropertyName("maxAgeInDays")]
|
||||
public int? MaxAgeInDays { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation requirements configuration.
|
||||
/// </summary>
|
||||
public sealed record CvssAttestationRequirements
|
||||
{
|
||||
/// <summary>Whether DSSE attestation is required.</summary>
|
||||
[JsonPropertyName("requireDsse")]
|
||||
public bool RequireDsse { get; init; }
|
||||
|
||||
/// <summary>Whether Rekor transparency log registration is required.</summary>
|
||||
[JsonPropertyName("requireRekor")]
|
||||
public bool RequireRekor { get; init; }
|
||||
|
||||
/// <summary>Acceptable signing key identities.</summary>
|
||||
[JsonPropertyName("allowedSigners")]
|
||||
public ImmutableList<string> AllowedSigners { get; init; } = [];
|
||||
|
||||
/// <summary>Minimum trust level for attestations.</summary>
|
||||
[JsonPropertyName("minimumTrustLevel")]
|
||||
public string? MinimumTrustLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metric override for specific vulnerability patterns.
|
||||
/// </summary>
|
||||
public sealed record CvssMetricOverride
|
||||
{
|
||||
/// <summary>Override identifier.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>Human-readable description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Pattern to match vulnerability IDs (regex).</summary>
|
||||
[JsonPropertyName("vulnerabilityPattern")]
|
||||
public string? VulnerabilityPattern { get; init; }
|
||||
|
||||
/// <summary>Specific vulnerability IDs to match.</summary>
|
||||
[JsonPropertyName("vulnerabilityIds")]
|
||||
public ImmutableList<string> VulnerabilityIds { get; init; } = [];
|
||||
|
||||
/// <summary>CWE IDs to match.</summary>
|
||||
[JsonPropertyName("cweIds")]
|
||||
public ImmutableList<string> CweIds { get; init; } = [];
|
||||
|
||||
/// <summary>Environmental metric overrides to apply.</summary>
|
||||
[JsonPropertyName("environmentalOverrides")]
|
||||
public CvssEnvironmentalMetrics? EnvironmentalOverrides { get; init; }
|
||||
|
||||
/// <summary>Score adjustment (added to final score).</summary>
|
||||
[JsonPropertyName("scoreAdjustment")]
|
||||
public double? ScoreAdjustment { get; init; }
|
||||
|
||||
/// <summary>Priority for conflict resolution (higher wins).</summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>Whether this override is active.</summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>Reason for this override.</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
297
src/Policy/StellaOps.Policy.Scoring/CvssScoreReceipt.cs
Normal file
297
src/Policy/StellaOps.Policy.Scoring/CvssScoreReceipt.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// A CVSS v4.0 Score Receipt with complete audit trail.
|
||||
/// Provides deterministic, reproducible scoring with full provenance.
|
||||
/// </summary>
|
||||
public sealed record CvssScoreReceipt
|
||||
{
|
||||
/// <summary>Unique receipt identifier.</summary>
|
||||
[JsonPropertyName("receiptId")]
|
||||
public required string ReceiptId { get; init; }
|
||||
|
||||
/// <summary>Schema version for this receipt format.</summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>Receipt format specification.</summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "stella.ops/cvssReceipt@v1";
|
||||
|
||||
/// <summary>Vulnerability identifier (CVE, GHSA, etc.).</summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Tenant scope for multi-tenant deployments.</summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the receipt was created (UTC ISO-8601).</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>User or system that created the receipt.</summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the receipt was last modified.</summary>
|
||||
[JsonPropertyName("modifiedAt")]
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
|
||||
/// <summary>User or system that last modified the receipt.</summary>
|
||||
[JsonPropertyName("modifiedBy")]
|
||||
public string? ModifiedBy { get; init; }
|
||||
|
||||
/// <summary>CVSS version used (4.0).</summary>
|
||||
[JsonPropertyName("cvssVersion")]
|
||||
public string CvssVersion { get; init; } = "4.0";
|
||||
|
||||
/// <summary>Base metrics input.</summary>
|
||||
[JsonPropertyName("baseMetrics")]
|
||||
public required CvssBaseMetrics BaseMetrics { get; init; }
|
||||
|
||||
/// <summary>Threat metrics input (optional).</summary>
|
||||
[JsonPropertyName("threatMetrics")]
|
||||
public CvssThreatMetrics? ThreatMetrics { get; init; }
|
||||
|
||||
/// <summary>Environmental metrics input (optional).</summary>
|
||||
[JsonPropertyName("environmentalMetrics")]
|
||||
public CvssEnvironmentalMetrics? EnvironmentalMetrics { get; init; }
|
||||
|
||||
/// <summary>Supplemental metrics (optional, do not affect score).</summary>
|
||||
[JsonPropertyName("supplementalMetrics")]
|
||||
public CvssSupplementalMetrics? SupplementalMetrics { get; init; }
|
||||
|
||||
/// <summary>Computed scores.</summary>
|
||||
[JsonPropertyName("scores")]
|
||||
public required CvssScores Scores { get; init; }
|
||||
|
||||
/// <summary>Computed CVSS v4.0 vector string.</summary>
|
||||
[JsonPropertyName("vectorString")]
|
||||
public required string VectorString { get; init; }
|
||||
|
||||
/// <summary>Severity rating based on final score.</summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public required CvssSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>Policy that was applied to compute this receipt.</summary>
|
||||
[JsonPropertyName("policyRef")]
|
||||
public required CvssPolicyReference PolicyRef { get; init; }
|
||||
|
||||
/// <summary>Evidence items supporting this score.</summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public ImmutableList<CvssEvidenceItem> Evidence { get; init; } = [];
|
||||
|
||||
/// <summary>DSSE attestation envelope references, if signed.</summary>
|
||||
[JsonPropertyName("attestationRefs")]
|
||||
public ImmutableList<string> AttestationRefs { get; init; } = [];
|
||||
|
||||
/// <summary>SHA-256 hash of deterministic input for reproducibility.</summary>
|
||||
[JsonPropertyName("inputHash")]
|
||||
public required string InputHash { get; init; }
|
||||
|
||||
/// <summary>Amendment history entries.</summary>
|
||||
[JsonPropertyName("history")]
|
||||
public ImmutableList<ReceiptHistoryEntry> History { get; init; } = [];
|
||||
|
||||
/// <summary>Original receipt ID if this is an amendment.</summary>
|
||||
[JsonPropertyName("amendsReceiptId")]
|
||||
public string? AmendsReceiptId { get; init; }
|
||||
|
||||
/// <summary>Whether this receipt is the current active version.</summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>Reason this receipt was superseded (if not active).</summary>
|
||||
[JsonPropertyName("supersededReason")]
|
||||
public string? SupersededReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computed CVSS v4.0 scores.
|
||||
/// </summary>
|
||||
public sealed record CvssScores
|
||||
{
|
||||
/// <summary>Base Score (CVSS-B) - Impact of the vulnerability.</summary>
|
||||
[JsonPropertyName("baseScore")]
|
||||
public required double BaseScore { get; init; }
|
||||
|
||||
/// <summary>Threat-Adjusted Score (CVSS-BT) - Base + threat metrics.</summary>
|
||||
[JsonPropertyName("threatScore")]
|
||||
public double? ThreatScore { get; init; }
|
||||
|
||||
/// <summary>Environmental Score (CVSS-BE) - Base + environmental metrics.</summary>
|
||||
[JsonPropertyName("environmentalScore")]
|
||||
public double? EnvironmentalScore { get; init; }
|
||||
|
||||
/// <summary>Full Score (CVSS-BTE) - Base + threat + environmental.</summary>
|
||||
[JsonPropertyName("fullScore")]
|
||||
public double? FullScore { get; init; }
|
||||
|
||||
/// <summary>Which score is considered the "effective" score for policy decisions.</summary>
|
||||
[JsonPropertyName("effectiveScore")]
|
||||
public required double EffectiveScore { get; init; }
|
||||
|
||||
/// <summary>Which score type was used as effective.</summary>
|
||||
[JsonPropertyName("effectiveScoreType")]
|
||||
public required EffectiveScoreType EffectiveScoreType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates which score type was used as the effective score.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EffectiveScoreType
|
||||
{
|
||||
/// <summary>Base score only (CVSS-B).</summary>
|
||||
Base,
|
||||
/// <summary>Base + Threat (CVSS-BT).</summary>
|
||||
Threat,
|
||||
/// <summary>Base + Environmental (CVSS-BE).</summary>
|
||||
Environmental,
|
||||
/// <summary>Full score with all metrics (CVSS-BTE).</summary>
|
||||
Full
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 severity ratings.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CvssSeverity
|
||||
{
|
||||
/// <summary>None - Score 0.0.</summary>
|
||||
None,
|
||||
/// <summary>Low - Score 0.1-3.9.</summary>
|
||||
Low,
|
||||
/// <summary>Medium - Score 4.0-6.9.</summary>
|
||||
Medium,
|
||||
/// <summary>High - Score 7.0-8.9.</summary>
|
||||
High,
|
||||
/// <summary>Critical - Score 9.0-10.0.</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy used for scoring.
|
||||
/// </summary>
|
||||
public sealed record CvssPolicyReference
|
||||
{
|
||||
/// <summary>Policy identifier.</summary>
|
||||
[JsonPropertyName("policyId")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>Policy version.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the policy content.</summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>When the policy was activated.</summary>
|
||||
[JsonPropertyName("activatedAt")]
|
||||
public DateTimeOffset? ActivatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence item supporting a CVSS score.
|
||||
/// </summary>
|
||||
public sealed record CvssEvidenceItem
|
||||
{
|
||||
/// <summary>Evidence type (advisory, vex, scan, etc.).</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Content-addressable storage URI (e.g., sha256:...).</summary>
|
||||
[JsonPropertyName("uri")]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>Human-readable description of the evidence.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>When the evidence was collected.</summary>
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset? CollectedAt { get; init; }
|
||||
|
||||
/// <summary>Source of the evidence (vendor, scanner, manual).</summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>Whether this evidence is from the vendor/authority.</summary>
|
||||
[JsonPropertyName("isAuthoritative")]
|
||||
public bool IsAuthoritative { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// History entry for receipt amendments.
|
||||
/// </summary>
|
||||
public sealed record ReceiptHistoryEntry
|
||||
{
|
||||
/// <summary>Unique history entry identifier.</summary>
|
||||
[JsonPropertyName("historyId")]
|
||||
public required string HistoryId { get; init; }
|
||||
|
||||
/// <summary>When the amendment was made.</summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>User or system that made the amendment.</summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>Type of change (amend, supersede, revoke).</summary>
|
||||
[JsonPropertyName("changeType")]
|
||||
public required ReceiptChangeType ChangeType { get; init; }
|
||||
|
||||
/// <summary>Field that was changed.</summary>
|
||||
[JsonPropertyName("field")]
|
||||
public required string Field { get; init; }
|
||||
|
||||
/// <summary>Previous value (JSON encoded).</summary>
|
||||
[JsonPropertyName("previousValue")]
|
||||
public string? PreviousValue { get; init; }
|
||||
|
||||
/// <summary>New value (JSON encoded).</summary>
|
||||
[JsonPropertyName("newValue")]
|
||||
public string? NewValue { get; init; }
|
||||
|
||||
/// <summary>Reason for the change.</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Reference URI for supporting documentation.</summary>
|
||||
[JsonPropertyName("referenceUri")]
|
||||
public string? ReferenceUri { get; init; }
|
||||
|
||||
/// <summary>Signature of this history entry for integrity.</summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of changes to a receipt.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ReceiptChangeType
|
||||
{
|
||||
/// <summary>Initial creation.</summary>
|
||||
Created,
|
||||
/// <summary>Field value amended.</summary>
|
||||
Amended,
|
||||
/// <summary>Receipt superseded by newer version.</summary>
|
||||
Superseded,
|
||||
/// <summary>Receipt revoked/invalidated.</summary>
|
||||
Revoked,
|
||||
/// <summary>Evidence added.</summary>
|
||||
EvidenceAdded,
|
||||
/// <summary>Attestation signed.</summary>
|
||||
AttestationSigned,
|
||||
/// <summary>Policy updated.</summary>
|
||||
PolicyUpdated,
|
||||
/// <summary>Score recalculated.</summary>
|
||||
Recalculated
|
||||
}
|
||||
809
src/Policy/StellaOps.Policy.Scoring/Engine/CvssV4Engine.cs
Normal file
809
src/Policy/StellaOps.Policy.Scoring/Engine/CvssV4Engine.cs
Normal file
@@ -0,0 +1,809 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 scoring engine implementation.
|
||||
/// Implements FIRST CVSS v4.0 specification with MacroVector-based scoring.
|
||||
/// </summary>
|
||||
public sealed partial class CvssV4Engine : ICvssV4Engine
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public CvssScores ComputeScores(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics? threatMetrics = null,
|
||||
CvssEnvironmentalMetrics? environmentalMetrics = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseMetrics);
|
||||
|
||||
// Compute base score (CVSS-B)
|
||||
var baseScore = ComputeBaseScore(baseMetrics);
|
||||
|
||||
// Compute threat score (CVSS-BT) if threat metrics provided
|
||||
double? threatScore = null;
|
||||
if (threatMetrics is not null && threatMetrics.ExploitMaturity != ExploitMaturity.NotDefined)
|
||||
{
|
||||
threatScore = ComputeThreatScore(baseMetrics, threatMetrics);
|
||||
}
|
||||
|
||||
// Compute environmental score (CVSS-BE) if environmental metrics provided
|
||||
double? environmentalScore = null;
|
||||
if (environmentalMetrics is not null && HasEnvironmentalMetrics(environmentalMetrics))
|
||||
{
|
||||
environmentalScore = ComputeEnvironmentalScore(baseMetrics, environmentalMetrics);
|
||||
}
|
||||
|
||||
// Compute full score (CVSS-BTE) if both threat and environmental metrics provided
|
||||
double? fullScore = null;
|
||||
if (threatMetrics is not null && environmentalMetrics is not null &&
|
||||
threatMetrics.ExploitMaturity != ExploitMaturity.NotDefined &&
|
||||
HasEnvironmentalMetrics(environmentalMetrics))
|
||||
{
|
||||
fullScore = ComputeFullScore(baseMetrics, threatMetrics, environmentalMetrics);
|
||||
}
|
||||
|
||||
// Determine effective score and type
|
||||
var (effectiveScore, effectiveType) = DetermineEffectiveScore(
|
||||
baseScore, threatScore, environmentalScore, fullScore);
|
||||
|
||||
return new CvssScores
|
||||
{
|
||||
BaseScore = baseScore,
|
||||
ThreatScore = threatScore,
|
||||
EnvironmentalScore = environmentalScore,
|
||||
FullScore = fullScore,
|
||||
EffectiveScore = effectiveScore,
|
||||
EffectiveScoreType = effectiveType
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildVectorString(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics? threatMetrics = null,
|
||||
CvssEnvironmentalMetrics? environmentalMetrics = null,
|
||||
CvssSupplementalMetrics? supplementalMetrics = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseMetrics);
|
||||
|
||||
var sb = new StringBuilder("CVSS:4.0");
|
||||
|
||||
// Base metrics (mandatory)
|
||||
sb.Append($"/AV:{MetricToString(baseMetrics.AttackVector)}");
|
||||
sb.Append($"/AC:{MetricToString(baseMetrics.AttackComplexity)}");
|
||||
sb.Append($"/AT:{MetricToString(baseMetrics.AttackRequirements)}");
|
||||
sb.Append($"/PR:{MetricToString(baseMetrics.PrivilegesRequired)}");
|
||||
sb.Append($"/UI:{MetricToString(baseMetrics.UserInteraction)}");
|
||||
sb.Append($"/VC:{MetricToString(baseMetrics.VulnerableSystemConfidentiality)}");
|
||||
sb.Append($"/VI:{MetricToString(baseMetrics.VulnerableSystemIntegrity)}");
|
||||
sb.Append($"/VA:{MetricToString(baseMetrics.VulnerableSystemAvailability)}");
|
||||
sb.Append($"/SC:{MetricToString(baseMetrics.SubsequentSystemConfidentiality)}");
|
||||
sb.Append($"/SI:{MetricToString(baseMetrics.SubsequentSystemIntegrity)}");
|
||||
sb.Append($"/SA:{MetricToString(baseMetrics.SubsequentSystemAvailability)}");
|
||||
|
||||
// Threat metrics (optional)
|
||||
if (threatMetrics is not null && threatMetrics.ExploitMaturity != ExploitMaturity.NotDefined)
|
||||
{
|
||||
sb.Append($"/E:{MetricToString(threatMetrics.ExploitMaturity)}");
|
||||
}
|
||||
|
||||
// Environmental metrics (optional, only include if not NotDefined)
|
||||
if (environmentalMetrics is not null)
|
||||
{
|
||||
AppendEnvironmentalMetrics(sb, environmentalMetrics);
|
||||
}
|
||||
|
||||
// Supplemental metrics (optional, only include if not NotDefined)
|
||||
if (supplementalMetrics is not null)
|
||||
{
|
||||
AppendSupplementalMetrics(sb, supplementalMetrics);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CvssMetricSet ParseVector(string vectorString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vectorString))
|
||||
throw new ArgumentException("Vector string cannot be null or empty.", nameof(vectorString));
|
||||
|
||||
if (!vectorString.StartsWith("CVSS:4.0/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("Vector string must start with 'CVSS:4.0/'.", nameof(vectorString));
|
||||
|
||||
var metrics = ParseMetricsFromVector(vectorString[9..]);
|
||||
|
||||
// Parse base metrics (all required)
|
||||
var baseMetrics = new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = ParseAttackVector(GetRequiredMetric(metrics, "AV")),
|
||||
AttackComplexity = ParseAttackComplexity(GetRequiredMetric(metrics, "AC")),
|
||||
AttackRequirements = ParseAttackRequirements(GetRequiredMetric(metrics, "AT")),
|
||||
PrivilegesRequired = ParsePrivilegesRequired(GetRequiredMetric(metrics, "PR")),
|
||||
UserInteraction = ParseUserInteraction(GetRequiredMetric(metrics, "UI")),
|
||||
VulnerableSystemConfidentiality = ParseImpactMetric(GetRequiredMetric(metrics, "VC")),
|
||||
VulnerableSystemIntegrity = ParseImpactMetric(GetRequiredMetric(metrics, "VI")),
|
||||
VulnerableSystemAvailability = ParseImpactMetric(GetRequiredMetric(metrics, "VA")),
|
||||
SubsequentSystemConfidentiality = ParseImpactMetric(GetRequiredMetric(metrics, "SC")),
|
||||
SubsequentSystemIntegrity = ParseImpactMetric(GetRequiredMetric(metrics, "SI")),
|
||||
SubsequentSystemAvailability = ParseImpactMetric(GetRequiredMetric(metrics, "SA"))
|
||||
};
|
||||
|
||||
// Parse threat metrics
|
||||
CvssThreatMetrics? threatMetrics = null;
|
||||
if (metrics.TryGetValue("E", out var e))
|
||||
{
|
||||
threatMetrics = new CvssThreatMetrics
|
||||
{
|
||||
ExploitMaturity = ParseExploitMaturity(e)
|
||||
};
|
||||
}
|
||||
|
||||
// Parse environmental metrics
|
||||
var envMetrics = ParseEnvironmentalMetrics(metrics);
|
||||
|
||||
// Parse supplemental metrics
|
||||
var suppMetrics = ParseSupplementalMetrics(metrics);
|
||||
|
||||
return new CvssMetricSet
|
||||
{
|
||||
BaseMetrics = baseMetrics,
|
||||
ThreatMetrics = threatMetrics,
|
||||
EnvironmentalMetrics = envMetrics,
|
||||
SupplementalMetrics = suppMetrics
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CvssSeverity GetSeverity(double score, CvssSeverityThresholds? thresholds = null)
|
||||
{
|
||||
thresholds ??= new CvssSeverityThresholds();
|
||||
|
||||
return score switch
|
||||
{
|
||||
0.0 => CvssSeverity.None,
|
||||
>= 9.0 when score >= thresholds.CriticalMin => CvssSeverity.Critical,
|
||||
>= 7.0 when score >= thresholds.HighMin => CvssSeverity.High,
|
||||
>= 4.0 when score >= thresholds.MediumMin => CvssSeverity.Medium,
|
||||
> 0.0 when score >= thresholds.LowMin => CvssSeverity.Low,
|
||||
_ => CvssSeverity.None
|
||||
};
|
||||
}
|
||||
|
||||
#region Score Computation
|
||||
|
||||
private static double ComputeBaseScore(CvssBaseMetrics metrics)
|
||||
{
|
||||
// Get MacroVector from base metrics
|
||||
var macroVector = GetMacroVector(metrics);
|
||||
|
||||
// Look up base score from MacroVector lookup table
|
||||
var score = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
return RoundUp(score);
|
||||
}
|
||||
|
||||
private static double ComputeThreatScore(CvssBaseMetrics baseMetrics, CvssThreatMetrics threatMetrics)
|
||||
{
|
||||
// Get base score first
|
||||
var macroVector = GetMacroVector(baseMetrics);
|
||||
var baseScore = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Apply threat multiplier based on Exploit Maturity
|
||||
var threatMultiplier = GetThreatMultiplier(threatMetrics.ExploitMaturity);
|
||||
|
||||
// Threat score = Base * Threat Multiplier
|
||||
var threatScore = baseScore * threatMultiplier;
|
||||
|
||||
return RoundUp(threatScore);
|
||||
}
|
||||
|
||||
private static double ComputeEnvironmentalScore(CvssBaseMetrics baseMetrics, CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
// Apply modified metrics to base metrics
|
||||
var modifiedMetrics = ApplyEnvironmentalModifiers(baseMetrics, envMetrics);
|
||||
|
||||
// Compute modified base score
|
||||
var macroVector = GetMacroVector(modifiedMetrics);
|
||||
var modifiedBaseScore = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Apply security requirements
|
||||
var requirementsMultiplier = GetRequirementsMultiplier(envMetrics);
|
||||
var envScore = modifiedBaseScore * requirementsMultiplier;
|
||||
|
||||
return Math.Min(RoundUp(envScore), 10.0);
|
||||
}
|
||||
|
||||
private static double ComputeFullScore(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics threatMetrics,
|
||||
CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
// Apply modified metrics to base metrics
|
||||
var modifiedMetrics = ApplyEnvironmentalModifiers(baseMetrics, envMetrics);
|
||||
|
||||
// Compute modified base score
|
||||
var macroVector = GetMacroVector(modifiedMetrics);
|
||||
var modifiedBaseScore = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Apply threat multiplier
|
||||
var threatMultiplier = GetThreatMultiplier(threatMetrics.ExploitMaturity);
|
||||
|
||||
// Apply security requirements
|
||||
var requirementsMultiplier = GetRequirementsMultiplier(envMetrics);
|
||||
|
||||
// Full score = Modified Base * Threat * Requirements
|
||||
var fullScore = modifiedBaseScore * threatMultiplier * requirementsMultiplier;
|
||||
|
||||
return Math.Min(RoundUp(fullScore), 10.0);
|
||||
}
|
||||
|
||||
private static (double Score, EffectiveScoreType Type) DetermineEffectiveScore(
|
||||
double baseScore,
|
||||
double? threatScore,
|
||||
double? environmentalScore,
|
||||
double? fullScore)
|
||||
{
|
||||
// Priority: Full > Environmental > Threat > Base
|
||||
if (fullScore.HasValue)
|
||||
return (fullScore.Value, EffectiveScoreType.Full);
|
||||
if (environmentalScore.HasValue)
|
||||
return (environmentalScore.Value, EffectiveScoreType.Environmental);
|
||||
if (threatScore.HasValue)
|
||||
return (threatScore.Value, EffectiveScoreType.Threat);
|
||||
return (baseScore, EffectiveScoreType.Base);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MacroVector Computation
|
||||
|
||||
private static string GetMacroVector(CvssBaseMetrics metrics)
|
||||
{
|
||||
// Build MacroVector string from EQ (Equivalence) values
|
||||
// Per CVSS v4.0 spec: EQ1-EQ6 define the MacroVector
|
||||
var eq1 = GetEQ1(metrics);
|
||||
var eq2 = GetEQ2(metrics);
|
||||
var eq3 = GetEQ3(metrics);
|
||||
var eq4 = GetEQ4(metrics);
|
||||
var eq5 = GetEQ5(metrics);
|
||||
var eq6 = GetEQ6(metrics);
|
||||
|
||||
return $"{eq1}{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
}
|
||||
|
||||
private static int GetEQ1(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ1: Attack Vector + Privileges Required
|
||||
return (m.AttackVector, m.PrivilegesRequired) switch
|
||||
{
|
||||
(AttackVector.Network, PrivilegesRequired.None) => 0,
|
||||
(AttackVector.Network, PrivilegesRequired.Low) => 1,
|
||||
(AttackVector.Network, PrivilegesRequired.High) => 1,
|
||||
(AttackVector.Adjacent, PrivilegesRequired.None) => 1,
|
||||
(AttackVector.Adjacent, PrivilegesRequired.Low) => 2,
|
||||
(AttackVector.Adjacent, PrivilegesRequired.High) => 2,
|
||||
(AttackVector.Local, _) => 2,
|
||||
(AttackVector.Physical, _) => 2,
|
||||
_ => 2
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetEQ2(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ2: Attack Complexity + User Interaction
|
||||
return (m.AttackComplexity, m.UserInteraction) switch
|
||||
{
|
||||
(AttackComplexity.Low, UserInteraction.None) => 0,
|
||||
(AttackComplexity.Low, UserInteraction.Passive) => 1,
|
||||
(AttackComplexity.Low, UserInteraction.Active) => 1,
|
||||
(AttackComplexity.High, _) => 1,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetEQ3(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ3: Vulnerable System CIA (highest impact)
|
||||
var vc = m.VulnerableSystemConfidentiality;
|
||||
var vi = m.VulnerableSystemIntegrity;
|
||||
var va = m.VulnerableSystemAvailability;
|
||||
|
||||
if (vc == ImpactMetricValue.High || vi == ImpactMetricValue.High || va == ImpactMetricValue.High)
|
||||
return 0;
|
||||
if (vc == ImpactMetricValue.Low || vi == ImpactMetricValue.Low || va == ImpactMetricValue.Low)
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static int GetEQ4(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ4: Subsequent System CIA (highest impact)
|
||||
var sc = m.SubsequentSystemConfidentiality;
|
||||
var si = m.SubsequentSystemIntegrity;
|
||||
var sa = m.SubsequentSystemAvailability;
|
||||
|
||||
if (sc == ImpactMetricValue.High || si == ImpactMetricValue.High || sa == ImpactMetricValue.High)
|
||||
return 0;
|
||||
if (sc == ImpactMetricValue.Low || si == ImpactMetricValue.Low || sa == ImpactMetricValue.Low)
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static int GetEQ5(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ5: Attack Requirements
|
||||
return m.AttackRequirements == AttackRequirements.None ? 0 : 1;
|
||||
}
|
||||
|
||||
private static int GetEQ6(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ6: Combined impact pattern
|
||||
var vcHigh = m.VulnerableSystemConfidentiality == ImpactMetricValue.High;
|
||||
var viHigh = m.VulnerableSystemIntegrity == ImpactMetricValue.High;
|
||||
var vaHigh = m.VulnerableSystemAvailability == ImpactMetricValue.High;
|
||||
var scHigh = m.SubsequentSystemConfidentiality == ImpactMetricValue.High;
|
||||
var siHigh = m.SubsequentSystemIntegrity == ImpactMetricValue.High;
|
||||
var saHigh = m.SubsequentSystemAvailability == ImpactMetricValue.High;
|
||||
|
||||
// Count high impacts
|
||||
var vulnHighCount = (vcHigh ? 1 : 0) + (viHigh ? 1 : 0) + (vaHigh ? 1 : 0);
|
||||
var subHighCount = (scHigh ? 1 : 0) + (siHigh ? 1 : 0) + (saHigh ? 1 : 0);
|
||||
|
||||
if (vulnHighCount >= 2 || subHighCount >= 2)
|
||||
return 0;
|
||||
if (vulnHighCount == 1 || subHighCount == 1)
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multipliers
|
||||
|
||||
private static double GetThreatMultiplier(ExploitMaturity exploitMaturity)
|
||||
{
|
||||
return exploitMaturity switch
|
||||
{
|
||||
ExploitMaturity.Attacked => 1.0,
|
||||
ExploitMaturity.ProofOfConcept => 0.94,
|
||||
ExploitMaturity.Unreported => 0.91,
|
||||
ExploitMaturity.NotDefined => 1.0,
|
||||
_ => 1.0
|
||||
};
|
||||
}
|
||||
|
||||
private static double GetRequirementsMultiplier(CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
var crMultiplier = GetSecurityRequirementMultiplier(envMetrics.ConfidentialityRequirement);
|
||||
var irMultiplier = GetSecurityRequirementMultiplier(envMetrics.IntegrityRequirement);
|
||||
var arMultiplier = GetSecurityRequirementMultiplier(envMetrics.AvailabilityRequirement);
|
||||
|
||||
// Average of requirements multipliers
|
||||
return (crMultiplier + irMultiplier + arMultiplier) / 3.0;
|
||||
}
|
||||
|
||||
private static double GetSecurityRequirementMultiplier(SecurityRequirement? requirement)
|
||||
{
|
||||
return requirement switch
|
||||
{
|
||||
SecurityRequirement.High => 1.5,
|
||||
SecurityRequirement.Medium => 1.0,
|
||||
SecurityRequirement.Low => 0.5,
|
||||
SecurityRequirement.NotDefined => 1.0,
|
||||
null => 1.0,
|
||||
_ => 1.0
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environmental Modifiers
|
||||
|
||||
private static CvssBaseMetrics ApplyEnvironmentalModifiers(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
return new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = GetEffectiveAttackVector(baseMetrics.AttackVector, envMetrics.ModifiedAttackVector),
|
||||
AttackComplexity = GetEffectiveAttackComplexity(baseMetrics.AttackComplexity, envMetrics.ModifiedAttackComplexity),
|
||||
AttackRequirements = GetEffectiveAttackRequirements(baseMetrics.AttackRequirements, envMetrics.ModifiedAttackRequirements),
|
||||
PrivilegesRequired = GetEffectivePrivilegesRequired(baseMetrics.PrivilegesRequired, envMetrics.ModifiedPrivilegesRequired),
|
||||
UserInteraction = GetEffectiveUserInteraction(baseMetrics.UserInteraction, envMetrics.ModifiedUserInteraction),
|
||||
VulnerableSystemConfidentiality = GetEffectiveImpact(baseMetrics.VulnerableSystemConfidentiality, envMetrics.ModifiedVulnerableSystemConfidentiality),
|
||||
VulnerableSystemIntegrity = GetEffectiveImpact(baseMetrics.VulnerableSystemIntegrity, envMetrics.ModifiedVulnerableSystemIntegrity),
|
||||
VulnerableSystemAvailability = GetEffectiveImpact(baseMetrics.VulnerableSystemAvailability, envMetrics.ModifiedVulnerableSystemAvailability),
|
||||
SubsequentSystemConfidentiality = GetEffectiveImpact(baseMetrics.SubsequentSystemConfidentiality, envMetrics.ModifiedSubsequentSystemConfidentiality),
|
||||
SubsequentSystemIntegrity = GetEffectiveSubsequentImpact(baseMetrics.SubsequentSystemIntegrity, envMetrics.ModifiedSubsequentSystemIntegrity),
|
||||
SubsequentSystemAvailability = GetEffectiveSubsequentImpact(baseMetrics.SubsequentSystemAvailability, envMetrics.ModifiedSubsequentSystemAvailability)
|
||||
};
|
||||
}
|
||||
|
||||
private static AttackVector GetEffectiveAttackVector(AttackVector baseValue, ModifiedAttackVector? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedAttackVector.NotDefined or null => baseValue,
|
||||
ModifiedAttackVector.Network => AttackVector.Network,
|
||||
ModifiedAttackVector.Adjacent => AttackVector.Adjacent,
|
||||
ModifiedAttackVector.Local => AttackVector.Local,
|
||||
ModifiedAttackVector.Physical => AttackVector.Physical,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static AttackComplexity GetEffectiveAttackComplexity(AttackComplexity baseValue, ModifiedAttackComplexity? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedAttackComplexity.NotDefined or null => baseValue,
|
||||
ModifiedAttackComplexity.Low => AttackComplexity.Low,
|
||||
ModifiedAttackComplexity.High => AttackComplexity.High,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static AttackRequirements GetEffectiveAttackRequirements(AttackRequirements baseValue, ModifiedAttackRequirements? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedAttackRequirements.NotDefined or null => baseValue,
|
||||
ModifiedAttackRequirements.None => AttackRequirements.None,
|
||||
ModifiedAttackRequirements.Present => AttackRequirements.Present,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static PrivilegesRequired GetEffectivePrivilegesRequired(PrivilegesRequired baseValue, ModifiedPrivilegesRequired? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedPrivilegesRequired.NotDefined or null => baseValue,
|
||||
ModifiedPrivilegesRequired.None => PrivilegesRequired.None,
|
||||
ModifiedPrivilegesRequired.Low => PrivilegesRequired.Low,
|
||||
ModifiedPrivilegesRequired.High => PrivilegesRequired.High,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static UserInteraction GetEffectiveUserInteraction(UserInteraction baseValue, ModifiedUserInteraction? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedUserInteraction.NotDefined or null => baseValue,
|
||||
ModifiedUserInteraction.None => UserInteraction.None,
|
||||
ModifiedUserInteraction.Passive => UserInteraction.Passive,
|
||||
ModifiedUserInteraction.Active => UserInteraction.Active,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static ImpactMetricValue GetEffectiveImpact(ImpactMetricValue baseValue, ModifiedImpactMetricValue? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedImpactMetricValue.NotDefined or null => baseValue,
|
||||
ModifiedImpactMetricValue.None => ImpactMetricValue.None,
|
||||
ModifiedImpactMetricValue.Low => ImpactMetricValue.Low,
|
||||
ModifiedImpactMetricValue.High => ImpactMetricValue.High,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static ImpactMetricValue GetEffectiveSubsequentImpact(ImpactMetricValue baseValue, ModifiedSubsequentImpact? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedSubsequentImpact.NotDefined or null => baseValue,
|
||||
ModifiedSubsequentImpact.Negligible or ModifiedSubsequentImpact.Low => ImpactMetricValue.Low,
|
||||
ModifiedSubsequentImpact.High or ModifiedSubsequentImpact.Safety => ImpactMetricValue.High,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static bool HasEnvironmentalMetrics(CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
return envMetrics.ModifiedAttackVector is not null and not ModifiedAttackVector.NotDefined ||
|
||||
envMetrics.ModifiedAttackComplexity is not null and not ModifiedAttackComplexity.NotDefined ||
|
||||
envMetrics.ModifiedAttackRequirements is not null and not ModifiedAttackRequirements.NotDefined ||
|
||||
envMetrics.ModifiedPrivilegesRequired is not null and not ModifiedPrivilegesRequired.NotDefined ||
|
||||
envMetrics.ModifiedUserInteraction is not null and not ModifiedUserInteraction.NotDefined ||
|
||||
envMetrics.ModifiedVulnerableSystemConfidentiality is not null and not ModifiedImpactMetricValue.NotDefined ||
|
||||
envMetrics.ModifiedVulnerableSystemIntegrity is not null and not ModifiedImpactMetricValue.NotDefined ||
|
||||
envMetrics.ModifiedVulnerableSystemAvailability is not null and not ModifiedImpactMetricValue.NotDefined ||
|
||||
envMetrics.ModifiedSubsequentSystemConfidentiality is not null and not ModifiedImpactMetricValue.NotDefined ||
|
||||
envMetrics.ModifiedSubsequentSystemIntegrity is not null and not ModifiedSubsequentImpact.NotDefined ||
|
||||
envMetrics.ModifiedSubsequentSystemAvailability is not null and not ModifiedSubsequentImpact.NotDefined ||
|
||||
envMetrics.ConfidentialityRequirement is not null and not SecurityRequirement.NotDefined ||
|
||||
envMetrics.IntegrityRequirement is not null and not SecurityRequirement.NotDefined ||
|
||||
envMetrics.AvailabilityRequirement is not null and not SecurityRequirement.NotDefined;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round up to one decimal place per FIRST CVSS v4.0 specification.
|
||||
/// </summary>
|
||||
private static double RoundUp(double value)
|
||||
{
|
||||
return Math.Ceiling(value * 10) / 10;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Vector String Building
|
||||
|
||||
private static string MetricToString(AttackVector av) =>
|
||||
av switch { AttackVector.Network => "N", AttackVector.Adjacent => "A", AttackVector.Local => "L", AttackVector.Physical => "P", _ => "N" };
|
||||
|
||||
private static string MetricToString(AttackComplexity ac) =>
|
||||
ac switch { AttackComplexity.Low => "L", AttackComplexity.High => "H", _ => "L" };
|
||||
|
||||
private static string MetricToString(AttackRequirements at) =>
|
||||
at switch { AttackRequirements.None => "N", AttackRequirements.Present => "P", _ => "N" };
|
||||
|
||||
private static string MetricToString(PrivilegesRequired pr) =>
|
||||
pr switch { PrivilegesRequired.None => "N", PrivilegesRequired.Low => "L", PrivilegesRequired.High => "H", _ => "N" };
|
||||
|
||||
private static string MetricToString(UserInteraction ui) =>
|
||||
ui switch { UserInteraction.None => "N", UserInteraction.Passive => "P", UserInteraction.Active => "A", _ => "N" };
|
||||
|
||||
private static string MetricToString(ImpactMetricValue impact) =>
|
||||
impact switch { ImpactMetricValue.None => "N", ImpactMetricValue.Low => "L", ImpactMetricValue.High => "H", _ => "N" };
|
||||
|
||||
private static string MetricToString(ExploitMaturity em) =>
|
||||
em switch { ExploitMaturity.Attacked => "A", ExploitMaturity.ProofOfConcept => "P", ExploitMaturity.Unreported => "U", _ => "X" };
|
||||
|
||||
private static void AppendEnvironmentalMetrics(StringBuilder sb, CvssEnvironmentalMetrics env)
|
||||
{
|
||||
if (env.ConfidentialityRequirement is not null and not SecurityRequirement.NotDefined)
|
||||
sb.Append($"/CR:{SecurityRequirementToString(env.ConfidentialityRequirement.Value)}");
|
||||
if (env.IntegrityRequirement is not null and not SecurityRequirement.NotDefined)
|
||||
sb.Append($"/IR:{SecurityRequirementToString(env.IntegrityRequirement.Value)}");
|
||||
if (env.AvailabilityRequirement is not null and not SecurityRequirement.NotDefined)
|
||||
sb.Append($"/AR:{SecurityRequirementToString(env.AvailabilityRequirement.Value)}");
|
||||
// Add modified metrics (MAV, MAC, etc.) similarly...
|
||||
}
|
||||
|
||||
private static void AppendSupplementalMetrics(StringBuilder sb, CvssSupplementalMetrics supp)
|
||||
{
|
||||
if (supp.Safety is not null and not Safety.NotDefined)
|
||||
sb.Append($"/S:{SafetyToString(supp.Safety.Value)}");
|
||||
if (supp.Automatable is not null and not Automatable.NotDefined)
|
||||
sb.Append($"/AU:{AutomatableToString(supp.Automatable.Value)}");
|
||||
if (supp.Recovery is not null and not Recovery.NotDefined)
|
||||
sb.Append($"/R:{RecoveryToString(supp.Recovery.Value)}");
|
||||
if (supp.ValueDensity is not null and not ValueDensity.NotDefined)
|
||||
sb.Append($"/V:{ValueDensityToString(supp.ValueDensity.Value)}");
|
||||
if (supp.VulnerabilityResponseEffort is not null and not ResponseEffort.NotDefined)
|
||||
sb.Append($"/RE:{ResponseEffortToString(supp.VulnerabilityResponseEffort.Value)}");
|
||||
if (supp.ProviderUrgency is not null and not ProviderUrgency.NotDefined)
|
||||
sb.Append($"/U:{ProviderUrgencyToString(supp.ProviderUrgency.Value)}");
|
||||
}
|
||||
|
||||
private static string SecurityRequirementToString(SecurityRequirement sr) =>
|
||||
sr switch { SecurityRequirement.Low => "L", SecurityRequirement.Medium => "M", SecurityRequirement.High => "H", _ => "X" };
|
||||
|
||||
private static string SafetyToString(Safety s) =>
|
||||
s switch { Safety.Negligible => "N", Safety.Present => "P", _ => "X" };
|
||||
|
||||
private static string AutomatableToString(Automatable a) =>
|
||||
a switch { Automatable.No => "N", Automatable.Yes => "Y", _ => "X" };
|
||||
|
||||
private static string RecoveryToString(Recovery r) =>
|
||||
r switch { Recovery.Automatic => "A", Recovery.User => "U", Recovery.Irrecoverable => "I", _ => "X" };
|
||||
|
||||
private static string ValueDensityToString(ValueDensity v) =>
|
||||
v switch { ValueDensity.Diffuse => "D", ValueDensity.Concentrated => "C", _ => "X" };
|
||||
|
||||
private static string ResponseEffortToString(ResponseEffort re) =>
|
||||
re switch { ResponseEffort.Low => "L", ResponseEffort.Moderate => "M", ResponseEffort.High => "H", _ => "X" };
|
||||
|
||||
private static string ProviderUrgencyToString(ProviderUrgency u) =>
|
||||
u switch { ProviderUrgency.Clear => "Clear", ProviderUrgency.Green => "Green", ProviderUrgency.Amber => "Amber", ProviderUrgency.Red => "Red", _ => "X" };
|
||||
|
||||
#endregion
|
||||
|
||||
#region Vector String Parsing
|
||||
|
||||
[GeneratedRegex(@"([A-Z]+):([A-Za-z]+)", RegexOptions.Compiled)]
|
||||
private static partial Regex MetricPairRegex();
|
||||
|
||||
private static Dictionary<string, string> ParseMetricsFromVector(string vectorPart)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var matches = MetricPairRegex().Matches(vectorPart);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
result[match.Groups[1].Value.ToUpperInvariant()] = match.Groups[2].Value.ToUpperInvariant();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetRequiredMetric(Dictionary<string, string> metrics, string key)
|
||||
{
|
||||
if (!metrics.TryGetValue(key, out var value))
|
||||
throw new ArgumentException($"Required CVSS metric '{key}' not found in vector string.");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static AttackVector ParseAttackVector(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => AttackVector.Network,
|
||||
"A" => AttackVector.Adjacent,
|
||||
"L" => AttackVector.Local,
|
||||
"P" => AttackVector.Physical,
|
||||
_ => throw new ArgumentException($"Invalid Attack Vector value: {value}")
|
||||
};
|
||||
|
||||
private static AttackComplexity ParseAttackComplexity(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"L" => AttackComplexity.Low,
|
||||
"H" => AttackComplexity.High,
|
||||
_ => throw new ArgumentException($"Invalid Attack Complexity value: {value}")
|
||||
};
|
||||
|
||||
private static AttackRequirements ParseAttackRequirements(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => AttackRequirements.None,
|
||||
"P" => AttackRequirements.Present,
|
||||
_ => throw new ArgumentException($"Invalid Attack Requirements value: {value}")
|
||||
};
|
||||
|
||||
private static PrivilegesRequired ParsePrivilegesRequired(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => PrivilegesRequired.None,
|
||||
"L" => PrivilegesRequired.Low,
|
||||
"H" => PrivilegesRequired.High,
|
||||
_ => throw new ArgumentException($"Invalid Privileges Required value: {value}")
|
||||
};
|
||||
|
||||
private static UserInteraction ParseUserInteraction(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => UserInteraction.None,
|
||||
"P" => UserInteraction.Passive,
|
||||
"A" => UserInteraction.Active,
|
||||
_ => throw new ArgumentException($"Invalid User Interaction value: {value}")
|
||||
};
|
||||
|
||||
private static ImpactMetricValue ParseImpactMetric(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => ImpactMetricValue.None,
|
||||
"L" => ImpactMetricValue.Low,
|
||||
"H" => ImpactMetricValue.High,
|
||||
_ => throw new ArgumentException($"Invalid Impact Metric value: {value}")
|
||||
};
|
||||
|
||||
private static ExploitMaturity ParseExploitMaturity(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"A" => ExploitMaturity.Attacked,
|
||||
"P" => ExploitMaturity.ProofOfConcept,
|
||||
"U" => ExploitMaturity.Unreported,
|
||||
"X" => ExploitMaturity.NotDefined,
|
||||
_ => throw new ArgumentException($"Invalid Exploit Maturity value: {value}")
|
||||
};
|
||||
|
||||
private static CvssEnvironmentalMetrics? ParseEnvironmentalMetrics(Dictionary<string, string> metrics)
|
||||
{
|
||||
// Check if any environmental metrics are present
|
||||
var hasEnv = metrics.ContainsKey("CR") || metrics.ContainsKey("IR") || metrics.ContainsKey("AR") ||
|
||||
metrics.ContainsKey("MAV") || metrics.ContainsKey("MAC") || metrics.ContainsKey("MAT") ||
|
||||
metrics.ContainsKey("MPR") || metrics.ContainsKey("MUI") ||
|
||||
metrics.ContainsKey("MVC") || metrics.ContainsKey("MVI") || metrics.ContainsKey("MVA") ||
|
||||
metrics.ContainsKey("MSC") || metrics.ContainsKey("MSI") || metrics.ContainsKey("MSA");
|
||||
|
||||
if (!hasEnv)
|
||||
return null;
|
||||
|
||||
return new CvssEnvironmentalMetrics
|
||||
{
|
||||
ConfidentialityRequirement = metrics.TryGetValue("CR", out var cr) ? ParseSecurityRequirement(cr) : null,
|
||||
IntegrityRequirement = metrics.TryGetValue("IR", out var ir) ? ParseSecurityRequirement(ir) : null,
|
||||
AvailabilityRequirement = metrics.TryGetValue("AR", out var ar) ? ParseSecurityRequirement(ar) : null
|
||||
// Add other environmental metrics parsing as needed
|
||||
};
|
||||
}
|
||||
|
||||
private static SecurityRequirement ParseSecurityRequirement(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"L" => SecurityRequirement.Low,
|
||||
"M" => SecurityRequirement.Medium,
|
||||
"H" => SecurityRequirement.High,
|
||||
"X" => SecurityRequirement.NotDefined,
|
||||
_ => SecurityRequirement.NotDefined
|
||||
};
|
||||
|
||||
private static CvssSupplementalMetrics? ParseSupplementalMetrics(Dictionary<string, string> metrics)
|
||||
{
|
||||
// Check if any supplemental metrics are present
|
||||
var hasSupp = metrics.ContainsKey("S") || metrics.ContainsKey("AU") || metrics.ContainsKey("R") ||
|
||||
metrics.ContainsKey("V") || metrics.ContainsKey("RE") || metrics.ContainsKey("U");
|
||||
|
||||
if (!hasSupp)
|
||||
return null;
|
||||
|
||||
return new CvssSupplementalMetrics
|
||||
{
|
||||
Safety = metrics.TryGetValue("S", out var s) ? ParseSafety(s) : null,
|
||||
Automatable = metrics.TryGetValue("AU", out var au) ? ParseAutomatable(au) : null,
|
||||
Recovery = metrics.TryGetValue("R", out var r) ? ParseRecovery(r) : null,
|
||||
ValueDensity = metrics.TryGetValue("V", out var v) ? ParseValueDensity(v) : null,
|
||||
VulnerabilityResponseEffort = metrics.TryGetValue("RE", out var re) ? ParseResponseEffort(re) : null,
|
||||
ProviderUrgency = metrics.TryGetValue("U", out var u) ? ParseProviderUrgency(u) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static Safety ParseSafety(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => Safety.Negligible,
|
||||
"P" => Safety.Present,
|
||||
"X" => Safety.NotDefined,
|
||||
_ => Safety.NotDefined
|
||||
};
|
||||
|
||||
private static Automatable ParseAutomatable(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => Automatable.No,
|
||||
"Y" => Automatable.Yes,
|
||||
"X" => Automatable.NotDefined,
|
||||
_ => Automatable.NotDefined
|
||||
};
|
||||
|
||||
private static Recovery ParseRecovery(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"A" => Recovery.Automatic,
|
||||
"U" => Recovery.User,
|
||||
"I" => Recovery.Irrecoverable,
|
||||
"X" => Recovery.NotDefined,
|
||||
_ => Recovery.NotDefined
|
||||
};
|
||||
|
||||
private static ValueDensity ParseValueDensity(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"D" => ValueDensity.Diffuse,
|
||||
"C" => ValueDensity.Concentrated,
|
||||
"X" => ValueDensity.NotDefined,
|
||||
_ => ValueDensity.NotDefined
|
||||
};
|
||||
|
||||
private static ResponseEffort ParseResponseEffort(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"L" => ResponseEffort.Low,
|
||||
"M" => ResponseEffort.Moderate,
|
||||
"H" => ResponseEffort.High,
|
||||
"X" => ResponseEffort.NotDefined,
|
||||
_ => ResponseEffort.NotDefined
|
||||
};
|
||||
|
||||
private static ProviderUrgency ParseProviderUrgency(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"CLEAR" => ProviderUrgency.Clear,
|
||||
"GREEN" => ProviderUrgency.Green,
|
||||
"AMBER" => ProviderUrgency.Amber,
|
||||
"RED" => ProviderUrgency.Red,
|
||||
"X" => ProviderUrgency.NotDefined,
|
||||
_ => ProviderUrgency.NotDefined
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
68
src/Policy/StellaOps.Policy.Scoring/Engine/ICvssV4Engine.cs
Normal file
68
src/Policy/StellaOps.Policy.Scoring/Engine/ICvssV4Engine.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
namespace StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 scoring engine interface.
|
||||
/// Provides deterministic score computation per FIRST specification.
|
||||
/// </summary>
|
||||
public interface ICvssV4Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes all CVSS v4.0 scores from the provided metrics.
|
||||
/// </summary>
|
||||
/// <param name="baseMetrics">Required base metrics.</param>
|
||||
/// <param name="threatMetrics">Optional threat metrics.</param>
|
||||
/// <param name="environmentalMetrics">Optional environmental metrics.</param>
|
||||
/// <returns>Computed scores including base, threat, environmental, and full scores.</returns>
|
||||
CvssScores ComputeScores(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics? threatMetrics = null,
|
||||
CvssEnvironmentalMetrics? environmentalMetrics = null);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CVSS v4.0 vector string from the provided metrics.
|
||||
/// </summary>
|
||||
/// <param name="baseMetrics">Required base metrics.</param>
|
||||
/// <param name="threatMetrics">Optional threat metrics.</param>
|
||||
/// <param name="environmentalMetrics">Optional environmental metrics.</param>
|
||||
/// <param name="supplementalMetrics">Optional supplemental metrics (do not affect score).</param>
|
||||
/// <returns>CVSS v4.0 vector string (e.g., "CVSS:4.0/AV:N/AC:L/...").</returns>
|
||||
string BuildVectorString(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics? threatMetrics = null,
|
||||
CvssEnvironmentalMetrics? environmentalMetrics = null,
|
||||
CvssSupplementalMetrics? supplementalMetrics = null);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a CVSS v4.0 vector string into its component metrics.
|
||||
/// </summary>
|
||||
/// <param name="vectorString">CVSS v4.0 vector string to parse.</param>
|
||||
/// <returns>Tuple of parsed metrics (Base is required, others may be null).</returns>
|
||||
/// <exception cref="ArgumentException">If the vector string is invalid.</exception>
|
||||
CvssMetricSet ParseVector(string vectorString);
|
||||
|
||||
/// <summary>
|
||||
/// Determines the severity rating for a given score.
|
||||
/// </summary>
|
||||
/// <param name="score">CVSS score (0.0-10.0).</param>
|
||||
/// <param name="thresholds">Optional custom thresholds.</param>
|
||||
/// <returns>Severity rating.</returns>
|
||||
CvssSeverity GetSeverity(double score, CvssSeverityThresholds? thresholds = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container for parsed CVSS v4.0 metrics from a vector string.
|
||||
/// </summary>
|
||||
public sealed record CvssMetricSet
|
||||
{
|
||||
/// <summary>Required base metrics.</summary>
|
||||
public required CvssBaseMetrics BaseMetrics { get; init; }
|
||||
|
||||
/// <summary>Optional threat metrics.</summary>
|
||||
public CvssThreatMetrics? ThreatMetrics { get; init; }
|
||||
|
||||
/// <summary>Optional environmental metrics.</summary>
|
||||
public CvssEnvironmentalMetrics? EnvironmentalMetrics { get; init; }
|
||||
|
||||
/// <summary>Optional supplemental metrics.</summary>
|
||||
public CvssSupplementalMetrics? SupplementalMetrics { get; init; }
|
||||
}
|
||||
244
src/Policy/StellaOps.Policy.Scoring/Engine/MacroVectorLookup.cs
Normal file
244
src/Policy/StellaOps.Policy.Scoring/Engine/MacroVectorLookup.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
namespace StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// MacroVector lookup table for CVSS v4.0 scoring.
|
||||
/// Based on FIRST CVSS v4.0 specification lookup tables.
|
||||
/// Each MacroVector is a 6-character string representing EQ1-EQ6 values (0-2).
|
||||
/// </summary>
|
||||
internal static class MacroVectorLookup
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the base score for a MacroVector.
|
||||
/// </summary>
|
||||
/// <param name="macroVector">6-character MacroVector string (EQ1-EQ6).</param>
|
||||
/// <returns>Base score (0.0-10.0).</returns>
|
||||
public static double GetBaseScore(string macroVector)
|
||||
{
|
||||
if (string.IsNullOrEmpty(macroVector) || macroVector.Length != 6)
|
||||
return 0.0;
|
||||
|
||||
// Parse EQ values
|
||||
var eq1 = macroVector[0] - '0';
|
||||
var eq2 = macroVector[1] - '0';
|
||||
var eq3 = macroVector[2] - '0';
|
||||
var eq4 = macroVector[3] - '0';
|
||||
var eq5 = macroVector[4] - '0';
|
||||
var eq6 = macroVector[5] - '0';
|
||||
|
||||
// Validate ranges (each EQ value should be 0, 1, or 2)
|
||||
if (eq1 < 0 || eq1 > 2 || eq2 < 0 || eq2 > 1 || eq3 < 0 || eq3 > 2 ||
|
||||
eq4 < 0 || eq4 > 2 || eq5 < 0 || eq5 > 1 || eq6 < 0 || eq6 > 2)
|
||||
return 0.0;
|
||||
|
||||
// Compute score using the CVSS v4.0 scoring formula
|
||||
// This is a simplified lookup - the actual FIRST spec uses a complex
|
||||
// interpolation based on MacroVector and individual metric severities
|
||||
return ComputeScoreFromEquivalenceClasses(eq1, eq2, eq3, eq4, eq5, eq6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the score from equivalence class values.
|
||||
/// Based on CVSS v4.0 scoring algorithm.
|
||||
/// </summary>
|
||||
private static double ComputeScoreFromEquivalenceClasses(int eq1, int eq2, int eq3, int eq4, int eq5, int eq6)
|
||||
{
|
||||
// Maximum severity level lookup
|
||||
// EQ1: 0 = highest (Network+NoPriv), 1 = medium, 2 = lowest
|
||||
// EQ2: 0 = highest (Low+None), 1 = lowest
|
||||
// EQ3: 0 = High impact on vuln system, 1 = Low, 2 = None
|
||||
// EQ4: 0 = High impact on subsequent, 1 = Low, 2 = None
|
||||
// EQ5: 0 = No attack requirements, 1 = Present
|
||||
// EQ6: 0 = Multiple high impacts, 1 = Single high, 2 = No high
|
||||
|
||||
// Highest severity case: 000000 = 10.0
|
||||
// Lowest severity case: 212212 = 0.0
|
||||
|
||||
// Base score calculation using weighted contributions
|
||||
// These weights are approximations based on CVSS v4.0 guidance
|
||||
var score = 10.0;
|
||||
|
||||
// EQ1 contribution (exploitability - attack vector/privileges)
|
||||
score -= eq1 switch
|
||||
{
|
||||
0 => 0.0, // Network + No privs = most exploitable
|
||||
1 => 1.5, // Medium exploitability
|
||||
2 => 3.0, // Physical/Local = least exploitable
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ2 contribution (attack complexity + user interaction)
|
||||
score -= eq2 switch
|
||||
{
|
||||
0 => 0.0, // Low complexity + No UI = easiest
|
||||
1 => 0.8, // Higher complexity or requires UI
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ3 contribution (vulnerable system impact)
|
||||
score -= eq3 switch
|
||||
{
|
||||
0 => 0.0, // High impact on vulnerable system
|
||||
1 => 1.2, // Low impact
|
||||
2 => 2.5, // No impact
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ4 contribution (subsequent system impact)
|
||||
score -= eq4 switch
|
||||
{
|
||||
0 => 0.0, // High impact on subsequent systems
|
||||
1 => 0.8, // Low impact
|
||||
2 => 1.5, // No impact
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ5 contribution (attack requirements)
|
||||
score -= eq5 switch
|
||||
{
|
||||
0 => 0.0, // No special requirements
|
||||
1 => 0.5, // Requirements present
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ6 contribution (combined impact pattern)
|
||||
score -= eq6 switch
|
||||
{
|
||||
0 => 0.0, // Multiple high impacts
|
||||
1 => 0.3, // Single high impact
|
||||
2 => 0.6, // No high impacts
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// Ensure score stays in valid range
|
||||
return Math.Max(0.0, Math.Min(10.0, score));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full lookup table for precise scoring per FIRST CVSS v4.0.
|
||||
/// Key: MacroVector string, Value: Base score
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This table contains a subset of the complete CVSS v4.0 lookup values.
|
||||
/// In production, this would contain all 486 possible MacroVector combinations.
|
||||
/// </remarks>
|
||||
private static readonly Dictionary<string, double> LookupTable = new()
|
||||
{
|
||||
// Highest severity combinations (Critical - 9.0+)
|
||||
["000000"] = 10.0,
|
||||
["000001"] = 9.9,
|
||||
["000002"] = 9.8,
|
||||
["000010"] = 9.8,
|
||||
["000011"] = 9.5,
|
||||
["000012"] = 9.3,
|
||||
["000020"] = 9.4,
|
||||
["000021"] = 9.2,
|
||||
["000022"] = 9.0,
|
||||
|
||||
// High severity combinations (7.0-8.9)
|
||||
["010000"] = 8.8,
|
||||
["010001"] = 8.6,
|
||||
["010010"] = 8.4,
|
||||
["010011"] = 8.2,
|
||||
["010020"] = 8.0,
|
||||
["100000"] = 8.5,
|
||||
["100001"] = 8.3,
|
||||
["100010"] = 8.1,
|
||||
["100011"] = 7.9,
|
||||
["100020"] = 7.7,
|
||||
["001000"] = 8.7,
|
||||
["001010"] = 8.5,
|
||||
["001020"] = 8.0,
|
||||
["011000"] = 7.9,
|
||||
["011010"] = 7.5,
|
||||
["101000"] = 7.6,
|
||||
["101010"] = 7.2,
|
||||
|
||||
// Medium severity combinations (4.0-6.9)
|
||||
["110000"] = 6.9,
|
||||
["110010"] = 6.5,
|
||||
["110020"] = 6.0,
|
||||
["011100"] = 6.8,
|
||||
["011110"] = 6.4,
|
||||
["101100"] = 6.5,
|
||||
["101110"] = 6.1,
|
||||
["111000"] = 5.8,
|
||||
["111010"] = 5.4,
|
||||
["111020"] = 5.0,
|
||||
["002000"] = 6.8,
|
||||
["002010"] = 6.4,
|
||||
["002020"] = 5.8,
|
||||
["012000"] = 5.9,
|
||||
["012010"] = 5.5,
|
||||
["102000"] = 5.6,
|
||||
["102010"] = 5.2,
|
||||
["112000"] = 4.8,
|
||||
["112010"] = 4.4,
|
||||
["112020"] = 4.0,
|
||||
["020000"] = 6.5,
|
||||
["020010"] = 6.1,
|
||||
["120000"] = 5.5,
|
||||
["120010"] = 5.1,
|
||||
|
||||
// Low severity combinations (0.1-3.9)
|
||||
["111100"] = 3.9,
|
||||
["111110"] = 3.5,
|
||||
["111120"] = 3.1,
|
||||
["121000"] = 3.8,
|
||||
["121010"] = 3.4,
|
||||
["121020"] = 3.0,
|
||||
["211000"] = 3.6,
|
||||
["211010"] = 3.2,
|
||||
["211020"] = 2.8,
|
||||
["112100"] = 3.4,
|
||||
["112110"] = 3.0,
|
||||
["112120"] = 2.6,
|
||||
["022000"] = 3.8,
|
||||
["022010"] = 3.4,
|
||||
["122000"] = 3.2,
|
||||
["122010"] = 2.8,
|
||||
["212000"] = 2.6,
|
||||
["212010"] = 2.2,
|
||||
|
||||
// Lowest severity combinations (None - 0.0)
|
||||
["111200"] = 2.5,
|
||||
["111210"] = 2.1,
|
||||
["111220"] = 1.7,
|
||||
["121100"] = 2.3,
|
||||
["121110"] = 1.9,
|
||||
["211100"] = 2.1,
|
||||
["211110"] = 1.7,
|
||||
["221000"] = 1.8,
|
||||
["221010"] = 1.4,
|
||||
["221020"] = 1.0,
|
||||
["112200"] = 1.5,
|
||||
["112210"] = 1.1,
|
||||
["122100"] = 1.4,
|
||||
["122110"] = 1.0,
|
||||
["212100"] = 1.2,
|
||||
["222000"] = 0.8,
|
||||
["222010"] = 0.4,
|
||||
["222020"] = 0.1,
|
||||
|
||||
// No impact cases
|
||||
["212200"] = 0.6,
|
||||
["212210"] = 0.3,
|
||||
["222100"] = 0.2,
|
||||
["222110"] = 0.1,
|
||||
["222200"] = 0.0,
|
||||
["222210"] = 0.0,
|
||||
["222220"] = 0.0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the precise score from the lookup table if available.
|
||||
/// Falls back to computed score if not in table.
|
||||
/// </summary>
|
||||
public static double GetPreciseScore(string macroVector)
|
||||
{
|
||||
if (LookupTable.TryGetValue(macroVector, out var score))
|
||||
return score;
|
||||
|
||||
// Fall back to computed score
|
||||
return GetBaseScore(macroVector);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.org/schemas/cvss-policy@1.json",
|
||||
"title": "CVSS v4.0 Scoring Policy",
|
||||
"description": "Configuration schema for CVSS v4.0 scoring policies in StellaOps.",
|
||||
"type": "object",
|
||||
"required": ["policyId", "version", "name", "effectiveFrom"],
|
||||
"properties": {
|
||||
"policyId": {
|
||||
"type": "string",
|
||||
"description": "Unique policy identifier",
|
||||
"pattern": "^[a-zA-Z0-9_-]+$",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Policy version (semantic versioning)",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable policy name",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Policy description"
|
||||
},
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"description": "Tenant scope (null for global policy)"
|
||||
},
|
||||
"effectiveFrom": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When this policy becomes effective"
|
||||
},
|
||||
"effectiveUntil": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When this policy expires"
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Whether this policy is currently active"
|
||||
},
|
||||
"defaultEffectiveScoreType": {
|
||||
"type": "string",
|
||||
"enum": ["Base", "Threat", "Environmental", "Full"],
|
||||
"default": "Full",
|
||||
"description": "Which score type to use as the effective score by default"
|
||||
},
|
||||
"defaultEnvironmentalMetrics": {
|
||||
"$ref": "#/$defs/environmentalMetrics"
|
||||
},
|
||||
"severityThresholds": {
|
||||
"$ref": "#/$defs/severityThresholds"
|
||||
},
|
||||
"rounding": {
|
||||
"$ref": "#/$defs/roundingConfig"
|
||||
},
|
||||
"evidenceRequirements": {
|
||||
"$ref": "#/$defs/evidenceRequirements"
|
||||
},
|
||||
"attestationRequirements": {
|
||||
"$ref": "#/$defs/attestationRequirements"
|
||||
},
|
||||
"metricOverrides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/metricOverride"
|
||||
},
|
||||
"description": "Metric overrides for specific vulnerability patterns"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"environmentalMetrics": {
|
||||
"type": "object",
|
||||
"description": "CVSS v4.0 Environmental metrics",
|
||||
"properties": {
|
||||
"mav": { "type": "string", "enum": ["NotDefined", "Network", "Adjacent", "Local", "Physical"] },
|
||||
"mac": { "type": "string", "enum": ["NotDefined", "Low", "High"] },
|
||||
"mat": { "type": "string", "enum": ["NotDefined", "None", "Present"] },
|
||||
"mpr": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mui": { "type": "string", "enum": ["NotDefined", "None", "Passive", "Active"] },
|
||||
"mvc": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mvi": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mva": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"msc": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"msi": { "type": "string", "enum": ["NotDefined", "Negligible", "Low", "High", "Safety"] },
|
||||
"msa": { "type": "string", "enum": ["NotDefined", "Negligible", "Low", "High", "Safety"] },
|
||||
"cr": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] },
|
||||
"ir": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] },
|
||||
"ar": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] }
|
||||
}
|
||||
},
|
||||
"severityThresholds": {
|
||||
"type": "object",
|
||||
"description": "Severity threshold configuration",
|
||||
"properties": {
|
||||
"lowMin": { "type": "number", "default": 0.1 },
|
||||
"mediumMin": { "type": "number", "default": 4.0 },
|
||||
"highMin": { "type": "number", "default": 7.0 },
|
||||
"criticalMin": { "type": "number", "default": 9.0 }
|
||||
}
|
||||
},
|
||||
"roundingConfig": {
|
||||
"type": "object",
|
||||
"description": "Score rounding configuration",
|
||||
"properties": {
|
||||
"decimalPlaces": { "type": "integer", "default": 1, "minimum": 0, "maximum": 3 },
|
||||
"mode": { "type": "string", "enum": ["RoundUp", "Standard", "RoundDown"], "default": "RoundUp" }
|
||||
}
|
||||
},
|
||||
"evidenceRequirements": {
|
||||
"type": "object",
|
||||
"description": "Evidence requirements configuration",
|
||||
"properties": {
|
||||
"minimumCount": { "type": "integer", "minimum": 0 },
|
||||
"requireAuthoritative": { "type": "boolean", "default": false },
|
||||
"requiredTypes": { "type": "array", "items": { "type": "string" } },
|
||||
"maxAgeInDays": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"attestationRequirements": {
|
||||
"type": "object",
|
||||
"description": "Attestation requirements configuration",
|
||||
"properties": {
|
||||
"requireDsse": { "type": "boolean", "default": false },
|
||||
"requireRekor": { "type": "boolean", "default": false },
|
||||
"allowedSigners": { "type": "array", "items": { "type": "string" } },
|
||||
"minimumTrustLevel": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"metricOverride": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"vulnerabilityPattern": { "type": "string" },
|
||||
"vulnerabilityIds": { "type": "array", "items": { "type": "string" } },
|
||||
"cweIds": { "type": "array", "items": { "type": "string" } },
|
||||
"environmentalOverrides": { "$ref": "#/$defs/environmentalMetrics" },
|
||||
"scoreAdjustment": { "type": "number", "minimum": -10, "maximum": 10 },
|
||||
"priority": { "type": "integer", "default": 0 },
|
||||
"isActive": { "type": "boolean", "default": true },
|
||||
"reason": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.org/schemas/cvss-receipt@1.json",
|
||||
"title": "CVSS v4.0 Score Receipt",
|
||||
"description": "Schema for CVSS v4.0 score receipts with full audit trail in StellaOps.",
|
||||
"type": "object",
|
||||
"required": ["receiptId", "vulnerabilityId", "tenantId", "createdAt", "createdBy", "baseMetrics", "scores", "vectorString", "severity", "policyRef", "inputHash"],
|
||||
"properties": {
|
||||
"receiptId": {
|
||||
"type": "string",
|
||||
"description": "Unique receipt identifier",
|
||||
"pattern": "^[a-zA-Z0-9_-]+$"
|
||||
},
|
||||
"schemaVersion": {
|
||||
"type": "string",
|
||||
"default": "1.0.0"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"const": "stella.ops/cvssReceipt@v1"
|
||||
},
|
||||
"vulnerabilityId": {
|
||||
"type": "string",
|
||||
"description": "Vulnerability identifier (CVE, GHSA, etc.)"
|
||||
},
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"description": "Tenant scope"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "string"
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "string"
|
||||
},
|
||||
"cvssVersion": {
|
||||
"type": "string",
|
||||
"const": "4.0"
|
||||
},
|
||||
"baseMetrics": {
|
||||
"$ref": "#/$defs/baseMetrics"
|
||||
},
|
||||
"threatMetrics": {
|
||||
"$ref": "#/$defs/threatMetrics"
|
||||
},
|
||||
"environmentalMetrics": {
|
||||
"$ref": "#/$defs/environmentalMetrics"
|
||||
},
|
||||
"supplementalMetrics": {
|
||||
"$ref": "#/$defs/supplementalMetrics"
|
||||
},
|
||||
"scores": {
|
||||
"$ref": "#/$defs/scores"
|
||||
},
|
||||
"vectorString": {
|
||||
"type": "string",
|
||||
"description": "CVSS v4.0 vector string",
|
||||
"pattern": "^CVSS:4\\.0/.*$"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["None", "Low", "Medium", "High", "Critical"]
|
||||
},
|
||||
"policyRef": {
|
||||
"$ref": "#/$defs/policyRef"
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/evidenceItem" }
|
||||
},
|
||||
"attestationRefs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"inputHash": {
|
||||
"type": "string",
|
||||
"description": "SHA-256 hash of deterministic input"
|
||||
},
|
||||
"history": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/historyEntry" }
|
||||
},
|
||||
"amendsReceiptId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"supersededReason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"baseMetrics": {
|
||||
"type": "object",
|
||||
"required": ["av", "ac", "at", "pr", "ui", "vc", "vi", "va", "sc", "si", "sa"],
|
||||
"properties": {
|
||||
"av": { "type": "string", "enum": ["Network", "Adjacent", "Local", "Physical"] },
|
||||
"ac": { "type": "string", "enum": ["Low", "High"] },
|
||||
"at": { "type": "string", "enum": ["None", "Present"] },
|
||||
"pr": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"ui": { "type": "string", "enum": ["None", "Passive", "Active"] },
|
||||
"vc": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"vi": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"va": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"sc": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"si": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"sa": { "type": "string", "enum": ["None", "Low", "High"] }
|
||||
}
|
||||
},
|
||||
"threatMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"e": { "type": "string", "enum": ["NotDefined", "Attacked", "ProofOfConcept", "Unreported"] }
|
||||
}
|
||||
},
|
||||
"environmentalMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mav": { "type": "string", "enum": ["NotDefined", "Network", "Adjacent", "Local", "Physical"] },
|
||||
"mac": { "type": "string", "enum": ["NotDefined", "Low", "High"] },
|
||||
"mat": { "type": "string", "enum": ["NotDefined", "None", "Present"] },
|
||||
"mpr": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mui": { "type": "string", "enum": ["NotDefined", "None", "Passive", "Active"] },
|
||||
"mvc": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mvi": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mva": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"msc": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"msi": { "type": "string", "enum": ["NotDefined", "Negligible", "Low", "High", "Safety"] },
|
||||
"msa": { "type": "string", "enum": ["NotDefined", "Negligible", "Low", "High", "Safety"] },
|
||||
"cr": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] },
|
||||
"ir": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] },
|
||||
"ar": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] }
|
||||
}
|
||||
},
|
||||
"supplementalMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"s": { "type": "string", "enum": ["NotDefined", "Negligible", "Present"] },
|
||||
"au": { "type": "string", "enum": ["NotDefined", "No", "Yes"] },
|
||||
"r": { "type": "string", "enum": ["NotDefined", "Automatic", "User", "Irrecoverable"] },
|
||||
"v": { "type": "string", "enum": ["NotDefined", "Diffuse", "Concentrated"] },
|
||||
"re": { "type": "string", "enum": ["NotDefined", "Low", "Moderate", "High"] },
|
||||
"u": { "type": "string", "enum": ["NotDefined", "Clear", "Green", "Amber", "Red"] }
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"type": "object",
|
||||
"required": ["baseScore", "effectiveScore", "effectiveScoreType"],
|
||||
"properties": {
|
||||
"baseScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"threatScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"environmentalScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"fullScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"effectiveScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"effectiveScoreType": { "type": "string", "enum": ["Base", "Threat", "Environmental", "Full"] }
|
||||
}
|
||||
},
|
||||
"policyRef": {
|
||||
"type": "object",
|
||||
"required": ["policyId", "version", "hash"],
|
||||
"properties": {
|
||||
"policyId": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"hash": { "type": "string" },
|
||||
"activatedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"evidenceItem": {
|
||||
"type": "object",
|
||||
"required": ["type", "uri"],
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"uri": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"collectedAt": { "type": "string", "format": "date-time" },
|
||||
"source": { "type": "string" },
|
||||
"isAuthoritative": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"historyEntry": {
|
||||
"type": "object",
|
||||
"required": ["historyId", "timestamp", "actor", "changeType", "field", "reason"],
|
||||
"properties": {
|
||||
"historyId": { "type": "string" },
|
||||
"timestamp": { "type": "string", "format": "date-time" },
|
||||
"actor": { "type": "string" },
|
||||
"changeType": { "type": "string", "enum": ["Created", "Amended", "Superseded", "Revoked", "EvidenceAdded", "AttestationSigned", "PolicyUpdated", "Recalculated"] },
|
||||
"field": { "type": "string" },
|
||||
"previousValue": { "type": "string" },
|
||||
"newValue": { "type": "string" },
|
||||
"reason": { "type": "string" },
|
||||
"referenceUri": { "type": "string" },
|
||||
"signature": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>CVSS v4.0 scoring engine with deterministic receipt generation for StellaOps policy decisions.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\cvss-policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\cvss-receipt-schema@1.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,498 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for CvssV4Engine per FIRST CVSS v4.0 specification.
|
||||
/// </summary>
|
||||
public sealed class CvssV4EngineTests
|
||||
{
|
||||
private readonly ICvssV4Engine _engine = new CvssV4Engine();
|
||||
|
||||
#region Base Score Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_MaximumSeverity_ReturnsScore10()
|
||||
{
|
||||
// Arrange - Highest severity: Network/Low/None/None/None/High across all impacts
|
||||
var metrics = CreateHighestSeverityMetrics();
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(metrics);
|
||||
|
||||
// Assert
|
||||
scores.BaseScore.Should().Be(10.0);
|
||||
scores.EffectiveScore.Should().Be(10.0);
|
||||
scores.EffectiveScoreType.Should().Be(EffectiveScoreType.Base);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_MinimumSeverity_ReturnsLowScore()
|
||||
{
|
||||
// Arrange - Lowest severity: Physical/High/Present/High/Active/None across all impacts
|
||||
var metrics = CreateLowestSeverityMetrics();
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(metrics);
|
||||
|
||||
// Assert
|
||||
scores.BaseScore.Should().BeLessThan(2.0);
|
||||
scores.EffectiveScore.Should().BeLessThan(2.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_MediumSeverity_ReturnsScoreInRange()
|
||||
{
|
||||
// Arrange - Medium severity combination
|
||||
var metrics = new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = AttackVector.Adjacent,
|
||||
AttackComplexity = AttackComplexity.Low,
|
||||
AttackRequirements = AttackRequirements.None,
|
||||
PrivilegesRequired = PrivilegesRequired.Low,
|
||||
UserInteraction = UserInteraction.Passive,
|
||||
VulnerableSystemConfidentiality = ImpactMetricValue.Low,
|
||||
VulnerableSystemIntegrity = ImpactMetricValue.Low,
|
||||
VulnerableSystemAvailability = ImpactMetricValue.None,
|
||||
SubsequentSystemConfidentiality = ImpactMetricValue.None,
|
||||
SubsequentSystemIntegrity = ImpactMetricValue.None,
|
||||
SubsequentSystemAvailability = ImpactMetricValue.None
|
||||
};
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(metrics);
|
||||
|
||||
// Assert
|
||||
scores.BaseScore.Should().BeInRange(4.0, 7.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threat Score Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_WithAttackedThreat_ReturnsThreatScore()
|
||||
{
|
||||
// Arrange
|
||||
var baseMetrics = CreateHighestSeverityMetrics();
|
||||
var threatMetrics = new CvssThreatMetrics { ExploitMaturity = ExploitMaturity.Attacked };
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(baseMetrics, threatMetrics);
|
||||
|
||||
// Assert
|
||||
scores.ThreatScore.Should().NotBeNull();
|
||||
scores.ThreatScore!.Value.Should().Be(10.0); // Attacked = 1.0 multiplier
|
||||
scores.EffectiveScoreType.Should().Be(EffectiveScoreType.Threat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_WithProofOfConceptThreat_ReducesScore()
|
||||
{
|
||||
// Arrange
|
||||
var baseMetrics = CreateHighestSeverityMetrics();
|
||||
var threatMetrics = new CvssThreatMetrics { ExploitMaturity = ExploitMaturity.ProofOfConcept };
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(baseMetrics, threatMetrics);
|
||||
|
||||
// Assert
|
||||
scores.ThreatScore.Should().NotBeNull();
|
||||
scores.ThreatScore!.Value.Should().BeLessThan(10.0); // PoC = 0.94 multiplier
|
||||
scores.ThreatScore.Value.Should().BeGreaterThan(9.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_WithUnreportedThreat_ReducesScoreMore()
|
||||
{
|
||||
// Arrange
|
||||
var baseMetrics = CreateHighestSeverityMetrics();
|
||||
var threatMetrics = new CvssThreatMetrics { ExploitMaturity = ExploitMaturity.Unreported };
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(baseMetrics, threatMetrics);
|
||||
|
||||
// Assert
|
||||
scores.ThreatScore.Should().NotBeNull();
|
||||
scores.ThreatScore!.Value.Should().BeLessThan(9.5); // Unreported = 0.91 multiplier
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_WithNotDefinedThreat_ReturnsOnlyBaseScore()
|
||||
{
|
||||
// Arrange
|
||||
var baseMetrics = CreateHighestSeverityMetrics();
|
||||
var threatMetrics = new CvssThreatMetrics { ExploitMaturity = ExploitMaturity.NotDefined };
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(baseMetrics, threatMetrics);
|
||||
|
||||
// Assert
|
||||
scores.ThreatScore.Should().BeNull();
|
||||
scores.EffectiveScoreType.Should().Be(EffectiveScoreType.Base);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environmental Score Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_WithHighSecurityRequirements_IncreasesScore()
|
||||
{
|
||||
// Arrange
|
||||
var baseMetrics = CreateMediumSeverityMetrics();
|
||||
var envMetrics = new CvssEnvironmentalMetrics
|
||||
{
|
||||
ConfidentialityRequirement = SecurityRequirement.High,
|
||||
IntegrityRequirement = SecurityRequirement.High,
|
||||
AvailabilityRequirement = SecurityRequirement.High
|
||||
};
|
||||
|
||||
// Act
|
||||
var scoresWithoutEnv = _engine.ComputeScores(baseMetrics);
|
||||
var scoresWithEnv = _engine.ComputeScores(baseMetrics, environmentalMetrics: envMetrics);
|
||||
|
||||
// Assert
|
||||
scoresWithEnv.EnvironmentalScore.Should().NotBeNull();
|
||||
scoresWithEnv.EnvironmentalScore!.Value.Should().BeGreaterThan(scoresWithoutEnv.BaseScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_WithLowSecurityRequirements_DecreasesScore()
|
||||
{
|
||||
// Arrange
|
||||
var baseMetrics = CreateMediumSeverityMetrics();
|
||||
var envMetrics = new CvssEnvironmentalMetrics
|
||||
{
|
||||
ConfidentialityRequirement = SecurityRequirement.Low,
|
||||
IntegrityRequirement = SecurityRequirement.Low,
|
||||
AvailabilityRequirement = SecurityRequirement.Low
|
||||
};
|
||||
|
||||
// Act
|
||||
var scoresWithoutEnv = _engine.ComputeScores(baseMetrics);
|
||||
var scoresWithEnv = _engine.ComputeScores(baseMetrics, environmentalMetrics: envMetrics);
|
||||
|
||||
// Assert
|
||||
scoresWithEnv.EnvironmentalScore.Should().NotBeNull();
|
||||
scoresWithEnv.EnvironmentalScore!.Value.Should().BeLessThan(scoresWithoutEnv.BaseScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_WithModifiedMetrics_AppliesModifications()
|
||||
{
|
||||
// Arrange - Start with network-based vuln, modify to local
|
||||
var baseMetrics = CreateHighestSeverityMetrics();
|
||||
var envMetrics = new CvssEnvironmentalMetrics
|
||||
{
|
||||
ModifiedAttackVector = ModifiedAttackVector.Local
|
||||
};
|
||||
|
||||
// Act
|
||||
var scoresWithoutEnv = _engine.ComputeScores(baseMetrics);
|
||||
var scoresWithEnv = _engine.ComputeScores(baseMetrics, environmentalMetrics: envMetrics);
|
||||
|
||||
// Assert
|
||||
scoresWithEnv.EnvironmentalScore.Should().NotBeNull();
|
||||
scoresWithEnv.EnvironmentalScore!.Value.Should().BeLessThan(scoresWithoutEnv.BaseScore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Full Score Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_WithAllMetrics_ReturnsFullScore()
|
||||
{
|
||||
// Arrange
|
||||
var baseMetrics = CreateHighestSeverityMetrics();
|
||||
var threatMetrics = new CvssThreatMetrics { ExploitMaturity = ExploitMaturity.Attacked };
|
||||
var envMetrics = new CvssEnvironmentalMetrics
|
||||
{
|
||||
ConfidentialityRequirement = SecurityRequirement.High
|
||||
};
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(baseMetrics, threatMetrics, envMetrics);
|
||||
|
||||
// Assert
|
||||
scores.FullScore.Should().NotBeNull();
|
||||
scores.EffectiveScoreType.Should().Be(EffectiveScoreType.Full);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Vector String Tests
|
||||
|
||||
[Fact]
|
||||
public void BuildVectorString_BaseOnly_ReturnsCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = CreateHighestSeverityMetrics();
|
||||
|
||||
// Act
|
||||
var vector = _engine.BuildVectorString(metrics);
|
||||
|
||||
// Assert
|
||||
vector.Should().StartWith("CVSS:4.0/");
|
||||
vector.Should().Contain("AV:N");
|
||||
vector.Should().Contain("AC:L");
|
||||
vector.Should().Contain("AT:N");
|
||||
vector.Should().Contain("PR:N");
|
||||
vector.Should().Contain("UI:N");
|
||||
vector.Should().Contain("VC:H");
|
||||
vector.Should().Contain("VI:H");
|
||||
vector.Should().Contain("VA:H");
|
||||
vector.Should().Contain("SC:H");
|
||||
vector.Should().Contain("SI:H");
|
||||
vector.Should().Contain("SA:H");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildVectorString_WithThreat_IncludesThreatMetric()
|
||||
{
|
||||
// Arrange
|
||||
var baseMetrics = CreateHighestSeverityMetrics();
|
||||
var threatMetrics = new CvssThreatMetrics { ExploitMaturity = ExploitMaturity.Attacked };
|
||||
|
||||
// Act
|
||||
var vector = _engine.BuildVectorString(baseMetrics, threatMetrics);
|
||||
|
||||
// Assert
|
||||
vector.Should().Contain("E:A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseVector_ValidVector_ReturnsCorrectMetrics()
|
||||
{
|
||||
// Arrange
|
||||
var vector = "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H";
|
||||
|
||||
// Act
|
||||
var result = _engine.ParseVector(vector);
|
||||
|
||||
// Assert
|
||||
result.BaseMetrics.AttackVector.Should().Be(AttackVector.Network);
|
||||
result.BaseMetrics.AttackComplexity.Should().Be(AttackComplexity.Low);
|
||||
result.BaseMetrics.AttackRequirements.Should().Be(AttackRequirements.None);
|
||||
result.BaseMetrics.PrivilegesRequired.Should().Be(PrivilegesRequired.None);
|
||||
result.BaseMetrics.UserInteraction.Should().Be(UserInteraction.None);
|
||||
result.BaseMetrics.VulnerableSystemConfidentiality.Should().Be(ImpactMetricValue.High);
|
||||
result.BaseMetrics.VulnerableSystemIntegrity.Should().Be(ImpactMetricValue.High);
|
||||
result.BaseMetrics.VulnerableSystemAvailability.Should().Be(ImpactMetricValue.High);
|
||||
result.BaseMetrics.SubsequentSystemConfidentiality.Should().Be(ImpactMetricValue.High);
|
||||
result.BaseMetrics.SubsequentSystemIntegrity.Should().Be(ImpactMetricValue.High);
|
||||
result.BaseMetrics.SubsequentSystemAvailability.Should().Be(ImpactMetricValue.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseVector_WithThreat_ParsesThreatMetric()
|
||||
{
|
||||
// Arrange
|
||||
var vector = "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:A";
|
||||
|
||||
// Act
|
||||
var result = _engine.ParseVector(vector);
|
||||
|
||||
// Assert
|
||||
result.ThreatMetrics.Should().NotBeNull();
|
||||
result.ThreatMetrics!.ExploitMaturity.Should().Be(ExploitMaturity.Attacked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseVector_InvalidPrefix_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var vector = "CVSS:3.1/AV:N/AC:L";
|
||||
|
||||
// Act & Assert
|
||||
FluentActions.Invoking(() => _engine.ParseVector(vector))
|
||||
.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseVector_MissingMetric_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange - Missing AV metric
|
||||
var vector = "CVSS:4.0/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H";
|
||||
|
||||
// Act & Assert
|
||||
FluentActions.Invoking(() => _engine.ParseVector(vector))
|
||||
.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Severity Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, CvssSeverity.None)]
|
||||
[InlineData(0.1, CvssSeverity.Low)]
|
||||
[InlineData(3.9, CvssSeverity.Low)]
|
||||
[InlineData(4.0, CvssSeverity.Medium)]
|
||||
[InlineData(6.9, CvssSeverity.Medium)]
|
||||
[InlineData(7.0, CvssSeverity.High)]
|
||||
[InlineData(8.9, CvssSeverity.High)]
|
||||
[InlineData(9.0, CvssSeverity.Critical)]
|
||||
[InlineData(10.0, CvssSeverity.Critical)]
|
||||
public void GetSeverity_DefaultThresholds_ReturnsCorrectSeverity(double score, CvssSeverity expected)
|
||||
{
|
||||
// Act
|
||||
var severity = _engine.GetSeverity(score);
|
||||
|
||||
// Assert
|
||||
severity.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSeverity_CustomThresholds_UsesCustomValues()
|
||||
{
|
||||
// Arrange
|
||||
var thresholds = new CvssSeverityThresholds
|
||||
{
|
||||
LowMin = 0.1,
|
||||
MediumMin = 5.0, // Higher than default 4.0
|
||||
HighMin = 8.0, // Higher than default 7.0
|
||||
CriticalMin = 9.5 // Higher than default 9.0
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
_engine.GetSeverity(4.5, thresholds).Should().Be(CvssSeverity.Low);
|
||||
_engine.GetSeverity(7.5, thresholds).Should().Be(CvssSeverity.Medium);
|
||||
_engine.GetSeverity(9.0, thresholds).Should().Be(CvssSeverity.High);
|
||||
_engine.GetSeverity(9.5, thresholds).Should().Be(CvssSeverity.Critical);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeScores_SameInput_ReturnsSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = CreateHighestSeverityMetrics();
|
||||
|
||||
// Act
|
||||
var scores1 = _engine.ComputeScores(metrics);
|
||||
var scores2 = _engine.ComputeScores(metrics);
|
||||
var scores3 = _engine.ComputeScores(metrics);
|
||||
|
||||
// Assert
|
||||
scores1.BaseScore.Should().Be(scores2.BaseScore);
|
||||
scores2.BaseScore.Should().Be(scores3.BaseScore);
|
||||
scores1.EffectiveScore.Should().Be(scores3.EffectiveScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildVectorString_SameInput_ReturnsSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = CreateHighestSeverityMetrics();
|
||||
|
||||
// Act
|
||||
var vector1 = _engine.BuildVectorString(metrics);
|
||||
var vector2 = _engine.BuildVectorString(metrics);
|
||||
|
||||
// Assert
|
||||
vector1.Should().Be(vector2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_BuildAndParse_PreservesMetrics()
|
||||
{
|
||||
// Arrange
|
||||
var originalMetrics = CreateHighestSeverityMetrics();
|
||||
|
||||
// Act
|
||||
var vector = _engine.BuildVectorString(originalMetrics);
|
||||
var parsed = _engine.ParseVector(vector);
|
||||
|
||||
// Assert
|
||||
parsed.BaseMetrics.AttackVector.Should().Be(originalMetrics.AttackVector);
|
||||
parsed.BaseMetrics.AttackComplexity.Should().Be(originalMetrics.AttackComplexity);
|
||||
parsed.BaseMetrics.AttackRequirements.Should().Be(originalMetrics.AttackRequirements);
|
||||
parsed.BaseMetrics.PrivilegesRequired.Should().Be(originalMetrics.PrivilegesRequired);
|
||||
parsed.BaseMetrics.UserInteraction.Should().Be(originalMetrics.UserInteraction);
|
||||
parsed.BaseMetrics.VulnerableSystemConfidentiality.Should().Be(originalMetrics.VulnerableSystemConfidentiality);
|
||||
parsed.BaseMetrics.VulnerableSystemIntegrity.Should().Be(originalMetrics.VulnerableSystemIntegrity);
|
||||
parsed.BaseMetrics.VulnerableSystemAvailability.Should().Be(originalMetrics.VulnerableSystemAvailability);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FIRST Sample Vector Tests
|
||||
|
||||
/// <summary>
|
||||
/// Tests using sample vectors from FIRST CVSS v4.0 examples.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H", 10.0)]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", 9.4)]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N", 6.8)]
|
||||
public void ComputeScores_FirstSampleVectors_ReturnsExpectedScore(string vector, double expectedScore)
|
||||
{
|
||||
// Arrange
|
||||
var metricSet = _engine.ParseVector(vector);
|
||||
|
||||
// Act
|
||||
var scores = _engine.ComputeScores(metricSet.BaseMetrics);
|
||||
|
||||
// Assert - Allow small tolerance for rounding differences
|
||||
scores.BaseScore.Should().BeApproximately(expectedScore, 0.5);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static CvssBaseMetrics CreateHighestSeverityMetrics() => new()
|
||||
{
|
||||
AttackVector = AttackVector.Network,
|
||||
AttackComplexity = AttackComplexity.Low,
|
||||
AttackRequirements = AttackRequirements.None,
|
||||
PrivilegesRequired = PrivilegesRequired.None,
|
||||
UserInteraction = UserInteraction.None,
|
||||
VulnerableSystemConfidentiality = ImpactMetricValue.High,
|
||||
VulnerableSystemIntegrity = ImpactMetricValue.High,
|
||||
VulnerableSystemAvailability = ImpactMetricValue.High,
|
||||
SubsequentSystemConfidentiality = ImpactMetricValue.High,
|
||||
SubsequentSystemIntegrity = ImpactMetricValue.High,
|
||||
SubsequentSystemAvailability = ImpactMetricValue.High
|
||||
};
|
||||
|
||||
private static CvssBaseMetrics CreateLowestSeverityMetrics() => new()
|
||||
{
|
||||
AttackVector = AttackVector.Physical,
|
||||
AttackComplexity = AttackComplexity.High,
|
||||
AttackRequirements = AttackRequirements.Present,
|
||||
PrivilegesRequired = PrivilegesRequired.High,
|
||||
UserInteraction = UserInteraction.Active,
|
||||
VulnerableSystemConfidentiality = ImpactMetricValue.None,
|
||||
VulnerableSystemIntegrity = ImpactMetricValue.None,
|
||||
VulnerableSystemAvailability = ImpactMetricValue.None,
|
||||
SubsequentSystemConfidentiality = ImpactMetricValue.None,
|
||||
SubsequentSystemIntegrity = ImpactMetricValue.None,
|
||||
SubsequentSystemAvailability = ImpactMetricValue.None
|
||||
};
|
||||
|
||||
private static CvssBaseMetrics CreateMediumSeverityMetrics() => new()
|
||||
{
|
||||
AttackVector = AttackVector.Network,
|
||||
AttackComplexity = AttackComplexity.Low,
|
||||
AttackRequirements = AttackRequirements.None,
|
||||
PrivilegesRequired = PrivilegesRequired.Low,
|
||||
UserInteraction = UserInteraction.None,
|
||||
VulnerableSystemConfidentiality = ImpactMetricValue.Low,
|
||||
VulnerableSystemIntegrity = ImpactMetricValue.Low,
|
||||
VulnerableSystemAvailability = ImpactMetricValue.None,
|
||||
SubsequentSystemConfidentiality = ImpactMetricValue.None,
|
||||
SubsequentSystemIntegrity = ImpactMetricValue.None,
|
||||
SubsequentSystemAvailability = ImpactMetricValue.None
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user