feat: Initialize Zastava Webhook service with TLS and Authority authentication

- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint.
- Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately.
- Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly.
- Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
master
2025-10-19 18:36:22 +03:00
parent 2062da7a8b
commit d099a90f9b
966 changed files with 91038 additions and 1850 deletions

View File

@@ -0,0 +1,284 @@
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
internal static class IngestEndpoints
{
private const string AdminScope = "vex.admin";
public static void MapIngestEndpoints(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/excititor");
group.MapPost("/init", HandleInitAsync);
group.MapPost("/ingest/run", HandleRunAsync);
group.MapPost("/ingest/resume", HandleResumeAsync);
group.MapPost("/reconcile", HandleReconcileAsync);
}
private static async Task<IResult> HandleInitAsync(
HttpContext httpContext,
ExcititorInitRequest request,
IVexIngestOrchestrator orchestrator,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
if (scopeResult is not null)
{
return scopeResult;
}
var providerIds = NormalizeProviders(request.Providers);
var options = new IngestInitOptions(providerIds, request.Resume ?? false, timeProvider);
var summary = await orchestrator.InitializeAsync(options, cancellationToken).ConfigureAwait(false);
var message = $"Initialized {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
return Results.Ok(new
{
message,
runId = summary.RunId,
startedAt = summary.StartedAt,
completedAt = summary.CompletedAt,
providers = summary.Providers.Select(static provider => new
{
provider.providerId,
provider.displayName,
provider.status,
provider.durationMs,
provider.error
})
});
}
private static async Task<IResult> HandleRunAsync(
HttpContext httpContext,
ExcititorIngestRunRequest request,
IVexIngestOrchestrator orchestrator,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryParseDateTimeOffset(request.Since, out var since, out var sinceError))
{
return Results.BadRequest(new { message = sinceError });
}
if (!TryParseTimeSpan(request.Window, out var window, out var windowError))
{
return Results.BadRequest(new { message = windowError });
}
var providerIds = NormalizeProviders(request.Providers);
var options = new IngestRunOptions(
providerIds,
since,
window,
request.Force ?? false,
timeProvider);
var summary = await orchestrator.RunAsync(options, cancellationToken).ConfigureAwait(false);
var message = $"Ingest run completed for {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
return Results.Ok(new
{
message,
runId = summary.RunId,
startedAt = summary.StartedAt,
completedAt = summary.CompletedAt,
durationMs = summary.Duration.TotalMilliseconds,
providers = summary.Providers.Select(static provider => new
{
provider.providerId,
provider.status,
provider.documents,
provider.claims,
provider.startedAt,
provider.completedAt,
provider.durationMs,
provider.lastDigest,
provider.lastUpdated,
provider.checkpoint,
provider.error
})
});
}
private static async Task<IResult> HandleResumeAsync(
HttpContext httpContext,
ExcititorIngestResumeRequest request,
IVexIngestOrchestrator orchestrator,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
if (scopeResult is not null)
{
return scopeResult;
}
var providerIds = NormalizeProviders(request.Providers);
var options = new IngestResumeOptions(providerIds, request.Checkpoint, timeProvider);
var summary = await orchestrator.ResumeAsync(options, cancellationToken).ConfigureAwait(false);
var message = $"Resume run completed for {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed.";
return Results.Ok(new
{
message,
runId = summary.RunId,
startedAt = summary.StartedAt,
completedAt = summary.CompletedAt,
durationMs = summary.Duration.TotalMilliseconds,
providers = summary.Providers.Select(static provider => new
{
provider.providerId,
provider.status,
provider.documents,
provider.claims,
provider.startedAt,
provider.completedAt,
provider.durationMs,
provider.since,
provider.checkpoint,
provider.error
})
});
}
private static async Task<IResult> HandleReconcileAsync(
HttpContext httpContext,
ExcititorReconcileRequest request,
IVexIngestOrchestrator orchestrator,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope);
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryParseTimeSpan(request.MaxAge, out var maxAge, out var error))
{
return Results.BadRequest(new { message = error });
}
var providerIds = NormalizeProviders(request.Providers);
var options = new ReconcileOptions(providerIds, maxAge, timeProvider);
var summary = await orchestrator.ReconcileAsync(options, cancellationToken).ConfigureAwait(false);
var message = $"Reconcile completed for {summary.ProviderCount} provider(s); {summary.ReconciledCount} reconciled, {summary.SkippedCount} skipped, {summary.FailureCount} failed.";
return Results.Ok(new
{
message,
runId = summary.RunId,
startedAt = summary.StartedAt,
completedAt = summary.CompletedAt,
durationMs = summary.Duration.TotalMilliseconds,
providers = summary.Providers.Select(static provider => new
{
provider.providerId,
provider.status,
provider.action,
provider.lastUpdated,
provider.threshold,
provider.documents,
provider.claims,
provider.error
})
});
}
private static ImmutableArray<string> NormalizeProviders(IReadOnlyCollection<string>? providers)
{
if (providers is null || providers.Count == 0)
{
return ImmutableArray<string>.Empty;
}
var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var provider in providers)
{
if (string.IsNullOrWhiteSpace(provider))
{
continue;
}
set.Add(provider.Trim());
}
return set.ToImmutableArray();
}
private static bool TryParseDateTimeOffset(string? value, out DateTimeOffset? result, out string? error)
{
result = null;
error = null;
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
if (DateTimeOffset.TryParse(
value.Trim(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
result = parsed;
return true;
}
error = "Invalid 'since' value. Use ISO-8601 format (e.g. 2025-10-19T12:30:00Z).";
return false;
}
private static bool TryParseTimeSpan(string? value, out TimeSpan? result, out string? error)
{
result = null;
error = null;
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
if (TimeSpan.TryParse(value.Trim(), CultureInfo.InvariantCulture, out var parsed) && parsed >= TimeSpan.Zero)
{
result = parsed;
return true;
}
error = "Invalid duration value. Use TimeSpan format (e.g. 1.00:00:00).";
return false;
}
private sealed record ExcititorInitRequest(IReadOnlyList<string>? Providers, bool? Resume);
private sealed record ExcititorIngestRunRequest(
IReadOnlyList<string>? Providers,
string? Since,
string? Window,
bool? Force);
private sealed record ExcititorIngestResumeRequest(
IReadOnlyList<string>? Providers,
string? Checkpoint);
private sealed record ExcititorReconcileRequest(
IReadOnlyList<string>? Providers,
string? MaxAge);
}

View File

@@ -0,0 +1,419 @@
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Options;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
internal static class MirrorEndpoints
{
public static void MapMirrorEndpoints(WebApplication app)
{
var group = app.MapGroup("/excititor/mirror");
group.MapGet("/domains", HandleListDomainsAsync);
group.MapGet("/domains/{domainId}", HandleDomainDetailAsync);
group.MapGet("/domains/{domainId}/index", HandleDomainIndexAsync);
group.MapGet("/domains/{domainId}/exports/{exportKey}", HandleExportMetadataAsync);
group.MapGet("/domains/{domainId}/exports/{exportKey}/download", HandleExportDownloadAsync);
}
private static async Task<IResult> HandleListDomainsAsync(
HttpContext httpContext,
IOptions<MirrorDistributionOptions> options,
CancellationToken cancellationToken)
{
var domains = options.Value.Domains
.Select(static domain => new MirrorDomainSummary(
domain.Id,
string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName,
domain.RequireAuthentication,
Math.Max(domain.MaxIndexRequestsPerHour, 0),
Math.Max(domain.MaxDownloadRequestsPerHour, 0)))
.ToArray();
await WriteJsonAsync(httpContext, new MirrorDomainListResponse(domains), StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
private static async Task<IResult> HandleDomainDetailAsync(
string domainId,
HttpContext httpContext,
IOptions<MirrorDistributionOptions> options,
CancellationToken cancellationToken)
{
if (!TryFindDomain(options.Value, domainId, out var domain))
{
return Results.NotFound();
}
var response = new MirrorDomainDetail(
domain.Id,
string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName,
domain.RequireAuthentication,
Math.Max(domain.MaxIndexRequestsPerHour, 0),
Math.Max(domain.MaxDownloadRequestsPerHour, 0),
domain.Exports.Select(static export => export.Key).OrderBy(static key => key, StringComparer.Ordinal).ToImmutableArray());
await WriteJsonAsync(httpContext, response, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
private static async Task<IResult> HandleDomainIndexAsync(
string domainId,
HttpContext httpContext,
IOptions<MirrorDistributionOptions> options,
MirrorRateLimiter rateLimiter,
IVexExportStore exportStore,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!TryFindDomain(options.Value, domainId, out var domain))
{
return Results.NotFound();
}
if (domain.RequireAuthentication && (httpContext.User?.Identity?.IsAuthenticated is not true))
{
return Results.Unauthorized();
}
if (!rateLimiter.TryAcquire(domain.Id, "index", Math.Max(domain.MaxIndexRequestsPerHour, 0), out var retryAfter))
{
if (retryAfter is { } retry)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retry.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
}
await WritePlainTextAsync(httpContext, "mirror index quota exceeded", StatusCodes.Status429TooManyRequests, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
var resolvedExports = new List<MirrorExportIndexEntry>();
foreach (var exportOption in domain.Exports)
{
if (!TryBuildExportPlan(exportOption, out var plan, out var error))
{
resolvedExports.Add(new MirrorExportIndexEntry(
exportOption.Key,
null,
null,
exportOption.Format,
null,
null,
0,
null,
null,
error ?? "invalid_export_configuration"));
continue;
}
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
resolvedExports.Add(new MirrorExportIndexEntry(
exportOption.Key,
null,
plan.Signature.Value,
plan.Format.ToString().ToLowerInvariant(),
null,
null,
0,
null,
null,
"manifest_not_found"));
continue;
}
resolvedExports.Add(new MirrorExportIndexEntry(
exportOption.Key,
manifest.ExportId,
manifest.QuerySignature.Value,
manifest.Format.ToString().ToLowerInvariant(),
manifest.CreatedAt,
manifest.Artifact,
manifest.SizeBytes,
manifest.ConsensusRevision,
manifest.Attestation is null ? null : new MirrorExportAttestation(manifest.Attestation.PredicateType, manifest.Attestation.Rekor?.Location, manifest.Attestation.EnvelopeDigest, manifest.Attestation.SignedAt),
null));
}
var indexResponse = new MirrorDomainIndex(
domain.Id,
string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName,
timeProvider.GetUtcNow(),
resolvedExports.ToImmutableArray());
await WriteJsonAsync(httpContext, indexResponse, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
private static async Task<IResult> HandleExportMetadataAsync(
string domainId,
string exportKey,
HttpContext httpContext,
IOptions<MirrorDistributionOptions> options,
MirrorRateLimiter rateLimiter,
IVexExportStore exportStore,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!TryFindDomain(options.Value, domainId, out var domain))
{
return Results.NotFound();
}
if (domain.RequireAuthentication && (httpContext.User?.Identity?.IsAuthenticated is not true))
{
return Results.Unauthorized();
}
if (!TryFindExport(domain, exportKey, out var exportOptions))
{
return Results.NotFound();
}
if (!TryBuildExportPlan(exportOptions, out var plan, out var error))
{
await WritePlainTextAsync(httpContext, error ?? "invalid_export_configuration", StatusCodes.Status503ServiceUnavailable, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
return Results.NotFound();
}
var payload = new MirrorExportMetadata(
domain.Id,
exportOptions.Key,
manifest.ExportId,
manifest.QuerySignature.Value,
manifest.Format.ToString().ToLowerInvariant(),
manifest.CreatedAt,
manifest.Artifact,
manifest.SizeBytes,
manifest.SourceProviders,
manifest.Attestation is null ? null : new MirrorExportAttestation(manifest.Attestation.PredicateType, manifest.Attestation.Rekor?.Location, manifest.Attestation.EnvelopeDigest, manifest.Attestation.SignedAt));
await WriteJsonAsync(httpContext, payload, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
private static async Task<IResult> HandleExportDownloadAsync(
string domainId,
string exportKey,
HttpContext httpContext,
IOptions<MirrorDistributionOptions> options,
MirrorRateLimiter rateLimiter,
IVexExportStore exportStore,
IEnumerable<IVexArtifactStore> artifactStores,
CancellationToken cancellationToken)
{
if (!TryFindDomain(options.Value, domainId, out var domain))
{
return Results.NotFound();
}
if (domain.RequireAuthentication && (httpContext.User?.Identity?.IsAuthenticated is not true))
{
return Results.Unauthorized();
}
if (!rateLimiter.TryAcquire(domain.Id, "download", Math.Max(domain.MaxDownloadRequestsPerHour, 0), out var retryAfter))
{
if (retryAfter is { } retry)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retry.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
}
await WritePlainTextAsync(httpContext, "mirror download quota exceeded", StatusCodes.Status429TooManyRequests, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
if (!TryFindExport(domain, exportKey, out var exportOptions) || !TryBuildExportPlan(exportOptions, out var plan, out _))
{
return Results.NotFound();
}
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
return Results.NotFound();
}
Stream? contentStream = null;
foreach (var store in artifactStores)
{
contentStream = await store.OpenReadAsync(manifest.Artifact, cancellationToken).ConfigureAwait(false);
if (contentStream is not null)
{
break;
}
}
if (contentStream is null)
{
return Results.NotFound();
}
await using (contentStream.ConfigureAwait(false))
{
var contentType = ResolveContentType(manifest.Format);
httpContext.Response.StatusCode = StatusCodes.Status200OK;
httpContext.Response.ContentType = contentType;
httpContext.Response.Headers.ContentDisposition = FormattableString.Invariant($"attachment; filename=\"{BuildDownloadFileName(domain.Id, exportOptions.Key, manifest.Format)}\"");
await contentStream.CopyToAsync(httpContext.Response.Body, cancellationToken).ConfigureAwait(false);
}
return Results.Empty;
}
private static bool TryFindDomain(MirrorDistributionOptions options, string domainId, out MirrorDomainOptions domain)
{
domain = options.Domains.FirstOrDefault(d => string.Equals(d.Id, domainId, StringComparison.OrdinalIgnoreCase))!;
return domain is not null;
}
private static bool TryFindExport(MirrorDomainOptions domain, string exportKey, out MirrorExportOptions export)
{
export = domain.Exports.FirstOrDefault(e => string.Equals(e.Key, exportKey, StringComparison.OrdinalIgnoreCase))!;
return export is not null;
}
private static bool TryBuildExportPlan(MirrorExportOptions exportOptions, out MirrorExportPlan plan, out string? error)
{
plan = null!;
error = null;
if (string.IsNullOrWhiteSpace(exportOptions.Key))
{
error = "missing_export_key";
return false;
}
if (string.IsNullOrWhiteSpace(exportOptions.Format) || !Enum.TryParse<VexExportFormat>(exportOptions.Format, ignoreCase: true, out var format))
{
error = "unsupported_export_format";
return false;
}
var filters = exportOptions.Filters.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value)).ToArray();
var sorts = exportOptions.Sort.Select(pair => new VexQuerySort(pair.Key, pair.Value)).ToArray();
var query = VexQuery.Create(filters.Select(kv => new VexQueryFilter(kv.Key, kv.Value)), sorts, exportOptions.Limit, exportOptions.Offset, exportOptions.View);
var signature = VexQuerySignature.FromQuery(query);
plan = new MirrorExportPlan(format, query, signature);
return true;
}
private static string ResolveContentType(VexExportFormat format)
=> format switch
{
VexExportFormat.Json => "application/json",
VexExportFormat.JsonLines => "application/jsonl",
VexExportFormat.OpenVex => "application/json",
VexExportFormat.Csaf => "application/json",
_ => "application/octet-stream",
};
private static string BuildDownloadFileName(string domainId, string exportKey, VexExportFormat format)
{
var builder = new StringBuilder(domainId.Length + exportKey.Length + 8);
builder.Append(domainId).Append('-').Append(exportKey);
builder.Append(format switch
{
VexExportFormat.Json => ".json",
VexExportFormat.JsonLines => ".jsonl",
VexExportFormat.OpenVex => ".openvex.json",
VexExportFormat.Csaf => ".csaf.json",
_ => ".bin",
});
return builder.ToString();
}
private static async Task WritePlainTextAsync(HttpContext context, string message, int statusCode, CancellationToken cancellationToken)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(message, cancellationToken);
}
private static async Task WriteJsonAsync<T>(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
var json = VexCanonicalJsonSerializer.Serialize(payload);
await context.Response.WriteAsync(json, cancellationToken);
}
private sealed record MirrorExportPlan(
VexExportFormat Format,
VexQuery Query,
VexQuerySignature Signature);
}
internal sealed record MirrorDomainListResponse(IReadOnlyList<MirrorDomainSummary> Domains);
internal sealed record MirrorDomainSummary(
string Id,
string DisplayName,
bool RequireAuthentication,
int MaxIndexRequestsPerHour,
int MaxDownloadRequestsPerHour);
internal sealed record MirrorDomainDetail(
string Id,
string DisplayName,
bool RequireAuthentication,
int MaxIndexRequestsPerHour,
int MaxDownloadRequestsPerHour,
IReadOnlyList<string> Exports);
internal sealed record MirrorDomainIndex(
string Id,
string DisplayName,
DateTimeOffset GeneratedAt,
IReadOnlyList<MirrorExportIndexEntry> Exports);
internal sealed record MirrorExportIndexEntry(
string ExportKey,
string? ExportId,
string? QuerySignature,
string Format,
DateTimeOffset? CreatedAt,
VexContentAddress? Artifact,
long SizeBytes,
string? ConsensusRevision,
MirrorExportAttestation? Attestation,
string? Status);
internal sealed record MirrorExportAttestation(
string PredicateType,
string? RekorLocation,
string? EnvelopeDigest,
DateTimeOffset? SignedAt);
internal sealed record MirrorExportMetadata(
string DomainId,
string ExportKey,
string ExportId,
string QuerySignature,
string Format,
DateTimeOffset CreatedAt,
VexContentAddress Artifact,
long SizeBytes,
IReadOnlyList<string> SourceProviders,
MirrorExportAttestation? Attestation);

View File

@@ -0,0 +1,504 @@
namespace StellaOps.Excititor.WebService.Endpoints;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Attestation;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
internal static class ResolveEndpoint
{
private const int MaxSubjectPairs = 256;
public static void MapResolveEndpoint(WebApplication app)
{
app.MapPost("/excititor/resolve", HandleResolveAsync);
}
private static async Task<IResult> HandleResolveAsync(
VexResolveRequest request,
HttpContext httpContext,
IVexClaimStore claimStore,
IVexConsensusStore consensusStore,
IVexProviderStore providerStore,
IVexPolicyProvider policyProvider,
TimeProvider timeProvider,
ILoggerFactory loggerFactory,
IVexAttestationClient? attestationClient,
IVexSigner? signer,
CancellationToken cancellationToken)
{
if (request is null)
{
return Results.BadRequest("Request payload is required.");
}
var logger = loggerFactory.CreateLogger("ResolveEndpoint");
var productKeys = NormalizeValues(request.ProductKeys, request.Purls);
var vulnerabilityIds = NormalizeValues(request.VulnerabilityIds);
if (productKeys.Count == 0)
{
await WritePlainTextAsync(httpContext, "At least one productKey or purl must be provided.", StatusCodes.Status400BadRequest, cancellationToken);
return Results.Empty;
}
if (vulnerabilityIds.Count == 0)
{
await WritePlainTextAsync(httpContext, "At least one vulnerabilityId must be provided.", StatusCodes.Status400BadRequest, cancellationToken);
return Results.Empty;
}
var pairCount = (long)productKeys.Count * vulnerabilityIds.Count;
if (pairCount > MaxSubjectPairs)
{
await WritePlainTextAsync(httpContext, FormattableString.Invariant($"A maximum of {MaxSubjectPairs} subject pairs are allowed per request."), StatusCodes.Status400BadRequest, cancellationToken);
return Results.Empty;
}
var snapshot = policyProvider.GetSnapshot();
if (!string.IsNullOrWhiteSpace(request.PolicyRevisionId) &&
!string.Equals(request.PolicyRevisionId.Trim(), snapshot.RevisionId, StringComparison.Ordinal))
{
var conflictPayload = new
{
message = $"Requested policy revision '{request.PolicyRevisionId}' does not match active revision '{snapshot.RevisionId}'.",
activeRevision = snapshot.RevisionId,
requestedRevision = request.PolicyRevisionId,
};
await WriteJsonAsync(httpContext, conflictPayload, StatusCodes.Status409Conflict, cancellationToken);
return Results.Empty;
}
var resolver = new VexConsensusResolver(snapshot.ConsensusPolicy);
var resolvedAt = timeProvider.GetUtcNow();
var providerCache = new Dictionary<string, VexProvider>(StringComparer.Ordinal);
var results = new List<VexResolveResult>((int)pairCount);
foreach (var productKey in productKeys)
{
foreach (var vulnerabilityId in vulnerabilityIds)
{
var claims = await claimStore.FindAsync(vulnerabilityId, productKey, since: null, cancellationToken)
.ConfigureAwait(false);
var claimArray = claims.Count == 0 ? Array.Empty<VexClaim>() : claims.ToArray();
var signals = AggregateSignals(claimArray);
var providers = await LoadProvidersAsync(claimArray, providerStore, providerCache, cancellationToken)
.ConfigureAwait(false);
var product = ResolveProduct(claimArray, productKey);
var calculatedAt = timeProvider.GetUtcNow();
var resolution = resolver.Resolve(new VexConsensusRequest(
vulnerabilityId,
product,
claimArray,
providers,
calculatedAt,
snapshot.ConsensusOptions.WeightCeiling,
signals,
snapshot.RevisionId,
snapshot.Digest));
var consensus = resolution.Consensus;
if (!string.Equals(consensus.PolicyVersion, snapshot.Version, StringComparison.Ordinal) ||
!string.Equals(consensus.PolicyRevisionId, snapshot.RevisionId, StringComparison.Ordinal) ||
!string.Equals(consensus.PolicyDigest, snapshot.Digest, StringComparison.Ordinal))
{
consensus = new VexConsensus(
consensus.VulnerabilityId,
consensus.Product,
consensus.Status,
consensus.CalculatedAt,
consensus.Sources,
consensus.Conflicts,
consensus.Signals,
snapshot.Version,
consensus.Summary,
snapshot.RevisionId,
snapshot.Digest);
}
await consensusStore.SaveAsync(consensus, cancellationToken).ConfigureAwait(false);
var payload = PreparePayload(consensus);
var contentSignature = await TrySignAsync(signer, payload, logger, cancellationToken).ConfigureAwait(false);
var attestation = await BuildAttestationAsync(
attestationClient,
consensus,
snapshot,
payload,
logger,
cancellationToken).ConfigureAwait(false);
var decisions = resolution.DecisionLog.IsDefault
? Array.Empty<VexConsensusDecisionTelemetry>()
: resolution.DecisionLog.ToArray();
results.Add(new VexResolveResult(
consensus.VulnerabilityId,
consensus.Product.Key,
consensus.Status,
consensus.CalculatedAt,
consensus.Sources,
consensus.Conflicts,
consensus.Signals,
consensus.Summary,
consensus.PolicyRevisionId ?? snapshot.RevisionId,
consensus.PolicyVersion ?? snapshot.Version,
consensus.PolicyDigest ?? snapshot.Digest,
decisions,
new VexResolveEnvelope(
payload.Artifact,
contentSignature,
attestation.Metadata,
attestation.Envelope,
attestation.Signature)));
}
}
var policy = new VexResolvePolicy(
snapshot.RevisionId,
snapshot.Version,
snapshot.Digest,
request.PolicyRevisionId?.Trim());
var response = new VexResolveResponse(resolvedAt, policy, results);
await WriteJsonAsync(httpContext, response, StatusCodes.Status200OK, cancellationToken);
return Results.Empty;
}
private static List<string> NormalizeValues(params IReadOnlyList<string>?[] sources)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var source in sources)
{
if (source is null)
{
continue;
}
foreach (var value in source)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var normalized = value.Trim();
if (seen.Add(normalized))
{
result.Add(normalized);
}
}
}
return result;
}
private static VexSignalSnapshot? AggregateSignals(IReadOnlyList<VexClaim> claims)
{
if (claims.Count == 0)
{
return null;
}
VexSeveritySignal? bestSeverity = null;
double? bestScore = null;
bool kevPresent = false;
bool kevTrue = false;
double? bestEpss = null;
foreach (var claim in claims)
{
if (claim.Signals is null)
{
continue;
}
var severity = claim.Signals.Severity;
if (severity is not null)
{
var score = severity.Score;
if (bestSeverity is null ||
(score is not null && (bestScore is null || score.Value > bestScore.Value)) ||
(score is null && bestScore is null && !string.IsNullOrWhiteSpace(severity.Label) && string.IsNullOrWhiteSpace(bestSeverity.Label)))
{
bestSeverity = severity;
bestScore = severity.Score;
}
}
if (claim.Signals.Kev is { } kevValue)
{
kevPresent = true;
if (kevValue)
{
kevTrue = true;
}
}
if (claim.Signals.Epss is { } epss)
{
if (bestEpss is null || epss > bestEpss.Value)
{
bestEpss = epss;
}
}
}
if (bestSeverity is null && !kevPresent && bestEpss is null)
{
return null;
}
bool? kev = kevTrue ? true : (kevPresent ? false : null);
return new VexSignalSnapshot(bestSeverity, kev, bestEpss);
}
private static async Task<IReadOnlyDictionary<string, VexProvider>> LoadProvidersAsync(
IReadOnlyList<VexClaim> claims,
IVexProviderStore providerStore,
IDictionary<string, VexProvider> cache,
CancellationToken cancellationToken)
{
if (claims.Count == 0)
{
return ImmutableDictionary<string, VexProvider>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, VexProvider>(StringComparer.Ordinal);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var providerId in claims.Select(claim => claim.ProviderId))
{
if (!seen.Add(providerId))
{
continue;
}
if (cache.TryGetValue(providerId, out var cached))
{
builder[providerId] = cached;
continue;
}
var provider = await providerStore.FindAsync(providerId, cancellationToken).ConfigureAwait(false);
if (provider is not null)
{
cache[providerId] = provider;
builder[providerId] = provider;
}
}
return builder.ToImmutable();
}
private static VexProduct ResolveProduct(IReadOnlyList<VexClaim> claims, string productKey)
{
if (claims.Count > 0)
{
return claims[0].Product;
}
var inferredPurl = productKey.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) ? productKey : null;
return new VexProduct(productKey, name: null, version: null, purl: inferredPurl);
}
private static ConsensusPayload PreparePayload(VexConsensus consensus)
{
var canonicalJson = VexCanonicalJsonSerializer.Serialize(consensus);
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
var digest = SHA256.HashData(bytes);
var digestHex = Convert.ToHexString(digest).ToLowerInvariant();
var address = new VexContentAddress("sha256", digestHex);
return new ConsensusPayload(address, bytes, canonicalJson);
}
private static async ValueTask<ResolveSignature?> TrySignAsync(
IVexSigner? signer,
ConsensusPayload payload,
ILogger logger,
CancellationToken cancellationToken)
{
if (signer is null)
{
return null;
}
try
{
var signature = await signer.SignAsync(payload.Bytes, cancellationToken).ConfigureAwait(false);
return new ResolveSignature(signature.Signature, signature.KeyId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to sign resolve payload {Digest}", payload.Artifact.ToUri());
return null;
}
}
private static async ValueTask<ResolveAttestation> BuildAttestationAsync(
IVexAttestationClient? attestationClient,
VexConsensus consensus,
VexPolicySnapshot snapshot,
ConsensusPayload payload,
ILogger logger,
CancellationToken cancellationToken)
{
if (attestationClient is null)
{
return new ResolveAttestation(null, null, null);
}
try
{
var exportId = BuildAttestationExportId(consensus.VulnerabilityId, consensus.Product.Key);
var filters = new[]
{
new KeyValuePair<string, string>("vulnerabilityId", consensus.VulnerabilityId),
new KeyValuePair<string, string>("productKey", consensus.Product.Key),
new KeyValuePair<string, string>("policyRevisionId", snapshot.RevisionId),
};
var querySignature = VexQuerySignature.FromFilters(filters);
var providerIds = consensus.Sources
.Select(source => source.ProviderId)
.Distinct(StringComparer.Ordinal)
.ToImmutableArray();
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadataBuilder["consensusDigest"] = payload.Artifact.ToUri();
metadataBuilder["policyRevisionId"] = snapshot.RevisionId;
metadataBuilder["policyVersion"] = snapshot.Version;
if (!string.IsNullOrWhiteSpace(snapshot.Digest))
{
metadataBuilder["policyDigest"] = snapshot.Digest;
}
var response = await attestationClient.SignAsync(new VexAttestationRequest(
exportId,
querySignature,
payload.Artifact,
VexExportFormat.Json,
consensus.CalculatedAt,
providerIds,
metadataBuilder.ToImmutable()), cancellationToken).ConfigureAwait(false);
var envelopeJson = response.Diagnostics.TryGetValue("envelope", out var envelopeValue)
? envelopeValue
: null;
ResolveSignature? signature = null;
if (!string.IsNullOrWhiteSpace(envelopeJson))
{
try
{
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson);
var dsseSignature = envelope?.Signatures?.FirstOrDefault();
if (dsseSignature is not null)
{
signature = new ResolveSignature(dsseSignature.Signature, dsseSignature.KeyId);
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to deserialize DSSE envelope for resolve export {ExportId}", exportId);
}
}
return new ResolveAttestation(response.Attestation, envelopeJson, signature);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Unable to produce attestation for {VulnerabilityId}/{ProductKey}", consensus.VulnerabilityId, consensus.Product.Key);
return new ResolveAttestation(null, null, null);
}
}
private static string BuildAttestationExportId(string vulnerabilityId, string productKey)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(productKey));
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return FormattableString.Invariant($"resolve/{vulnerabilityId}/{digest}");
}
private sealed record ConsensusPayload(VexContentAddress Artifact, byte[] Bytes, string CanonicalJson);
private static async Task WritePlainTextAsync(HttpContext context, string message, int statusCode, CancellationToken cancellationToken)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(message, cancellationToken);
}
private static async Task WriteJsonAsync<T>(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
var json = VexCanonicalJsonSerializer.Serialize(payload);
await context.Response.WriteAsync(json, cancellationToken);
}
}
public sealed record VexResolveRequest(
IReadOnlyList<string>? ProductKeys,
IReadOnlyList<string>? Purls,
IReadOnlyList<string>? VulnerabilityIds,
string? PolicyRevisionId);
internal sealed record VexResolvePolicy(
string ActiveRevisionId,
string Version,
string Digest,
string? RequestedRevisionId);
internal sealed record VexResolveResponse(
DateTimeOffset ResolvedAt,
VexResolvePolicy Policy,
IReadOnlyList<VexResolveResult> Results);
internal sealed record VexResolveResult(
string VulnerabilityId,
string ProductKey,
VexConsensusStatus Status,
DateTimeOffset CalculatedAt,
IReadOnlyList<VexConsensusSource> Sources,
IReadOnlyList<VexConsensusConflict> Conflicts,
VexSignalSnapshot? Signals,
string? Summary,
string PolicyRevisionId,
string PolicyVersion,
string PolicyDigest,
IReadOnlyList<VexConsensusDecisionTelemetry> Decisions,
VexResolveEnvelope Envelope);
internal sealed record VexResolveEnvelope(
VexContentAddress Artifact,
ResolveSignature? ContentSignature,
VexAttestationMetadata? Attestation,
string? AttestationEnvelope,
ResolveSignature? AttestationSignature);
internal sealed record ResolveSignature(string Value, string? KeyId);
internal sealed record ResolveAttestation(
VexAttestationMetadata? Metadata,
string? Envelope,
ResolveSignature? Signature);

View File

@@ -0,0 +1,52 @@
using System.Collections.Generic;
namespace StellaOps.Excititor.WebService.Options;
public sealed class MirrorDistributionOptions
{
public const string SectionName = "Excititor:Mirror";
public List<MirrorDomainOptions> Domains { get; } = new();
}
public sealed class MirrorDomainOptions
{
public string Id { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool RequireAuthentication { get; set; }
= false;
/// <summary>
/// Maximum index requests allowed per rolling window.
/// </summary>
public int MaxIndexRequestsPerHour { get; set; } = 120;
/// <summary>
/// Maximum export downloads allowed per rolling window.
/// </summary>
public int MaxDownloadRequestsPerHour { get; set; } = 600;
public List<MirrorExportOptions> Exports { get; } = new();
}
public sealed class MirrorExportOptions
{
public string Key { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public Dictionary<string, string> Filters { get; } = new();
public Dictionary<string, bool> Sort { get; } = new();
public int? Limit { get; set; }
= null;
public int? Offset { get; set; }
= null;
public string? View { get; set; }
= null;
}

View File

@@ -1,13 +1,23 @@
using System.Collections.Generic;
using System.Linq;
using System.Collections.Immutable;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Extensions;
using StellaOps.Excititor.Attestation;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.ArtifactStores.S3.Extensions;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.Excititor.Formats.CycloneDX;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Endpoints;
using StellaOps.Excititor.WebService.Options;
using StellaOps.Excititor.WebService.Services;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
@@ -18,11 +28,19 @@ services.AddOptions<VexMongoStorageOptions>()
.ValidateOnStart();
services.AddExcititorMongoStorage();
services.AddCsafNormalizer();
services.AddCycloneDxNormalizer();
services.AddOpenVexNormalizer();
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
services.AddSingleton<IVexIngestOrchestrator, VexIngestOrchestrator>();
services.AddVexExportEngine();
services.AddVexExportCacheServices();
services.AddVexAttestation();
services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client"));
services.AddVexPolicy();
services.AddRedHatCsafConnector();
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
services.AddSingleton<MirrorRateLimiter>();
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
if (rekorSection.Exists())
@@ -64,9 +82,15 @@ if (offlineSection.Exists())
services.AddEndpointsApiExplorer();
services.AddHealthChecks();
services.AddSingleton(TimeProvider.System);
services.AddMemoryCache();
services.AddAuthentication();
services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/excititor/status", async (HttpContext context,
IEnumerable<IVexArtifactStore> artifactStores,
IOptions<VexMongoStorageOptions> mongoOptions,
@@ -84,8 +108,139 @@ app.MapGet("/excititor/status", async (HttpContext context,
app.MapHealthChecks("/excititor/health");
app.MapPost("/excititor/statements", async (
VexStatementIngestRequest request,
IVexClaimStore claimStore,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request?.Statements is null || request.Statements.Count == 0)
{
return Results.BadRequest("At least one statement must be provided.");
}
var claims = request.Statements.Select(statement => statement.ToDomainClaim());
await claimStore.AppendAsync(claims, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
return Results.Accepted();
});
app.MapGet("/excititor/statements/{vulnerabilityId}/{productKey}", async (
string vulnerabilityId,
string productKey,
DateTimeOffset? since,
IVexClaimStore claimStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return Results.BadRequest("vulnerabilityId and productKey are required.");
}
var claims = await claimStore.FindAsync(vulnerabilityId.Trim(), productKey.Trim(), since, cancellationToken).ConfigureAwait(false);
return Results.Ok(claims);
});
IngestEndpoints.MapIngestEndpoints(app);
ResolveEndpoint.MapResolveEndpoint(app);
MirrorEndpoints.MapMirrorEndpoints(app);
app.Run();
public partial class Program;
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
internal sealed record VexStatementIngestRequest(IReadOnlyList<VexStatementEntry> Statements);
internal sealed record VexStatementEntry(
string VulnerabilityId,
string ProviderId,
string ProductKey,
string? ProductName,
string? ProductVersion,
string? ProductPurl,
string? ProductCpe,
IReadOnlyList<string>? ComponentIdentifiers,
VexClaimStatus Status,
VexJustification? Justification,
string? Detail,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen,
VexDocumentFormat DocumentFormat,
string DocumentDigest,
string DocumentUri,
string? DocumentRevision,
VexSignatureMetadataRequest? Signature,
VexConfidenceRequest? Confidence,
VexSignalRequest? Signals,
IReadOnlyDictionary<string, string>? Metadata)
{
public VexClaim ToDomainClaim()
{
var product = new VexProduct(
ProductKey,
ProductName,
ProductVersion,
ProductPurl,
ProductCpe,
ComponentIdentifiers ?? Array.Empty<string>());
if (!Uri.TryCreate(DocumentUri, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException($"DocumentUri '{DocumentUri}' is not a valid absolute URI.");
}
var document = new VexClaimDocument(
DocumentFormat,
DocumentDigest,
uri,
DocumentRevision,
Signature?.ToDomain());
var additionalMetadata = Metadata is null
? ImmutableDictionary<string, string>.Empty
: Metadata.ToImmutableDictionary(StringComparer.Ordinal);
return new VexClaim(
VulnerabilityId,
ProviderId,
product,
Status,
document,
FirstSeen,
LastSeen,
Justification,
Detail,
Confidence?.ToDomain(),
Signals?.ToDomain(),
additionalMetadata);
}
}
internal sealed record VexSignatureMetadataRequest(
string Type,
string? Subject,
string? Issuer,
string? KeyId,
DateTimeOffset? VerifiedAt,
string? TransparencyLogReference)
{
public VexSignatureMetadata ToDomain()
=> new(Type, Subject, Issuer, KeyId, VerifiedAt, TransparencyLogReference);
}
internal sealed record VexConfidenceRequest(string Level, double? Score, string? Method)
{
public VexConfidence ToDomain() => new(Level, Score, Method);
}
internal sealed record VexSignalRequest(VexSeveritySignalRequest? Severity, bool? Kev, double? Epss)
{
public VexSignalSnapshot ToDomain()
=> new(Severity?.ToDomain(), Kev, Epss);
}
internal sealed record VexSeveritySignalRequest(string Scheme, double? Score, string? Label, string? Vector)
{
public VexSeveritySignal ToDomain() => new(Scheme, Score, Label, Vector);
}

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Excititor.WebService.Services;
internal sealed class MirrorRateLimiter
{
private readonly IMemoryCache _cache;
private readonly TimeProvider _timeProvider;
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter)
{
retryAfter = null;
if (limit <= 0 || limit == int.MaxValue)
{
return true;
}
var key = CreateKey(domainId, scope);
var now = _timeProvider.GetUtcNow();
var counter = _cache.Get<Counter>(key);
if (counter is null || now - counter.WindowStart >= Window)
{
counter = new Counter(now, 0);
}
if (counter.Count >= limit)
{
var windowEnd = counter.WindowStart + Window;
retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
return false;
}
counter = counter with { Count = counter.Count + 1 };
var absoluteExpiration = counter.WindowStart + Window;
_cache.Set(key, counter, absoluteExpiration);
return true;
}
private static string CreateKey(string domainId, string scope)
=> string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) =>
{
state.domainId.AsSpan().CopyTo(span);
span[state.domainId.Length] = '|';
state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]);
});
private sealed record Counter(DateTimeOffset WindowStart, int Count);
}

View File

@@ -0,0 +1,54 @@
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
namespace StellaOps.Excititor.WebService.Services;
internal static class ScopeAuthorization
{
public static IResult? RequireScope(HttpContext context, string scope)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (string.IsNullOrWhiteSpace(scope))
{
throw new ArgumentException("Scope must be provided.", nameof(scope));
}
var user = context.User;
if (user?.Identity?.IsAuthenticated is not true)
{
return Results.Unauthorized();
}
if (!HasScope(user, scope))
{
return Results.Forbid();
}
return null;
}
private static bool HasScope(ClaimsPrincipal user, string requiredScope)
{
var comparison = StringComparer.OrdinalIgnoreCase;
foreach (var claim in user.FindAll("scope").Concat(user.FindAll("scp")))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (scopes.Any(scope => comparison.Equals(scope, requiredScope)))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,570 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.WebService.Services;
internal interface IVexIngestOrchestrator
{
Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken);
Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken);
Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken);
Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken);
}
internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator
{
private readonly IServiceProvider _serviceProvider;
private readonly IReadOnlyDictionary<string, IVexConnector> _connectors;
private readonly IVexRawStore _rawStore;
private readonly IVexClaimStore _claimStore;
private readonly IVexProviderStore _providerStore;
private readonly IVexConnectorStateRepository _stateRepository;
private readonly IVexNormalizerRouter _normalizerRouter;
private readonly IVexSignatureVerifier _signatureVerifier;
private readonly TimeProvider _timeProvider;
private readonly ILogger<VexIngestOrchestrator> _logger;
public VexIngestOrchestrator(
IServiceProvider serviceProvider,
IEnumerable<IVexConnector> connectors,
IVexRawStore rawStore,
IVexClaimStore claimStore,
IVexProviderStore providerStore,
IVexConnectorStateRepository stateRepository,
IVexNormalizerRouter normalizerRouter,
IVexSignatureVerifier signatureVerifier,
TimeProvider timeProvider,
ILogger<VexIngestOrchestrator> logger)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_rawStore = rawStore ?? throw new ArgumentNullException(nameof(rawStore));
_claimStore = claimStore ?? throw new ArgumentNullException(nameof(claimStore));
_providerStore = providerStore ?? throw new ArgumentNullException(nameof(providerStore));
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
_normalizerRouter = normalizerRouter ?? throw new ArgumentNullException(nameof(normalizerRouter));
_signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (connectors is null)
{
throw new ArgumentNullException(nameof(connectors));
}
_connectors = connectors
.GroupBy(connector => connector.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
}
public async Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
var runId = Guid.NewGuid();
var startedAt = _timeProvider.GetUtcNow();
var results = ImmutableArray.CreateBuilder<InitProviderResult>();
var (handles, missing) = ResolveConnectors(options.Providers);
foreach (var providerId in missing)
{
results.Add(new InitProviderResult(providerId, providerId, "missing", TimeSpan.Zero, "Provider connector is not registered."));
}
foreach (var handle in handles)
{
var stopwatch = Stopwatch.StartNew();
try
{
await ValidateConnectorAsync(handle, cancellationToken).ConfigureAwait(false);
await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
results.Add(new InitProviderResult(
handle.Descriptor.Id,
handle.Descriptor.DisplayName,
"succeeded",
stopwatch.Elapsed,
error: null));
_logger.LogInformation("Excititor init validated provider {ProviderId} in {Duration}ms.", handle.Descriptor.Id, stopwatch.Elapsed.TotalMilliseconds);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
stopwatch.Stop();
results.Add(new InitProviderResult(
handle.Descriptor.Id,
handle.Descriptor.DisplayName,
"cancelled",
stopwatch.Elapsed,
"Operation cancelled."));
_logger.LogWarning("Excititor init cancelled for provider {ProviderId}.", handle.Descriptor.Id);
}
catch (Exception ex)
{
stopwatch.Stop();
results.Add(new InitProviderResult(
handle.Descriptor.Id,
handle.Descriptor.DisplayName,
"failed",
stopwatch.Elapsed,
ex.Message));
_logger.LogError(ex, "Excititor init failed for provider {ProviderId}: {Message}", handle.Descriptor.Id, ex.Message);
}
}
var completedAt = _timeProvider.GetUtcNow();
return new InitSummary(runId, startedAt, completedAt, results.ToImmutable());
}
public async Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
var runId = Guid.NewGuid();
var startedAt = _timeProvider.GetUtcNow();
var since = ResolveSince(options.Since, options.Window, startedAt);
var results = ImmutableArray.CreateBuilder<ProviderRunResult>();
var (handles, missing) = ResolveConnectors(options.Providers);
foreach (var providerId in missing)
{
results.Add(ProviderRunResult.Missing(providerId, since));
}
foreach (var handle in handles)
{
var result = await ExecuteRunAsync(handle, since, options.Force, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
var completedAt = _timeProvider.GetUtcNow();
return new IngestRunSummary(runId, startedAt, completedAt, results.ToImmutable());
}
public async Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
var runId = Guid.NewGuid();
var startedAt = _timeProvider.GetUtcNow();
var results = ImmutableArray.CreateBuilder<ProviderRunResult>();
var (handles, missing) = ResolveConnectors(options.Providers);
foreach (var providerId in missing)
{
results.Add(ProviderRunResult.Missing(providerId, since: null));
}
foreach (var handle in handles)
{
var since = await ResolveResumeSinceAsync(handle.Descriptor.Id, options.Checkpoint, cancellationToken).ConfigureAwait(false);
var result = await ExecuteRunAsync(handle, since, force: false, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
var completedAt = _timeProvider.GetUtcNow();
return new IngestRunSummary(runId, startedAt, completedAt, results.ToImmutable());
}
public async Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
var runId = Guid.NewGuid();
var startedAt = _timeProvider.GetUtcNow();
var threshold = options.MaxAge is null ? (DateTimeOffset?)null : startedAt - options.MaxAge.Value;
var results = ImmutableArray.CreateBuilder<ReconcileProviderResult>();
var (handles, missing) = ResolveConnectors(options.Providers);
foreach (var providerId in missing)
{
results.Add(new ReconcileProviderResult(providerId, "missing", "missing", null, threshold, 0, 0, "Provider connector is not registered."));
}
foreach (var handle in handles)
{
try
{
var state = await _stateRepository.GetAsync(handle.Descriptor.Id, cancellationToken).ConfigureAwait(false);
var lastUpdated = state?.LastUpdated;
var stale = threshold.HasValue && (lastUpdated is null || lastUpdated < threshold.Value);
if (stale || state is null)
{
var since = stale ? threshold : lastUpdated;
var result = await ExecuteRunAsync(handle, since, force: false, cancellationToken).ConfigureAwait(false);
results.Add(new ReconcileProviderResult(
handle.Descriptor.Id,
result.Status,
"reconciled",
result.LastUpdated ?? result.CompletedAt,
threshold,
result.Documents,
result.Claims,
result.Error));
}
else
{
results.Add(new ReconcileProviderResult(
handle.Descriptor.Id,
"succeeded",
"skipped",
lastUpdated,
threshold,
documents: 0,
claims: 0,
error: null));
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
results.Add(new ReconcileProviderResult(
handle.Descriptor.Id,
"cancelled",
"cancelled",
null,
threshold,
0,
0,
"Operation cancelled."));
_logger.LogWarning("Excititor reconcile cancelled for provider {ProviderId}.", handle.Descriptor.Id);
}
catch (Exception ex)
{
results.Add(new ReconcileProviderResult(
handle.Descriptor.Id,
"failed",
"failed",
null,
threshold,
0,
0,
ex.Message));
_logger.LogError(ex, "Excititor reconcile failed for provider {ProviderId}: {Message}", handle.Descriptor.Id, ex.Message);
}
}
var completedAt = _timeProvider.GetUtcNow();
return new ReconcileSummary(runId, startedAt, completedAt, results.ToImmutable());
}
private async Task ValidateConnectorAsync(ConnectorHandle handle, CancellationToken cancellationToken)
{
await handle.Connector.ValidateAsync(VexConnectorSettings.Empty, cancellationToken).ConfigureAwait(false);
}
private async Task EnsureProviderRegistrationAsync(VexConnectorDescriptor descriptor, CancellationToken cancellationToken)
{
var existing = await _providerStore.FindAsync(descriptor.Id, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return;
}
var provider = new VexProvider(descriptor.Id, descriptor.DisplayName, descriptor.Kind);
await _providerStore.SaveAsync(provider, cancellationToken).ConfigureAwait(false);
}
private async Task<ProviderRunResult> ExecuteRunAsync(
ConnectorHandle handle,
DateTimeOffset? since,
bool force,
CancellationToken cancellationToken)
{
var providerId = handle.Descriptor.Id;
var startedAt = _timeProvider.GetUtcNow();
var stopwatch = Stopwatch.StartNew();
try
{
await ValidateConnectorAsync(handle, cancellationToken).ConfigureAwait(false);
await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false);
if (force)
{
var resetState = new VexConnectorState(providerId, null, ImmutableArray<string>.Empty);
await _stateRepository.SaveAsync(resetState, cancellationToken).ConfigureAwait(false);
}
var context = new VexConnectorContext(
since,
VexConnectorSettings.Empty,
_rawStore,
_signatureVerifier,
_normalizerRouter,
_serviceProvider);
var documents = 0;
var claims = 0;
string? lastDigest = null;
await foreach (var document in handle.Connector.FetchAsync(context, cancellationToken).ConfigureAwait(false))
{
documents++;
lastDigest = document.Digest;
var batch = await _normalizerRouter.NormalizeAsync(document, cancellationToken).ConfigureAwait(false);
if (!batch.Claims.IsDefaultOrEmpty && batch.Claims.Length > 0)
{
claims += batch.Claims.Length;
await _claimStore.AppendAsync(batch.Claims, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
}
}
stopwatch.Stop();
var completedAt = _timeProvider.GetUtcNow();
var state = await _stateRepository.GetAsync(providerId, cancellationToken).ConfigureAwait(false);
var checkpoint = state?.DocumentDigests.IsDefaultOrEmpty == false
? state.DocumentDigests[^1]
: lastDigest;
var result = new ProviderRunResult(
providerId,
"succeeded",
documents,
claims,
startedAt,
completedAt,
stopwatch.Elapsed,
lastDigest,
state?.LastUpdated,
checkpoint,
null,
since);
_logger.LogInformation(
"Excititor ingest provider {ProviderId} completed: documents={Documents} claims={Claims} since={Since} duration={Duration}ms",
providerId,
documents,
claims,
since?.ToString("O", CultureInfo.InvariantCulture),
result.Duration.TotalMilliseconds);
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
stopwatch.Stop();
var cancelledAt = _timeProvider.GetUtcNow();
_logger.LogWarning("Excititor ingest provider {ProviderId} cancelled.", providerId);
return new ProviderRunResult(
providerId,
"cancelled",
0,
0,
startedAt,
cancelledAt,
stopwatch.Elapsed,
null,
null,
null,
"Operation cancelled.",
since);
}
catch (Exception ex)
{
stopwatch.Stop();
var failedAt = _timeProvider.GetUtcNow();
_logger.LogError(ex, "Excititor ingest provider {ProviderId} failed: {Message}", providerId, ex.Message);
return new ProviderRunResult(
providerId,
"failed",
0,
0,
startedAt,
failedAt,
stopwatch.Elapsed,
null,
null,
null,
ex.Message,
since);
}
}
private async Task<DateTimeOffset?> ResolveResumeSinceAsync(string providerId, string? checkpoint, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(checkpoint))
{
if (DateTimeOffset.TryParse(
checkpoint.Trim(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed;
}
var digest = checkpoint.Trim();
var document = await _rawStore.FindByDigestAsync(digest, cancellationToken).ConfigureAwait(false);
if (document is not null)
{
return document.RetrievedAt;
}
}
var state = await _stateRepository.GetAsync(providerId, cancellationToken).ConfigureAwait(false);
return state?.LastUpdated;
}
private static DateTimeOffset? ResolveSince(DateTimeOffset? since, TimeSpan? window, DateTimeOffset reference)
{
if (since.HasValue)
{
return since.Value;
}
if (window is { } duration && duration > TimeSpan.Zero)
{
var candidate = reference - duration;
return candidate < DateTimeOffset.MinValue ? DateTimeOffset.MinValue : candidate;
}
return null;
}
private (IReadOnlyList<ConnectorHandle> Handles, ImmutableArray<string> Missing) ResolveConnectors(ImmutableArray<string> requestedProviders)
{
var handles = new List<ConnectorHandle>();
var missing = ImmutableArray.CreateBuilder<string>();
if (requestedProviders.IsDefaultOrEmpty || requestedProviders.Length == 0)
{
foreach (var connector in _connectors.Values.OrderBy(static x => x.Id, StringComparer.OrdinalIgnoreCase))
{
handles.Add(new ConnectorHandle(connector, CreateDescriptor(connector)));
}
return (handles, missing.ToImmutable());
}
foreach (var providerId in requestedProviders)
{
if (_connectors.TryGetValue(providerId, out var connector))
{
handles.Add(new ConnectorHandle(connector, CreateDescriptor(connector)));
}
else
{
missing.Add(providerId);
}
}
return (handles, missing.ToImmutable());
}
private static VexConnectorDescriptor CreateDescriptor(IVexConnector connector)
=> connector switch
{
VexConnectorBase baseConnector => baseConnector.Descriptor,
_ => new VexConnectorDescriptor(connector.Id, connector.Kind, connector.Id)
};
private sealed record ConnectorHandle(IVexConnector Connector, VexConnectorDescriptor Descriptor);
}
internal sealed record IngestInitOptions(
ImmutableArray<string> Providers,
bool Resume);
internal sealed record IngestRunOptions(
ImmutableArray<string> Providers,
DateTimeOffset? Since,
TimeSpan? Window,
bool Force);
internal sealed record IngestResumeOptions(
ImmutableArray<string> Providers,
string? Checkpoint);
internal sealed record ReconcileOptions(
ImmutableArray<string> Providers,
TimeSpan? MaxAge);
internal sealed record InitSummary(
Guid RunId,
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
ImmutableArray<InitProviderResult> Providers)
{
public int ProviderCount => Providers.Length;
public int SuccessCount => Providers.Count(result => string.Equals(result.Status, "succeeded", StringComparison.OrdinalIgnoreCase));
public int FailureCount => Providers.Count(result => string.Equals(result.Status, "failed", StringComparison.OrdinalIgnoreCase));
}
internal sealed record InitProviderResult(
string ProviderId,
string DisplayName,
string Status,
TimeSpan Duration,
string? Error);
internal sealed record IngestRunSummary(
Guid RunId,
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
ImmutableArray<ProviderRunResult> Providers)
{
public int ProviderCount => Providers.Length;
public int SuccessCount => Providers.Count(provider => string.Equals(provider.Status, "succeeded", StringComparison.OrdinalIgnoreCase));
public int FailureCount => Providers.Count(provider => string.Equals(provider.Status, "failed", StringComparison.OrdinalIgnoreCase));
public TimeSpan Duration => CompletedAt - StartedAt;
}
internal sealed record ProviderRunResult(
string ProviderId,
string Status,
int Documents,
int Claims,
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
TimeSpan Duration,
string? LastDigest,
DateTimeOffset? LastUpdated,
string? Checkpoint,
string? Error,
DateTimeOffset? Since)
{
public static ProviderRunResult Missing(string providerId, DateTimeOffset? since)
=> new(providerId, "missing", 0, 0, DateTimeOffset.MinValue, DateTimeOffset.MinValue, TimeSpan.Zero, null, null, null, "Provider connector is not registered.", since);
}
internal sealed record ReconcileSummary(
Guid RunId,
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
ImmutableArray<ReconcileProviderResult> Providers)
{
public int ProviderCount => Providers.Length;
public int ReconciledCount => Providers.Count(result => string.Equals(result.Action, "reconciled", StringComparison.OrdinalIgnoreCase));
public int SkippedCount => Providers.Count(result => string.Equals(result.Action, "skipped", StringComparison.OrdinalIgnoreCase));
public int FailureCount => Providers.Count(result => string.Equals(result.Status, "failed", StringComparison.OrdinalIgnoreCase));
public TimeSpan Duration => CompletedAt - StartedAt;
}
internal sealed record ReconcileProviderResult(
string ProviderId,
string Status,
string Action,
DateTimeOffset? LastUpdated,
DateTimeOffset? Threshold,
int Documents,
int Claims,
string? Error);

View File

@@ -10,7 +10,12 @@
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Storage.Mongo\StellaOps.Excititor.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Export\StellaOps.Excititor.Export.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Policy\StellaOps.Excititor.Policy.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Attestation\StellaOps.Excititor.Attestation.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.ArtifactStores.S3\StellaOps.Excititor.ArtifactStores.S3.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Connectors.RedHat.CSAF\StellaOps.Excititor.Connectors.RedHat.CSAF.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Formats.CSAF\StellaOps.Excititor.Formats.CSAF.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Formats.CycloneDX\StellaOps.Excititor.Formats.CycloneDX.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Formats.OpenVEX\StellaOps.Excititor.Formats.OpenVEX.csproj" />
</ItemGroup>
</Project>

View File

@@ -3,7 +3,7 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|EXCITITOR-WEB-01-001 Minimal API bootstrap & DI|Team Excititor WebService|EXCITITOR-CORE-01-003, EXCITITOR-STORAGE-01-003|**DONE (2025-10-17)** Minimal API host composes storage/export/attestation/artifact stores, binds Mongo/attestation options, and exposes `/excititor/status` + health endpoints with regression coverage in `StatusEndpointTests`.|
|EXCITITOR-WEB-01-002 Ingest & reconcile endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001|TODO Implement `/excititor/init`, `/excititor/ingest/run`, `/excititor/ingest/resume`, `/excititor/reconcile` with token scope enforcement and structured run telemetry.|
|EXCITITOR-WEB-01-003 Export & verify endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|TODO Add `/excititor/export`, `/excititor/export/{id}`, `/excititor/export/{id}/download`, `/excititor/verify`, returning artifact + attestation metadata with cache awareness.|
|EXCITITOR-WEB-01-004 Resolve API & signed responses|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-ATTEST-01-002|TODO Deliver `/excititor/resolve` (subject/context), return consensus + score envelopes, attach cosign/Rekor metadata, and document auth + rate guardrails.|
|EXCITITOR-WEB-01-005 Mirror distribution endpoints|Team Excititor WebService|EXCITITOR-EXPORT-01-007, DEVOPS-MIRROR-08-001|TODO Provide domain-scoped mirror index/download APIs for consensus exports, enforce quota/auth, and document sync workflow for downstream Excititor deployments.|
|EXCITITOR-WEB-01-002 Ingest & reconcile endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001|**DOING (2025-10-19)** Prereqs EXCITITOR-WEB-01-001, EXCITITOR-EXPORT-01-001, and EXCITITOR-ATTEST-01-001 verified DONE; drafting `/excititor/init`, `/excititor/ingest/run`, `/excititor/ingest/resume`, `/excititor/reconcile` with scope enforcement & structured telemetry plan.|
|EXCITITOR-WEB-01-003 Export & verify endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|**DOING (2025-10-19)** Prereqs confirmed (EXCITITOR-WEB-01-001, EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001); preparing `/excititor/export*` surfaces and `/excititor/verify` with artifact/attestation metadata caching strategy.|
|EXCITITOR-WEB-01-004 Resolve API & signed responses|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-ATTEST-01-002|**DOING (2025-10-19)** Prereqs EXCITITOR-WEB-01-001, EXCITITOR-ATTEST-01-001, and EXCITITOR-ATTEST-01-002 verified DONE; planning `/excititor/resolve` signed response flow with consensus envelope + attestation metadata wiring.|
|EXCITITOR-WEB-01-005 Mirror distribution endpoints|Team Excititor WebService|EXCITITOR-EXPORT-01-007, DEVOPS-MIRROR-08-001|**DONE (2025-10-19)** `/excititor/mirror` surfaces domain listings, indices, metadata, and downloads with quota/auth checks; tests cover Happy-path listing/download (`dotnet test src/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj`).|