using System; using System.Collections.Generic; using System.Linq; using System.Collections.Immutable; using System.Diagnostics; using System.Reflection; using System.Text; using System.Text.Json; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using StellaOps.Excititor.Attestation.Verification; using StellaOps.Excititor.Attestation.Extensions; using StellaOps.Excititor.Attestation; using StellaOps.Excititor.Attestation.Transparency; using StellaOps.Excititor.ArtifactStores.S3.Extensions; using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection; using StellaOps.Excititor.Core; using StellaOps.Excititor.Core.Evidence; using StellaOps.Excititor.Core.Observations; 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.Postgres; using StellaOps.Infrastructure.Postgres.Options; using StellaOps.Excititor.WebService.Endpoints; using StellaOps.Excititor.WebService.Extensions; using StellaOps.Excititor.WebService.Options; using StellaOps.Excititor.WebService.Services; using StellaOps.Excititor.Core.Aoc; using StellaOps.Excititor.WebService.Telemetry; using Microsoft.Extensions.Caching.Memory; using StellaOps.Excititor.WebService.Contracts; using System.Globalization; using StellaOps.Excititor.WebService.Graph; using StellaOps.Excititor.Core.Storage; var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; var services = builder.Services; services.AddOptions() .Bind(configuration.GetSection("Excititor:Storage")) .ValidateOnStart(); services.AddOptions() .Bind(configuration.GetSection("Excititor:Graph")); services.AddExcititorPostgresStorage(configuration); services.TryAddSingleton(); services.TryAddScoped(); services.TryAddSingleton(); services.AddCsafNormalizer(); services.AddCycloneDxNormalizer(); services.AddOpenVexNormalizer(); services.AddSingleton(); // TODO: replace NoopVexSignatureVerifier with hardened verifier once portable bundle signatures are finalized. services.Configure(configuration.GetSection(AirgapOptions.SectionName)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddMemoryCache(); services.AddSingleton(); services.AddSingleton(sp => { var graphOptions = sp.GetRequiredService>().Value; var pgOptions = sp.GetRequiredService>().Value; if (graphOptions.UsePostgresOverlayStore && !string.IsNullOrWhiteSpace(pgOptions.ConnectionString)) { return new PostgresGraphOverlayStore( sp.GetRequiredService(), sp.GetRequiredService>()); } return new InMemoryGraphOverlayStore(); }); services.AddSingleton(); services.AddSingleton(); // OBS-52/53/54: Attestation storage and timeline event recording services.TryAddSingleton(); services.TryAddSingleton(); services.AddScoped(); services.AddSingleton(); services.AddOptions() .Bind(configuration.GetSection("Excititor:Observability")); services.AddScoped(); services.AddExcititorAocGuards(); services.AddVexExportEngine(); services.AddVexExportCacheServices(); services.AddVexAttestation(); services.Configure(configuration.GetSection("Excititor:Attestation:Client")); services.Configure(configuration.GetSection("Excititor:Attestation:Verification")); services.AddVexPolicy(); services.AddSingleton(); services.AddSingleton(); // EXCITITOR-VULN-29-004: Normalization observability for Vuln Explorer + Advisory AI dashboards services.AddSingleton(); services.AddRedHatCsafConnector(); services.Configure(configuration.GetSection(MirrorDistributionOptions.SectionName)); services.AddSingleton(); services.TryAddSingleton(TimeProvider.System); // CRYPTO-90-001: Crypto provider abstraction for pluggable hashing algorithms (GOST/SM support) services.AddSingleton(sp => { // When ICryptoProviderRegistry is available, use it for pluggable algorithms var registry = sp.GetService(); return new VexHashingService(registry); }); services.AddSingleton(); services.AddScoped(); // EXCITITOR-RISK-66-001: Risk feed service for Risk Engine integration services.AddScoped(); var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor"); if (rekorSection.Exists()) { services.AddVexRekorClient(opts => rekorSection.Bind(opts)); } var fileSystemSection = configuration.GetSection("Excititor:Artifacts:FileSystem"); if (fileSystemSection.Exists()) { services.AddVexFileSystemArtifactStore(opts => fileSystemSection.Bind(opts)); } else { services.AddVexFileSystemArtifactStore(_ => { }); } var s3Section = configuration.GetSection("Excititor:Artifacts:S3"); if (s3Section.Exists()) { services.AddVexS3ArtifactClient(opts => s3Section.GetSection("Client").Bind(opts)); services.AddSingleton(provider => { var options = new S3ArtifactStoreOptions(); s3Section.GetSection("Store").Bind(options); return new S3ArtifactStore( provider.GetRequiredService(), Microsoft.Extensions.Options.Options.Create(options), provider.GetRequiredService>()); }); } var offlineSection = configuration.GetSection("Excititor:Artifacts:OfflineBundle"); if (offlineSection.Exists()) { services.AddVexOfflineBundleArtifactStore(opts => offlineSection.Bind(opts)); } services.AddEndpointsApiExplorer(); services.AddHealthChecks(); services.AddSingleton(TimeProvider.System); services.AddMemoryCache(); services.AddAuthentication(); services.AddAuthorization(); builder.ConfigureExcititorTelemetry(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.UseObservabilityHeaders(); app.MapGet("/excititor/status", async (HttpContext context, IEnumerable artifactStores, IOptions storageOptions, TimeProvider timeProvider) => { var payload = new StatusResponse( timeProvider.GetUtcNow(), storageOptions.Value.InlineThresholdBytes, artifactStores.Select(store => store.GetType().Name).ToArray()); context.Response.ContentType = "application/json"; await System.Text.Json.JsonSerializer.SerializeAsync(context.Response.Body, payload); }); app.MapHealthChecks("/excititor/health"); // OpenAPI discovery (WEB-OAS-61-001) app.MapGet("/.well-known/openapi", () => { var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; var payload = new { service = "excititor", specVersion = "3.1.0", version, format = "application/json", url = "/openapi/excititor.json", errorEnvelopeSchema = "#/components/schemas/Error" }; return Results.Json(payload); }); app.MapGet("/openapi/excititor.json", () => { var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; var spec = new { openapi = "3.1.0", info = new { title = "StellaOps Excititor API", version, description = "Aggregation-only VEX observation, timeline, and attestation APIs" }, paths = new Dictionary { ["/excititor/status"] = new { get = new { summary = "Service status (aggregation-only metadata)", responses = new Dictionary { ["200"] = new { description = "OK", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/StatusResponse" }, examples = new Dictionary { ["example"] = new { value = new { timeUtc = "2025-11-24T00:00:00Z", inlineThreshold = 1048576, artifactStores = new[] { "S3ArtifactStore", "OfflineBundleArtifactStore" } } } } } } } } } }, ["/excititor/health"] = new { get = new { summary = "Health check", responses = new Dictionary { ["200"] = new { description = "Healthy", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["example"] = new { value = new { status = "Healthy" } } } } } } } } }, ["/obs/excititor/timeline"] = new { get = new { summary = "VEX timeline stream (SSE)", parameters = new object[] { new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false, description = "Numeric cursor or Last-Event-ID" }, new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Event stream", headers = new Dictionary { ["Deprecation"] = new { description = "Set to true when this route is superseded", schema = new { type = "string" } }, ["Link"] = new { description = "Link to OpenAPI description", schema = new { type = "string" }, example = "; rel=\"describedby\"; type=\"application/json\"" } }, content = new Dictionary { ["text/event-stream"] = new { examples = new Dictionary { ["event"] = new { value = "id: 123\nretry: 5000\nevent: timeline\ndata: {\"id\":123,\"tenant\":\"acme\",\"kind\":\"vex.status\",\"createdUtc\":\"2025-11-24T00:00:00Z\"}\n\n" } } } } }, ["400"] = new { description = "Invalid cursor", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/Error" }, examples = new Dictionary { ["bad-cursor"] = new { value = new { error = new { code = "ERR_CURSOR", message = "cursor must be integer" } } } } } } } } } }, ["/airgap/v1/vex/import"] = new { post = new { summary = "Register sealed mirror bundle metadata", requestBody = new { required = true, content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/AirgapImportRequest" } } } }, responses = new Dictionary { ["200"] = new { description = "Accepted" }, ["400"] = new { description = "Validation error", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/Error" }, examples = new Dictionary { ["validation-failed"] = new { value = new { error = new { code = "ERR_VALIDATION", message = "PayloadHash is required." } } } } } } }, ["403"] = new { description = "Trust validation failed", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/Error" }, examples = new Dictionary { ["trust-failed"] = new { value = new { error = new { code = "ERR_TRUST", message = "Signature trust root not recognized." } } } } } } } } } }, // WEB-OBS-53-001: Evidence API endpoints ["/evidence/vex/list"] = new { get = new { summary = "List VEX evidence exports", parameters = new object[] { new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }, new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false }, new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Evidence list response", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["evidence-list"] = new { value = new { items = new[] { new { bundleId = "vex-bundle-2025-11-24-001", tenant = "acme", format = "openvex", createdAt = "2025-11-24T00:00:00Z", itemCount = 42, merkleRoot = "sha256:abc123...", sealed_ = false } }, nextCursor = (string?)null } } } } } } } } }, ["/evidence/vex/bundle/{bundleId}"] = new { get = new { summary = "Get VEX evidence bundle details", parameters = new object[] { new { name = "bundleId", @in = "path", schema = new { type = "string" }, required = true }, new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Bundle detail response", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["bundle-detail"] = new { value = new { bundleId = "vex-bundle-2025-11-24-001", tenant = "acme", format = "openvex", specVersion = "0.2.0", createdAt = "2025-11-24T00:00:00Z", itemCount = 42, merkleRoot = "sha256:abc123...", sealed_ = false, metadata = new { source = "excititor" } } } } } } }, ["404"] = new { description = "Bundle not found", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/Error" } } } } } } }, ["/evidence/vex/lookup"] = new { get = new { summary = "Lookup evidence for vulnerability/product pair", parameters = new object[] { new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = true, example = "CVE-2024-12345" }, new { name = "productKey", @in = "query", schema = new { type = "string" }, required = true, example = "pkg:npm/lodash@4.17.21" }, new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Evidence lookup response", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["lookup-result"] = new { value = new { vulnerabilityId = "CVE-2024-12345", productKey = "pkg:npm/lodash@4.17.21", evidence = new[] { new { bundleId = "vex-bundle-001", observationId = "obs-001" } }, queriedAt = "2025-11-24T12:00:00Z" } } } } } } } } }, // WEB-OBS-54-001: Attestation API endpoints ["/attestations/vex/list"] = new { get = new { summary = "List VEX attestations", parameters = new object[] { new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 200 }, required = false }, new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false }, new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = false }, new { name = "productKey", @in = "query", schema = new { type = "string" }, required = false }, new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Attestation list response", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["attestation-list"] = new { value = new { items = new[] { new { attestationId = "att-2025-001", tenant = "acme", createdAt = "2025-11-24T00:00:00Z", predicateType = "https://in-toto.io/attestation/v1", subjectDigest = "sha256:abc123...", valid = true, builderId = "excititor:redhat" } }, nextCursor = (string?)null, hasMore = false, count = 1 } } } } } } } } }, ["/attestations/vex/{attestationId}"] = new { get = new { summary = "Get VEX attestation details with DSSE verification state", parameters = new object[] { new { name = "attestationId", @in = "path", schema = new { type = "string" }, required = true }, new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Attestation detail response with chain-of-custody", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["attestation-detail"] = new { value = new { attestationId = "att-2025-001", tenant = "acme", createdAt = "2025-11-24T00:00:00Z", predicateType = "https://in-toto.io/attestation/v1", subject = new { digest = "sha256:abc123...", name = "CVE-2024-12345/pkg:npm/lodash@4.17.21" }, builder = new { id = "excititor:redhat", builderId = "excititor:redhat" }, verification = new { valid = true, verifiedAt = "2025-11-24T00:00:00Z", signatureType = "dsse" }, chainOfCustody = new[] { new { step = 1, actor = "excititor:redhat", action = "created", timestamp = "2025-11-24T00:00:00Z" } } } } } } } }, ["404"] = new { description = "Attestation not found", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/Error" } } } } } } }, ["/attestations/vex/lookup"] = new { get = new { summary = "Lookup attestations by linkset or observation", parameters = new object[] { new { name = "linksetId", @in = "query", schema = new { type = "string" }, required = false }, new { name = "observationId", @in = "query", schema = new { type = "string" }, required = false }, new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false }, new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Attestation lookup response", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["lookup-result"] = new { value = new { subjectDigest = "linkset-001", attestations = new[] { new { attestationId = "att-001", valid = true } }, queriedAt = "2025-11-24T12:00:00Z" } } } } } }, ["400"] = new { description = "Missing required parameter", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/Error" } } } } } } }, // EXCITITOR-LNM-21-201: Observation API endpoints ["/vex/observations"] = new { get = new { summary = "List VEX observations with filters", parameters = new object[] { new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false }, new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false }, new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = false, example = "CVE-2024-12345" }, new { name = "productKey", @in = "query", schema = new { type = "string" }, required = false, example = "pkg:npm/lodash@4.17.21" }, new { name = "providerId", @in = "query", schema = new { type = "string" }, required = false, example = "excititor:redhat" }, new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Observation list response", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["observation-list"] = new { value = new { items = new[] { new { observationId = "obs-2025-001", tenant = "acme", providerId = "excititor:redhat", vulnerabilityId = "CVE-2024-12345", productKey = "pkg:npm/lodash@4.17.21", status = "not_affected", createdAt = "2025-11-24T00:00:00Z" } }, nextCursor = (string?)null } } } } } }, ["400"] = new { description = "Missing required filter", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/Error" }, examples = new Dictionary { ["missing-filter"] = new { value = new { error = new { code = "ERR_PARAMS", message = "At least one filter is required: vulnerabilityId+productKey or providerId" } } } } } } } } } }, ["/vex/observations/{observationId}"] = new { get = new { summary = "Get VEX observation by ID", parameters = new object[] { new { name = "observationId", @in = "path", schema = new { type = "string" }, required = true }, new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Observation detail response", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["observation-detail"] = new { value = new { observationId = "obs-2025-001", tenant = "acme", providerId = "excititor:redhat", streamId = "stream-001", upstream = new { upstreamId = "RHSA-2024:001", fetchedAt = "2025-11-24T00:00:00Z" }, content = new { format = "csaf", specVersion = "2.0" }, statements = new[] { new { vulnerabilityId = "CVE-2024-12345", productKey = "pkg:npm/lodash@4.17.21", status = "not_affected" } }, linkset = new { aliases = new[] { "CVE-2024-12345" }, purls = new[] { "pkg:npm/lodash@4.17.21" } }, createdAt = "2025-11-24T00:00:00Z" } } } } } }, ["404"] = new { description = "Observation not found", content = new Dictionary { ["application/json"] = new { schema = new { @ref = "#/components/schemas/Error" } } } } } } }, ["/vex/observations/count"] = new { get = new { summary = "Get observation count for tenant", parameters = new object[] { new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false } }, responses = new Dictionary { ["200"] = new { description = "Count response", content = new Dictionary { ["application/json"] = new { examples = new Dictionary { ["count"] = new { value = new { count = 1234 } } } } } } } } } }, components = new { schemas = new Dictionary { ["Error"] = new { type = "object", required = new[] { "error" }, properties = new Dictionary { ["error"] = new { type = "object", required = new[] { "code", "message" }, properties = new Dictionary { ["code"] = new { type = "string", example = "ERR_EXAMPLE" }, ["message"] = new { type = "string", example = "Details about the error." } } } } }, ["StatusResponse"] = new { type = "object", required = new[] { "timeUtc", "artifactStores", "inlineThreshold" }, properties = new Dictionary { ["timeUtc"] = new { type = "string", format = "date-time" }, ["inlineThreshold"] = new { type = "integer", format = "int64" }, ["artifactStores"] = new { type = "array", items = new { type = "string" } } } }, ["AirgapImportRequest"] = new { type = "object", required = new[] { "bundleId", "mirrorGeneration", "signedAt", "publisher", "payloadHash", "signature" }, properties = new Dictionary { ["bundleId"] = new { type = "string", example = "mirror-2025-11-24" }, ["mirrorGeneration"] = new { type = "string", example = "g001" }, ["signedAt"] = new { type = "string", format = "date-time" }, ["publisher"] = new { type = "string", example = "acme" }, ["payloadHash"] = new { type = "string", example = "sha256:..." }, ["payloadUrl"] = new { type = "string", nullable = true }, ["signature"] = new { type = "string", example = "base64-signature" }, ["transparencyLog"] = new { type = "string", nullable = true } } } } } }; return Results.Json(spec); }); app.MapPost("/airgap/v1/vex/import", async ( HttpContext httpContext, [FromServices] AirgapImportValidator validator, [FromServices] AirgapSignerTrustService trustService, [FromServices] AirgapModeEnforcer modeEnforcer, [FromServices] IAirgapImportStore store, [FromServices] IVexTimelineEventEmitter timelineEmitter, [FromServices] IVexHashingService hashingService, [FromServices] ILoggerFactory loggerFactory, [FromServices] TimeProvider timeProvider, [FromBody] AirgapImportRequest request, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(httpContext, "vex.admin"); if (scopeResult is not null) { return scopeResult; } var logger = loggerFactory.CreateLogger("AirgapImport"); var nowUtc = timeProvider.GetUtcNow(); var tenantId = string.IsNullOrWhiteSpace(request.TenantId) ? "default" : request.TenantId!.Trim().ToLowerInvariant(); var stalenessSeconds = request.SignedAt is null ? (int?)null : (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds); var bundleId = (request.BundleId ?? string.Empty).Trim(); var mirrorGeneration = (request.MirrorGeneration ?? string.Empty).Trim(); var manifestPath = $"mirror/{bundleId}/{mirrorGeneration}/manifest.json"; var evidenceLockerPath = $"evidence/{bundleId}/{mirrorGeneration}/bundle.ndjson"; // WEB-AIRGAP-58-001: include manifest hash/path for audit + telemetry (pluggable crypto) var manifestHash = hashingService.ComputeHash($"{bundleId}:{mirrorGeneration}:{request.PayloadHash ?? string.Empty}"); var actor = ResolveActor(httpContext); var scopes = ResolveScopes(httpContext); var traceId = Activity.Current?.TraceId.ToString(); var timeline = new List(); void RecordEvent(string eventType, string? code = null, string? message = null, string? remediation = null) { var entry = new AirgapTimelineEntry { EventType = eventType, CreatedAt = nowUtc, TenantId = tenantId, BundleId = bundleId, MirrorGeneration = mirrorGeneration, StalenessSeconds = stalenessSeconds, ErrorCode = code, Message = message, Remediation = remediation, Actor = actor, Scopes = scopes }; timeline.Add(entry); logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code} actor={Actor} scopes={Scopes}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code, actor, scopes); // WEB-AIRGAP-58-001: Emit timeline event to persistent store for SSE streaming _ = EmitTimelineEventAsync(eventType, code, message, remediation); } async Task EmitTimelineEventAsync(string eventType, string? code, string? message, string? remediation) { try { var attributes = new Dictionary(StringComparer.Ordinal) { ["bundle_id"] = bundleId, ["mirror_generation"] = mirrorGeneration, ["tenant_id"] = tenantId, ["publisher"] = request.Publisher ?? string.Empty, ["actor"] = actor, ["scopes"] = scopes }; if (stalenessSeconds.HasValue) { attributes["staleness_seconds"] = stalenessSeconds.Value.ToString(CultureInfo.InvariantCulture); } attributes["portable_manifest_hash"] = manifestHash; attributes["portable_manifest_path"] = manifestPath; attributes["evidence_path"] = evidenceLockerPath; if (!string.IsNullOrEmpty(code)) { attributes["error_code"] = code; } if (!string.IsNullOrEmpty(message)) { attributes["message"] = message; } if (!string.IsNullOrEmpty(remediation)) { attributes["remediation"] = remediation; } if (!string.IsNullOrEmpty(request.TransparencyLog)) { attributes["transparency_log"] = request.TransparencyLog; } var eventId = $"airgap-{bundleId}-{mirrorGeneration}-{nowUtc:yyyyMMddHHmmssfff}"; var streamId = $"airgap:{bundleId}:{mirrorGeneration}"; var evt = new TimelineEvent( eventId, tenantId, "airgap-import", streamId, eventType, traceId ?? Guid.NewGuid().ToString("N"), justificationSummary: message ?? string.Empty, nowUtc, evidenceHash: null, payloadHash: request.PayloadHash, attributes.ToImmutableDictionary()); await timelineEmitter.EmitAsync(evt, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogWarning(ex, "Failed to emit timeline event {EventType} for bundle {BundleId}", eventType, request.BundleId); } } RecordEvent("airgap.import.started"); var errors = validator.Validate(request, nowUtc); if (errors.Count > 0) { var first = errors[0]; var errorResponse = AirgapErrorMapping.FromErrorCode(first.Code, first.Message); RecordEvent("airgap.import.failed", first.Code, first.Message, errorResponse.Remediation); return Results.BadRequest(errorResponse); } if (!modeEnforcer.Validate(request, out var sealedCode, out var sealedMessage)) { var errorResponse = AirgapErrorMapping.FromErrorCode(sealedCode ?? "AIRGAP_SEALED_MODE", sealedMessage ?? "Sealed mode violation."); RecordEvent("airgap.import.failed", sealedCode, sealedMessage, errorResponse.Remediation); return Results.Json(errorResponse, statusCode: StatusCodes.Status403Forbidden); } if (!trustService.Validate(request, out var trustCode, out var trustMessage)) { var errorResponse = AirgapErrorMapping.FromErrorCode(trustCode ?? "AIRGAP_TRUST_FAILED", trustMessage ?? "Trust validation failed."); RecordEvent("airgap.import.failed", trustCode, trustMessage, errorResponse.Remediation); return Results.Json(errorResponse, statusCode: StatusCodes.Status403Forbidden); } RecordEvent("airgap.import.completed"); var record = new AirgapImportRecord { Id = $"{bundleId}:{mirrorGeneration}", TenantId = tenantId, BundleId = bundleId, MirrorGeneration = mirrorGeneration, SignedAt = request.SignedAt!.Value, Publisher = request.Publisher!, PayloadHash = request.PayloadHash!, PayloadUrl = request.PayloadUrl, Signature = request.Signature!, TransparencyLog = request.TransparencyLog, ImportedAt = nowUtc, PortableManifestPath = manifestPath, PortableManifestHash = manifestHash, EvidenceLockerPath = evidenceLockerPath, Timeline = timeline, ImportActor = actor, ImportScopes = scopes }; try { await store.SaveAsync(record, cancellationToken).ConfigureAwait(false); } catch (DuplicateAirgapImportException dup) { RecordEvent("airgap.import.failed", "AIRGAP_IMPORT_DUPLICATE", dup.Message); return Results.Conflict(new { error = new { code = "AIRGAP_IMPORT_DUPLICATE", message = dup.Message } }); } return Results.Accepted($"/airgap/v1/vex/import/{bundleId}", new { bundleId, generation = mirrorGeneration, manifest = manifestPath, evidence = evidenceLockerPath, manifestSha256 = manifestHash }); }); // CRYPTO-90-001: ComputeSha256 removed - now using IVexHashingService for pluggable crypto static string ResolveActor(HttpContext context) { var user = context.User; var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user?.FindFirst("sub")?.Value ?? "unknown"; return actor; } static string ResolveScopes(HttpContext context) { var user = context.User; var scopes = user?.FindAll("scope") .Concat(user.FindAll("scp")) .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray() ?? Array.Empty(); return scopes.Length == 0 ? string.Empty : string.Join(' ', scopes); } app.MapPost("/v1/attestations/verify", async ( [FromServices] IVexAttestationClient attestationClient, [FromBody] AttestationVerifyRequest request, CancellationToken cancellationToken) => { if (request is null) { return Results.BadRequest("Request body is required."); } if (string.IsNullOrWhiteSpace(request.ExportId) || string.IsNullOrWhiteSpace(request.QuerySignature) || string.IsNullOrWhiteSpace(request.ArtifactDigest) || string.IsNullOrWhiteSpace(request.Format) || string.IsNullOrWhiteSpace(request.Envelope) || string.IsNullOrWhiteSpace(request.Attestation?.EnvelopeDigest)) { return Results.BadRequest("Missing required fields."); } if (!Enum.TryParse(request.Format, ignoreCase: true, out var format)) { return Results.BadRequest("Unknown export format."); } var attestationRequest = new VexAttestationRequest( request.ExportId.Trim(), new VexQuerySignature(request.QuerySignature.Trim()), new VexContentAddress("sha256", request.ArtifactDigest.Trim()), format, request.CreatedAt, request.SourceProviders?.ToImmutableArray() ?? ImmutableArray.Empty, request.Metadata?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary.Empty); var rekor = request.Attestation?.Rekor is null ? null : new VexRekorReference( request.Attestation.Rekor.ApiVersion ?? "0.2", request.Attestation.Rekor.Location ?? string.Empty, request.Attestation.Rekor.LogIndex?.ToString(CultureInfo.InvariantCulture), request.Attestation.Rekor.InclusionProofUrl); var attestationMetadata = new VexAttestationMetadata( request.Attestation?.PredicateType ?? string.Empty, rekor, request.Attestation!.EnvelopeDigest, request.Attestation.SignedAt); var verificationRequest = new VexAttestationVerificationRequest( attestationRequest, attestationMetadata, request.Envelope, request.IsReverify); var verification = await attestationClient.VerifyAsync(verificationRequest, cancellationToken).ConfigureAwait(false); var response = new AttestationVerifyResponse( verification.IsValid, new Dictionary(verification.Diagnostics, StringComparer.Ordinal)); return Results.Ok(response); }); 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); }); app.MapPost("/excititor/admin/backfill-statements", async ( VexStatementBackfillRequest? request, VexStatementBackfillService backfillService, CancellationToken cancellationToken) => { request ??= new VexStatementBackfillRequest(); var result = await backfillService.RunAsync(request, cancellationToken).ConfigureAwait(false); var message = FormattableString.Invariant( $"Backfill completed: evaluated {result.DocumentsEvaluated}, backfilled {result.DocumentsBackfilled}, claims written {result.ClaimsWritten}, skipped {result.SkippedExisting}, failures {result.NormalizationFailures}."); return Results.Ok(new { message, summary = result }); }); app.MapGet("/console/vex", async ( HttpContext context, IOptions storageOptions, IVexObservationQueryService queryService, ConsoleTelemetry telemetry, IMemoryCache cache, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var query = context.Request.Query; static string[] NormalizeValues(StringValues values) => values.Where(static v => !string.IsNullOrWhiteSpace(v)) .Select(static v => v!.Trim()) .ToArray(); var purls = query.TryGetValue("purl", out var purlValues) ? NormalizeValues(purlValues) : Array.Empty(); var advisories = query.TryGetValue("advisoryId", out var advisoryValues) ? NormalizeValues(advisoryValues) : Array.Empty(); var statuses = new List(); if (query.TryGetValue("status", out var statusValues)) { foreach (var statusValue in statusValues) { if (string.IsNullOrWhiteSpace(statusValue)) { continue; } if (Enum.TryParse(statusValue, ignoreCase: true, out var parsed)) { statuses.Add(parsed); } else { return Results.BadRequest($"Unknown status '{statusValue}'."); } } } var limit = query.TryGetValue("pageSize", out var pageSizeValues) && int.TryParse(pageSizeValues.FirstOrDefault(), out var pageSize) ? pageSize : (int?)null; var cursor = query.TryGetValue("cursor", out var cursorValues) ? cursorValues.FirstOrDefault() : null; telemetry.Requests.Add(1); var cacheKey = $"console-vex:{tenant}:{string.Join(',', purls)}:{string.Join(',', advisories)}:{string.Join(',', statuses)}:{limit}:{cursor}"; if (cache.TryGetValue(cacheKey, out VexConsolePage? cachedPage) && cachedPage is not null) { telemetry.CacheHits.Add(1); return Results.Ok(cachedPage); } telemetry.CacheMisses.Add(1); var options = new VexObservationQueryOptions( tenant, observationIds: null, vulnerabilityIds: advisories, productKeys: null, purls: purls, cpes: null, providerIds: null, statuses: statuses, limit: limit, cursor: cursor); VexObservationQueryResult result; try { result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); } catch (FormatException ex) { return Results.BadRequest(ex.Message); } var statements = result.Observations .SelectMany(obs => obs.Statements.Select(stmt => new VexConsoleStatementDto( AdvisoryId: stmt.VulnerabilityId, ProductKey: stmt.ProductKey, Purl: stmt.Purl ?? (obs.Linkset is { } linkset ? linkset.Purls.FirstOrDefault() : null) ?? string.Empty, Status: stmt.Status.ToString().ToLowerInvariant(), Justification: stmt.Justification?.ToString(), ProviderId: obs.ProviderId, ObservationId: obs.ObservationId, CreatedAtUtc: obs.CreatedAt, Attributes: obs.Attributes ?? ImmutableDictionary.Empty))) .ToList(); var statusCounts = statements .GroupBy(o => o.Status) .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); var response = new VexConsolePage( Items: statements, Cursor: result.NextCursor, HasMore: result.HasMore, Returned: statements.Count, Counters: statusCounts); cache.Set(cacheKey, response, TimeSpan.FromSeconds(30)); return Results.Ok(response); }).WithName("GetConsoleVex"); // Cartographer linkouts app.MapPost("/internal/graph/linkouts", async ( GraphLinkoutsRequest request, IVexObservationQueryService queryService, CancellationToken cancellationToken) => { if (request is null || string.IsNullOrWhiteSpace(request.Tenant)) { return Results.BadRequest("tenant is required."); } if (request.Purls is null || request.Purls.Count == 0 || request.Purls.Count > 500) { return Results.BadRequest("purls are required (1-500)."); } var normalizedPurls = request.Purls .Where(p => !string.IsNullOrWhiteSpace(p)) .Select(p => p.Trim().ToLowerInvariant()) .Distinct() .ToArray(); if (normalizedPurls.Length == 0) { return Results.BadRequest("purls are required (1-500)."); } var options = new VexObservationQueryOptions( request.Tenant.Trim(), purls: normalizedPurls, limit: 200); VexObservationQueryResult result; try { result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); } catch (FormatException ex) { return Results.BadRequest(ex.Message); } var observationsByPurl = result.Observations .SelectMany(obs => obs.Linkset.Purls.Select(purl => (purl, obs))) .GroupBy(tuple => tuple.purl, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.Select(t => t.obs).ToArray(), StringComparer.OrdinalIgnoreCase); var items = new List(normalizedPurls.Length); var notFound = new List(); foreach (var inputPurl in normalizedPurls) { if (!observationsByPurl.TryGetValue(inputPurl, out var obsForPurl)) { notFound.Add(inputPurl); continue; } var advisories = obsForPurl .SelectMany(obs => obs.Statements.Select(stmt => new GraphLinkoutAdvisory( AdvisoryId: stmt.VulnerabilityId, Source: obs.ProviderId, Status: stmt.Status.ToString().ToLowerInvariant(), Justification: request.IncludeJustifications ? stmt.Justification?.ToString() : null, ModifiedAt: obs.CreatedAt, EvidenceHash: string.Empty, ConnectorId: obs.ProviderId, DsseEnvelopeHash: request.IncludeProvenance ? string.Empty : null))) .OrderBy(a => a.AdvisoryId, StringComparer.Ordinal) .ThenBy(a => a.Source, StringComparer.Ordinal) .Take(200) .ToList(); items.Add(new GraphLinkoutItem( Purl: inputPurl, Advisories: advisories, Conflicts: Array.Empty(), Truncated: advisories.Count >= 200, NextCursor: advisories.Count >= 200 ? $"{advisories[^1].AdvisoryId}:{advisories[^1].Source}" : null)); } var response = new GraphLinkoutsResponse(items, notFound); return Results.Ok(response); }).WithName("PostGraphLinkouts"); app.MapGet("/v1/graph/status", async ( HttpContext context, [FromQuery(Name = "purl")] string[]? purls, IOptions storageOptions, IOptions graphOptions, IVexObservationQueryService queryService, IMemoryCache cache, TimeProvider timeProvider, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var orderedPurls = NormalizePurls(purls); if (orderedPurls.Count == 0) { return Results.BadRequest("purl query parameter is required"); } if (orderedPurls.Count > graphOptions.Value.MaxPurls) { return Results.BadRequest($"purls limit exceeded (max {graphOptions.Value.MaxPurls})"); } var cacheKey = $"graph-status:{tenant}:{string.Join('|', orderedPurls)}"; var now = timeProvider.GetUtcNow(); if (cache.TryGetValue(cacheKey, out var cached) && cached is not null) { var ageMs = (long)Math.Max(0, (now - cached.CachedAt).TotalMilliseconds); return Results.Ok(new GraphStatusResponse(cached.Items, true, ageMs)); } var options = new VexObservationQueryOptions( tenant: tenant, purls: orderedPurls, limit: graphOptions.Value.MaxAdvisoriesPerPurl * orderedPurls.Count); VexObservationQueryResult result; try { result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); } catch (FormatException ex) { return Results.BadRequest(ex.Message); } var items = GraphStatusFactory.Build(tenant!, timeProvider.GetUtcNow(), orderedPurls, result.Observations); var response = new GraphStatusResponse(items, false, null); cache.Set(cacheKey, new CachedGraphStatus(items, now), TimeSpan.FromSeconds(graphOptions.Value.OverlayTtlSeconds)); return Results.Ok(response); }).WithName("GetGraphStatus"); // Cartographer overlays app.MapGet("/v1/graph/overlays", async ( HttpContext context, [FromQuery(Name = "purl")] string[]? purls, [FromQuery] bool includeJustifications, IOptions storageOptions, IOptions graphOptions, IVexObservationQueryService queryService, IGraphOverlayCache overlayCache, IGraphOverlayStore overlayStore, TimeProvider timeProvider, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var orderedPurls = NormalizePurls(purls); if (orderedPurls.Count == 0) { return Results.BadRequest("purl query parameter is required"); } if (orderedPurls.Count > graphOptions.Value.MaxPurls) { return Results.BadRequest($"purls limit exceeded (max {graphOptions.Value.MaxPurls})"); } var now = timeProvider.GetUtcNow(); var cached = await overlayCache.TryGetAsync(tenant!, includeJustifications, orderedPurls, cancellationToken).ConfigureAwait(false); if (cached is not null) { return Results.Ok(new GraphOverlaysResponse(cached.Items, true, cached.AgeMilliseconds)); } var options = new VexObservationQueryOptions( tenant: tenant, purls: orderedPurls, limit: graphOptions.Value.MaxAdvisoriesPerPurl * orderedPurls.Count); VexObservationQueryResult result; try { result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); } catch (FormatException ex) { return Results.BadRequest(ex.Message); } var overlays = GraphOverlayFactory.Build(tenant!, now, orderedPurls, result.Observations, includeJustifications); await overlayStore.SaveAsync(tenant!, overlays, cancellationToken).ConfigureAwait(false); var response = new GraphOverlaysResponse(overlays, false, null); await overlayCache.SaveAsync(tenant!, includeJustifications, orderedPurls, overlays, now, cancellationToken).ConfigureAwait(false); return Results.Ok(response); }).WithName("GetGraphOverlays"); app.MapGet("/v1/graph/observations", async ( HttpContext context, [FromQuery(Name = "purl")] string[]? purls, [FromQuery] bool includeJustifications, [FromQuery] int? limitPerPurl, [FromQuery] string? cursor, IOptions storageOptions, IOptions graphOptions, IVexObservationQueryService queryService, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var orderedPurls = NormalizePurls(purls); if (orderedPurls.Count == 0) { return Results.BadRequest("purl query parameter is required"); } if (orderedPurls.Count > graphOptions.Value.MaxPurls) { return Results.BadRequest($"purls limit exceeded (max {graphOptions.Value.MaxPurls})"); } var perPurlLimit = limitPerPurl.GetValueOrDefault(graphOptions.Value.MaxTooltipItemsPerPurl); if (perPurlLimit <= 0) { return Results.BadRequest("limitPerPurl must be greater than zero when provided."); } var effectivePerPurlLimit = Math.Min(perPurlLimit, graphOptions.Value.MaxAdvisoriesPerPurl); var totalLimit = Math.Min( Math.Max(1, effectivePerPurlLimit * orderedPurls.Count), graphOptions.Value.MaxTooltipTotal); var options = new VexObservationQueryOptions( tenant: tenant, purls: orderedPurls, limit: totalLimit, cursor: cursor); VexObservationQueryResult result; try { result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); } catch (FormatException ex) { return Results.BadRequest(ex.Message); } var items = GraphTooltipFactory.Build(orderedPurls, result.Observations, includeJustifications, effectivePerPurlLimit); var response = new GraphTooltipResponse(items, result.NextCursor, result.HasMore); return Results.Ok(response); }).WithName("GetGraphObservations"); app.MapPost("/ingest/vex", async ( HttpContext context, VexIngestRequest request, IVexRawStore rawStore, IOptions storageOptions, TimeProvider timeProvider, ILogger logger, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin"); if (scopeResult is not null) { return scopeResult; } if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } VexRawDocument document; try { document = VexRawRequestMapper.Map(request, tenant, timeProvider); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or FormatException) { return ValidationProblem(ex.Message); } var existing = await rawStore.FindByDigestAsync(document.Digest, cancellationToken).ConfigureAwait(false); try { await rawStore.StoreAsync(document, cancellationToken).ConfigureAwait(false); } catch (ExcititorAocGuardException guardException) { EvidenceTelemetry.RecordGuardViolations(tenant, "ingest", guardException); logger.LogWarning( guardException, "AOC guard rejected VEX ingest tenant={Tenant} digest={Digest}", tenant, document.Digest); return MapGuardException(guardException); } var inserted = existing is null; if (inserted) { context.Response.Headers.Location = $"/vex/raw/{Uri.EscapeDataString(document.Digest)}"; } var response = new VexIngestResponse(document.Digest, inserted, tenant, document.RetrievedAt); return Results.Json(response, statusCode: inserted ? StatusCodes.Status201Created : StatusCodes.Status200OK); }); app.MapGet("/vex/raw", async ( HttpContext context, IVexRawStore rawStore, IOptions storageOptions, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); if (scopeResult is not null) { return scopeResult; } if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var query = context.Request.Query; var providerFilter = BuildStringFilterSet(query["providerId"]); var digestFilter = BuildStringFilterSet(query["digest"]); var formatFilter = query.TryGetValue("format", out var formats) ? formats .Where(static f => !string.IsNullOrWhiteSpace(f)) .Select(static f => Enum.TryParse(f, true, out var parsed) ? parsed : (VexDocumentFormat?)null) .Where(static f => f is not null) .Select(static f => f!.Value) .ToArray() : Array.Empty(); var since = ParseSinceTimestamp(query["since"]); var cursorToken = query.TryGetValue("cursor", out var cursorValues) ? cursorValues.FirstOrDefault() : null; VexRawCursor? cursor = null; if (!string.IsNullOrWhiteSpace(cursorToken) && TryDecodeCursor(cursorToken, out var cursorTime, out var cursorId)) { cursor = new VexRawCursor(cursorTime, cursorId); } var limit = ResolveLimit(query["limit"], defaultValue: 50, min: 1, max: 200); var page = await rawStore.QueryAsync( new VexRawQuery( tenant, providerFilter, digestFilter, formatFilter, since, Until: null, cursor, limit), cancellationToken).ConfigureAwait(false); var summaries = page.Items .Select(summary => new VexRawSummaryResponse( summary.Digest, summary.ProviderId, summary.Format.ToString().ToLowerInvariant(), summary.SourceUri.ToString(), summary.RetrievedAt, summary.InlineContent, summary.Metadata)) .ToList(); var nextCursor = page.NextCursor is null ? null : EncodeCursor(page.NextCursor.RetrievedAt.UtcDateTime, page.NextCursor.Digest); return Results.Json(new VexRawListResponse(summaries, nextCursor, page.HasMore)); }); app.MapGet("/vex/raw/{digest}", async ( string digest, HttpContext context, IVexRawStore rawStore, IOptions storageOptions, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); if (scopeResult is not null) { return scopeResult; } if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out _, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(digest)) { return ValidationProblem("digest is required."); } var record = await rawStore.FindByDigestAsync(digest.Trim(), cancellationToken).ConfigureAwait(false); if (record is null) { return Results.NotFound(); } var rawDocument = VexRawDocumentMapper.ToRawModel(record, storageOptions.Value.DefaultTenant); var response = new VexRawRecordResponse(record.Digest, rawDocument, record.RetrievedAt); return Results.Json(response); }); app.MapGet("/vex/raw/{digest}/provenance", async ( string digest, HttpContext context, IVexRawStore rawStore, IOptions storageOptions, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); if (scopeResult is not null) { return scopeResult; } if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out _, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(digest)) { return ValidationProblem("digest is required."); } var record = await rawStore.FindByDigestAsync(digest.Trim(), cancellationToken).ConfigureAwait(false); if (record is null) { return Results.NotFound(); } var rawDocument = VexRawDocumentMapper.ToRawModel(record, storageOptions.Value.DefaultTenant); var response = new VexRawProvenanceResponse( record.Digest, rawDocument.Tenant, rawDocument.Source, rawDocument.Upstream, record.RetrievedAt); return Results.Json(response); }); app.MapGet("/v1/vex/observations/{vulnerabilityId}/{productKey}", async ( HttpContext context, string vulnerabilityId, string productKey, [FromServices] IVexObservationProjectionService projectionService, [FromServices] IOptions storageOptions, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); if (scopeResult is not null) { return scopeResult; } if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey)) { return ValidationProblem("vulnerabilityId and productKey are required."); } var providerFilter = BuildStringFilterSet(context.Request.Query["providerId"]); var statusFilter = BuildStatusFilter(context.Request.Query["status"]); var since = ParseSinceTimestamp(context.Request.Query["since"]); // Evidence chunks follow doc limits: default 500, max 2000. var limit = ResolveLimit(context.Request.Query["limit"], defaultValue: 500, min: 1, max: 2000); var request = new VexObservationProjectionRequest( tenant, vulnerabilityId.Trim(), productKey.Trim(), providerFilter, statusFilter, since, limit); VexObservationProjectionResult result; try { result = await projectionService.QueryAsync(request, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { EvidenceTelemetry.RecordObservationOutcome(tenant, "cancelled"); throw; } catch { EvidenceTelemetry.RecordObservationOutcome(tenant, "error"); throw; } var projectionStatements = result.Statements; EvidenceTelemetry.RecordObservationOutcome(tenant, "success", projectionStatements.Count, result.Truncated); EvidenceTelemetry.RecordSignatureStatus(tenant, projectionStatements); var statements = projectionStatements .Select(ToResponse) .ToList(); var response = new VexObservationProjectionResponse( request.VulnerabilityId, request.ProductKey, result.GeneratedAtUtc, result.TotalCount, result.Truncated, statements); // Set total/truncated headers for clients (spec: Excititor-Results-*). context.Response.Headers["Excititor-Results-Total"] = result.TotalCount.ToString(CultureInfo.InvariantCulture); context.Response.Headers["Excititor-Results-Truncated"] = result.Truncated ? "true" : "false"; return Results.Json(response); }); app.MapPost("/aoc/verify", async ( HttpContext context, VexAocVerifyRequest? request, IVexRawStore rawStore, IVexRawWriteGuard guard, IOptions storageOptions, TimeProvider timeProvider, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin"); if (scopeResult is not null) { return scopeResult; } if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var now = timeProvider.GetUtcNow(); var since = (request?.Since ?? now.AddHours(-24)).UtcDateTime; var until = (request?.Until ?? now).UtcDateTime; if (since >= until) { since = until.AddHours(-1); } var limit = Math.Clamp(request?.Limit ?? 100, 1, 500); var sources = request?.Sources? .Where(static value => !string.IsNullOrWhiteSpace(value)) .Select(static value => value!.Trim()) .ToArray(); var requestedCodes = request?.Codes? .Where(static value => !string.IsNullOrWhiteSpace(value)) .Select(static value => value!.Trim()) .ToArray(); var page = await rawStore.QueryAsync( new VexRawQuery( tenant, sources ?? Array.Empty(), Array.Empty(), Array.Empty(), Since: new DateTimeOffset(since, TimeSpan.Zero), Until: new DateTimeOffset(until, TimeSpan.Zero), Cursor: null, Limit: limit), cancellationToken).ConfigureAwait(false); var checkedCount = 0; var violationMap = new Dictionary Examples)>(StringComparer.OrdinalIgnoreCase); const int MaxExamplesPerCode = 5; foreach (var item in page.Items) { var digestValue = item.Digest; var provider = item.ProviderId; var domainDocument = await rawStore.FindByDigestAsync(digestValue, cancellationToken).ConfigureAwait(false); if (domainDocument is null) { continue; } var rawDocument = VexRawDocumentMapper.ToRawModel(domainDocument, storageOptions.Value.DefaultTenant); try { guard.EnsureValid(rawDocument); checkedCount++; } catch (ExcititorAocGuardException guardException) { EvidenceTelemetry.RecordGuardViolations(tenant, "aoc_verify", guardException); checkedCount++; foreach (var violation in guardException.Violations) { var code = violation.ErrorCode; if (requestedCodes is { Length: > 0 } && !requestedCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) { continue; } if (!violationMap.TryGetValue(code, out var aggregate)) { aggregate = (0, new List(MaxExamplesPerCode)); } aggregate.Count++; if (aggregate.Examples.Count < MaxExamplesPerCode) { aggregate.Examples.Add(new VexAocVerifyViolationExample( provider, digestValue, rawDocument.Upstream.ContentHash, violation.Path)); } violationMap[code] = aggregate; } } } var violations = violationMap .Select(pair => new VexAocVerifyViolation(pair.Key, pair.Value.Count, pair.Value.Examples)) .OrderByDescending(violation => violation.Count) .ToList(); var response = new VexAocVerifyResponse( tenant, new VexAocVerifyWindow(new DateTimeOffset(since, TimeSpan.Zero), new DateTimeOffset(until, TimeSpan.Zero)), new VexAocVerifyChecked(0, checkedCount), violations, new VexAocVerifyMetrics(checkedCount, violations.Sum(v => v.Count)), page.HasMore); return Results.Json(response); }); app.MapGet("/obs/excititor/health", async ( HttpContext httpContext, ExcititorHealthService healthService, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(httpContext, "vex.admin"); if (scopeResult is not null) { return scopeResult; } var payload = await healthService.GetAsync(cancellationToken).ConfigureAwait(false); return Results.Ok(payload); }); // POST /api/v1/vex/candidates/{candidateId}/approve - SPRINT_4000_0100_0002 app.MapPost("/api/v1/vex/candidates/{candidateId}/approve", async ( HttpContext context, string candidateId, VexCandidateApprovalRequest request, IOptions storageOptions, TimeProvider timeProvider, ILogger logger, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin"); if (scopeResult is not null) return scopeResult; if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError; if (string.IsNullOrWhiteSpace(candidateId)) return Results.BadRequest(new { error = "candidate_id is required" }); if (string.IsNullOrWhiteSpace(request.Status)) return Results.BadRequest(new { error = "status is required" }); if (string.IsNullOrWhiteSpace(request.Justification)) return Results.BadRequest(new { error = "justification is required" }); var actorId = context.User.FindFirst("sub")?.Value ?? "anonymous"; var now = timeProvider.GetUtcNow(); var statementId = $"vex-stmt-{Guid.NewGuid():N}"; logger.LogInformation("VEX candidate {CandidateId} approved by {ActorId}", candidateId, actorId); var response = new VexStatementResponse { StatementId = statementId, VulnerabilityId = $"CVE-{Math.Abs(candidateId.GetHashCode()):X8}", ProductId = "unknown-product", Status = request.Status, Justification = request.Justification, JustificationText = request.JustificationText, Timestamp = now, ValidUntil = request.ValidUntil, ApprovedBy = actorId, SourceCandidate = candidateId, DsseEnvelopeDigest = null }; return Results.Created($"/api/v1/vex/statements/{statementId}", response); }).WithName("ApproveVexCandidate"); // POST /api/v1/vex/candidates/{candidateId}/reject - SPRINT_4000_0100_0002 app.MapPost("/api/v1/vex/candidates/{candidateId}/reject", async ( HttpContext context, string candidateId, VexCandidateRejectionRequest request, IOptions storageOptions, TimeProvider timeProvider, ILogger logger, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin"); if (scopeResult is not null) return scopeResult; if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError; if (string.IsNullOrWhiteSpace(candidateId)) return Results.BadRequest(new { error = "candidate_id is required" }); if (string.IsNullOrWhiteSpace(request.Reason)) return Results.BadRequest(new { error = "reason is required" }); var actorId = context.User.FindFirst("sub")?.Value ?? "anonymous"; var now = timeProvider.GetUtcNow(); logger.LogInformation("VEX candidate {CandidateId} rejected by {ActorId}", candidateId, actorId); var response = new VexCandidateDto { CandidateId = candidateId, FindingId = "unknown", VulnerabilityId = $"CVE-{Math.Abs(candidateId.GetHashCode()):X8}", ProductId = "unknown", SuggestedStatus = "not_affected", SuggestedJustification = "vulnerable_code_not_present", JustificationText = null, Confidence = 0.8, Source = "smart_diff", EvidenceDigests = null, CreatedAt = now.AddDays(-1), ExpiresAt = now.AddDays(29), Status = "rejected", ReviewedBy = actorId, ReviewedAt = now }; return Results.Ok(response); }).WithName("RejectVexCandidate"); // GET /api/v1/vex/candidates - SPRINT_4000_0100_0002 app.MapGet("/api/v1/vex/candidates", async ( HttpContext context, IOptions storageOptions, TimeProvider timeProvider, [FromQuery] string? findingId, [FromQuery] int? limit, CancellationToken cancellationToken) => { var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); if (scopeResult is not null) return scopeResult; if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError; var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100); var response = new VexCandidatesListResponse { Items = Array.Empty(), Total = 0, Limit = take, Offset = 0 }; return Results.Ok(response); }).WithName("ListVexCandidates"); // VEX timeline SSE (WEB-OBS-52-001) app.MapGet("/obs/excititor/timeline", async ( HttpContext context, IOptions storageOptions, [FromServices] IVexTimelineEventStore timelineStore, TimeProvider timeProvider, ILoggerFactory loggerFactory, [FromQuery] string? cursor, [FromQuery] int? limit, [FromQuery] string? eventType, [FromQuery] string? providerId, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var logger = loggerFactory.CreateLogger("ExcititorTimeline"); var take = Math.Clamp(limit.GetValueOrDefault(10), 1, 100); // Parse cursor as ISO-8601 timestamp or Last-Event-ID header DateTimeOffset? cursorTimestamp = null; var candidateCursor = cursor ?? context.Request.Headers["Last-Event-ID"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(candidateCursor)) { if (DateTimeOffset.TryParse(candidateCursor, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) { cursorTimestamp = parsed; } else { return Results.BadRequest(new { error = new { code = "ERR_CURSOR", message = "cursor must be ISO-8601 timestamp" } }); } } context.Response.Headers.CacheControl = "no-store"; context.Response.Headers["X-Accel-Buffering"] = "no"; context.Response.Headers["Link"] = "; rel=\"describedby\"; type=\"application/json\""; context.Response.ContentType = "text/event-stream"; await context.Response.WriteAsync("retry: 5000\n\n", cancellationToken).ConfigureAwait(false); // Fetch real timeline events from the store IReadOnlyList events; var now = timeProvider.GetUtcNow(); if (!string.IsNullOrWhiteSpace(eventType)) { events = await timelineStore.FindByEventTypeAsync(tenant, eventType, take, cancellationToken).ConfigureAwait(false); } else if (!string.IsNullOrWhiteSpace(providerId)) { events = await timelineStore.FindByProviderAsync(tenant, providerId, take, cancellationToken).ConfigureAwait(false); } else if (cursorTimestamp.HasValue) { // Get events after the cursor timestamp events = await timelineStore.FindByTimeRangeAsync(tenant, cursorTimestamp.Value, now, take, cancellationToken).ConfigureAwait(false); } else { events = await timelineStore.GetRecentAsync(tenant, take, cancellationToken).ConfigureAwait(false); } foreach (var evt in events) { cancellationToken.ThrowIfCancellationRequested(); var sseEvent = new ExcititorTimelineEvent( Type: evt.EventType, Tenant: evt.Tenant, Source: evt.ProviderId, Count: evt.Attributes.TryGetValue("observation_count", out var countStr) && int.TryParse(countStr, out var count) ? count : 1, Errors: evt.Attributes.TryGetValue("error_count", out var errStr) && int.TryParse(errStr, out var errCount) ? errCount : 0, TraceId: evt.TraceId, OccurredAt: evt.CreatedAt.ToString("O", CultureInfo.InvariantCulture)); await context.Response.WriteAsync($"id: {evt.CreatedAt:O}\n", cancellationToken).ConfigureAwait(false); await context.Response.WriteAsync($"event: {evt.EventType}\n", cancellationToken).ConfigureAwait(false); await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(sseEvent)}\n\n", cancellationToken).ConfigureAwait(false); } await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); var nextCursor = events.Count > 0 ? events[^1].CreatedAt.ToString("O", CultureInfo.InvariantCulture) : now.ToString("O", CultureInfo.InvariantCulture); context.Response.Headers["X-Next-Cursor"] = nextCursor; logger.LogInformation("obs excititor timeline emitted {Count} events for tenant {Tenant} cursor {Cursor} next {Next}", events.Count, tenant, candidateCursor, nextCursor); return Results.Empty; }).WithName("GetExcititorTimeline"); IngestEndpoints.MapIngestEndpoints(app); ResolveEndpoint.MapResolveEndpoint(app); MirrorEndpoints.MapMirrorEndpoints(app); MirrorRegistrationEndpoints.MapMirrorRegistrationEndpoints(app); // Evidence and Attestation APIs (WEB-OBS-53-001, WEB-OBS-54-001) EvidenceEndpoints.MapEvidenceEndpoints(app); AttestationEndpoints.MapAttestationEndpoints(app); PolicyEndpoints.MapPolicyEndpoints(app); // Observation and Linkset APIs (EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202) ObservationEndpoints.MapObservationEndpoints(app); LinksetEndpoints.MapLinksetEndpoints(app); // Risk Feed APIs (EXCITITOR-RISK-66-001) RiskFeedEndpoints.MapRiskFeedEndpoints(app); app.Run(); internal sealed record ExcititorTimelineEvent( string Type, string Tenant, string Source, int Count, int Errors, string? TraceId, string OccurredAt); public partial class Program; internal sealed record StatusResponse(DateTimeOffset UtcNow, int InlineThreshold, string[] ArtifactStores); internal sealed record VexStatementIngestRequest(IReadOnlyList Statements); internal sealed record VexStatementEntry( string VulnerabilityId, string ProviderId, string ProductKey, string? ProductName, string? ProductVersion, string? ProductPurl, string? ProductCpe, IReadOnlyList? 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? Metadata) { public VexClaim ToDomainClaim() { var product = new VexProduct( ProductKey, ProductName, ProductVersion, ProductPurl, ProductCpe, ComponentIdentifiers ?? Array.Empty()); 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.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); }