up
This commit is contained in:
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user