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; /// /// Policy-facing VEX lookup endpoints (EXCITITOR-POLICY-20-001). /// Aggregation-only: returns raw observations/statements without consensus or severity. /// 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 LookupVexAsync( HttpContext context, [FromBody] PolicyVexLookupRequest request, IOptions 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(), 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> ResolveOverlaysAsync( IGraphOverlayStore overlayStore, string tenant, IReadOnlyList advisories, IReadOnlyList 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 providers, GraphOverlayItem overlay) => providers.Count == 0 || providers.Contains(overlay.Source, StringComparer.OrdinalIgnoreCase); private static bool MatchesStatus(ISet 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(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> FallbackClaimsAsync( IVexClaimStore claimStore, IReadOnlyList advisories, IReadOnlyList purls, ISet providers, ISet statuses, int limit, CancellationToken cancellationToken) { var results = new List(); 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(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; } }