Files
git.stella-ops.org/src/Excititor/StellaOps.Excititor.WebService/Endpoints/PolicyEndpoints.cs
master 2bd189387e
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
up
2025-12-10 19:15:01 +02:00

279 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Policy-facing VEX lookup endpoints (EXCITITOR-POLICY-20-001).
/// Aggregation-only: returns raw observations/statements without consensus or severity.
/// </summary>
public static class PolicyEndpoints
{
public static void MapPolicyEndpoints(this WebApplication app)
{
app.MapPost("/policy/v1/vex/lookup", LookupVexAsync)
.WithName("Policy_VexLookup")
.WithDescription("Batch VEX lookup by advisory_key and product (aggregation-only)");
}
private static async Task<IResult> LookupVexAsync(
HttpContext context,
[FromBody] PolicyVexLookupRequest request,
IOptions<VexStorageOptions> storageOptions,
[FromServices] IGraphOverlayStore overlayStore,
[FromServices] IVexClaimStore? claimStore,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
// AuthN/Z
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError!;
}
// Validate input
if ((request.AdvisoryKeys.Count == 0) && (request.Purls.Count == 0))
{
return Results.BadRequest(new { error = new { code = "ERR_REQUEST", message = "advisory_keys or purls must be provided" } });
}
var advisories = request.AdvisoryKeys
.Where(a => !string.IsNullOrWhiteSpace(a))
.Select(a => a.Trim())
.ToList();
var purls = request.Purls
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.ToList();
var statusFilter = request.Statuses
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim().ToLowerInvariant())
.ToImmutableHashSet();
var providerFilter = request.Providers
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
var overlays = await ResolveOverlaysAsync(overlayStore, tenant!, advisories, purls, request.Limit, cancellationToken).ConfigureAwait(false);
var filtered = overlays
.Where(o => MatchesProvider(providerFilter, o))
.Where(o => MatchesStatus(statusFilter, o))
.OrderBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.Purl, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.Source, StringComparer.OrdinalIgnoreCase)
.Take(Math.Clamp(request.Limit, 1, 500))
.ToList();
if (filtered.Count > 0)
{
var grouped = filtered
.GroupBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.Select(group => new PolicyVexLookupItem(
group.Key,
new[] { group.Key },
group.Select(MapStatement).ToList()))
.ToList();
var response = new PolicyVexLookupResponse(grouped, filtered.Count, timeProvider.GetUtcNow());
return Results.Ok(response);
}
if (claimStore is null)
{
return Results.Ok(new PolicyVexLookupResponse(Array.Empty<PolicyVexLookupItem>(), 0, timeProvider.GetUtcNow()));
}
var claimResults = await FallbackClaimsAsync(claimStore, advisories, purls, providerFilter, statusFilter, request.Limit, cancellationToken).ConfigureAwait(false);
var groupedClaims = claimResults
.GroupBy(c => c.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.Select(group => new PolicyVexLookupItem(group.Key, new[] { group.Key }, group.ToList()))
.ToList();
return Results.Ok(new PolicyVexLookupResponse(groupedClaims, claimResults.Count, timeProvider.GetUtcNow()));
}
private static async Task<IReadOnlyList<GraphOverlayItem>> ResolveOverlaysAsync(
IGraphOverlayStore overlayStore,
string tenant,
IReadOnlyList<string> advisories,
IReadOnlyList<string> purls,
int limit,
CancellationToken cancellationToken)
{
if (purls.Count > 0)
{
var overlays = await overlayStore.FindByPurlsAsync(tenant, purls, cancellationToken).ConfigureAwait(false);
if (advisories.Count == 0)
{
return overlays;
}
return overlays.Where(o => advisories.Contains(o.AdvisoryId, StringComparer.OrdinalIgnoreCase)).ToList();
}
return await overlayStore.FindByAdvisoriesAsync(tenant, advisories, limit, cancellationToken).ConfigureAwait(false);
}
private static bool MatchesProvider(ISet<string> providers, GraphOverlayItem overlay)
=> providers.Count == 0 || providers.Contains(overlay.Source, StringComparer.OrdinalIgnoreCase);
private static bool MatchesStatus(ISet<string> statuses, GraphOverlayItem overlay)
=> statuses.Count == 0 || statuses.Contains(overlay.Status, StringComparer.OrdinalIgnoreCase);
private static PolicyVexStatement MapStatement(GraphOverlayItem overlay)
{
var firstSeen = overlay.Observations.Count == 0
? overlay.GeneratedAt
: overlay.Observations.Min(o => o.FetchedAt);
var lastSeen = overlay.Observations.Count == 0
? overlay.GeneratedAt
: overlay.Observations.Max(o => o.FetchedAt);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["schemaVersion"] = overlay.SchemaVersion,
["linksetId"] = overlay.Provenance.LinksetId,
["linksetHash"] = overlay.Provenance.LinksetHash,
["source"] = overlay.Source
};
if (!string.IsNullOrWhiteSpace(overlay.Provenance.PlanCacheKey))
{
metadata["planCacheKey"] = overlay.Provenance.PlanCacheKey!;
}
var justification = overlay.Justifications.FirstOrDefault();
var primaryObservation = overlay.Observations.FirstOrDefault();
return new PolicyVexStatement(
ObservationId: primaryObservation?.Id ?? $"{overlay.Source}:{overlay.AdvisoryId}",
ProviderId: overlay.Source,
Status: overlay.Status,
ProductKey: overlay.Purl,
Purl: overlay.Purl,
Cpe: null,
Version: null,
Justification: justification?.Kind,
Detail: justification?.Reason,
FirstSeen: firstSeen,
LastSeen: lastSeen,
Signature: null,
Metadata: metadata);
}
private static async Task<List<PolicyVexStatement>> FallbackClaimsAsync(
IVexClaimStore claimStore,
IReadOnlyList<string> advisories,
IReadOnlyList<string> purls,
ISet<string> providers,
ISet<string> statuses,
int limit,
CancellationToken cancellationToken)
{
var results = new List<PolicyVexStatement>();
foreach (var advisory in advisories)
{
var claims = await claimStore.FindByVulnerabilityAsync(advisory, limit, cancellationToken).ConfigureAwait(false);
var filtered = claims
.Where(c => providers.Count == 0 || providers.Contains(c.ProviderId, StringComparer.OrdinalIgnoreCase))
.Where(c => statuses.Count == 0 || statuses.Contains(c.Status.ToString().ToLowerInvariant()))
.Where(c => purls.Count == 0 || purls.Contains(c.Product.Key, StringComparer.OrdinalIgnoreCase))
.OrderByDescending(c => c.LastSeen)
.ThenBy(c => c.ProviderId, StringComparer.Ordinal)
.Take(limit);
results.AddRange(filtered.Select(MapClaimStatement));
if (results.Count >= limit)
{
break;
}
}
return results;
}
private static PolicyVexStatement MapClaimStatement(VexClaim claim)
{
var observationId = $"{claim.ProviderId}:{claim.Document.Digest}";
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["document_digest"] = claim.Document.Digest,
["document_uri"] = claim.Document.SourceUri.ToString()
};
if (!string.IsNullOrWhiteSpace(claim.Document.Revision))
{
metadata["document_revision"] = claim.Document.Revision!;
}
return new PolicyVexStatement(
ObservationId: observationId,
ProviderId: claim.ProviderId,
Status: claim.Status.ToString(),
ProductKey: claim.Product.Key,
Purl: claim.Product.Purl,
Cpe: claim.Product.Cpe,
Version: claim.Product.Version,
Justification: claim.Justification?.ToString(),
Detail: claim.Detail,
FirstSeen: claim.FirstSeen,
LastSeen: claim.LastSeen,
Signature: claim.Document.Signature,
Metadata: metadata);
}
private static bool TryResolveTenant(
HttpContext context,
VexStorageOptions options,
out string tenant,
out IResult? problem)
{
problem = null;
tenant = string.Empty;
var headerTenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenant = headerTenant.Trim().ToLowerInvariant();
}
else if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
{
tenant = options.DefaultTenant.Trim().ToLowerInvariant();
}
else
{
problem = Results.BadRequest(new
{
error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header is required" }
});
return false;
}
return true;
}
}