up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.WebService.Contracts;
/// <summary>
/// Response for /attestations/vex/{attestationId} endpoint.
/// </summary>
public sealed record VexAttestationDetailResponse(
[property: JsonPropertyName("attestationId")] string AttestationId,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("subject")] VexAttestationSubject Subject,
[property: JsonPropertyName("builder")] VexAttestationBuilderIdentity Builder,
[property: JsonPropertyName("verification")] VexAttestationVerificationState Verification,
[property: JsonPropertyName("chainOfCustody")] IReadOnlyList<VexAttestationCustodyLink> ChainOfCustody,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
/// <summary>
/// Subject of the attestation (what was signed).
/// </summary>
public sealed record VexAttestationSubject(
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("digestAlgorithm")] string DigestAlgorithm,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("uri")] string? Uri);
/// <summary>
/// Builder identity for the attestation.
/// </summary>
public sealed record VexAttestationBuilderIdentity(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("builderId")] string? BuilderId,
[property: JsonPropertyName("invocationId")] string? InvocationId);
/// <summary>
/// DSSE verification state.
/// </summary>
public sealed record VexAttestationVerificationState(
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("verifiedAt")] DateTimeOffset? VerifiedAt,
[property: JsonPropertyName("signatureType")] string? SignatureType,
[property: JsonPropertyName("keyId")] string? KeyId,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("envelopeDigest")] string? EnvelopeDigest,
[property: JsonPropertyName("diagnostics")] IReadOnlyDictionary<string, string> Diagnostics);
/// <summary>
/// Chain-of-custody link in the attestation provenance.
/// </summary>
public sealed record VexAttestationCustodyLink(
[property: JsonPropertyName("step")] int Step,
[property: JsonPropertyName("actor")] string Actor,
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("reference")] string? Reference);
/// <summary>
/// Response for /attestations/vex/list endpoint.
/// </summary>
public sealed record VexAttestationListResponse(
[property: JsonPropertyName("items")] IReadOnlyList<VexAttestationListItem> Items,
[property: JsonPropertyName("cursor")] string? Cursor,
[property: JsonPropertyName("hasMore")] bool HasMore,
[property: JsonPropertyName("total")] int Total);
/// <summary>
/// Summary item for attestation list.
/// </summary>
public sealed record VexAttestationListItem(
[property: JsonPropertyName("attestationId")] string AttestationId,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("subjectDigest")] string SubjectDigest,
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("builderId")] string? BuilderId);
/// <summary>
/// Response for /attestations/vex/lookup endpoint.
/// </summary>
public sealed record VexAttestationLookupResponse(
[property: JsonPropertyName("subjectDigest")] string SubjectDigest,
[property: JsonPropertyName("attestations")] IReadOnlyList<VexAttestationListItem> Attestations,
[property: JsonPropertyName("queriedAt")] DateTimeOffset QueriedAt);

View File

@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.WebService.Contracts;
/// <summary>
/// Response for /evidence/vex/bundle/{bundleId} endpoint.
/// </summary>
public sealed record VexEvidenceBundleResponse(
[property: JsonPropertyName("bundleId")] string BundleId,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("itemCount")] int ItemCount,
[property: JsonPropertyName("verification")] VexEvidenceVerificationMetadata? Verification,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
/// <summary>
/// Verification metadata for evidence bundles.
/// </summary>
public sealed record VexEvidenceVerificationMetadata(
[property: JsonPropertyName("verified")] bool Verified,
[property: JsonPropertyName("verifiedAt")] DateTimeOffset? VerifiedAt,
[property: JsonPropertyName("signatureType")] string? SignatureType,
[property: JsonPropertyName("keyId")] string? KeyId,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("transparencyRef")] string? TransparencyRef);
/// <summary>
/// Response for /evidence/vex/list endpoint.
/// </summary>
public sealed record VexEvidenceListResponse(
[property: JsonPropertyName("items")] IReadOnlyList<VexEvidenceListItem> Items,
[property: JsonPropertyName("cursor")] string? Cursor,
[property: JsonPropertyName("hasMore")] bool HasMore,
[property: JsonPropertyName("total")] int Total);
/// <summary>
/// Summary item for evidence list.
/// </summary>
public sealed record VexEvidenceListItem(
[property: JsonPropertyName("bundleId")] string BundleId,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("itemCount")] int ItemCount,
[property: JsonPropertyName("verified")] bool Verified);
/// <summary>
/// Response for /evidence/vex/lookup endpoint.
/// </summary>
public sealed record VexEvidenceLookupResponse(
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("productKey")] string ProductKey,
[property: JsonPropertyName("evidenceItems")] IReadOnlyList<VexEvidenceItem> EvidenceItems,
[property: JsonPropertyName("queriedAt")] DateTimeOffset QueriedAt);
/// <summary>
/// Individual evidence item for a vuln/product pair.
/// </summary>
public sealed record VexEvidenceItem(
[property: JsonPropertyName("observationId")] string ObservationId,
[property: JsonPropertyName("providerId")] string ProviderId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification")] string? Justification,
[property: JsonPropertyName("firstSeen")] DateTimeOffset FirstSeen,
[property: JsonPropertyName("lastSeen")] DateTimeOffset LastSeen,
[property: JsonPropertyName("documentDigest")] string DocumentDigest,
[property: JsonPropertyName("verification")] VexEvidenceVerificationMetadata? Verification);
/// <summary>
/// Response for /vuln/evidence/vex/{advisory_key} endpoint.
/// Returns tenant-scoped raw statements for Vuln Explorer evidence tabs.
/// </summary>
public sealed record VexAdvisoryEvidenceResponse(
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
[property: JsonPropertyName("canonicalKey")] string CanonicalKey,
[property: JsonPropertyName("scope")] string Scope,
[property: JsonPropertyName("aliases")] IReadOnlyList<VexAdvisoryLinkResponse> Aliases,
[property: JsonPropertyName("statements")] IReadOnlyList<VexAdvisoryStatementResponse> Statements,
[property: JsonPropertyName("queriedAt")] DateTimeOffset QueriedAt,
[property: JsonPropertyName("totalCount")] int TotalCount);
/// <summary>
/// Advisory link for traceability (CVE, GHSA, RHSA, etc.).
/// </summary>
public sealed record VexAdvisoryLinkResponse(
[property: JsonPropertyName("identifier")] string Identifier,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("isOriginal")] bool IsOriginal);
/// <summary>
/// Raw VEX statement for an advisory with provenance and attestation metadata.
/// </summary>
public sealed record VexAdvisoryStatementResponse(
[property: JsonPropertyName("statementId")] string StatementId,
[property: JsonPropertyName("providerId")] string ProviderId,
[property: JsonPropertyName("product")] VexAdvisoryProductResponse Product,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification")] string? Justification,
[property: JsonPropertyName("detail")] string? Detail,
[property: JsonPropertyName("firstSeen")] DateTimeOffset FirstSeen,
[property: JsonPropertyName("lastSeen")] DateTimeOffset LastSeen,
[property: JsonPropertyName("provenance")] VexAdvisoryProvenanceResponse Provenance,
[property: JsonPropertyName("attestation")] VexAdvisoryAttestationResponse? Attestation);
/// <summary>
/// Product information for an advisory statement.
/// </summary>
public sealed record VexAdvisoryProductResponse(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("cpe")] string? Cpe);
/// <summary>
/// Provenance metadata for a VEX statement.
/// </summary>
public sealed record VexAdvisoryProvenanceResponse(
[property: JsonPropertyName("documentDigest")] string DocumentDigest,
[property: JsonPropertyName("documentFormat")] string DocumentFormat,
[property: JsonPropertyName("sourceUri")] string SourceUri,
[property: JsonPropertyName("revision")] string? Revision,
[property: JsonPropertyName("insertedAt")] DateTimeOffset InsertedAt);
/// <summary>
/// Attestation metadata for signature verification.
/// </summary>
public sealed record VexAdvisoryAttestationResponse(
[property: JsonPropertyName("signatureType")] string SignatureType,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("subject")] string? Subject,
[property: JsonPropertyName("keyId")] string? KeyId,
[property: JsonPropertyName("verifiedAt")] DateTimeOffset? VerifiedAt,
[property: JsonPropertyName("transparencyLogRef")] string? TransparencyLogRef,
[property: JsonPropertyName("trustWeight")] decimal? TrustWeight,
[property: JsonPropertyName("trustTier")] string? TrustTier);

View File

@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Attestation API endpoints (WEB-OBS-54-001).
/// Exposes /attestations/vex/* endpoints returning DSSE verification state,
/// builder identity, and chain-of-custody links.
/// </summary>
public static class AttestationEndpoints
{
public static void MapAttestationEndpoints(this WebApplication app)
{
// GET /attestations/vex/list - List attestations
app.MapGet("/attestations/vex/list", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IMongoDatabase database,
TimeProvider timeProvider,
[FromQuery] int? limit,
[FromQuery] string? cursor,
[FromQuery] string? vulnerabilityId,
[FromQuery] string? productKey,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 200);
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Attestations);
var builder = Builders<BsonDocument>.Filter;
var filters = new List<FilterDefinition<BsonDocument>>();
if (!string.IsNullOrWhiteSpace(vulnerabilityId))
{
filters.Add(builder.Eq("VulnerabilityId", vulnerabilityId.Trim().ToUpperInvariant()));
}
if (!string.IsNullOrWhiteSpace(productKey))
{
filters.Add(builder.Eq("ProductKey", productKey.Trim().ToLowerInvariant()));
}
// Parse cursor if provided
if (!string.IsNullOrWhiteSpace(cursor) && TryDecodeCursor(cursor, out var cursorTime, out var cursorId))
{
var ltTime = builder.Lt("IssuedAt", cursorTime);
var eqTimeLtId = builder.And(
builder.Eq("IssuedAt", cursorTime),
builder.Lt("_id", cursorId));
filters.Add(builder.Or(ltTime, eqTimeLtId));
}
var filter = filters.Count == 0 ? builder.Empty : builder.And(filters);
var sort = Builders<BsonDocument>.Sort.Descending("IssuedAt").Descending("_id");
var documents = await collection
.Find(filter)
.Sort(sort)
.Limit(take)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var items = documents.Select(doc => ToListItem(doc, tenant, timeProvider)).ToList();
string? nextCursor = null;
var hasMore = documents.Count == take;
if (hasMore && documents.Count > 0)
{
var last = documents[^1];
var lastTime = last.GetValue("IssuedAt", BsonNull.Value).ToUniversalTime();
var lastId = last.GetValue("_id", BsonNull.Value).AsString;
nextCursor = EncodeCursor(lastTime, lastId);
}
var response = new VexAttestationListResponse(items, nextCursor, hasMore, items.Count);
return Results.Ok(response);
}).WithName("ListVexAttestations");
// GET /attestations/vex/{attestationId} - Get attestation details
app.MapGet("/attestations/vex/{attestationId}", async (
HttpContext context,
string attestationId,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexAttestationLinkStore attestationStore,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(attestationId))
{
return Results.BadRequest(new { error = new { code = "ERR_ATTESTATION_ID", message = "attestationId is required" } });
}
var attestation = await attestationStore.FindAsync(attestationId.Trim(), cancellationToken).ConfigureAwait(false);
if (attestation is null)
{
return Results.NotFound(new { error = new { code = "ERR_NOT_FOUND", message = $"Attestation '{attestationId}' not found" } });
}
// Build subject from observation context
var subjectDigest = attestation.Metadata.TryGetValue("digest", out var dig) ? dig : attestation.ObservationId;
var subject = new VexAttestationSubject(
Digest: subjectDigest,
DigestAlgorithm: "sha256",
Name: $"{attestation.VulnerabilityId}/{attestation.ProductKey}",
Uri: null);
var builder = new VexAttestationBuilderIdentity(
Id: attestation.SupplierId,
Version: null,
BuilderId: attestation.SupplierId,
InvocationId: attestation.ObservationId);
// Get verification state from metadata
var isValid = attestation.Metadata.TryGetValue("verified", out var verified) && verified == "true";
DateTimeOffset? verifiedAt = null;
if (attestation.Metadata.TryGetValue("verifiedAt", out var verifiedAtStr) &&
DateTimeOffset.TryParse(verifiedAtStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedVerifiedAt))
{
verifiedAt = parsedVerifiedAt;
}
var verification = new VexAttestationVerificationState(
Valid: isValid,
VerifiedAt: verifiedAt,
SignatureType: attestation.Metadata.GetValueOrDefault("signatureType", "dsse"),
KeyId: attestation.Metadata.GetValueOrDefault("keyId"),
Issuer: attestation.Metadata.GetValueOrDefault("issuer"),
EnvelopeDigest: attestation.Metadata.GetValueOrDefault("envelopeDigest"),
Diagnostics: attestation.Metadata);
var custodyLinks = new List<VexAttestationCustodyLink>
{
new(
Step: 1,
Actor: attestation.SupplierId,
Action: "created",
Timestamp: attestation.IssuedAt,
Reference: attestation.AttestationId)
};
// Add linkset link
custodyLinks.Add(new VexAttestationCustodyLink(
Step: 2,
Actor: "excititor",
Action: "linked_to_observation",
Timestamp: attestation.IssuedAt,
Reference: attestation.LinksetId));
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["observationId"] = attestation.ObservationId,
["linksetId"] = attestation.LinksetId,
["vulnerabilityId"] = attestation.VulnerabilityId,
["productKey"] = attestation.ProductKey
};
if (!string.IsNullOrWhiteSpace(attestation.JustificationSummary))
{
metadata["justificationSummary"] = attestation.JustificationSummary;
}
var response = new VexAttestationDetailResponse(
AttestationId: attestation.AttestationId,
Tenant: tenant,
CreatedAt: attestation.IssuedAt,
PredicateType: attestation.Metadata.GetValueOrDefault("predicateType", "https://in-toto.io/attestation/v1"),
Subject: subject,
Builder: builder,
Verification: verification,
ChainOfCustody: custodyLinks,
Metadata: metadata);
return Results.Ok(response);
}).WithName("GetVexAttestation");
// GET /attestations/vex/lookup - Lookup attestations by linkset or observation
app.MapGet("/attestations/vex/lookup", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IMongoDatabase database,
TimeProvider timeProvider,
[FromQuery] string? linksetId,
[FromQuery] string? observationId,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(linksetId) && string.IsNullOrWhiteSpace(observationId))
{
return Results.BadRequest(new { error = new { code = "ERR_PARAMS", message = "Either linksetId or observationId is required" } });
}
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Attestations);
var builder = Builders<BsonDocument>.Filter;
FilterDefinition<BsonDocument> filter;
if (!string.IsNullOrWhiteSpace(linksetId))
{
filter = builder.Eq("LinksetId", linksetId.Trim());
}
else
{
filter = builder.Eq("ObservationId", observationId!.Trim());
}
var sort = Builders<BsonDocument>.Sort.Descending("IssuedAt");
var documents = await collection
.Find(filter)
.Sort(sort)
.Limit(take)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var items = documents.Select(doc => ToListItem(doc, tenant, timeProvider)).ToList();
var response = new VexAttestationLookupResponse(
SubjectDigest: linksetId ?? observationId ?? string.Empty,
Attestations: items,
QueriedAt: timeProvider.GetUtcNow());
return Results.Ok(response);
}).WithName("LookupVexAttestations");
}
private static VexAttestationListItem ToListItem(BsonDocument doc, string tenant, TimeProvider timeProvider)
{
return new VexAttestationListItem(
AttestationId: doc.GetValue("_id", BsonNull.Value).AsString ?? string.Empty,
Tenant: tenant,
CreatedAt: doc.GetValue("IssuedAt", BsonNull.Value).IsBsonDateTime
? new DateTimeOffset(doc["IssuedAt"].ToUniversalTime(), TimeSpan.Zero)
: timeProvider.GetUtcNow(),
PredicateType: "https://in-toto.io/attestation/v1",
SubjectDigest: doc.GetValue("ObservationId", BsonNull.Value).AsString ?? string.Empty,
Valid: doc.Contains("Metadata") && !doc["Metadata"].IsBsonNull &&
doc["Metadata"].AsBsonDocument.Contains("verified") &&
doc["Metadata"]["verified"].AsString == "true",
BuilderId: doc.GetValue("SupplierId", BsonNull.Value).AsString);
}
private static bool TryResolveTenant(HttpContext context, VexMongoStorageOptions options, out string tenant, out IResult? problem)
{
tenant = options.DefaultTenant;
problem = null;
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerValues) && headerValues.Count > 0)
{
var requestedTenant = headerValues[0]?.Trim();
if (string.IsNullOrEmpty(requestedTenant))
{
problem = Results.BadRequest(new { error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header must not be empty" } });
return false;
}
if (!string.Equals(requestedTenant, options.DefaultTenant, StringComparison.OrdinalIgnoreCase))
{
problem = Results.Json(
new { error = new { code = "ERR_TENANT_FORBIDDEN", message = $"Tenant '{requestedTenant}' is not allowed" } },
statusCode: StatusCodes.Status403Forbidden);
return false;
}
tenant = requestedTenant;
}
return true;
}
private static bool TryDecodeCursor(string cursor, out DateTime timestamp, out string id)
{
timestamp = default;
id = string.Empty;
try
{
var payload = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
var parts = payload.Split('|');
if (parts.Length != 2)
{
return false;
}
if (!DateTimeOffset.TryParse(parts[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
{
return false;
}
timestamp = parsed.UtcDateTime;
id = parts[1];
return true;
}
catch
{
return false;
}
}
private static string EncodeCursor(DateTime timestamp, string id)
{
var payload = FormattableString.Invariant($"{timestamp:O}|{id}");
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
}
}

View File

@@ -0,0 +1,311 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Canonicalization;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Evidence API endpoints (WEB-OBS-53-001).
/// Exposes /evidence/vex/* endpoints that fetch locker bundles, enforce scopes,
/// and surface verification metadata without synthesizing verdicts.
/// </summary>
public static class EvidenceEndpoints
{
public static void MapEvidenceEndpoints(this WebApplication app)
{
// GET /evidence/vex/list - List evidence exports
app.MapGet("/evidence/vex/list", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IMongoDatabase database,
TimeProvider timeProvider,
[FromQuery] int? limit,
[FromQuery] string? cursor,
[FromQuery] string? format,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 200);
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Exports);
var builder = Builders<BsonDocument>.Filter;
var filters = new List<FilterDefinition<BsonDocument>>();
if (!string.IsNullOrWhiteSpace(format))
{
filters.Add(builder.Eq("Format", format.Trim().ToLowerInvariant()));
}
// Parse cursor if provided (base64-encoded timestamp|id)
if (!string.IsNullOrWhiteSpace(cursor) && TryDecodeCursor(cursor, out var cursorTime, out var cursorId))
{
var ltTime = builder.Lt("CreatedAt", cursorTime);
var eqTimeLtId = builder.And(
builder.Eq("CreatedAt", cursorTime),
builder.Lt("_id", cursorId));
filters.Add(builder.Or(ltTime, eqTimeLtId));
}
var filter = filters.Count == 0 ? builder.Empty : builder.And(filters);
var sort = Builders<BsonDocument>.Sort.Descending("CreatedAt").Descending("_id");
var documents = await collection
.Find(filter)
.Sort(sort)
.Limit(take)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var items = documents.Select(doc => new VexEvidenceListItem(
BundleId: doc.GetValue("ExportId", BsonNull.Value).AsString ?? doc.GetValue("_id", BsonNull.Value).AsString,
Tenant: tenant,
CreatedAt: doc.GetValue("CreatedAt", BsonNull.Value).IsBsonDateTime
? new DateTimeOffset(doc["CreatedAt"].ToUniversalTime(), TimeSpan.Zero)
: timeProvider.GetUtcNow(),
ContentHash: doc.GetValue("ArtifactDigest", BsonNull.Value).AsString ?? string.Empty,
Format: doc.GetValue("Format", BsonNull.Value).AsString ?? "json",
ItemCount: doc.GetValue("ClaimCount", BsonNull.Value).IsInt32 ? doc["ClaimCount"].AsInt32 : 0,
Verified: doc.Contains("Attestation") && !doc["Attestation"].IsBsonNull)).ToList();
string? nextCursor = null;
var hasMore = documents.Count == take;
if (hasMore && documents.Count > 0)
{
var last = documents[^1];
var lastTime = last.GetValue("CreatedAt", BsonNull.Value).ToUniversalTime();
var lastId = last.GetValue("_id", BsonNull.Value).AsString;
nextCursor = EncodeCursor(lastTime, lastId);
}
var response = new VexEvidenceListResponse(items, nextCursor, hasMore, items.Count);
return Results.Ok(response);
}).WithName("ListVexEvidence");
// GET /evidence/vex/bundle/{bundleId} - Get evidence bundle details
app.MapGet("/evidence/vex/bundle/{bundleId}", async (
HttpContext context,
string bundleId,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IMongoDatabase database,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(bundleId))
{
return Results.BadRequest(new { error = new { code = "ERR_BUNDLE_ID", message = "bundleId is required" } });
}
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Exports);
var filter = Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Eq("_id", bundleId.Trim()),
Builders<BsonDocument>.Filter.Eq("ExportId", bundleId.Trim()));
var doc = await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (doc is null)
{
return Results.NotFound(new { error = new { code = "ERR_NOT_FOUND", message = $"Evidence bundle '{bundleId}' not found" } });
}
VexEvidenceVerificationMetadata? verification = null;
if (doc.Contains("Attestation") && !doc["Attestation"].IsBsonNull)
{
var att = doc["Attestation"].AsBsonDocument;
verification = new VexEvidenceVerificationMetadata(
Verified: true,
VerifiedAt: att.Contains("SignedAt") && att["SignedAt"].IsBsonDateTime
? new DateTimeOffset(att["SignedAt"].ToUniversalTime(), TimeSpan.Zero)
: null,
SignatureType: "dsse",
KeyId: att.GetValue("KeyId", BsonNull.Value).AsString,
Issuer: att.GetValue("Issuer", BsonNull.Value).AsString,
TransparencyRef: att.Contains("Rekor") && !att["Rekor"].IsBsonNull
? att["Rekor"].AsBsonDocument.GetValue("Location", BsonNull.Value).AsString
: null);
}
var metadata = new Dictionary<string, string>(StringComparer.Ordinal);
if (doc.Contains("SourceProviders") && doc["SourceProviders"].IsBsonArray)
{
metadata["sourceProviders"] = string.Join(",", doc["SourceProviders"].AsBsonArray.Select(v => v.AsString));
}
if (doc.Contains("PolicyRevisionId") && !doc["PolicyRevisionId"].IsBsonNull)
{
metadata["policyRevisionId"] = doc["PolicyRevisionId"].AsString;
}
var response = new VexEvidenceBundleResponse(
BundleId: doc.GetValue("ExportId", BsonNull.Value).AsString ?? bundleId.Trim(),
Tenant: tenant,
CreatedAt: doc.GetValue("CreatedAt", BsonNull.Value).IsBsonDateTime
? new DateTimeOffset(doc["CreatedAt"].ToUniversalTime(), TimeSpan.Zero)
: timeProvider.GetUtcNow(),
ContentHash: doc.GetValue("ArtifactDigest", BsonNull.Value).AsString ?? string.Empty,
Format: doc.GetValue("Format", BsonNull.Value).AsString ?? "json",
ItemCount: doc.GetValue("ClaimCount", BsonNull.Value).IsInt32 ? doc["ClaimCount"].AsInt32 : 0,
Verification: verification,
Metadata: metadata);
return Results.Ok(response);
}).WithName("GetVexEvidenceBundle");
// GET /evidence/vex/lookup - Lookup evidence for vuln/product pair
app.MapGet("/evidence/vex/lookup", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexObservationProjectionService projectionService,
TimeProvider timeProvider,
[FromQuery] string vulnerabilityId,
[FromQuery] string productKey,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return Results.BadRequest(new { error = new { code = "ERR_PARAMS", message = "vulnerabilityId and productKey are required" } });
}
var take = Math.Clamp(limit.GetValueOrDefault(100), 1, 500);
var request = new VexObservationProjectionRequest(
tenant,
vulnerabilityId.Trim(),
productKey.Trim(),
ImmutableHashSet<string>.Empty,
ImmutableHashSet<VexClaimStatus>.Empty,
null,
take);
var result = await projectionService.QueryAsync(request, cancellationToken).ConfigureAwait(false);
var items = result.Statements.Select(s => new VexEvidenceItem(
ObservationId: s.ObservationId,
ProviderId: s.ProviderId,
Status: s.Status.ToString().ToLowerInvariant(),
Justification: s.Justification?.ToString().ToLowerInvariant(),
FirstSeen: s.FirstSeen,
LastSeen: s.LastSeen,
DocumentDigest: s.Document.Digest,
Verification: s.Signature is null ? null : new VexEvidenceVerificationMetadata(
Verified: s.Signature.VerifiedAt.HasValue,
VerifiedAt: s.Signature.VerifiedAt,
SignatureType: s.Signature.Type,
KeyId: s.Signature.KeyId,
Issuer: s.Signature.Issuer,
TransparencyRef: null))).ToList();
var response = new VexEvidenceLookupResponse(
VulnerabilityId: vulnerabilityId.Trim(),
ProductKey: productKey.Trim(),
EvidenceItems: items,
QueriedAt: timeProvider.GetUtcNow());
return Results.Ok(response);
}).WithName("LookupVexEvidence");
}
private static bool TryResolveTenant(HttpContext context, VexMongoStorageOptions options, out string tenant, out IResult? problem)
{
tenant = options.DefaultTenant;
problem = null;
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerValues) && headerValues.Count > 0)
{
var requestedTenant = headerValues[0]?.Trim();
if (string.IsNullOrEmpty(requestedTenant))
{
problem = Results.BadRequest(new { error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header must not be empty" } });
return false;
}
if (!string.Equals(requestedTenant, options.DefaultTenant, StringComparison.OrdinalIgnoreCase))
{
problem = Results.Json(
new { error = new { code = "ERR_TENANT_FORBIDDEN", message = $"Tenant '{requestedTenant}' is not allowed" } },
statusCode: StatusCodes.Status403Forbidden);
return false;
}
tenant = requestedTenant;
}
return true;
}
private static bool TryDecodeCursor(string cursor, out DateTime timestamp, out string id)
{
timestamp = default;
id = string.Empty;
try
{
var payload = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
var parts = payload.Split('|');
if (parts.Length != 2)
{
return false;
}
if (!DateTimeOffset.TryParse(parts[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
{
return false;
}
timestamp = parsed.UtcDateTime;
id = parts[1];
return true;
}
catch
{
return false;
}
}
private static string EncodeCursor(DateTime timestamp, string id)
{
var payload = FormattableString.Invariant($"{timestamp:O}|{id}");
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
}
}

View File

@@ -0,0 +1,366 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
using StellaOps.Excititor.WebService.Telemetry;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Linkset API endpoints (EXCITITOR-LNM-21-202).
/// Exposes /vex/linksets/* endpoints that surface alias mappings, conflict markers,
/// and provenance proofs exactly as stored. Errors map to ERR_AGG_* codes.
/// </summary>
public static class LinksetEndpoints
{
public static void MapLinksetEndpoints(this WebApplication app)
{
var group = app.MapGroup("/vex/linksets");
// GET /vex/linksets - List linksets with filters
group.MapGet("", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexLinksetStore linksetStore,
[FromQuery] int? limit,
[FromQuery] string? cursor,
[FromQuery] string? vulnerabilityId,
[FromQuery] string? productKey,
[FromQuery] string? providerId,
[FromQuery] bool? hasConflicts,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
IReadOnlyList<VexLinkset> linksets;
// Route to appropriate query method based on filters
if (hasConflicts == true)
{
linksets = await linksetStore
.FindWithConflictsAsync(tenant, take, cancellationToken)
.ConfigureAwait(false);
}
else if (!string.IsNullOrWhiteSpace(vulnerabilityId))
{
linksets = await linksetStore
.FindByVulnerabilityAsync(tenant, vulnerabilityId.Trim(), take, cancellationToken)
.ConfigureAwait(false);
}
else if (!string.IsNullOrWhiteSpace(productKey))
{
linksets = await linksetStore
.FindByProductKeyAsync(tenant, productKey.Trim(), take, cancellationToken)
.ConfigureAwait(false);
}
else if (!string.IsNullOrWhiteSpace(providerId))
{
linksets = await linksetStore
.FindByProviderAsync(tenant, providerId.Trim(), take, cancellationToken)
.ConfigureAwait(false);
}
else
{
return Results.BadRequest(new
{
error = new
{
code = "ERR_AGG_PARAMS",
message = "At least one filter is required: vulnerabilityId, productKey, providerId, or hasConflicts=true"
}
});
}
var items = linksets
.Take(take)
.Select(ToListItem)
.ToList();
// Record conflict metrics (EXCITITOR-OBS-51-001)
foreach (var linkset in linksets.Take(take))
{
if (linkset.HasConflicts)
{
LinksetTelemetry.RecordLinksetDisagreements(tenant, linkset);
}
}
var hasMore = linksets.Count > take;
string? nextCursor = null;
if (hasMore && items.Count > 0)
{
var last = linksets[items.Count - 1];
nextCursor = EncodeCursor(last.UpdatedAt.UtcDateTime, last.LinksetId);
}
var response = new VexLinksetListResponse(items, nextCursor);
return Results.Ok(response);
}).WithName("ListVexLinksets");
// GET /vex/linksets/{linksetId} - Get linkset by ID
group.MapGet("/{linksetId}", async (
HttpContext context,
string linksetId,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexLinksetStore linksetStore,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(linksetId))
{
return Results.BadRequest(new
{
error = new { code = "ERR_AGG_PARAMS", message = "linksetId is required" }
});
}
var linkset = await linksetStore
.GetByIdAsync(tenant, linksetId.Trim(), cancellationToken)
.ConfigureAwait(false);
if (linkset is null)
{
return Results.NotFound(new
{
error = new { code = "ERR_AGG_NOT_FOUND", message = $"Linkset '{linksetId}' not found" }
});
}
var response = ToDetailResponse(linkset);
return Results.Ok(response);
}).WithName("GetVexLinkset");
// GET /vex/linksets/lookup - Lookup linkset by vulnerability and product
group.MapGet("/lookup", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexLinksetStore linksetStore,
[FromQuery] string? vulnerabilityId,
[FromQuery] string? productKey,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return Results.BadRequest(new
{
error = new { code = "ERR_AGG_PARAMS", message = "vulnerabilityId and productKey are required" }
});
}
var linksetId = VexLinkset.CreateLinksetId(tenant, vulnerabilityId.Trim(), productKey.Trim());
var linkset = await linksetStore
.GetByIdAsync(tenant, linksetId, cancellationToken)
.ConfigureAwait(false);
if (linkset is null)
{
return Results.NotFound(new
{
error = new { code = "ERR_AGG_NOT_FOUND", message = "No linkset found for the specified vulnerability and product" }
});
}
var response = ToDetailResponse(linkset);
return Results.Ok(response);
}).WithName("LookupVexLinkset");
// GET /vex/linksets/count - Get linkset counts for tenant
group.MapGet("/count", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexLinksetStore linksetStore,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var total = await linksetStore
.CountAsync(tenant, cancellationToken)
.ConfigureAwait(false);
var withConflicts = await linksetStore
.CountWithConflictsAsync(tenant, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new LinksetCountResponse(total, withConflicts));
}).WithName("CountVexLinksets");
// GET /vex/linksets/conflicts - List linksets with conflicts (shorthand)
group.MapGet("/conflicts", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexLinksetStore linksetStore,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
var linksets = await linksetStore
.FindWithConflictsAsync(tenant, take, cancellationToken)
.ConfigureAwait(false);
var items = linksets.Select(ToListItem).ToList();
var response = new VexLinksetListResponse(items, null);
return Results.Ok(response);
}).WithName("ListVexLinksetConflicts");
}
private static VexLinksetListItem ToListItem(VexLinkset linkset)
{
return new VexLinksetListItem(
LinksetId: linkset.LinksetId,
Tenant: linkset.Tenant,
VulnerabilityId: linkset.VulnerabilityId,
ProductKey: linkset.ProductKey,
ProviderIds: linkset.ProviderIds.ToList(),
Statuses: linkset.Statuses.ToList(),
Aliases: Array.Empty<string>(), // Aliases are in observations, not linksets
Purls: Array.Empty<string>(),
Cpes: Array.Empty<string>(),
References: Array.Empty<VexLinksetReference>(),
Disagreements: linkset.Disagreements
.Select(d => new VexLinksetDisagreement(d.ProviderId, d.Status, d.Justification, d.Confidence))
.ToList(),
Observations: linkset.Observations
.Select(o => new VexLinksetObservationRef(o.ObservationId, o.ProviderId, o.Status, o.Confidence))
.ToList(),
CreatedAt: linkset.CreatedAt);
}
private static VexLinksetDetailResponse ToDetailResponse(VexLinkset linkset)
{
return new VexLinksetDetailResponse(
LinksetId: linkset.LinksetId,
Tenant: linkset.Tenant,
VulnerabilityId: linkset.VulnerabilityId,
ProductKey: linkset.ProductKey,
ProviderIds: linkset.ProviderIds.ToList(),
Statuses: linkset.Statuses.ToList(),
Confidence: linkset.Confidence.ToString().ToLowerInvariant(),
HasConflicts: linkset.HasConflicts,
Disagreements: linkset.Disagreements
.Select(d => new VexLinksetDisagreement(d.ProviderId, d.Status, d.Justification, d.Confidence))
.ToList(),
Observations: linkset.Observations
.Select(o => new VexLinksetObservationRef(o.ObservationId, o.ProviderId, o.Status, o.Confidence))
.ToList(),
CreatedAt: linkset.CreatedAt,
UpdatedAt: linkset.UpdatedAt);
}
private static bool TryResolveTenant(
HttpContext context,
VexMongoStorageOptions options,
out string tenant,
out IResult? problem)
{
problem = null;
tenant = string.Empty;
var headerTenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenant = headerTenant.Trim().ToLowerInvariant();
}
else if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
{
tenant = options.DefaultTenant.Trim().ToLowerInvariant();
}
else
{
problem = Results.BadRequest(new
{
error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header is required" }
});
return false;
}
return true;
}
private static string EncodeCursor(DateTime timestamp, string id)
{
var raw = $"{timestamp:O}|{id}";
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw));
}
}
// Detail response for single linkset
public sealed record VexLinksetDetailResponse(
[property: JsonPropertyName("linksetId")] string LinksetId,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: JsonPropertyName("productKey")] string ProductKey,
[property: JsonPropertyName("providerIds")] IReadOnlyList<string> ProviderIds,
[property: JsonPropertyName("statuses")] IReadOnlyList<string> Statuses,
[property: JsonPropertyName("confidence")] string Confidence,
[property: JsonPropertyName("hasConflicts")] bool HasConflicts,
[property: JsonPropertyName("disagreements")] IReadOnlyList<VexLinksetDisagreement> Disagreements,
[property: JsonPropertyName("observations")] IReadOnlyList<VexLinksetObservationRef> Observations,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("updatedAt")] DateTimeOffset UpdatedAt);
// Count response
public sealed record LinksetCountResponse(
[property: JsonPropertyName("total")] long Total,
[property: JsonPropertyName("withConflicts")] long WithConflicts);

View File

@@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Observation API endpoints (EXCITITOR-LNM-21-201).
/// Exposes /vex/observations/* endpoints with filters for advisory/product/provider,
/// strict RBAC, and deterministic pagination (no derived verdict fields).
/// </summary>
public static class ObservationEndpoints
{
public static void MapObservationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/vex/observations");
// GET /vex/observations - List observations with filters
group.MapGet("", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexObservationStore observationStore,
TimeProvider timeProvider,
[FromQuery] int? limit,
[FromQuery] string? cursor,
[FromQuery] string? vulnerabilityId,
[FromQuery] string? productKey,
[FromQuery] string? providerId,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
IReadOnlyList<VexObservation> observations;
// Route to appropriate query method based on filters
if (!string.IsNullOrWhiteSpace(vulnerabilityId) && !string.IsNullOrWhiteSpace(productKey))
{
observations = await observationStore
.FindByVulnerabilityAndProductAsync(tenant, vulnerabilityId.Trim(), productKey.Trim(), cancellationToken)
.ConfigureAwait(false);
}
else if (!string.IsNullOrWhiteSpace(providerId))
{
observations = await observationStore
.FindByProviderAsync(tenant, providerId.Trim(), take, cancellationToken)
.ConfigureAwait(false);
}
else
{
// No filter - return empty for now (full list requires pagination infrastructure)
return Results.BadRequest(new
{
error = new
{
code = "ERR_PARAMS",
message = "At least one filter is required: vulnerabilityId+productKey or providerId"
}
});
}
var items = observations
.Take(take)
.Select(obs => ToListItem(obs))
.ToList();
var hasMore = observations.Count > take;
string? nextCursor = null;
if (hasMore && items.Count > 0)
{
var last = observations[items.Count - 1];
nextCursor = EncodeCursor(last.CreatedAt.UtcDateTime, last.ObservationId);
}
var response = new VexObservationListResponse(items, nextCursor);
return Results.Ok(response);
}).WithName("ListVexObservations");
// GET /vex/observations/{observationId} - Get observation by ID
group.MapGet("/{observationId}", async (
HttpContext context,
string observationId,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexObservationStore observationStore,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(observationId))
{
return Results.BadRequest(new
{
error = new { code = "ERR_PARAMS", message = "observationId is required" }
});
}
var observation = await observationStore
.GetByIdAsync(tenant, observationId.Trim(), cancellationToken)
.ConfigureAwait(false);
if (observation is null)
{
return Results.NotFound(new
{
error = new { code = "ERR_NOT_FOUND", message = $"Observation '{observationId}' not found" }
});
}
var response = ToDetailResponse(observation);
return Results.Ok(response);
}).WithName("GetVexObservation");
// GET /vex/observations/count - Get observation count for tenant
group.MapGet("/count", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexObservationStore observationStore,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
var count = await observationStore
.CountAsync(tenant, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new { count });
}).WithName("CountVexObservations");
}
private static VexObservationListItem ToListItem(VexObservation obs)
{
var firstStatement = obs.Statements.FirstOrDefault();
return new VexObservationListItem(
ObservationId: obs.ObservationId,
Tenant: obs.Tenant,
ProviderId: obs.ProviderId,
VulnerabilityId: firstStatement?.VulnerabilityId ?? string.Empty,
ProductKey: firstStatement?.ProductKey ?? string.Empty,
Status: firstStatement?.Status.ToString().ToLowerInvariant() ?? "unknown",
CreatedAt: obs.CreatedAt,
LastObserved: firstStatement?.LastObserved,
Purls: obs.Linkset.Purls.ToList());
}
private static VexObservationDetailResponse ToDetailResponse(VexObservation obs)
{
var upstream = new VexObservationUpstreamResponse(
obs.Upstream.UpstreamId,
obs.Upstream.DocumentVersion,
obs.Upstream.FetchedAt,
obs.Upstream.ReceivedAt,
obs.Upstream.ContentHash,
obs.Upstream.Signature.Present
? new VexObservationSignatureResponse(
obs.Upstream.Signature.Format ?? "dsse",
obs.Upstream.Signature.KeyId,
Issuer: null,
VerifiedAtUtc: null)
: null);
var content = new VexObservationContentResponse(
obs.Content.Format,
obs.Content.SpecVersion);
var statements = obs.Statements
.Select(stmt => new VexObservationStatementItem(
stmt.VulnerabilityId,
stmt.ProductKey,
stmt.Status.ToString().ToLowerInvariant(),
stmt.LastObserved,
stmt.Locator,
stmt.Justification?.ToString().ToLowerInvariant(),
stmt.IntroducedVersion,
stmt.FixedVersion))
.ToList();
var linkset = new VexObservationLinksetResponse(
obs.Linkset.Aliases.ToList(),
obs.Linkset.Purls.ToList(),
obs.Linkset.Cpes.ToList(),
obs.Linkset.References.Select(r => new VexObservationReferenceItem(r.Type, r.Url)).ToList());
return new VexObservationDetailResponse(
obs.ObservationId,
obs.Tenant,
obs.ProviderId,
obs.StreamId,
upstream,
content,
statements,
linkset,
obs.CreatedAt);
}
private static bool TryResolveTenant(
HttpContext context,
VexMongoStorageOptions options,
out string tenant,
out IResult? problem)
{
problem = null;
tenant = string.Empty;
var headerTenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenant = headerTenant.Trim().ToLowerInvariant();
}
else if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
{
tenant = options.DefaultTenant.Trim().ToLowerInvariant();
}
else
{
problem = Results.BadRequest(new
{
error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header is required" }
});
return false;
}
return true;
}
private static string EncodeCursor(DateTime timestamp, string id)
{
var raw = $"{timestamp:O}|{id}";
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw));
}
}
// Additional response DTOs for observation detail
public sealed record VexObservationUpstreamResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("upstreamId")] string UpstreamId,
[property: System.Text.Json.Serialization.JsonPropertyName("documentVersion")] string? DocumentVersion,
[property: System.Text.Json.Serialization.JsonPropertyName("fetchedAt")] DateTimeOffset FetchedAt,
[property: System.Text.Json.Serialization.JsonPropertyName("receivedAt")] DateTimeOffset ReceivedAt,
[property: System.Text.Json.Serialization.JsonPropertyName("contentHash")] string ContentHash,
[property: System.Text.Json.Serialization.JsonPropertyName("signature")] VexObservationSignatureResponse? Signature);
public sealed record VexObservationContentResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("format")] string Format,
[property: System.Text.Json.Serialization.JsonPropertyName("specVersion")] string? SpecVersion);
public sealed record VexObservationStatementItem(
[property: System.Text.Json.Serialization.JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
[property: System.Text.Json.Serialization.JsonPropertyName("productKey")] string ProductKey,
[property: System.Text.Json.Serialization.JsonPropertyName("status")] string Status,
[property: System.Text.Json.Serialization.JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
[property: System.Text.Json.Serialization.JsonPropertyName("locator")] string? Locator,
[property: System.Text.Json.Serialization.JsonPropertyName("justification")] string? Justification,
[property: System.Text.Json.Serialization.JsonPropertyName("introducedVersion")] string? IntroducedVersion,
[property: System.Text.Json.Serialization.JsonPropertyName("fixedVersion")] string? FixedVersion);
public sealed record VexObservationLinksetResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("aliases")] IReadOnlyList<string> Aliases,
[property: System.Text.Json.Serialization.JsonPropertyName("purls")] IReadOnlyList<string> Purls,
[property: System.Text.Json.Serialization.JsonPropertyName("cpes")] IReadOnlyList<string> Cpes,
[property: System.Text.Json.Serialization.JsonPropertyName("references")] IReadOnlyList<VexObservationReferenceItem> References);
public sealed record VexObservationReferenceItem(
[property: System.Text.Json.Serialization.JsonPropertyName("type")] string Type,
[property: System.Text.Json.Serialization.JsonPropertyName("url")] string Url);
public sealed record VexObservationDetailResponse(
[property: System.Text.Json.Serialization.JsonPropertyName("observationId")] string ObservationId,
[property: System.Text.Json.Serialization.JsonPropertyName("tenant")] string Tenant,
[property: System.Text.Json.Serialization.JsonPropertyName("providerId")] string ProviderId,
[property: System.Text.Json.Serialization.JsonPropertyName("streamId")] string StreamId,
[property: System.Text.Json.Serialization.JsonPropertyName("upstream")] VexObservationUpstreamResponse Upstream,
[property: System.Text.Json.Serialization.JsonPropertyName("content")] VexObservationContentResponse Content,
[property: System.Text.Json.Serialization.JsonPropertyName("statements")] IReadOnlyList<VexObservationStatementItem> Statements,
[property: System.Text.Json.Serialization.JsonPropertyName("linkset")] VexObservationLinksetResponse Linkset,
[property: System.Text.Json.Serialization.JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);

View File

@@ -66,6 +66,7 @@ internal static class TelemetryExtensions
metrics
.AddMeter(IngestionTelemetry.MeterName)
.AddMeter(EvidenceTelemetry.MeterName)
.AddMeter(LinksetTelemetry.MeterName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();

View File

@@ -76,6 +76,14 @@ 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>();
@@ -387,6 +395,471 @@ app.MapGet("/openapi/excititor.json", () =>
}
}
}
},
// 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
@@ -451,6 +924,8 @@ app.MapPost("/airgap/v1/vex/import", async (
[FromServices] AirgapSignerTrustService trustService,
[FromServices] AirgapModeEnforcer modeEnforcer,
[FromServices] IAirgapImportStore store,
[FromServices] IVexTimelineEventEmitter timelineEmitter,
[FromServices] IVexHashingService hashingService,
[FromServices] ILoggerFactory loggerFactory,
[FromServices] TimeProvider timeProvider,
[FromBody] AirgapImportRequest request,
@@ -465,6 +940,7 @@ app.MapPost("/airgap/v1/vex/import", async (
? (int?)null
: (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds);
var traceId = Activity.Current?.TraceId.ToString();
var timeline = new List<AirgapTimelineEntry>();
void RecordEvent(string eventType, string? code = null, string? message = null)
{
@@ -481,6 +957,54 @@ app.MapPost("/airgap/v1/vex/import", async (
};
timeline.Add(entry);
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code);
// WEB-AIRGAP-58-001: Emit timeline event to persistent store for SSE streaming
_ = EmitTimelineEventAsync(eventType, code, message);
}
async Task EmitTimelineEventAsync(string eventType, string? code, string? message)
{
try
{
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["bundle_id"] = request.BundleId ?? string.Empty,
["mirror_generation"] = request.MirrorGeneration ?? string.Empty
};
if (stalenessSeconds.HasValue)
{
attributes["staleness_seconds"] = stalenessSeconds.Value.ToString(CultureInfo.InvariantCulture);
}
if (!string.IsNullOrEmpty(code))
{
attributes["error_code"] = code;
}
if (!string.IsNullOrEmpty(message))
{
attributes["message"] = message;
}
var eventId = $"airgap-{request.BundleId}-{request.MirrorGeneration}-{nowUtc:yyyyMMddHHmmssfff}";
var streamId = $"airgap:{request.BundleId}:{request.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");
@@ -528,7 +1052,8 @@ app.MapPost("/airgap/v1/vex/import", async (
var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json";
var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson";
var manifestHash = ComputeSha256($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
// CRYPTO-90-001: Use IVexHashingService for pluggable crypto algorithms
var manifestHash = hashingService.ComputeHash($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
RecordEvent("airgap.import.completed");
@@ -578,12 +1103,7 @@ app.MapPost("/airgap/v1/vex/import", async (
});
});
static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
// CRYPTO-90-001: ComputeSha256 removed - now using IVexHashingService for pluggable crypto
app.MapPost("/v1/attestations/verify", async (
[FromServices] IVexAttestationClient attestationClient,
@@ -1666,10 +2186,13 @@ app.MapGet("/obs/excititor/health", async (
app.MapGet("/obs/excititor/timeline", async (
HttpContext context,
IOptions<VexMongoStorageOptions> 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))
@@ -1680,44 +2203,71 @@ app.MapGet("/obs/excititor/timeline", async (
var logger = loggerFactory.CreateLogger("ExcititorTimeline");
var take = Math.Clamp(limit.GetValueOrDefault(10), 1, 100);
var startId = 0;
// 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) && !int.TryParse(candidateCursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out startId))
if (!string.IsNullOrWhiteSpace(candidateCursor))
{
return Results.BadRequest(new { error = "cursor must be integer" });
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();
var events = Enumerable.Range(startId, take)
.Select(id => new ExcititorTimelineEvent(
Type: "evidence.update",
Tenant: tenant,
Source: "vex-runtime",
Count: 0,
Errors: 0,
TraceId: null,
OccurredAt: now.ToString("O", CultureInfo.InvariantCulture)))
.ToList();
foreach (var (evt, idx) in events.Select((e, i) => (e, i)))
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 id = startId + idx;
await context.Response.WriteAsync($"id: {id}\n", cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync($"event: {evt.Type}\n", cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(evt)}\n\n", cancellationToken).ConfigureAwait(false);
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 = startId + events.Count;
context.Response.Headers["X-Next-Cursor"] = nextCursor.ToString(CultureInfo.InvariantCulture);
logger.LogInformation("obs excititor timeline emitted {Count} events for tenant {Tenant} start {Start} next {Next}", events.Count, tenant, startId, nextCursor);
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");
@@ -1726,11 +2276,13 @@ IngestEndpoints.MapIngestEndpoints(app);
ResolveEndpoint.MapResolveEndpoint(app);
MirrorEndpoints.MapMirrorEndpoints(app);
app.MapGet("/v1/vex/observations", async (HttpContext _, CancellationToken __) =>
Results.StatusCode(StatusCodes.Status501NotImplemented));
// Evidence and Attestation APIs (WEB-OBS-53-001, WEB-OBS-54-001)
EvidenceEndpoints.MapEvidenceEndpoints(app);
AttestationEndpoints.MapAttestationEndpoints(app);
app.MapGet("/v1/vex/linksets", async (HttpContext _, CancellationToken __) =>
Results.StatusCode(StatusCodes.Status501NotImplemented));
// Observation and Linkset APIs (EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202)
ObservationEndpoints.MapObservationEndpoints(app);
LinksetEndpoints.MapLinksetEndpoints(app);
app.Run();

View File

@@ -0,0 +1,112 @@
using System;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Cryptography;
namespace StellaOps.Excititor.WebService.Services;
/// <summary>
/// Service interface for hashing operations in Excititor (CRYPTO-90-001).
/// Abstracts hashing implementation to support GOST/SM algorithms via ICryptoProviderRegistry.
/// </summary>
public interface IVexHashingService
{
/// <summary>
/// Compute hash of a UTF-8 encoded string.
/// </summary>
string ComputeHash(string value, string algorithm = "sha256");
/// <summary>
/// Compute hash of raw bytes.
/// </summary>
string ComputeHash(ReadOnlySpan<byte> data, string algorithm = "sha256");
/// <summary>
/// Try to compute hash of raw bytes with stack-allocated buffer optimization.
/// </summary>
bool TryComputeHash(ReadOnlySpan<byte> data, Span<byte> destination, out int bytesWritten, string algorithm = "sha256");
/// <summary>
/// Format a hash digest with algorithm prefix.
/// </summary>
string FormatDigest(string algorithm, ReadOnlySpan<byte> digest);
}
/// <summary>
/// Default implementation of <see cref="IVexHashingService"/> that uses ICryptoProviderRegistry
/// when available, falling back to System.Security.Cryptography for SHA-256.
/// </summary>
public sealed class VexHashingService : IVexHashingService
{
private readonly ICryptoProviderRegistry? _registry;
public VexHashingService(ICryptoProviderRegistry? registry = null)
{
_registry = registry;
}
public string ComputeHash(string value, string algorithm = "sha256")
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var bytes = Encoding.UTF8.GetBytes(value);
return ComputeHash(bytes, algorithm);
}
public string ComputeHash(ReadOnlySpan<byte> data, string algorithm = "sha256")
{
Span<byte> buffer = stackalloc byte[64]; // Large enough for SHA-512 and GOST
if (!TryComputeHash(data, buffer, out var written, algorithm))
{
throw new InvalidOperationException($"Failed to compute {algorithm} hash.");
}
return FormatDigest(algorithm, buffer[..written]);
}
public bool TryComputeHash(ReadOnlySpan<byte> data, Span<byte> destination, out int bytesWritten, string algorithm = "sha256")
{
bytesWritten = 0;
// Try to use crypto provider registry first for pluggable algorithms
if (_registry is not null)
{
try
{
var resolution = _registry.ResolveHasher(algorithm);
var hasher = resolution.Hasher;
var result = hasher.ComputeHash(data);
if (result.Length <= destination.Length)
{
result.CopyTo(destination);
bytesWritten = result.Length;
return true;
}
}
catch
{
// Fall through to built-in implementation
}
}
// Fall back to System.Security.Cryptography for standard algorithms
var normalizedAlgorithm = algorithm.ToLowerInvariant().Replace("-", string.Empty);
return normalizedAlgorithm switch
{
"sha256" => SHA256.TryHashData(data, destination, out bytesWritten),
"sha384" => SHA384.TryHashData(data, destination, out bytesWritten),
"sha512" => SHA512.TryHashData(data, destination, out bytesWritten),
_ => throw new NotSupportedException($"Unsupported hash algorithm: {algorithm}")
};
}
public string FormatDigest(string algorithm, ReadOnlySpan<byte> digest)
{
var normalizedAlgorithm = algorithm.ToLowerInvariant().Replace("-", string.Empty);
var hexDigest = Convert.ToHexString(digest).ToLowerInvariant();
return $"{normalizedAlgorithm}:{hexDigest}";
}
}

View File

@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.WebService.Telemetry;
/// <summary>
/// Telemetry metrics for VEX linkset and observation store operations (EXCITITOR-OBS-51-001).
/// Tracks ingest latency, scope resolution success, conflict rate, and signature verification
/// to support SLO burn alerts for AOC "evidence freshness" mission.
/// </summary>
internal static class LinksetTelemetry
{
public const string MeterName = "StellaOps.Excititor.WebService.Linksets";
private static readonly Meter Meter = new(MeterName);
// Ingest latency metrics
private static readonly Histogram<double> IngestLatencyHistogram =
Meter.CreateHistogram<double>(
"excititor.vex.ingest.latency_seconds",
unit: "s",
description: "Latency distribution for VEX observation and linkset store operations.");
private static readonly Counter<long> IngestOperationCounter =
Meter.CreateCounter<long>(
"excititor.vex.ingest.operations_total",
unit: "operations",
description: "Total count of VEX ingest operations by outcome.");
// Scope resolution metrics
private static readonly Counter<long> ScopeResolutionCounter =
Meter.CreateCounter<long>(
"excititor.vex.scope.resolution_total",
unit: "resolutions",
description: "Count of scope resolution attempts by outcome (success/failure).");
private static readonly Histogram<int> ScopeMatchCountHistogram =
Meter.CreateHistogram<int>(
"excititor.vex.scope.match_count",
unit: "matches",
description: "Distribution of matched scopes per resolution request.");
// Conflict/disagreement metrics
private static readonly Counter<long> LinksetConflictCounter =
Meter.CreateCounter<long>(
"excititor.vex.linkset.conflicts_total",
unit: "conflicts",
description: "Total count of linksets with provider disagreements detected.");
private static readonly Histogram<int> DisagreementCountHistogram =
Meter.CreateHistogram<int>(
"excititor.vex.linkset.disagreement_count",
unit: "disagreements",
description: "Distribution of disagreement count per linkset.");
private static readonly Counter<long> DisagreementByStatusCounter =
Meter.CreateCounter<long>(
"excititor.vex.linkset.disagreement_by_status",
unit: "disagreements",
description: "Disagreement counts broken down by conflicting status values.");
// Observation store metrics
private static readonly Counter<long> ObservationStoreCounter =
Meter.CreateCounter<long>(
"excititor.vex.observation.store_operations_total",
unit: "operations",
description: "Total observation store operations by type and outcome.");
private static readonly Histogram<int> ObservationBatchSizeHistogram =
Meter.CreateHistogram<int>(
"excititor.vex.observation.batch_size",
unit: "observations",
description: "Distribution of observation batch sizes for store operations.");
// Linkset store metrics
private static readonly Counter<long> LinksetStoreCounter =
Meter.CreateCounter<long>(
"excititor.vex.linkset.store_operations_total",
unit: "operations",
description: "Total linkset store operations by type and outcome.");
// Confidence metrics
private static readonly Histogram<double> LinksetConfidenceHistogram =
Meter.CreateHistogram<double>(
"excititor.vex.linkset.confidence_score",
unit: "score",
description: "Distribution of linkset confidence scores (0.0-1.0).");
/// <summary>
/// Records latency for a VEX ingest operation.
/// </summary>
public static void RecordIngestLatency(string? tenant, string operation, string outcome, double latencySeconds)
{
var tags = BuildBaseTags(tenant, operation, outcome);
IngestLatencyHistogram.Record(latencySeconds, tags);
IngestOperationCounter.Add(1, tags);
}
/// <summary>
/// Records a scope resolution attempt and its outcome.
/// </summary>
public static void RecordScopeResolution(string? tenant, string outcome, int matchCount = 0)
{
var normalizedTenant = NormalizeTenant(tenant);
var tags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("outcome", outcome),
};
ScopeResolutionCounter.Add(1, tags);
if (string.Equals(outcome, "success", StringComparison.OrdinalIgnoreCase) && matchCount > 0)
{
ScopeMatchCountHistogram.Record(matchCount, tags);
}
}
/// <summary>
/// Records conflict detection for a linkset.
/// </summary>
public static void RecordLinksetConflict(string? tenant, bool hasConflicts, int disagreementCount = 0)
{
var normalizedTenant = NormalizeTenant(tenant);
if (hasConflicts)
{
var conflictTags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
};
LinksetConflictCounter.Add(1, conflictTags);
if (disagreementCount > 0)
{
DisagreementCountHistogram.Record(disagreementCount, conflictTags);
}
}
}
/// <summary>
/// Records a linkset with detailed disagreement breakdown.
/// </summary>
public static void RecordLinksetDisagreements(string? tenant, VexLinkset linkset)
{
if (linkset is null || !linkset.HasConflicts)
{
return;
}
var normalizedTenant = NormalizeTenant(tenant);
RecordLinksetConflict(normalizedTenant, true, linkset.Disagreements.Length);
// Record disagreements by status
foreach (var disagreement in linkset.Disagreements)
{
var statusTags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("status", disagreement.Status.ToLowerInvariant()),
new KeyValuePair<string, object?>("provider", disagreement.ProviderId),
};
DisagreementByStatusCounter.Add(1, statusTags);
}
// Record confidence score
var confidenceScore = linkset.Confidence switch
{
VexLinksetConfidence.High => 0.9,
VexLinksetConfidence.Medium => 0.7,
VexLinksetConfidence.Low => 0.4,
_ => 0.5
};
var confidenceTags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("has_conflicts", linkset.HasConflicts),
};
LinksetConfidenceHistogram.Record(confidenceScore, confidenceTags);
}
/// <summary>
/// Records an observation store operation.
/// </summary>
public static void RecordObservationStoreOperation(
string? tenant,
string operation,
string outcome,
int batchSize = 1)
{
var tags = BuildBaseTags(tenant, operation, outcome);
ObservationStoreCounter.Add(1, tags);
if (batchSize > 0 && string.Equals(outcome, "success", StringComparison.OrdinalIgnoreCase))
{
var batchTags = new[]
{
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
new KeyValuePair<string, object?>("operation", operation),
};
ObservationBatchSizeHistogram.Record(batchSize, batchTags);
}
}
/// <summary>
/// Records a linkset store operation.
/// </summary>
public static void RecordLinksetStoreOperation(string? tenant, string operation, string outcome)
{
var tags = BuildBaseTags(tenant, operation, outcome);
LinksetStoreCounter.Add(1, tags);
}
/// <summary>
/// Records linkset confidence score distribution.
/// </summary>
public static void RecordLinksetConfidence(string? tenant, VexLinksetConfidence confidence, bool hasConflicts)
{
var score = confidence switch
{
VexLinksetConfidence.High => 0.9,
VexLinksetConfidence.Medium => 0.7,
VexLinksetConfidence.Low => 0.4,
_ => 0.5
};
var tags = new[]
{
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
new KeyValuePair<string, object?>("has_conflicts", hasConflicts),
new KeyValuePair<string, object?>("confidence_level", confidence.ToString().ToLowerInvariant()),
};
LinksetConfidenceHistogram.Record(score, tags);
}
private static string NormalizeTenant(string? tenant)
=> string.IsNullOrWhiteSpace(tenant) ? "default" : tenant;
private static KeyValuePair<string, object?>[] BuildBaseTags(string? tenant, string operation, string outcome)
=> new[]
{
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
new KeyValuePair<string, object?>("operation", operation),
new KeyValuePair<string, object?>("outcome", outcome),
};
}