This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,258 @@
namespace StellaOps.Findings.Ledger.WebService.Contracts;
/// <summary>
/// Request to compute VEX consensus for a vulnerability-product pair.
/// </summary>
public sealed record ComputeVexConsensusRequest(
string VulnerabilityId,
string ProductKey,
string? Mode,
double? MinimumWeightThreshold,
bool? StoreResult,
bool? EmitEvent);
/// <summary>
/// Request to compute VEX consensus for multiple pairs in batch.
/// </summary>
public sealed record ComputeVexConsensusBatchRequest(
IReadOnlyList<VexConsensusTarget> Targets,
string? Mode,
bool? StoreResults,
bool? EmitEvents);
/// <summary>
/// Target for consensus computation.
/// </summary>
public sealed record VexConsensusTarget(
string VulnerabilityId,
string ProductKey);
/// <summary>
/// Response from VEX consensus computation.
/// </summary>
public sealed record VexConsensusResponse(
string VulnerabilityId,
string ProductKey,
string Status,
string? Justification,
double ConfidenceScore,
string Outcome,
VexRationaleResponse Rationale,
IReadOnlyList<VexContributionResponse> Contributions,
IReadOnlyList<VexConflictResponse>? Conflicts,
string? ProjectionId,
DateTimeOffset ComputedAt);
/// <summary>
/// Rationale response in API format.
/// </summary>
public sealed record VexRationaleResponse(
string Summary,
IReadOnlyList<string> Factors,
IReadOnlyDictionary<string, double> StatusWeights);
/// <summary>
/// Statement contribution response.
/// </summary>
public sealed record VexContributionResponse(
string StatementId,
string? IssuerId,
string Status,
string? Justification,
double Weight,
double Contribution,
bool IsWinner);
/// <summary>
/// Conflict response.
/// </summary>
public sealed record VexConflictResponse(
string Statement1Id,
string Statement2Id,
string Status1,
string Status2,
string Severity,
string Resolution);
/// <summary>
/// Response from batch consensus computation.
/// </summary>
public sealed record VexConsensusBatchResponse(
IReadOnlyList<VexConsensusResponse> Results,
int TotalCount,
int SuccessCount,
int FailureCount,
DateTimeOffset CompletedAt);
/// <summary>
/// Request to query VEX consensus projections.
/// </summary>
public sealed record QueryVexProjectionsRequest(
string? VulnerabilityId,
string? ProductKey,
string? Status,
string? Outcome,
double? MinimumConfidence,
DateTimeOffset? ComputedAfter,
DateTimeOffset? ComputedBefore,
bool? StatusChanged,
int? Limit,
int? Offset,
string? SortBy,
bool? SortDescending);
/// <summary>
/// Response from projection query.
/// </summary>
public sealed record QueryVexProjectionsResponse(
IReadOnlyList<VexProjectionSummary> Projections,
int TotalCount,
int Offset,
int Limit);
/// <summary>
/// Summary of a projection for list responses.
/// </summary>
public sealed record VexProjectionSummary(
string ProjectionId,
string VulnerabilityId,
string ProductKey,
string Status,
string? Justification,
double ConfidenceScore,
string Outcome,
int StatementCount,
int ConflictCount,
DateTimeOffset ComputedAt,
bool StatusChanged);
/// <summary>
/// Detailed projection response.
/// </summary>
public sealed record VexProjectionDetailResponse(
string ProjectionId,
string VulnerabilityId,
string ProductKey,
string? TenantId,
string Status,
string? Justification,
double ConfidenceScore,
string Outcome,
int StatementCount,
int ConflictCount,
string RationaleSummary,
DateTimeOffset ComputedAt,
DateTimeOffset StoredAt,
string? PreviousProjectionId,
bool StatusChanged);
/// <summary>
/// Response from projection history query.
/// </summary>
public sealed record VexProjectionHistoryResponse(
string VulnerabilityId,
string ProductKey,
IReadOnlyList<VexProjectionSummary> History,
int TotalCount);
/// <summary>
/// Response from issuer directory query.
/// </summary>
public sealed record VexIssuerListResponse(
IReadOnlyList<VexIssuerSummary> Issuers,
int TotalCount);
/// <summary>
/// Summary of an issuer.
/// </summary>
public sealed record VexIssuerSummary(
string IssuerId,
string Name,
string Category,
string TrustTier,
string Status,
int KeyCount,
DateTimeOffset RegisteredAt);
/// <summary>
/// Detailed issuer response.
/// </summary>
public sealed record VexIssuerDetailResponse(
string IssuerId,
string Name,
string Category,
string TrustTier,
string Status,
IReadOnlyList<VexKeyFingerprintResponse> KeyFingerprints,
VexIssuerMetadataResponse? Metadata,
DateTimeOffset RegisteredAt,
DateTimeOffset? LastUpdatedAt,
DateTimeOffset? RevokedAt,
string? RevocationReason);
/// <summary>
/// Key fingerprint response.
/// </summary>
public sealed record VexKeyFingerprintResponse(
string Fingerprint,
string KeyType,
string? Algorithm,
string Status,
DateTimeOffset RegisteredAt,
DateTimeOffset? ExpiresAt);
/// <summary>
/// Issuer metadata response.
/// </summary>
public sealed record VexIssuerMetadataResponse(
string? Description,
string? Uri,
string? Email,
IReadOnlyList<string>? Tags);
/// <summary>
/// Request to register an issuer.
/// </summary>
public sealed record RegisterVexIssuerRequest(
string IssuerId,
string Name,
string Category,
string TrustTier,
IReadOnlyList<RegisterVexKeyRequest>? InitialKeys,
VexIssuerMetadataRequest? Metadata);
/// <summary>
/// Request to register a key.
/// </summary>
public sealed record RegisterVexKeyRequest(
string Fingerprint,
string KeyType,
string? Algorithm,
DateTimeOffset? ExpiresAt);
/// <summary>
/// Issuer metadata request.
/// </summary>
public sealed record VexIssuerMetadataRequest(
string? Description,
string? Uri,
string? Email,
IReadOnlyList<string>? Tags);
/// <summary>
/// Request to revoke an issuer or key.
/// </summary>
public sealed record RevokeVexIssuerRequest(
string Reason);
/// <summary>
/// Statistics about consensus projections.
/// </summary>
public sealed record VexConsensusStatisticsResponse(
int TotalProjections,
IReadOnlyDictionary<string, int> ByStatus,
IReadOnlyDictionary<string, int> ByOutcome,
double AverageConfidence,
int ProjectionsWithConflicts,
int StatusChangesLast24h,
DateTimeOffset ComputedAt);

View File

@@ -178,6 +178,7 @@ builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ITimeTravelRepository,
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresTimeTravelRepository>();
builder.Services.AddSingleton<SnapshotService>();
builder.Services.AddSingleton<VexConsensusService>();
var app = builder.Build();
@@ -1271,6 +1272,222 @@ app.MapGet("/v1/ledger/current-point", async Task<Results<JsonHttpResult<QueryPo
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// VexLens Consensus Endpoints (UI-PROOF-VEX-0215-010)
app.MapPost("/v1/vex-consensus/compute", async Task<Results<JsonHttpResult<VexConsensusResponse>, ProblemHttpResult>> (
HttpContext httpContext,
ComputeVexConsensusRequest request,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var result = await consensusService.ComputeConsensusAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
return TypedResults.Json(result);
})
.WithName("ComputeVexConsensus")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapPost("/v1/vex-consensus/compute-batch", async Task<Results<JsonHttpResult<VexConsensusBatchResponse>, ProblemHttpResult>> (
HttpContext httpContext,
ComputeVexConsensusBatchRequest request,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var result = await consensusService.ComputeConsensusBatchAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
return TypedResults.Json(result);
})
.WithName("ComputeVexConsensusBatch")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/vex-consensus/projections/{projectionId}", async Task<Results<JsonHttpResult<VexProjectionDetailResponse>, NotFound, ProblemHttpResult>> (
string projectionId,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
var result = await consensusService.GetProjectionAsync(projectionId, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return TypedResults.NotFound();
}
return TypedResults.Json(result);
})
.WithName("GetVexProjection")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/vex-consensus/projections", async Task<Results<JsonHttpResult<QueryVexProjectionsResponse>, ProblemHttpResult>> (
HttpContext httpContext,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var request = new QueryVexProjectionsRequest(
VulnerabilityId: httpContext.Request.Query["vulnerability_id"].ToString(),
ProductKey: httpContext.Request.Query["product_key"].ToString(),
Status: httpContext.Request.Query["status"].ToString(),
Outcome: httpContext.Request.Query["outcome"].ToString(),
MinimumConfidence: ParseDecimal(httpContext.Request.Query["min_confidence"].ToString()) is decimal d ? (double)d : null,
ComputedAfter: ParseDate(httpContext.Request.Query["computed_after"].ToString()),
ComputedBefore: ParseDate(httpContext.Request.Query["computed_before"].ToString()),
StatusChanged: ParseBool(httpContext.Request.Query["status_changed"].ToString()),
Limit: ParseInt(httpContext.Request.Query["limit"].ToString()),
Offset: ParseInt(httpContext.Request.Query["offset"].ToString()),
SortBy: httpContext.Request.Query["sort_by"].ToString(),
SortDescending: ParseBool(httpContext.Request.Query["sort_desc"].ToString()));
var result = await consensusService.QueryProjectionsAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
return TypedResults.Json(result);
})
.WithName("QueryVexProjections")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/vex-consensus/projections/latest", async Task<Results<JsonHttpResult<VexProjectionDetailResponse>, NotFound, ProblemHttpResult>> (
HttpContext httpContext,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var vulnId = httpContext.Request.Query["vulnerability_id"].ToString();
var productKey = httpContext.Request.Query["product_key"].ToString();
if (string.IsNullOrEmpty(vulnId) || string.IsNullOrEmpty(productKey))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_params", detail: "vulnerability_id and product_key are required.");
}
var result = await consensusService.GetLatestProjectionAsync(tenantId, vulnId, productKey, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return TypedResults.NotFound();
}
return TypedResults.Json(result);
})
.WithName("GetLatestVexProjection")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/vex-consensus/history", async Task<Results<JsonHttpResult<VexProjectionHistoryResponse>, ProblemHttpResult>> (
HttpContext httpContext,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var vulnId = httpContext.Request.Query["vulnerability_id"].ToString();
var productKey = httpContext.Request.Query["product_key"].ToString();
var limit = ParseInt(httpContext.Request.Query["limit"].ToString());
if (string.IsNullOrEmpty(vulnId) || string.IsNullOrEmpty(productKey))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_params", detail: "vulnerability_id and product_key are required.");
}
var result = await consensusService.GetProjectionHistoryAsync(tenantId, vulnId, productKey, limit, cancellationToken).ConfigureAwait(false);
return TypedResults.Json(result);
})
.WithName("GetVexProjectionHistory")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/vex-consensus/statistics", async Task<Results<JsonHttpResult<VexConsensusStatisticsResponse>, ProblemHttpResult>> (
HttpContext httpContext,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var result = await consensusService.GetStatisticsAsync(tenantId, cancellationToken).ConfigureAwait(false);
return TypedResults.Json(result);
})
.WithName("GetVexConsensusStatistics")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/vex-consensus/issuers", async Task<Results<JsonHttpResult<VexIssuerListResponse>, ProblemHttpResult>> (
HttpContext httpContext,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
var result = await consensusService.ListIssuersAsync(
httpContext.Request.Query["category"].ToString(),
httpContext.Request.Query["min_trust_tier"].ToString(),
httpContext.Request.Query["status"].ToString(),
httpContext.Request.Query["search"].ToString(),
ParseInt(httpContext.Request.Query["limit"].ToString()),
ParseInt(httpContext.Request.Query["offset"].ToString()),
cancellationToken).ConfigureAwait(false);
return TypedResults.Json(result);
})
.WithName("ListVexIssuers")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/vex-consensus/issuers/{issuerId}", async Task<Results<JsonHttpResult<VexIssuerDetailResponse>, NotFound, ProblemHttpResult>> (
string issuerId,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
var result = await consensusService.GetIssuerAsync(issuerId, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return TypedResults.NotFound();
}
return TypedResults.Json(result);
})
.WithName("GetVexIssuer")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapPost("/v1/vex-consensus/issuers", async Task<Results<Created<VexIssuerDetailResponse>, ProblemHttpResult>> (
RegisterVexIssuerRequest request,
VexConsensusService consensusService,
CancellationToken cancellationToken) =>
{
var result = await consensusService.RegisterIssuerAsync(request, cancellationToken).ConfigureAwait(false);
return TypedResults.Created($"/v1/vex-consensus/issuers/{result.IssuerId}", result);
})
.WithName("RegisterVexIssuer")
.RequireAuthorization(LedgerWritePolicy)
.Produces(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.Run();
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)

View File

@@ -0,0 +1,548 @@
using System.Collections.Concurrent;
using StellaOps.Findings.Ledger.WebService.Contracts;
namespace StellaOps.Findings.Ledger.WebService.Services;
/// <summary>
/// In-memory VEX consensus service for computing and storing consensus projections.
/// This implementation provides proof-linked VEX APIs for the UI.
/// </summary>
public sealed class VexConsensusService
{
private readonly ConcurrentDictionary<string, VexProjectionRecord> _projections = new();
private readonly ConcurrentDictionary<string, VexIssuerRecord> _issuers = new();
private readonly ConcurrentDictionary<string, List<VexStatementRecord>> _statements = new();
private long _projectionCounter = 0;
/// <summary>
/// Computes consensus for a vulnerability-product pair.
/// </summary>
public Task<VexConsensusResponse> ComputeConsensusAsync(
string tenantId,
ComputeVexConsensusRequest request,
CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{request.VulnerabilityId}:{request.ProductKey}";
var statements = _statements.GetValueOrDefault(key, []);
var contributions = new List<VexContributionResponse>();
var statusWeights = new Dictionary<string, double>
{
["not_affected"] = 0.0,
["affected"] = 0.0,
["fixed"] = 0.0,
["under_investigation"] = 0.0
};
double totalWeight = 0;
string winningStatus = "under_investigation";
string? winningJustification = null;
double maxWeight = 0;
foreach (var stmt in statements)
{
var weight = GetIssuerWeight(stmt.IssuerId);
totalWeight += weight;
var statusKey = stmt.Status.ToLowerInvariant().Replace("_", "");
if (statusWeights.ContainsKey(statusKey))
{
statusWeights[statusKey] += weight;
}
if (weight > maxWeight)
{
maxWeight = weight;
winningStatus = stmt.Status;
winningJustification = stmt.Justification;
}
contributions.Add(new VexContributionResponse(
StatementId: stmt.StatementId,
IssuerId: stmt.IssuerId,
Status: stmt.Status,
Justification: stmt.Justification,
Weight: weight,
Contribution: totalWeight > 0 ? weight / totalWeight : 0,
IsWinner: false));
}
// If no statements, return default investigation status
if (statements.Count == 0)
{
var defaultResponse = new VexConsensusResponse(
VulnerabilityId: request.VulnerabilityId,
ProductKey: request.ProductKey,
Status: "under_investigation",
Justification: null,
ConfidenceScore: 0.0,
Outcome: "no_data",
Rationale: new VexRationaleResponse(
Summary: "No VEX statements available for this vulnerability-product pair.",
Factors: ["no_statements"],
StatusWeights: statusWeights),
Contributions: [],
Conflicts: null,
ProjectionId: null,
ComputedAt: DateTimeOffset.UtcNow);
return Task.FromResult(defaultResponse);
}
// Mark winner
var winnerIdx = contributions.FindIndex(c => c.Weight == maxWeight);
if (winnerIdx >= 0)
{
var winner = contributions[winnerIdx];
contributions[winnerIdx] = winner with { IsWinner = true };
}
var confidence = totalWeight > 0 ? maxWeight / totalWeight : 0;
var outcome = DetermineOutcome(confidence, statements.Count);
string? projectionId = null;
if (request.StoreResult == true)
{
projectionId = StoreProjection(tenantId, request.VulnerabilityId, request.ProductKey,
winningStatus, winningJustification, confidence, outcome, statements.Count);
}
var response = new VexConsensusResponse(
VulnerabilityId: request.VulnerabilityId,
ProductKey: request.ProductKey,
Status: winningStatus,
Justification: winningJustification,
ConfidenceScore: confidence,
Outcome: outcome,
Rationale: new VexRationaleResponse(
Summary: $"Consensus determined from {statements.Count} statement(s) with weighted voting.",
Factors: BuildFactors(statements.Count, confidence),
StatusWeights: statusWeights),
Contributions: contributions,
Conflicts: null,
ProjectionId: projectionId,
ComputedAt: DateTimeOffset.UtcNow);
return Task.FromResult(response);
}
/// <summary>
/// Computes consensus for multiple pairs in batch.
/// </summary>
public async Task<VexConsensusBatchResponse> ComputeConsensusBatchAsync(
string tenantId,
ComputeVexConsensusBatchRequest request,
CancellationToken cancellationToken = default)
{
var results = new List<VexConsensusResponse>();
var failures = 0;
foreach (var target in request.Targets)
{
try
{
var singleRequest = new ComputeVexConsensusRequest(
VulnerabilityId: target.VulnerabilityId,
ProductKey: target.ProductKey,
Mode: request.Mode,
MinimumWeightThreshold: null,
StoreResult: request.StoreResults,
EmitEvent: request.EmitEvents);
var result = await ComputeConsensusAsync(tenantId, singleRequest, cancellationToken);
results.Add(result);
}
catch
{
failures++;
}
}
return new VexConsensusBatchResponse(
Results: results,
TotalCount: request.Targets.Count,
SuccessCount: results.Count,
FailureCount: failures,
CompletedAt: DateTimeOffset.UtcNow);
}
/// <summary>
/// Gets a projection by ID.
/// </summary>
public Task<VexProjectionDetailResponse?> GetProjectionAsync(
string projectionId,
CancellationToken cancellationToken = default)
{
if (_projections.TryGetValue(projectionId, out var record))
{
return Task.FromResult<VexProjectionDetailResponse?>(record.ToDetailResponse());
}
return Task.FromResult<VexProjectionDetailResponse?>(null);
}
/// <summary>
/// Gets the latest projection for a vulnerability-product pair.
/// </summary>
public Task<VexProjectionDetailResponse?> GetLatestProjectionAsync(
string tenantId,
string vulnerabilityId,
string productKey,
CancellationToken cancellationToken = default)
{
var matching = _projections.Values
.Where(p => p.TenantId == tenantId &&
p.VulnerabilityId == vulnerabilityId &&
p.ProductKey == productKey)
.OrderByDescending(p => p.ComputedAt)
.FirstOrDefault();
return Task.FromResult(matching?.ToDetailResponse());
}
/// <summary>
/// Queries consensus projections.
/// </summary>
public Task<QueryVexProjectionsResponse> QueryProjectionsAsync(
string tenantId,
QueryVexProjectionsRequest request,
CancellationToken cancellationToken = default)
{
var query = _projections.Values
.Where(p => p.TenantId == tenantId);
if (!string.IsNullOrEmpty(request.VulnerabilityId))
query = query.Where(p => p.VulnerabilityId == request.VulnerabilityId);
if (!string.IsNullOrEmpty(request.ProductKey))
query = query.Where(p => p.ProductKey == request.ProductKey);
if (!string.IsNullOrEmpty(request.Status))
query = query.Where(p => p.Status.Equals(request.Status, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(request.Outcome))
query = query.Where(p => p.Outcome.Equals(request.Outcome, StringComparison.OrdinalIgnoreCase));
if (request.MinimumConfidence.HasValue)
query = query.Where(p => p.ConfidenceScore >= request.MinimumConfidence.Value);
if (request.ComputedAfter.HasValue)
query = query.Where(p => p.ComputedAt >= request.ComputedAfter.Value);
if (request.ComputedBefore.HasValue)
query = query.Where(p => p.ComputedAt <= request.ComputedBefore.Value);
if (request.StatusChanged.HasValue)
query = query.Where(p => p.StatusChanged == request.StatusChanged.Value);
var total = query.Count();
query = (request.SortDescending ?? true)
? query.OrderByDescending(p => p.ComputedAt)
: query.OrderBy(p => p.ComputedAt);
var offset = request.Offset ?? 0;
var limit = request.Limit ?? 50;
var projections = query
.Skip(offset)
.Take(limit)
.Select(p => p.ToSummary())
.ToList();
return Task.FromResult(new QueryVexProjectionsResponse(
Projections: projections,
TotalCount: total,
Offset: offset,
Limit: limit));
}
/// <summary>
/// Gets projection history for a vulnerability-product pair.
/// </summary>
public Task<VexProjectionHistoryResponse> GetProjectionHistoryAsync(
string tenantId,
string vulnerabilityId,
string productKey,
int? limit,
CancellationToken cancellationToken = default)
{
var history = _projections.Values
.Where(p => p.TenantId == tenantId &&
p.VulnerabilityId == vulnerabilityId &&
p.ProductKey == productKey)
.OrderByDescending(p => p.ComputedAt)
.Take(limit ?? 100)
.Select(p => p.ToSummary())
.ToList();
return Task.FromResult(new VexProjectionHistoryResponse(
VulnerabilityId: vulnerabilityId,
ProductKey: productKey,
History: history,
TotalCount: history.Count));
}
/// <summary>
/// Gets consensus statistics.
/// </summary>
public Task<VexConsensusStatisticsResponse> GetStatisticsAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var projections = _projections.Values
.Where(p => p.TenantId == tenantId)
.ToList();
var byStatus = projections
.GroupBy(p => p.Status)
.ToDictionary(g => g.Key, g => g.Count());
var byOutcome = projections
.GroupBy(p => p.Outcome)
.ToDictionary(g => g.Key, g => g.Count());
var avgConfidence = projections.Count > 0
? projections.Average(p => p.ConfidenceScore)
: 0;
var withConflicts = projections.Count(p => p.ConflictCount > 0);
var last24h = DateTimeOffset.UtcNow.AddDays(-1);
var changesLast24h = projections.Count(p => p.StatusChanged && p.ComputedAt >= last24h);
return Task.FromResult(new VexConsensusStatisticsResponse(
TotalProjections: projections.Count,
ByStatus: byStatus,
ByOutcome: byOutcome,
AverageConfidence: avgConfidence,
ProjectionsWithConflicts: withConflicts,
StatusChangesLast24h: changesLast24h,
ComputedAt: DateTimeOffset.UtcNow));
}
/// <summary>
/// Lists registered issuers.
/// </summary>
public Task<VexIssuerListResponse> ListIssuersAsync(
string? category,
string? minimumTrustTier,
string? status,
string? searchTerm,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var query = _issuers.Values.AsEnumerable();
if (!string.IsNullOrEmpty(category))
query = query.Where(i => i.Category.Equals(category, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(status))
query = query.Where(i => i.Status.Equals(status, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(searchTerm))
query = query.Where(i => i.Name.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ||
i.IssuerId.Contains(searchTerm, StringComparison.OrdinalIgnoreCase));
var total = query.Count();
var issuers = query
.Skip(offset ?? 0)
.Take(limit ?? 50)
.Select(i => i.ToSummary())
.ToList();
return Task.FromResult(new VexIssuerListResponse(
Issuers: issuers,
TotalCount: total));
}
/// <summary>
/// Gets issuer details.
/// </summary>
public Task<VexIssuerDetailResponse?> GetIssuerAsync(
string issuerId,
CancellationToken cancellationToken = default)
{
if (_issuers.TryGetValue(issuerId, out var record))
{
return Task.FromResult<VexIssuerDetailResponse?>(record.ToDetailResponse());
}
return Task.FromResult<VexIssuerDetailResponse?>(null);
}
/// <summary>
/// Registers a new issuer.
/// </summary>
public Task<VexIssuerDetailResponse> RegisterIssuerAsync(
RegisterVexIssuerRequest request,
CancellationToken cancellationToken = default)
{
var record = new VexIssuerRecord(
IssuerId: request.IssuerId,
Name: request.Name,
Category: request.Category,
TrustTier: request.TrustTier,
Status: "active",
KeyFingerprints: request.InitialKeys?.Select(k => new VexKeyRecord(
Fingerprint: k.Fingerprint,
KeyType: k.KeyType,
Algorithm: k.Algorithm,
Status: "active",
RegisteredAt: DateTimeOffset.UtcNow,
ExpiresAt: k.ExpiresAt)).ToList() ?? [],
Metadata: request.Metadata != null ? new VexIssuerMetadata(
Description: request.Metadata.Description,
Uri: request.Metadata.Uri,
Email: request.Metadata.Email,
Tags: request.Metadata.Tags?.ToList()) : null,
RegisteredAt: DateTimeOffset.UtcNow,
LastUpdatedAt: null,
RevokedAt: null,
RevocationReason: null);
_issuers[request.IssuerId] = record;
return Task.FromResult(record.ToDetailResponse());
}
/// <summary>
/// Adds a VEX statement for consensus computation.
/// </summary>
public void AddStatement(string tenantId, string vulnerabilityId, string productKey, VexStatementRecord statement)
{
var key = $"{tenantId}:{vulnerabilityId}:{productKey}";
_statements.AddOrUpdate(key,
_ => [statement],
(_, list) => { list.Add(statement); return list; });
}
private double GetIssuerWeight(string? issuerId)
{
if (string.IsNullOrEmpty(issuerId)) return 0.5;
if (!_issuers.TryGetValue(issuerId, out var issuer)) return 0.5;
return issuer.TrustTier.ToLowerInvariant() switch
{
"authoritative" => 1.0,
"high" => 0.8,
"medium" => 0.5,
"low" => 0.3,
_ => 0.5
};
}
private string StoreProjection(string tenantId, string vulnId, string productKey,
string status, string? justification, double confidence, string outcome, int statementCount)
{
var id = $"proj-{Interlocked.Increment(ref _projectionCounter):D8}";
var now = DateTimeOffset.UtcNow;
var record = new VexProjectionRecord(
ProjectionId: id,
TenantId: tenantId,
VulnerabilityId: vulnId,
ProductKey: productKey,
Status: status,
Justification: justification,
ConfidenceScore: confidence,
Outcome: outcome,
StatementCount: statementCount,
ConflictCount: 0,
RationaleSummary: $"Consensus from {statementCount} statement(s)",
ComputedAt: now,
StoredAt: now,
PreviousProjectionId: null,
StatusChanged: false);
_projections[id] = record;
return id;
}
private static string DetermineOutcome(double confidence, int statementCount)
{
if (statementCount == 0) return "no_data";
if (confidence >= 0.8) return "high_confidence";
if (confidence >= 0.5) return "medium_confidence";
return "low_confidence";
}
private static List<string> BuildFactors(int statementCount, double confidence)
{
var factors = new List<string>();
if (statementCount == 1) factors.Add("single_source");
else if (statementCount > 1) factors.Add($"multi_source_{statementCount}");
if (confidence >= 0.8) factors.Add("high_agreement");
else if (confidence < 0.5) factors.Add("low_agreement");
return factors;
}
}
internal sealed record VexProjectionRecord(
string ProjectionId,
string TenantId,
string VulnerabilityId,
string ProductKey,
string Status,
string? Justification,
double ConfidenceScore,
string Outcome,
int StatementCount,
int ConflictCount,
string RationaleSummary,
DateTimeOffset ComputedAt,
DateTimeOffset StoredAt,
string? PreviousProjectionId,
bool StatusChanged)
{
public VexProjectionSummary ToSummary() => new(
ProjectionId, VulnerabilityId, ProductKey, Status, Justification,
ConfidenceScore, Outcome, StatementCount, ConflictCount, ComputedAt, StatusChanged);
public VexProjectionDetailResponse ToDetailResponse() => new(
ProjectionId, VulnerabilityId, ProductKey, TenantId, Status, Justification,
ConfidenceScore, Outcome, StatementCount, ConflictCount, RationaleSummary,
ComputedAt, StoredAt, PreviousProjectionId, StatusChanged);
}
internal sealed record VexIssuerRecord(
string IssuerId,
string Name,
string Category,
string TrustTier,
string Status,
List<VexKeyRecord> KeyFingerprints,
VexIssuerMetadata? Metadata,
DateTimeOffset RegisteredAt,
DateTimeOffset? LastUpdatedAt,
DateTimeOffset? RevokedAt,
string? RevocationReason)
{
public VexIssuerSummary ToSummary() => new(
IssuerId, Name, Category, TrustTier, Status, KeyFingerprints.Count, RegisteredAt);
public VexIssuerDetailResponse ToDetailResponse() => new(
IssuerId, Name, Category, TrustTier, Status,
KeyFingerprints.Select(k => k.ToResponse()).ToList(),
Metadata?.ToResponse(), RegisteredAt, LastUpdatedAt, RevokedAt, RevocationReason);
}
internal sealed record VexKeyRecord(
string Fingerprint,
string KeyType,
string? Algorithm,
string Status,
DateTimeOffset RegisteredAt,
DateTimeOffset? ExpiresAt)
{
public VexKeyFingerprintResponse ToResponse() => new(
Fingerprint, KeyType, Algorithm, Status, RegisteredAt, ExpiresAt);
}
internal sealed record VexIssuerMetadata(
string? Description,
string? Uri,
string? Email,
List<string>? Tags)
{
public VexIssuerMetadataResponse ToResponse() => new(Description, Uri, Email, Tags);
}
/// <summary>
/// VEX statement record for consensus computation.
/// </summary>
public sealed record VexStatementRecord(
string StatementId,
string? IssuerId,
string Status,
string? Justification,
DateTimeOffset IssuedAt);