Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
393 lines
14 KiB
C#
393 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Globalization;
|
|
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;
|
|
|
|
internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
|
{
|
|
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<ConcelierObservationsClient> logger;
|
|
private readonly IStellaOpsTokenClient? tokenClient;
|
|
private readonly object tokenSync = new();
|
|
|
|
private string? cachedAccessToken;
|
|
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
|
|
|
public ConcelierObservationsClient(
|
|
HttpClient httpClient,
|
|
StellaOpsCliOptions options,
|
|
ILogger<ConcelierObservationsClient> 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.ConcelierUrl) && httpClient.BaseAddress is null)
|
|
{
|
|
if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri))
|
|
{
|
|
httpClient.BaseAddress = baseUri;
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
|
AdvisoryObservationsQuery query,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
|
|
EnsureConfigured();
|
|
|
|
var requestUri = BuildRequestUri(query);
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
|
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to query observations (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var result = await JsonSerializer
|
|
.DeserializeAsync<AdvisoryObservationsResponse>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return result ?? new AdvisoryObservationsResponse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets advisory linkset with conflict information.
|
|
/// Per CLI-LNM-22-001.
|
|
/// </summary>
|
|
public async Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
|
AdvisoryLinksetQuery query,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
|
|
EnsureConfigured();
|
|
|
|
var requestUri = BuildLinksetRequestUri(query);
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
|
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
logger.LogError(
|
|
"Failed to query linkset (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var result = await JsonSerializer
|
|
.DeserializeAsync<AdvisoryLinksetResponse>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return result ?? new AdvisoryLinksetResponse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a single observation by ID.
|
|
/// Per CLI-LNM-22-001.
|
|
/// </summary>
|
|
public async Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
|
string tenant,
|
|
string observationId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
|
|
|
EnsureConfigured();
|
|
|
|
var requestUri = $"/concelier/observations/{Uri.EscapeDataString(observationId)}?tenant={Uri.EscapeDataString(tenant)}";
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
|
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
using var response = await httpClient.SendAsync(request, 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 observation (status {StatusCode}). Response: {Payload}",
|
|
(int)response.StatusCode,
|
|
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
return await JsonSerializer
|
|
.DeserializeAsync<AdvisoryLinksetObservation>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private static string BuildRequestUri(AdvisoryObservationsQuery query)
|
|
{
|
|
var builder = new StringBuilder("/concelier/observations?tenant=");
|
|
builder.Append(Uri.EscapeDataString(query.Tenant));
|
|
|
|
AppendValues(builder, "observationId", query.ObservationIds);
|
|
AppendValues(builder, "alias", query.Aliases);
|
|
AppendValues(builder, "purl", query.Purls);
|
|
AppendValues(builder, "cpe", query.Cpes);
|
|
|
|
if (query.Limit.HasValue && query.Limit.Value > 0)
|
|
{
|
|
builder.Append('&');
|
|
builder.Append("limit=");
|
|
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
|
{
|
|
builder.Append('&');
|
|
builder.Append("cursor=");
|
|
builder.Append(Uri.EscapeDataString(query.Cursor));
|
|
}
|
|
|
|
return builder.ToString();
|
|
|
|
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var value in values)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
builder.Append('&');
|
|
builder.Append(name);
|
|
builder.Append('=');
|
|
builder.Append(Uri.EscapeDataString(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string BuildLinksetRequestUri(AdvisoryLinksetQuery query)
|
|
{
|
|
var builder = new StringBuilder("/concelier/linkset?tenant=");
|
|
builder.Append(Uri.EscapeDataString(query.Tenant));
|
|
|
|
AppendValues(builder, "observationId", query.ObservationIds);
|
|
AppendValues(builder, "alias", query.Aliases);
|
|
AppendValues(builder, "purl", query.Purls);
|
|
AppendValues(builder, "cpe", query.Cpes);
|
|
AppendValues(builder, "source", query.Sources);
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.Severity))
|
|
{
|
|
builder.Append("&severity=");
|
|
builder.Append(Uri.EscapeDataString(query.Severity));
|
|
}
|
|
|
|
if (query.KevOnly.HasValue)
|
|
{
|
|
builder.Append("&kevOnly=");
|
|
builder.Append(query.KevOnly.Value ? "true" : "false");
|
|
}
|
|
|
|
if (query.HasFix.HasValue)
|
|
{
|
|
builder.Append("&hasFix=");
|
|
builder.Append(query.HasFix.Value ? "true" : "false");
|
|
}
|
|
|
|
if (query.Limit.HasValue && query.Limit.Value > 0)
|
|
{
|
|
builder.Append("&limit=");
|
|
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
|
{
|
|
builder.Append("&cursor=");
|
|
builder.Append(Uri.EscapeDataString(query.Cursor));
|
|
}
|
|
|
|
return builder.ToString();
|
|
|
|
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var value in values)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
builder.Append('&');
|
|
builder.Append(name);
|
|
builder.Append('=');
|
|
builder.Append(Uri.EscapeDataString(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
private void EnsureConfigured()
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
|
|
{
|
|
return;
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
"ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_URL.");
|
|
}
|
|
|
|
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
|
if (!string.IsNullOrWhiteSpace(token))
|
|
{
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
}
|
|
}
|
|
|
|
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(options.ApiKey))
|
|
{
|
|
return options.ApiKey;
|
|
}
|
|
|
|
if (tokenClient is null || string.IsNullOrWhiteSpace(options.Authority.Url))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
lock (tokenSync)
|
|
{
|
|
if (!string.IsNullOrEmpty(cachedAccessToken) && now < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
|
{
|
|
return cachedAccessToken;
|
|
}
|
|
}
|
|
|
|
var (scope, cacheKey) = BuildScopeAndCacheKey(options);
|
|
var cachedEntry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
|
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
|
|
{
|
|
lock (tokenSync)
|
|
{
|
|
cachedAccessToken = cachedEntry.AccessToken;
|
|
cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
|
|
return cachedAccessToken;
|
|
}
|
|
}
|
|
|
|
StellaOpsTokenResult token;
|
|
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(options.Authority.Password))
|
|
{
|
|
throw new InvalidOperationException("Authority password must be configured when username is provided.");
|
|
}
|
|
|
|
token = await tokenClient.RequestPasswordTokenAsync(
|
|
options.Authority.Username,
|
|
options.Authority.Password!,
|
|
scope,
|
|
null,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
|
|
|
lock (tokenSync)
|
|
{
|
|
cachedAccessToken = token.AccessToken;
|
|
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
|
return cachedAccessToken;
|
|
}
|
|
}
|
|
|
|
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
|
|
{
|
|
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
|
|
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
|
|
|
|
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
|
? $"user:{options.Authority.Username}"
|
|
: $"client:{options.Authority.ClientId}";
|
|
|
|
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
|
|
return (finalScope, cacheKey);
|
|
}
|
|
|
|
private static string EnsureScope(string scopes, string required)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(scopes))
|
|
{
|
|
return required;
|
|
}
|
|
|
|
var parts = scopes
|
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(static scope => scope.ToLowerInvariant())
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
if (!parts.Contains(required, StringComparer.Ordinal))
|
|
{
|
|
parts.Add(required);
|
|
}
|
|
|
|
return string.Join(' ', parts);
|
|
}
|
|
}
|