up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 00:45:16 +02:00
parent 3b96b2e3ea
commit 1c6730a1d2
95 changed files with 14504 additions and 463 deletions

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.WebService.Contracts;
/// <summary>
/// Response listing registered mirror bundles.
/// </summary>
public sealed record MirrorBundleListResponse(
[property: JsonPropertyName("bundles")] IReadOnlyList<MirrorBundleSummary> Bundles,
[property: JsonPropertyName("totalCount")] int TotalCount,
[property: JsonPropertyName("limit")] int Limit,
[property: JsonPropertyName("offset")] int Offset,
[property: JsonPropertyName("queriedAt")] DateTimeOffset QueriedAt);
/// <summary>
/// Summary of a registered mirror bundle.
/// </summary>
public sealed record MirrorBundleSummary(
[property: JsonPropertyName("bundleId")] string BundleId,
[property: JsonPropertyName("mirrorGeneration")] string MirrorGeneration,
[property: JsonPropertyName("publisher")] string Publisher,
[property: JsonPropertyName("signedAt")] DateTimeOffset SignedAt,
[property: JsonPropertyName("importedAt")] DateTimeOffset ImportedAt,
[property: JsonPropertyName("payloadHash")] string PayloadHash,
[property: JsonPropertyName("stalenessSeconds")] long StalenessSeconds,
[property: JsonPropertyName("status")] string Status);
/// <summary>
/// Detailed response for a registered mirror bundle with provenance.
/// </summary>
public sealed record MirrorBundleDetailResponse(
[property: JsonPropertyName("bundleId")] string BundleId,
[property: JsonPropertyName("mirrorGeneration")] string MirrorGeneration,
[property: JsonPropertyName("tenantId")] string TenantId,
[property: JsonPropertyName("publisher")] string Publisher,
[property: JsonPropertyName("signedAt")] DateTimeOffset SignedAt,
[property: JsonPropertyName("importedAt")] DateTimeOffset ImportedAt,
[property: JsonPropertyName("provenance")] MirrorBundleProvenance Provenance,
[property: JsonPropertyName("staleness")] MirrorBundleStaleness Staleness,
[property: JsonPropertyName("paths")] MirrorBundlePaths Paths,
[property: JsonPropertyName("timeline")] IReadOnlyList<MirrorBundleTimelineEntry> Timeline,
[property: JsonPropertyName("queriedAt")] DateTimeOffset QueriedAt);
/// <summary>
/// Provenance metadata for a mirror bundle.
/// </summary>
public sealed record MirrorBundleProvenance(
[property: JsonPropertyName("payloadHash")] string PayloadHash,
[property: JsonPropertyName("signature")] string Signature,
[property: JsonPropertyName("payloadUrl")] string? PayloadUrl,
[property: JsonPropertyName("transparencyLog")] string? TransparencyLog,
[property: JsonPropertyName("manifestHash")] string ManifestHash);
/// <summary>
/// Staleness metrics for a mirror bundle.
/// </summary>
public sealed record MirrorBundleStaleness(
[property: JsonPropertyName("sinceSignedSeconds")] long SinceSignedSeconds,
[property: JsonPropertyName("sinceImportedSeconds")] long SinceImportedSeconds,
[property: JsonPropertyName("signedAgeCategory")] string SignedAgeCategory,
[property: JsonPropertyName("importedAgeCategory")] string ImportedAgeCategory);
/// <summary>
/// Storage paths for a mirror bundle.
/// </summary>
public sealed record MirrorBundlePaths(
[property: JsonPropertyName("portableManifestPath")] string PortableManifestPath,
[property: JsonPropertyName("evidenceLockerPath")] string EvidenceLockerPath);
/// <summary>
/// Timeline entry for audit trail.
/// </summary>
public sealed record MirrorBundleTimelineEntry(
[property: JsonPropertyName("eventType")] string EventType,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("stalenessSeconds")] int? StalenessSeconds,
[property: JsonPropertyName("errorCode")] string? ErrorCode,
[property: JsonPropertyName("message")] string? Message);
/// <summary>
/// Response for timeline-only query.
/// </summary>
public sealed record MirrorBundleTimelineResponse(
[property: JsonPropertyName("bundleId")] string BundleId,
[property: JsonPropertyName("mirrorGeneration")] string MirrorGeneration,
[property: JsonPropertyName("timeline")] IReadOnlyList<MirrorBundleTimelineEntry> Timeline,
[property: JsonPropertyName("queriedAt")] DateTimeOffset QueriedAt);
/// <summary>
/// Structured error response for sealed-mode and airgap errors.
/// </summary>
public sealed record AirgapErrorResponse(
[property: JsonPropertyName("errorCode")] string ErrorCode,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("category")] string Category,
[property: JsonPropertyName("retryable")] bool Retryable,
[property: JsonPropertyName("details")] IReadOnlyDictionary<string, string>? Details);
/// <summary>
/// Maps sealed-mode error codes to structured error responses.
/// </summary>
public static class AirgapErrorMapping
{
public const string CategoryValidation = "validation";
public const string CategorySealedMode = "sealed_mode";
public const string CategoryTrust = "trust";
public const string CategoryDuplicate = "duplicate";
public const string CategoryNotFound = "not_found";
public static AirgapErrorResponse FromErrorCode(string errorCode, string message, IReadOnlyDictionary<string, string>? details = null)
{
var (category, retryable) = errorCode switch
{
"AIRGAP_EGRESS_BLOCKED" => (CategorySealedMode, false),
"AIRGAP_SOURCE_UNTRUSTED" => (CategoryTrust, false),
"AIRGAP_SIGNATURE_MISSING" => (CategoryValidation, false),
"AIRGAP_SIGNATURE_INVALID" => (CategoryValidation, false),
"AIRGAP_PAYLOAD_STALE" => (CategoryValidation, true),
"AIRGAP_PAYLOAD_MISMATCH" => (CategoryTrust, false),
"AIRGAP_DUPLICATE_IMPORT" => (CategoryDuplicate, false),
"AIRGAP_BUNDLE_NOT_FOUND" => (CategoryNotFound, false),
_ when errorCode.StartsWith("bundle_", StringComparison.Ordinal) => (CategoryValidation, false),
_ when errorCode.StartsWith("mirror_", StringComparison.Ordinal) => (CategoryValidation, false),
_ when errorCode.StartsWith("publisher_", StringComparison.Ordinal) => (CategoryValidation, false),
_ when errorCode.StartsWith("payload_", StringComparison.Ordinal) => (CategoryValidation, false),
_ when errorCode.StartsWith("signed_", StringComparison.Ordinal) => (CategoryValidation, false),
_ => (CategoryValidation, false),
};
return new AirgapErrorResponse(errorCode, message, category, retryable, details);
}
public static AirgapErrorResponse DuplicateImport(string bundleId, string mirrorGeneration)
=> new(
"AIRGAP_DUPLICATE_IMPORT",
$"Bundle '{bundleId}' generation '{mirrorGeneration}' has already been imported.",
CategoryDuplicate,
Retryable: false,
new Dictionary<string, string>
{
["bundleId"] = bundleId,
["mirrorGeneration"] = mirrorGeneration,
});
public static AirgapErrorResponse BundleNotFound(string bundleId, string? mirrorGeneration)
=> new(
"AIRGAP_BUNDLE_NOT_FOUND",
mirrorGeneration is null
? $"Bundle '{bundleId}' not found."
: $"Bundle '{bundleId}' generation '{mirrorGeneration}' not found.",
CategoryNotFound,
Retryable: false,
new Dictionary<string, string>
{
["bundleId"] = bundleId,
["mirrorGeneration"] = mirrorGeneration ?? string.Empty,
});
}
/// <summary>
/// Utility for computing staleness categories.
/// </summary>
public static class StalenessCalculator
{
public static long ComputeSeconds(DateTimeOffset then, DateTimeOffset now)
=> (long)Math.Max(0, Math.Ceiling((now - then).TotalSeconds));
public static string CategorizeAge(long seconds)
=> seconds switch
{
< 3600 => "fresh", // < 1 hour
< 86400 => "recent", // < 1 day
< 604800 => "stale", // < 1 week
< 2592000 => "old", // < 30 days
_ => "very_old", // >= 30 days
};
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Builder;
@@ -15,6 +16,7 @@ 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;
@@ -245,6 +247,195 @@ public static class EvidenceEndpoints
return Results.Ok(response);
}).WithName("LookupVexEvidence");
// GET /vuln/evidence/vex/{advisory_key} - Get evidence by advisory key (EXCITITOR-VULN-29-002)
app.MapGet("/vuln/evidence/vex/{advisory_key}", async (
HttpContext context,
string advisory_key,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IMongoDatabase database,
TimeProvider timeProvider,
[FromQuery] int? limit,
[FromQuery] string? cursor,
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(advisory_key))
{
NormalizationTelemetry.RecordAdvisoryKeyCanonicalizeError(tenant, "empty_key");
return Results.BadRequest(new { error = new { code = "ERR_ADVISORY_KEY", message = "advisory_key is required" } });
}
var stopwatch = Stopwatch.StartNew();
// Canonicalize the advisory key using VexAdvisoryKeyCanonicalizer
var canonicalizer = new VexAdvisoryKeyCanonicalizer();
VexCanonicalAdvisoryKey canonicalKey;
try
{
canonicalKey = canonicalizer.Canonicalize(advisory_key.Trim());
NormalizationTelemetry.RecordAdvisoryKeyCanonicalization(tenant, canonicalKey);
}
catch (ArgumentException ex)
{
NormalizationTelemetry.RecordAdvisoryKeyCanonicalizeError(tenant, "invalid_format", advisory_key);
return Results.BadRequest(new { error = new { code = "ERR_INVALID_ADVISORY_KEY", message = ex.Message } });
}
var take = Math.Clamp(limit.GetValueOrDefault(100), 1, 500);
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Statements);
var builder = Builders<BsonDocument>.Filter;
// Build filter to match by vulnerability ID (case-insensitive)
// Try original key, canonical key, and all aliases
var vulnerabilityFilters = new List<FilterDefinition<BsonDocument>>
{
builder.Regex("VulnerabilityId", new BsonRegularExpression($"^{EscapeRegex(advisory_key.Trim())}$", "i"))
};
// Add canonical key if different
if (!string.Equals(canonicalKey.AdvisoryKey, advisory_key.Trim(), StringComparison.OrdinalIgnoreCase))
{
vulnerabilityFilters.Add(builder.Regex("VulnerabilityId", new BsonRegularExpression($"^{EscapeRegex(canonicalKey.AdvisoryKey)}$", "i")));
}
// Add original ID if available
if (canonicalKey.OriginalId is { } originalId &&
!string.Equals(originalId, advisory_key.Trim(), StringComparison.OrdinalIgnoreCase))
{
vulnerabilityFilters.Add(builder.Regex("VulnerabilityId", new BsonRegularExpression($"^{EscapeRegex(originalId)}$", "i")));
}
var filter = builder.Or(vulnerabilityFilters);
// Apply cursor-based pagination if provided
if (!string.IsNullOrWhiteSpace(cursor) && TryDecodeCursor(cursor, out var cursorTime, out var cursorId))
{
var ltTime = builder.Lt("InsertedAt", cursorTime);
var eqTimeLtId = builder.And(
builder.Eq("InsertedAt", cursorTime),
builder.Lt("_id", ObjectId.Parse(cursorId)));
filter = builder.And(filter, builder.Or(ltTime, eqTimeLtId));
}
var sort = Builders<BsonDocument>.Sort.Descending("InsertedAt").Descending("_id");
var documents = await collection
.Find(filter)
.Sort(sort)
.Limit(take)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var now = timeProvider.GetUtcNow();
var statements = new List<VexAdvisoryStatementResponse>();
foreach (var doc in documents)
{
var provenance = new VexAdvisoryProvenanceResponse(
DocumentDigest: doc.GetValue("Document", BsonNull.Value).IsBsonDocument
? doc["Document"].AsBsonDocument.GetValue("Digest", BsonNull.Value).AsString ?? string.Empty
: string.Empty,
DocumentFormat: doc.GetValue("Document", BsonNull.Value).IsBsonDocument
? doc["Document"].AsBsonDocument.GetValue("Format", BsonNull.Value).AsString ?? "unknown"
: "unknown",
SourceUri: doc.GetValue("Document", BsonNull.Value).IsBsonDocument
? doc["Document"].AsBsonDocument.GetValue("SourceUri", BsonNull.Value).AsString ?? string.Empty
: string.Empty,
Revision: doc.GetValue("Document", BsonNull.Value).IsBsonDocument
? doc["Document"].AsBsonDocument.GetValue("Revision", BsonNull.Value).AsString
: null,
InsertedAt: doc.GetValue("InsertedAt", BsonNull.Value).IsBsonDateTime
? new DateTimeOffset(doc["InsertedAt"].ToUniversalTime(), TimeSpan.Zero)
: now);
VexAdvisoryAttestationResponse? attestation = null;
if (doc.GetValue("Document", BsonNull.Value).IsBsonDocument)
{
var docSection = doc["Document"].AsBsonDocument;
if (docSection.Contains("Signature") && !docSection["Signature"].IsBsonNull)
{
var sig = docSection["Signature"].AsBsonDocument;
var sigType = sig.GetValue("Type", BsonNull.Value).AsString;
if (!string.IsNullOrWhiteSpace(sigType))
{
attestation = new VexAdvisoryAttestationResponse(
SignatureType: sigType,
Issuer: sig.GetValue("Issuer", BsonNull.Value).AsString,
Subject: sig.GetValue("Subject", BsonNull.Value).AsString,
KeyId: sig.GetValue("KeyId", BsonNull.Value).AsString,
VerifiedAt: sig.Contains("VerifiedAt") && !sig["VerifiedAt"].IsBsonNull
? new DateTimeOffset(sig["VerifiedAt"].ToUniversalTime(), TimeSpan.Zero)
: null,
TransparencyLogRef: sig.GetValue("TransparencyLogReference", BsonNull.Value).AsString,
TrustWeight: sig.Contains("TrustWeight") && !sig["TrustWeight"].IsBsonNull
? (decimal)sig["TrustWeight"].ToDouble()
: null,
TrustTier: DeriveTrustTier(sig.GetValue("TrustIssuerId", BsonNull.Value).AsString));
}
}
}
var productDoc = doc.GetValue("Product", BsonNull.Value).IsBsonDocument
? doc["Product"].AsBsonDocument
: null;
var product = new VexAdvisoryProductResponse(
Key: productDoc?.GetValue("Key", BsonNull.Value).AsString ?? string.Empty,
Name: productDoc?.GetValue("Name", BsonNull.Value).AsString,
Version: productDoc?.GetValue("Version", BsonNull.Value).AsString,
Purl: productDoc?.GetValue("Purl", BsonNull.Value).AsString,
Cpe: productDoc?.GetValue("Cpe", BsonNull.Value).AsString);
statements.Add(new VexAdvisoryStatementResponse(
StatementId: doc.GetValue("_id", BsonNull.Value).ToString() ?? string.Empty,
ProviderId: doc.GetValue("ProviderId", BsonNull.Value).AsString ?? string.Empty,
Product: product,
Status: doc.GetValue("Status", BsonNull.Value).AsString ?? "unknown",
Justification: doc.GetValue("Justification", BsonNull.Value).AsString,
Detail: doc.GetValue("Detail", BsonNull.Value).AsString,
FirstSeen: doc.GetValue("FirstSeen", BsonNull.Value).IsBsonDateTime
? new DateTimeOffset(doc["FirstSeen"].ToUniversalTime(), TimeSpan.Zero)
: now,
LastSeen: doc.GetValue("LastSeen", BsonNull.Value).IsBsonDateTime
? new DateTimeOffset(doc["LastSeen"].ToUniversalTime(), TimeSpan.Zero)
: now,
Provenance: provenance,
Attestation: attestation));
}
var aliases = canonicalKey.Links
.Select(link => new VexAdvisoryLinkResponse(link.Identifier, link.Type, link.IsOriginal))
.ToList();
stopwatch.Stop();
NormalizationTelemetry.RecordEvidenceRetrieval(
tenant,
"success",
statements.Count,
stopwatch.Elapsed.TotalSeconds);
var response = new VexAdvisoryEvidenceResponse(
AdvisoryKey: advisory_key.Trim(),
CanonicalKey: canonicalKey.AdvisoryKey,
Scope: canonicalKey.Scope.ToString().ToLowerInvariant(),
Aliases: aliases,
Statements: statements,
QueriedAt: now,
TotalCount: statements.Count);
return Results.Ok(response);
}).WithName("GetVexAdvisoryEvidence");
}
private static bool TryResolveTenant(HttpContext context, VexMongoStorageOptions options, out string tenant, out IResult? problem)
@@ -308,4 +499,37 @@ public static class EvidenceEndpoints
var payload = FormattableString.Invariant($"{timestamp:O}|{id}");
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
}
private static string EscapeRegex(string input)
{
// Escape special regex characters for safe use in MongoDB regex
return System.Text.RegularExpressions.Regex.Escape(input);
}
private static string? DeriveTrustTier(string? issuerId)
{
if (string.IsNullOrWhiteSpace(issuerId))
{
return null;
}
var lowerIssuerId = issuerId.ToLowerInvariant();
if (lowerIssuerId.Contains("vendor") || lowerIssuerId.Contains("upstream"))
{
return "vendor";
}
if (lowerIssuerId.Contains("distro") || lowerIssuerId.Contains("rhel") ||
lowerIssuerId.Contains("ubuntu") || lowerIssuerId.Contains("debian"))
{
return "distro-trusted";
}
if (lowerIssuerId.Contains("community") || lowerIssuerId.Contains("oss"))
{
return "community";
}
return "other";
}
}

View File

@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Endpoints for mirror bundle registration, provenance exposure, and timeline queries (EXCITITOR-AIRGAP-56-001).
/// </summary>
internal static class MirrorRegistrationEndpoints
{
public static void MapMirrorRegistrationEndpoints(WebApplication app)
{
var group = app.MapGroup("/airgap/v1/mirror/bundles");
group.MapGet("/", HandleListBundlesAsync)
.WithName("ListMirrorBundles")
.WithDescription("List registered mirror bundles with pagination and optional filters.");
group.MapGet("/{bundleId}", HandleGetBundleAsync)
.WithName("GetMirrorBundle")
.WithDescription("Get mirror bundle details with provenance and staleness metrics.");
group.MapGet("/{bundleId}/timeline", HandleGetBundleTimelineAsync)
.WithName("GetMirrorBundleTimeline")
.WithDescription("Get timeline events for a mirror bundle.");
}
private static async Task<IResult> HandleListBundlesAsync(
HttpContext httpContext,
IAirgapImportStore importStore,
TimeProvider timeProvider,
ILogger<MirrorRegistrationEndpointsMarker> logger,
[FromQuery] string? publisher = null,
[FromQuery] string? importedAfter = null,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0,
CancellationToken cancellationToken = default)
{
var tenantId = ResolveTenantId(httpContext);
var now = timeProvider.GetUtcNow();
DateTimeOffset? afterFilter = null;
if (!string.IsNullOrWhiteSpace(importedAfter) && DateTimeOffset.TryParse(importedAfter, out var parsed))
{
afterFilter = parsed;
}
var clampedLimit = Math.Clamp(limit, 1, 100);
var clampedOffset = Math.Max(0, offset);
var records = await importStore.ListAsync(
tenantId,
publisher,
afterFilter,
clampedLimit,
clampedOffset,
cancellationToken).ConfigureAwait(false);
var totalCount = await importStore.CountAsync(
tenantId,
publisher,
afterFilter,
cancellationToken).ConfigureAwait(false);
var summaries = records.Select(record =>
{
var stalenessSeconds = StalenessCalculator.ComputeSeconds(record.SignedAt, now);
var status = DetermineStatus(record.Timeline);
return new MirrorBundleSummary(
record.BundleId,
record.MirrorGeneration,
record.Publisher,
record.SignedAt,
record.ImportedAt,
record.PayloadHash,
stalenessSeconds,
status);
}).ToList();
var response = new MirrorBundleListResponse(
summaries,
totalCount,
clampedLimit,
clampedOffset,
now);
await WriteJsonAsync(httpContext, response, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
private static async Task<IResult> HandleGetBundleAsync(
string bundleId,
HttpContext httpContext,
IAirgapImportStore importStore,
TimeProvider timeProvider,
ILogger<MirrorRegistrationEndpointsMarker> logger,
[FromQuery] string? generation = null,
CancellationToken cancellationToken = default)
{
var tenantId = ResolveTenantId(httpContext);
var now = timeProvider.GetUtcNow();
var record = await importStore.FindByBundleIdAsync(
tenantId,
bundleId,
generation,
cancellationToken).ConfigureAwait(false);
if (record is null)
{
var errorResponse = AirgapErrorMapping.BundleNotFound(bundleId, generation);
await WriteJsonAsync(httpContext, errorResponse, StatusCodes.Status404NotFound, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
var sinceSignedSeconds = StalenessCalculator.ComputeSeconds(record.SignedAt, now);
var sinceImportedSeconds = StalenessCalculator.ComputeSeconds(record.ImportedAt, now);
var staleness = new MirrorBundleStaleness(
sinceSignedSeconds,
sinceImportedSeconds,
StalenessCalculator.CategorizeAge(sinceSignedSeconds),
StalenessCalculator.CategorizeAge(sinceImportedSeconds));
var provenance = new MirrorBundleProvenance(
record.PayloadHash,
record.Signature,
record.PayloadUrl,
record.TransparencyLog,
record.PortableManifestHash);
var paths = new MirrorBundlePaths(
record.PortableManifestPath,
record.EvidenceLockerPath);
var timeline = record.Timeline
.OrderByDescending(e => e.CreatedAt)
.Select(e => new MirrorBundleTimelineEntry(
e.EventType,
e.CreatedAt,
e.StalenessSeconds,
e.ErrorCode,
e.Message))
.ToList();
var response = new MirrorBundleDetailResponse(
record.BundleId,
record.MirrorGeneration,
record.TenantId,
record.Publisher,
record.SignedAt,
record.ImportedAt,
provenance,
staleness,
paths,
timeline,
now);
await WriteJsonAsync(httpContext, response, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
private static async Task<IResult> HandleGetBundleTimelineAsync(
string bundleId,
HttpContext httpContext,
IAirgapImportStore importStore,
TimeProvider timeProvider,
ILogger<MirrorRegistrationEndpointsMarker> logger,
[FromQuery] string? generation = null,
CancellationToken cancellationToken = default)
{
var tenantId = ResolveTenantId(httpContext);
var now = timeProvider.GetUtcNow();
var record = await importStore.FindByBundleIdAsync(
tenantId,
bundleId,
generation,
cancellationToken).ConfigureAwait(false);
if (record is null)
{
var errorResponse = AirgapErrorMapping.BundleNotFound(bundleId, generation);
await WriteJsonAsync(httpContext, errorResponse, StatusCodes.Status404NotFound, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
var timeline = record.Timeline
.OrderByDescending(e => e.CreatedAt)
.Select(e => new MirrorBundleTimelineEntry(
e.EventType,
e.CreatedAt,
e.StalenessSeconds,
e.ErrorCode,
e.Message))
.ToList();
var response = new MirrorBundleTimelineResponse(
record.BundleId,
record.MirrorGeneration,
timeline,
now);
await WriteJsonAsync(httpContext, response, StatusCodes.Status200OK, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
private static string ResolveTenantId(HttpContext httpContext)
{
if (httpContext.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader)
&& !string.IsNullOrWhiteSpace(tenantHeader.ToString()))
{
return tenantHeader.ToString();
}
return "default";
}
private static string DetermineStatus(IEnumerable<AirgapTimelineEntry> timeline)
{
var entries = timeline.ToList();
if (entries.Count == 0)
{
return "unknown";
}
var latestEvent = entries.MaxBy(e => e.CreatedAt);
if (latestEvent is null)
{
return "unknown";
}
return latestEvent.EventType switch
{
"airgap.import.completed" => "completed",
"airgap.import.failed" => "failed",
"airgap.import.started" => "in_progress",
_ => "unknown",
};
}
private static async Task WriteJsonAsync<T>(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
var json = VexCanonicalJsonSerializer.Serialize(payload);
await context.Response.WriteAsync(json, cancellationToken);
}
}
/// <summary>
/// Marker type for logger category resolution.
/// </summary>
internal sealed class MirrorRegistrationEndpointsMarker { }

View File

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

View File

@@ -72,6 +72,8 @@ services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("
services.AddVexPolicy();
services.AddSingleton<IVexEvidenceChunkService, VexEvidenceChunkService>();
services.AddSingleton<ChunkTelemetry>();
// EXCITITOR-VULN-29-004: Normalization observability for Vuln Explorer + Advisory AI dashboards
services.AddSingleton<IVexNormalizationTelemetryRecorder, VexNormalizationTelemetryRecorder>();
services.AddRedHatCsafConnector();
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
services.AddSingleton<MirrorRateLimiter>();
@@ -2275,6 +2277,7 @@ app.MapGet("/obs/excititor/timeline", async (
IngestEndpoints.MapIngestEndpoints(app);
ResolveEndpoint.MapResolveEndpoint(app);
MirrorEndpoints.MapMirrorEndpoints(app);
MirrorRegistrationEndpoints.MapMirrorRegistrationEndpoints(app);
// Evidence and Attestation APIs (WEB-OBS-53-001, WEB-OBS-54-001)
EvidenceEndpoints.MapEvidenceEndpoints(app);

View File

@@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using StellaOps.Excititor.Core.Canonicalization;
namespace StellaOps.Excititor.WebService.Telemetry;
/// <summary>
/// Telemetry metrics for VEX normalization and canonicalization operations (EXCITITOR-VULN-29-004).
/// Tracks advisory/product key canonicalization, normalization errors, suppression scopes,
/// and withdrawn statement handling for Vuln Explorer and Advisory AI dashboards.
/// </summary>
internal static class NormalizationTelemetry
{
public const string MeterName = "StellaOps.Excititor.WebService.Normalization";
private static readonly Meter Meter = new(MeterName);
// Advisory key canonicalization metrics
private static readonly Counter<long> AdvisoryKeyCanonicalizeCounter =
Meter.CreateCounter<long>(
"excititor.vex.canonicalize.advisory_key_total",
unit: "operations",
description: "Total advisory key canonicalization operations by outcome.");
private static readonly Counter<long> AdvisoryKeyCanonicalizeErrorCounter =
Meter.CreateCounter<long>(
"excititor.vex.canonicalize.advisory_key_errors",
unit: "errors",
description: "Advisory key canonicalization errors by error type.");
private static readonly Counter<long> AdvisoryKeyScopeCounter =
Meter.CreateCounter<long>(
"excititor.vex.canonicalize.advisory_key_scope",
unit: "keys",
description: "Advisory keys processed by scope (global, ecosystem, vendor, distribution, unknown).");
// Product key canonicalization metrics
private static readonly Counter<long> ProductKeyCanonicalizeCounter =
Meter.CreateCounter<long>(
"excititor.vex.canonicalize.product_key_total",
unit: "operations",
description: "Total product key canonicalization operations by outcome.");
private static readonly Counter<long> ProductKeyCanonicalizeErrorCounter =
Meter.CreateCounter<long>(
"excititor.vex.canonicalize.product_key_errors",
unit: "errors",
description: "Product key canonicalization errors by error type.");
private static readonly Counter<long> ProductKeyScopeCounter =
Meter.CreateCounter<long>(
"excititor.vex.canonicalize.product_key_scope",
unit: "keys",
description: "Product keys processed by scope (package, component, ospackage, container, platform, unknown).");
private static readonly Counter<long> ProductKeyTypeCounter =
Meter.CreateCounter<long>(
"excititor.vex.canonicalize.product_key_type",
unit: "keys",
description: "Product keys processed by type (purl, cpe, rpm, deb, oci, platform, other).");
// Evidence retrieval metrics
private static readonly Counter<long> EvidenceRetrievalCounter =
Meter.CreateCounter<long>(
"excititor.vex.evidence.retrieval_total",
unit: "requests",
description: "Total evidence retrieval requests by outcome.");
private static readonly Histogram<int> EvidenceStatementCountHistogram =
Meter.CreateHistogram<int>(
"excititor.vex.evidence.statement_count",
unit: "statements",
description: "Distribution of statements returned per evidence retrieval request.");
private static readonly Histogram<double> EvidenceRetrievalLatencyHistogram =
Meter.CreateHistogram<double>(
"excititor.vex.evidence.retrieval_latency_seconds",
unit: "s",
description: "Latency distribution for evidence retrieval operations.");
// Normalization error metrics
private static readonly Counter<long> NormalizationErrorCounter =
Meter.CreateCounter<long>(
"excititor.vex.normalize.errors_total",
unit: "errors",
description: "Total normalization errors by type and provider.");
// Suppression scope metrics
private static readonly Counter<long> SuppressionScopeCounter =
Meter.CreateCounter<long>(
"excititor.vex.suppression.scope_total",
unit: "suppressions",
description: "Suppression scope applications by scope type.");
private static readonly Counter<long> SuppressionAppliedCounter =
Meter.CreateCounter<long>(
"excititor.vex.suppression.applied_total",
unit: "statements",
description: "Statements affected by suppression scopes.");
// Withdrawn statement metrics
private static readonly Counter<long> WithdrawnStatementCounter =
Meter.CreateCounter<long>(
"excititor.vex.withdrawn.statements_total",
unit: "statements",
description: "Total withdrawn statement detections by provider.");
private static readonly Counter<long> WithdrawnReplacementCounter =
Meter.CreateCounter<long>(
"excititor.vex.withdrawn.replacements_total",
unit: "replacements",
description: "Withdrawn statement replacements processed.");
/// <summary>
/// Records a successful advisory key canonicalization.
/// </summary>
public static void RecordAdvisoryKeyCanonicalization(
string? tenant,
VexCanonicalAdvisoryKey result)
{
var normalizedTenant = NormalizeTenant(tenant);
var scope = result.Scope.ToString().ToLowerInvariant();
AdvisoryKeyCanonicalizeCounter.Add(1, BuildOutcomeTags(normalizedTenant, "success"));
AdvisoryKeyScopeCounter.Add(1, BuildScopeTags(normalizedTenant, scope));
}
/// <summary>
/// Records an advisory key canonicalization error.
/// </summary>
public static void RecordAdvisoryKeyCanonicalizeError(
string? tenant,
string errorType,
string? advisoryKey = null)
{
var normalizedTenant = NormalizeTenant(tenant);
var tags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("error_type", errorType),
};
AdvisoryKeyCanonicalizeCounter.Add(1, BuildOutcomeTags(normalizedTenant, "error"));
AdvisoryKeyCanonicalizeErrorCounter.Add(1, tags);
}
/// <summary>
/// Records a successful product key canonicalization.
/// </summary>
public static void RecordProductKeyCanonicalization(
string? tenant,
VexCanonicalProductKey result)
{
var normalizedTenant = NormalizeTenant(tenant);
var scope = result.Scope.ToString().ToLowerInvariant();
var keyType = result.KeyType.ToString().ToLowerInvariant();
ProductKeyCanonicalizeCounter.Add(1, BuildOutcomeTags(normalizedTenant, "success"));
ProductKeyScopeCounter.Add(1, BuildScopeTags(normalizedTenant, scope));
ProductKeyTypeCounter.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("key_type", keyType),
});
}
/// <summary>
/// Records a product key canonicalization error.
/// </summary>
public static void RecordProductKeyCanonicalizeError(
string? tenant,
string errorType,
string? productKey = null)
{
var normalizedTenant = NormalizeTenant(tenant);
var tags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("error_type", errorType),
};
ProductKeyCanonicalizeCounter.Add(1, BuildOutcomeTags(normalizedTenant, "error"));
ProductKeyCanonicalizeErrorCounter.Add(1, tags);
}
/// <summary>
/// Records an evidence retrieval operation.
/// </summary>
public static void RecordEvidenceRetrieval(
string? tenant,
string outcome,
int statementCount,
double latencySeconds)
{
var normalizedTenant = NormalizeTenant(tenant);
var tags = BuildOutcomeTags(normalizedTenant, outcome);
EvidenceRetrievalCounter.Add(1, tags);
if (string.Equals(outcome, "success", StringComparison.OrdinalIgnoreCase))
{
EvidenceStatementCountHistogram.Record(statementCount, tags);
}
EvidenceRetrievalLatencyHistogram.Record(latencySeconds, tags);
}
/// <summary>
/// Records a normalization error.
/// </summary>
public static void RecordNormalizationError(
string? tenant,
string provider,
string errorType,
string? detail = null)
{
var normalizedTenant = NormalizeTenant(tenant);
var tags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("provider", string.IsNullOrWhiteSpace(provider) ? "unknown" : provider),
new KeyValuePair<string, object?>("error_type", errorType),
};
NormalizationErrorCounter.Add(1, tags);
}
/// <summary>
/// Records a suppression scope application.
/// </summary>
public static void RecordSuppressionScope(
string? tenant,
string scopeType,
int affectedStatements)
{
var normalizedTenant = NormalizeTenant(tenant);
var scopeTags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("scope_type", scopeType),
};
SuppressionScopeCounter.Add(1, scopeTags);
if (affectedStatements > 0)
{
SuppressionAppliedCounter.Add(affectedStatements, scopeTags);
}
}
/// <summary>
/// Records a withdrawn statement detection.
/// </summary>
public static void RecordWithdrawnStatement(
string? tenant,
string provider,
string? replacementId = null)
{
var normalizedTenant = NormalizeTenant(tenant);
var tags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("provider", string.IsNullOrWhiteSpace(provider) ? "unknown" : provider),
};
WithdrawnStatementCounter.Add(1, tags);
if (!string.IsNullOrWhiteSpace(replacementId))
{
WithdrawnReplacementCounter.Add(1, tags);
}
}
/// <summary>
/// Records batch withdrawn statement processing.
/// </summary>
public static void RecordWithdrawnStatements(
string? tenant,
string provider,
int totalWithdrawn,
int replacements)
{
var normalizedTenant = NormalizeTenant(tenant);
var tags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("provider", string.IsNullOrWhiteSpace(provider) ? "unknown" : provider),
};
if (totalWithdrawn > 0)
{
WithdrawnStatementCounter.Add(totalWithdrawn, tags);
}
if (replacements > 0)
{
WithdrawnReplacementCounter.Add(replacements, tags);
}
}
private static string NormalizeTenant(string? tenant)
=> string.IsNullOrWhiteSpace(tenant) ? "default" : tenant;
private static KeyValuePair<string, object?>[] BuildOutcomeTags(string tenant, string outcome)
=> new[]
{
new KeyValuePair<string, object?>("tenant", tenant),
new KeyValuePair<string, object?>("outcome", outcome),
};
private static KeyValuePair<string, object?>[] BuildScopeTags(string tenant, string scope)
=> new[]
{
new KeyValuePair<string, object?>("tenant", tenant),
new KeyValuePair<string, object?>("scope", scope),
};
}

View File

@@ -0,0 +1,87 @@
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.WebService.Telemetry;
/// <summary>
/// Implementation of <see cref="IVexNormalizationTelemetryRecorder"/> that bridges to
/// <see cref="NormalizationTelemetry"/> static metrics and structured logging (EXCITITOR-VULN-29-004).
/// </summary>
internal sealed class VexNormalizationTelemetryRecorder : IVexNormalizationTelemetryRecorder
{
private readonly ILogger<VexNormalizationTelemetryRecorder> _logger;
public VexNormalizationTelemetryRecorder(ILogger<VexNormalizationTelemetryRecorder> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void RecordNormalizationError(string? tenant, string provider, string errorType, string? detail = null)
{
NormalizationTelemetry.RecordNormalizationError(tenant, provider, errorType, detail);
_logger.LogWarning(
"VEX normalization error: tenant={Tenant} provider={Provider} errorType={ErrorType} detail={Detail}",
tenant ?? "default",
provider,
errorType,
detail ?? "(none)");
}
public void RecordSuppressionScope(string? tenant, string scopeType, int affectedStatements)
{
NormalizationTelemetry.RecordSuppressionScope(tenant, scopeType, affectedStatements);
if (affectedStatements > 0)
{
_logger.LogInformation(
"VEX suppression scope applied: tenant={Tenant} scopeType={ScopeType} affectedStatements={AffectedStatements}",
tenant ?? "default",
scopeType,
affectedStatements);
}
else
{
_logger.LogDebug(
"VEX suppression scope checked (no statements affected): tenant={Tenant} scopeType={ScopeType}",
tenant ?? "default",
scopeType);
}
}
public void RecordWithdrawnStatement(string? tenant, string provider, string? replacementId = null)
{
NormalizationTelemetry.RecordWithdrawnStatement(tenant, provider, replacementId);
if (string.IsNullOrWhiteSpace(replacementId))
{
_logger.LogInformation(
"VEX withdrawn statement detected: tenant={Tenant} provider={Provider}",
tenant ?? "default",
provider);
}
else
{
_logger.LogInformation(
"VEX withdrawn statement superseded: tenant={Tenant} provider={Provider} replacementId={ReplacementId}",
tenant ?? "default",
provider,
replacementId);
}
}
public void RecordWithdrawnStatements(string? tenant, string provider, int totalWithdrawn, int replacements)
{
NormalizationTelemetry.RecordWithdrawnStatements(tenant, provider, totalWithdrawn, replacements);
if (totalWithdrawn > 0)
{
_logger.LogInformation(
"VEX withdrawn statements batch: tenant={Tenant} provider={Provider} totalWithdrawn={TotalWithdrawn} replacements={Replacements}",
tenant ?? "default",
provider,
totalWithdrawn,
replacements);
}
}
}