Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
233 lines
8.2 KiB
C#
233 lines
8.2 KiB
C#
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.Extensions;
|
|
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 IStellaOpsTokenClient? _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,
|
|
IStellaOpsTokenClient? 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;
|
|
}
|
|
|
|
try
|
|
{
|
|
var tokenResult = await _tokenClient.GetAccessTokenAsync(
|
|
StellaOpsScopes.VexRead,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!string.IsNullOrWhiteSpace(tokenResult.AccessToken))
|
|
{
|
|
_cachedToken = tokenResult.AccessToken;
|
|
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "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('&', '?');
|
|
}
|
|
}
|