up
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -66,6 +66,7 @@ internal static class TelemetryExtensions
|
||||
metrics
|
||||
.AddMeter(IngestionTelemetry.MeterName)
|
||||
.AddMeter(EvidenceTelemetry.MeterName)
|
||||
.AddMeter(LinksetTelemetry.MeterName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user