Files
git.stella-ops.org/src/Excititor/StellaOps.Excititor.WebService/Program.cs
master 5590a99a1a Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
2025-12-23 23:51:58 +02:00

2353 lines
96 KiB
C#

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<VexStorageOptions>()
.Bind(configuration.GetSection("Excititor:Storage"))
.ValidateOnStart();
services.AddOptions<GraphOptions>()
.Bind(configuration.GetSection("Excititor:Graph"));
services.AddExcititorPostgresStorage(configuration);
services.TryAddSingleton<IVexProviderStore, InMemoryVexProviderStore>();
services.TryAddScoped<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.TryAddSingleton<IVexClaimStore, InMemoryVexClaimStore>();
services.AddCsafNormalizer();
services.AddCycloneDxNormalizer();
services.AddOpenVexNormalizer();
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
// TODO: replace NoopVexSignatureVerifier with hardened verifier once portable bundle signatures are finalized.
services.Configure<AirgapOptions>(configuration.GetSection(AirgapOptions.SectionName));
services.AddSingleton<AirgapImportValidator>();
services.AddSingleton<AirgapSignerTrustService>();
services.AddSingleton<AirgapModeEnforcer>();
services.AddSingleton<ConsoleTelemetry>();
services.AddMemoryCache();
services.AddSingleton<IGraphOverlayCache, GraphOverlayCacheStore>();
services.AddSingleton<IGraphOverlayStore>(sp =>
{
var graphOptions = sp.GetRequiredService<IOptions<GraphOptions>>().Value;
var pgOptions = sp.GetRequiredService<IOptions<PostgresOptions>>().Value;
if (graphOptions.UsePostgresOverlayStore && !string.IsNullOrWhiteSpace(pgOptions.ConnectionString))
{
return new PostgresGraphOverlayStore(
sp.GetRequiredService<ExcititorDataSource>(),
sp.GetRequiredService<ILogger<PostgresGraphOverlayStore>>());
}
return new InMemoryGraphOverlayStore();
});
services.AddSingleton<IVexEvidenceLockerService, VexEvidenceLockerService>();
services.AddSingleton<IVexEvidenceAttestor, StellaOps.Excititor.Attestation.Evidence.VexEvidenceAttestor>();
// OBS-52/53/54: Attestation storage and timeline event recording
services.TryAddSingleton<IVexAttestationStore, InMemoryVexAttestationStore>();
services.TryAddSingleton<IVexTimelineEventRecorder, VexTimelineEventRecorder>();
services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>();
services.AddSingleton<VexStatementBackfillService>();
services.AddOptions<ExcititorObservabilityOptions>()
.Bind(configuration.GetSection("Excititor:Observability"));
services.AddScoped<ExcititorHealthService>();
services.AddExcititorAocGuards();
services.AddVexExportEngine();
services.AddVexExportCacheServices();
services.AddVexAttestation();
services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client"));
services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("Excititor:Attestation:Verification"));
services.AddVexPolicy();
services.AddSingleton<IVexEvidenceChunkService, VexEvidenceChunkService>();
services.AddSingleton<ChunkTelemetry>();
// EXCITITOR-VULN-29-004: Normalization observability for Vuln Explorer + Advisory AI dashboards
services.AddSingleton<IVexNormalizationTelemetryRecorder, VexNormalizationTelemetryRecorder>();
services.AddRedHatCsafConnector();
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
services.AddSingleton<MirrorRateLimiter>();
services.TryAddSingleton(TimeProvider.System);
// CRYPTO-90-001: Crypto provider abstraction for pluggable hashing algorithms (GOST/SM support)
services.AddSingleton<IVexHashingService>(sp =>
{
// When ICryptoProviderRegistry is available, use it for pluggable algorithms
var registry = sp.GetService<StellaOps.Cryptography.ICryptoProviderRegistry>();
return new VexHashingService(registry);
});
services.AddSingleton<IVexObservationProjectionService, VexObservationProjectionService>();
services.AddScoped<IVexObservationQueryService, VexObservationQueryService>();
// EXCITITOR-RISK-66-001: Risk feed service for Risk Engine integration
services.AddScoped<StellaOps.Excititor.Core.RiskFeed.IRiskFeedService, OverlayRiskFeedService>();
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<IVexArtifactStore, S3ArtifactStore>(provider =>
{
var options = new S3ArtifactStoreOptions();
s3Section.GetSection("Store").Bind(options);
return new S3ArtifactStore(
provider.GetRequiredService<IS3ArtifactClient>(),
Microsoft.Extensions.Options.Options.Create(options),
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<S3ArtifactStore>>());
});
}
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<IVexArtifactStore> artifactStores,
IOptions<VexStorageOptions> 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<string, object>
{
["/excititor/status"] = new
{
get = new
{
summary = "Service status (aggregation-only metadata)",
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "OK",
content = new Dictionary<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/StatusResponse" },
examples = new Dictionary<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Healthy",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Event stream",
headers = new Dictionary<string, object>
{
["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 = "</openapi/excititor.json>; rel=\"describedby\"; type=\"application/json\""
}
},
content = new Dictionary<string, object>
{
["text/event-stream"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" },
examples = new Dictionary<string, object>
{
["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<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/AirgapImportRequest" }
}
}
},
responses = new Dictionary<string, object>
{
["200"] = new { description = "Accepted" },
["400"] = new
{
description = "Validation error",
content = new Dictionary<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" },
examples = new Dictionary<string, object>
{
["validation-failed"] = new
{
value = new
{
error = new
{
code = "ERR_VALIDATION",
message = "PayloadHash is required."
}
}
}
}
}
}
},
["403"] = new
{
description = "Trust validation failed",
content = new Dictionary<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" },
examples = new Dictionary<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Evidence list response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Bundle detail response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Evidence lookup response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Attestation list response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Attestation detail response with chain-of-custody",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Attestation lookup response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Observation list response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" },
examples = new Dictionary<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Observation detail response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object>
{
["200"] = new
{
description = "Count response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["count"] = new
{
value = new { count = 1234 }
}
}
}
}
}
}
}
}
},
components = new
{
schemas = new Dictionary<string, object>
{
["Error"] = new
{
type = "object",
required = new[] { "error" },
properties = new Dictionary<string, object>
{
["error"] = new
{
type = "object",
required = new[] { "code", "message" },
properties = new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object>
{
["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<AirgapTimelineEntry>();
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<string, string>(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<string>();
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<VexExportFormat>(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<string>.Empty,
request.Metadata?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary<string, string>.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<string, string>(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<VexStorageOptions> 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<string>();
var advisories = query.TryGetValue("advisoryId", out var advisoryValues)
? NormalizeValues(advisoryValues)
: Array.Empty<string>();
var statuses = new List<VexClaimStatus>();
if (query.TryGetValue("status", out var statusValues))
{
foreach (var statusValue in statusValues)
{
if (string.IsNullOrWhiteSpace(statusValue))
{
continue;
}
if (Enum.TryParse<VexClaimStatus>(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<string, string>.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<GraphLinkoutItem>(normalizedPurls.Length);
var notFound = new List<string>();
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<GraphLinkoutConflict>(),
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<VexStorageOptions> storageOptions,
IOptions<GraphOptions> 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<CachedGraphStatus>(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<VexStorageOptions> storageOptions,
IOptions<GraphOptions> 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<VexStorageOptions> storageOptions,
IOptions<GraphOptions> 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<VexStorageOptions> storageOptions,
TimeProvider timeProvider,
ILogger<Program> 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<VexStorageOptions> 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<VexDocumentFormat>(f, true, out var parsed) ? parsed : (VexDocumentFormat?)null)
.Where(static f => f is not null)
.Select(static f => f!.Value)
.ToArray()
: Array.Empty<VexDocumentFormat>();
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<VexStorageOptions> 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<VexStorageOptions> 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<VexStorageOptions> 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<VexStorageOptions> 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<string>(),
Array.Empty<string>(),
Array.Empty<VexDocumentFormat>(),
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<string, (int Count, List<VexAocVerifyViolationExample> 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<VexAocVerifyViolationExample>(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<VexStorageOptions> storageOptions, TimeProvider timeProvider, ILogger<Program> 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<VexStorageOptions> storageOptions, TimeProvider timeProvider, ILogger<Program> 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<VexStorageOptions> 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<VexCandidateDto>(), 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<VexStorageOptions> 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"] = "</openapi/excititor.json>; 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<TimelineEvent> 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<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);
}