sprints and audit work
This commit is contained in:
48
src/Cli/StellaOps.Cli/Services/IRationaleClient.cs
Normal file
48
src/Cli/StellaOps.Cli/Services/IRationaleClient.cs
Normal 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);
|
||||
}
|
||||
189
src/Cli/StellaOps.Cli/Services/Models/RationaleModels.cs
Normal file
189
src/Cli/StellaOps.Cli/Services/Models/RationaleModels.cs
Normal 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;
|
||||
}
|
||||
274
src/Cli/StellaOps.Cli/Services/RationaleClient.cs
Normal file
274
src/Cli/StellaOps.Cli/Services/RationaleClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user