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; /// /// HTTP client for VEX observation queries. /// Per CLI-LNM-22-002. /// internal sealed class VexObservationsClient : IVexObservationsClient { private readonly HttpClient _httpClient; private readonly IStellaOpsTokenClient? _tokenClient; private readonly ILogger _logger; private string? _cachedToken; private DateTimeOffset _tokenExpiry; private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; public VexObservationsClient( HttpClient httpClient, ILogger logger, IStellaOpsTokenClient? tokenClient = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _tokenClient = tokenClient; } public async Task 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(content, SerializerOptions) ?? new VexObservationResponse(); } public async Task 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(content, SerializerOptions) ?? new VexLinksetResponse(); } public async Task 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(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('&', '?'); } }