- 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.
2353 lines
96 KiB
C#
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);
|
|
}
|