feat: Implement CVSS receipt management client and models
Some checks failed
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
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-07 01:14:28 +02:00
parent 53889d85e7
commit 69651212ec
30 changed files with 815 additions and 109 deletions

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
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.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Policy.Scoring;
namespace StellaOps.Cli.Services;
internal sealed class CvssClient : ICvssClient
{
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<CvssClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public CvssClient(HttpClient httpClient, StellaOpsCliOptions options, ILogger<CvssClient> 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<CvssScoreReceipt?> CreateReceiptAsync(CreateCvssReceipt request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureConfigured();
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/cvss/receipts")
{
Content = JsonContent.Create(request, options: SerializerOptions)
};
await AuthorizeRequestAsync(httpRequest, StellaOps.Auth.Abstractions.StellaOpsScopes.PolicyRun, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
return await ReadResponseAsync<CvssScoreReceipt>(response, cancellationToken).ConfigureAwait(false);
}
public async Task<CvssScoreReceipt?> GetReceiptAsync(string receiptId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(receiptId);
EnsureConfigured();
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/cvss/receipts/{Uri.EscapeDataString(receiptId)}");
await AuthorizeRequestAsync(httpRequest, StellaOps.Auth.Abstractions.StellaOpsScopes.FindingsRead, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
return await ReadResponseAsync<CvssScoreReceipt>(response, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<ReceiptHistoryEntry>> GetHistoryAsync(string receiptId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(receiptId);
EnsureConfigured();
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/cvss/receipts/{Uri.EscapeDataString(receiptId)}/history");
await AuthorizeRequestAsync(httpRequest, StellaOps.Auth.Abstractions.StellaOpsScopes.FindingsRead, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
return await ReadResponseAsync<IReadOnlyList<ReceiptHistoryEntry>>(response, cancellationToken).ConfigureAwait(false)
?? Array.Empty<ReceiptHistoryEntry>();
}
public async Task<IReadOnlyList<CvssPolicy>> ListPoliciesAsync(CancellationToken cancellationToken)
{
EnsureConfigured();
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, "/api/cvss/policies");
await AuthorizeRequestAsync(httpRequest, StellaOps.Auth.Abstractions.StellaOpsScopes.PolicyRun, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
return await ReadResponseAsync<IReadOnlyList<CvssPolicy>>(response, cancellationToken).ConfigureAwait(false)
?? Array.Empty<CvssPolicy>();
}
private async Task<T?> ReadResponseAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return default;
}
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogWarning("CVSS request failed with {Status}: {Body}", (int)response.StatusCode, string.IsNullOrWhiteSpace(body) ? "<empty>" : body);
return default;
}
return await response.Content.ReadFromJsonAsync<T>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
private void EnsureConfigured()
{
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
{
throw new InvalidOperationException("Backend URL not configured. Set STELLAOPS_BACKEND_URL or --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 for scope {Scope}", scope);
return null;
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Policy.Scoring;
namespace StellaOps.Cli.Services;
internal interface ICvssClient
{
Task<CvssScoreReceipt?> CreateReceiptAsync(CreateCvssReceipt request, CancellationToken cancellationToken);
Task<CvssScoreReceipt?> GetReceiptAsync(string receiptId, CancellationToken cancellationToken);
Task<IReadOnlyList<ReceiptHistoryEntry>> GetHistoryAsync(string receiptId, CancellationToken cancellationToken);
Task<IReadOnlyList<CvssPolicy>> ListPoliciesAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using StellaOps.Attestor.Envelope;
using StellaOps.Policy.Scoring;
namespace StellaOps.Cli.Services.Models;
internal sealed record CreateCvssReceipt(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("policy")] CvssPolicy Policy,
[property: JsonPropertyName("baseMetrics")] CvssBaseMetrics BaseMetrics,
[property: JsonPropertyName("threatMetrics")] CvssThreatMetrics? ThreatMetrics,
[property: JsonPropertyName("environmentalMetrics")] CvssEnvironmentalMetrics? EnvironmentalMetrics,
[property: JsonPropertyName("supplementalMetrics")] CvssSupplementalMetrics? SupplementalMetrics,
[property: JsonPropertyName("evidence")] IReadOnlyList<CvssEvidenceItem> Evidence,
[property: JsonPropertyName("signingKey")] EnvelopeKey? SigningKey,
[property: JsonPropertyName("createdBy")] string? CreatedBy,
[property: JsonPropertyName("createdAt")] DateTimeOffset? CreatedAt);