279 lines
10 KiB
C#
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;
|
|
}
|
|
}
|