sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -0,0 +1,48 @@
// -----------------------------------------------------------------------------
// IRationaleClient.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-021 - Integrate into CLI triage commands
// Description: Client interface for verdict rationale API.
// -----------------------------------------------------------------------------
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for verdict rationale API operations.
/// </summary>
internal interface IRationaleClient
{
/// <summary>
/// Gets the verdict rationale for a finding.
/// </summary>
/// <param name="findingId">The finding ID.</param>
/// <param name="format">Output format: json, plaintext, or markdown.</param>
/// <param name="tenant">Optional tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rationale response, or null if not found.</returns>
Task<VerdictRationaleResponse?> GetRationaleAsync(
string findingId,
string format,
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Gets the verdict rationale as plain text.
/// </summary>
Task<RationalePlainTextResponse?> GetRationalePlainTextAsync(
string findingId,
string? tenant,
CancellationToken cancellationToken);
/// <summary>
/// Gets the verdict rationale as markdown.
/// </summary>
Task<RationalePlainTextResponse?> GetRationaleMarkdownAsync(
string findingId,
string? tenant,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,189 @@
// -----------------------------------------------------------------------------
// RationaleModels.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-021 - Integrate into CLI triage commands
// Description: CLI models for verdict rationale responses.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
/// <summary>
/// Response DTO for verdict rationale.
/// </summary>
public sealed class VerdictRationaleResponse
{
[JsonPropertyName("findingId")]
public string FindingId { get; set; } = string.Empty;
[JsonPropertyName("rationaleId")]
public string RationaleId { get; set; } = string.Empty;
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; set; } = "1.0";
[JsonPropertyName("evidence")]
public RationaleEvidenceModel? Evidence { get; set; }
[JsonPropertyName("policyClause")]
public RationalePolicyClauseModel? PolicyClause { get; set; }
[JsonPropertyName("attestations")]
public RationaleAttestationsModel? Attestations { get; set; }
[JsonPropertyName("decision")]
public RationaleDecisionModel? Decision { get; set; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; set; }
[JsonPropertyName("inputDigests")]
public RationaleInputDigestsModel? InputDigests { get; set; }
}
/// <summary>
/// Evidence section of the rationale.
/// </summary>
public sealed class RationaleEvidenceModel
{
[JsonPropertyName("cve")]
public string? Cve { get; set; }
[JsonPropertyName("componentPurl")]
public string? ComponentPurl { get; set; }
[JsonPropertyName("componentVersion")]
public string? ComponentVersion { get; set; }
[JsonPropertyName("vulnerableFunction")]
public string? VulnerableFunction { get; set; }
[JsonPropertyName("entryPoint")]
public string? EntryPoint { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// Policy clause section of the rationale.
/// </summary>
public sealed class RationalePolicyClauseModel
{
[JsonPropertyName("clauseId")]
public string? ClauseId { get; set; }
[JsonPropertyName("ruleDescription")]
public string? RuleDescription { get; set; }
[JsonPropertyName("conditions")]
public IReadOnlyList<string>? Conditions { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// Attestations section of the rationale.
/// </summary>
public sealed class RationaleAttestationsModel
{
[JsonPropertyName("pathWitness")]
public RationaleAttestationRefModel? PathWitness { get; set; }
[JsonPropertyName("vexStatements")]
public IReadOnlyList<RationaleAttestationRefModel>? VexStatements { get; set; }
[JsonPropertyName("provenance")]
public RationaleAttestationRefModel? Provenance { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// Reference to an attestation.
/// </summary>
public sealed class RationaleAttestationRefModel
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("digest")]
public string? Digest { get; set; }
[JsonPropertyName("summary")]
public string? Summary { get; set; }
}
/// <summary>
/// Decision section of the rationale.
/// </summary>
public sealed class RationaleDecisionModel
{
[JsonPropertyName("verdict")]
public string? Verdict { get; set; }
[JsonPropertyName("score")]
public double? Score { get; set; }
[JsonPropertyName("recommendation")]
public string? Recommendation { get; set; }
[JsonPropertyName("mitigation")]
public RationaleMitigationModel? Mitigation { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// Mitigation guidance.
/// </summary>
public sealed class RationaleMitigationModel
{
[JsonPropertyName("action")]
public string? Action { get; set; }
[JsonPropertyName("details")]
public string? Details { get; set; }
}
/// <summary>
/// Input digests for reproducibility.
/// </summary>
public sealed class RationaleInputDigestsModel
{
[JsonPropertyName("verdictDigest")]
public string? VerdictDigest { get; set; }
[JsonPropertyName("policyDigest")]
public string? PolicyDigest { get; set; }
[JsonPropertyName("evidenceDigest")]
public string? EvidenceDigest { get; set; }
}
/// <summary>
/// Plain text rationale response.
/// </summary>
public sealed class RationalePlainTextResponse
{
[JsonPropertyName("findingId")]
public string FindingId { get; set; } = string.Empty;
[JsonPropertyName("rationaleId")]
public string RationaleId { get; set; } = string.Empty;
[JsonPropertyName("format")]
public string Format { get; set; } = string.Empty;
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,274 @@
// -----------------------------------------------------------------------------
// RationaleClient.cs
// Sprint: SPRINT_20260106_001_001_LB_verdict_rationale_renderer
// Task: VRR-021 - Integrate into CLI triage commands
// Description: Client implementation for verdict rationale API.
// -----------------------------------------------------------------------------
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.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for verdict rationale API operations.
/// </summary>
internal sealed class RationaleClient : IRationaleClient
{
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<RationaleClient> _logger;
private readonly IStellaOpsTokenClient? _tokenClient;
private readonly object _tokenSync = new();
private string? _cachedAccessToken;
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public RationaleClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<RationaleClient> 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<VerdictRationaleResponse?> GetRationaleAsync(
string findingId,
string format,
string? tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
try
{
EnsureConfigured();
var uri = $"/api/v1/triage/findings/{Uri.EscapeDataString(findingId)}/rationale?format={Uri.EscapeDataString(format)}";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"&tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "triage.read", cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("Rationale not found for finding {FindingId}", findingId);
return null;
}
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError(
"Failed to get rationale (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<VerdictRationaleResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error while getting rationale for finding {FindingId}", findingId);
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogError(ex, "Request timed out while getting rationale for finding {FindingId}", findingId);
return null;
}
}
public async Task<RationalePlainTextResponse?> GetRationalePlainTextAsync(
string findingId,
string? tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
try
{
EnsureConfigured();
var uri = $"/api/v1/triage/findings/{Uri.EscapeDataString(findingId)}/rationale?format=plaintext";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"&tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "triage.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 rationale (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<RationalePlainTextResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error while getting rationale plaintext");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogError(ex, "Request timed out while getting rationale plaintext");
return null;
}
}
public async Task<RationalePlainTextResponse?> GetRationaleMarkdownAsync(
string findingId,
string? tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
try
{
EnsureConfigured();
var uri = $"/api/v1/triage/findings/{Uri.EscapeDataString(findingId)}/rationale?format=markdown";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"&tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "triage.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 rationale (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<RationalePlainTextResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error while getting rationale markdown");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogError(ex, "Request timed out while getting rationale markdown");
return null;
}
}
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;
}
}
try
{
var result = await _tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (_tokenSync)
{
_cachedAccessToken = result.AccessToken;
_cachedAccessTokenExpiresAt = result.ExpiresAt;
}
return result.AccessToken;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -16,13 +16,15 @@ public sealed class HttpTransport : IStellaOpsTransport
private readonly HttpClient _httpClient;
private readonly TransportOptions _options;
private readonly ILogger<HttpTransport> _logger;
private readonly Func<double> _jitterSource;
private bool _disposed;
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger)
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger, Func<double>? jitterSource = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jitterSource = jitterSource ?? Random.Shared.NextDouble;
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
{
@@ -114,11 +116,11 @@ public sealed class HttpTransport : IStellaOpsTransport
|| (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 500);
}
private static TimeSpan GetRetryDelay(int attempt)
private TimeSpan GetRetryDelay(int attempt)
{
// Exponential backoff with jitter
var baseDelay = Math.Pow(2, attempt);
var jitter = Random.Shared.NextDouble() * 0.5;
var jitter = _jitterSource() * 0.5;
return TimeSpan.FromSeconds(baseDelay + jitter);
}