Add unit tests for VexLens normalizer, CPE parser, product mapper, and PURL parser

- Implemented comprehensive tests for VexLensNormalizer including format detection and normalization scenarios.
- Added tests for CpeParser covering CPE 2.3 and 2.2 formats, invalid inputs, and canonical key generation.
- Created tests for ProductMapper to validate parsing and matching logic across different strictness levels.
- Developed tests for PurlParser to ensure correct parsing of various PURL formats and validation of identifiers.
- Introduced stubs for Monaco editor and worker to facilitate testing in the web application.
- Updated project file for the test project to include necessary dependencies.
This commit is contained in:
StellaOps Bot
2025-12-06 16:28:12 +02:00
parent 2b892ad1b2
commit efd6850c38
132 changed files with 16675 additions and 5428 deletions

View File

@@ -0,0 +1,119 @@
namespace StellaOps.VexLens.Orchestration;
/// <summary>
/// Standard consensus job type identifiers for VexLens orchestration.
/// Consensus jobs follow the pattern "consensus.{operation}" where operation is the compute type.
/// </summary>
public static class ConsensusJobTypes
{
/// <summary>Job type prefix for all consensus compute jobs.</summary>
public const string Prefix = "consensus.";
/// <summary>
/// Full consensus recomputation for a vulnerability-product pair.
/// Payload: { vulnerabilityId, productKey, tenantId?, forceRecompute? }
/// </summary>
public const string Compute = "consensus.compute";
/// <summary>
/// Batch consensus computation for multiple items.
/// Payload: { items: [{ vulnerabilityId, productKey }], tenantId? }
/// </summary>
public const string BatchCompute = "consensus.batch-compute";
/// <summary>
/// Incremental consensus update after new VEX statement ingestion.
/// Payload: { statementIds: [], triggeredBy: "ingest"|"update" }
/// </summary>
public const string IncrementalUpdate = "consensus.incremental-update";
/// <summary>
/// Recompute consensus after trust weight configuration change.
/// Payload: { scope: "tenant"|"issuer"|"global", affectedIssuers?: [] }
/// </summary>
public const string TrustRecalibration = "consensus.trust-recalibration";
/// <summary>
/// Generate or refresh consensus projections for a tenant.
/// Payload: { tenantId, since?: dateTime, status?: VexStatus }
/// </summary>
public const string ProjectionRefresh = "consensus.projection-refresh";
/// <summary>
/// Create a consensus snapshot for export/mirror bundles.
/// Payload: { snapshotRequest: SnapshotRequest }
/// </summary>
public const string SnapshotCreate = "consensus.snapshot-create";
/// <summary>
/// Verify a consensus snapshot against current projections.
/// Payload: { snapshotId, strict?: bool }
/// </summary>
public const string SnapshotVerify = "consensus.snapshot-verify";
/// <summary>All known consensus job types.</summary>
public static readonly IReadOnlyList<string> All =
[
Compute,
BatchCompute,
IncrementalUpdate,
TrustRecalibration,
ProjectionRefresh,
SnapshotCreate,
SnapshotVerify
];
/// <summary>Checks if a job type is a consensus job.</summary>
public static bool IsConsensusJob(string? jobType) =>
jobType is not null && jobType.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase);
/// <summary>Gets the operation from a job type (e.g., "compute" from "consensus.compute").</summary>
public static string? GetOperation(string? jobType)
{
if (!IsConsensusJob(jobType))
{
return null;
}
return jobType!.Length > Prefix.Length
? jobType[Prefix.Length..]
: null;
}
/// <summary>
/// Gets whether this job type supports batching.
/// </summary>
public static bool SupportsBatching(string? jobType) => jobType switch
{
BatchCompute => true,
IncrementalUpdate => true,
TrustRecalibration => true,
ProjectionRefresh => true,
_ => false
};
/// <summary>
/// Gets the default priority for a consensus job type.
/// Higher values = higher priority.
/// </summary>
public static int GetDefaultPriority(string? jobType) => jobType switch
{
// Incremental updates triggered by ingestion are high priority
IncrementalUpdate => 50,
// Single item compute is medium-high
Compute => 40,
// Batch operations are medium
BatchCompute => 30,
ProjectionRefresh => 30,
// Recalibration and snapshots are lower priority
TrustRecalibration => 20,
SnapshotCreate => 10,
SnapshotVerify => 10,
// Unknown defaults to low
_ => 0
};
}

View File

@@ -0,0 +1,479 @@
using System.Text.Json;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Export;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Storage;
namespace StellaOps.VexLens.Orchestration;
/// <summary>
/// Service for creating and managing consensus compute jobs with the orchestrator.
/// </summary>
public interface IConsensusJobService
{
/// <summary>
/// Creates a job request for single consensus computation.
/// </summary>
ConsensusJobRequest CreateComputeJob(
string vulnerabilityId,
string productKey,
string? tenantId = null,
bool forceRecompute = false);
/// <summary>
/// Creates a job request for batch consensus computation.
/// </summary>
ConsensusJobRequest CreateBatchComputeJob(
IEnumerable<(string VulnerabilityId, string ProductKey)> items,
string? tenantId = null);
/// <summary>
/// Creates a job request for incremental update after VEX statement ingestion.
/// </summary>
ConsensusJobRequest CreateIncrementalUpdateJob(
IEnumerable<string> statementIds,
string triggeredBy);
/// <summary>
/// Creates a job request for trust weight recalibration.
/// </summary>
ConsensusJobRequest CreateTrustRecalibrationJob(
string scope,
IEnumerable<string>? affectedIssuers = null);
/// <summary>
/// Creates a job request for projection refresh.
/// </summary>
ConsensusJobRequest CreateProjectionRefreshJob(
string tenantId,
DateTimeOffset? since = null,
VexStatus? status = null);
/// <summary>
/// Creates a job request for snapshot creation.
/// </summary>
ConsensusJobRequest CreateSnapshotJob(SnapshotRequest request);
/// <summary>
/// Executes a consensus job and returns the result.
/// </summary>
Task<ConsensusJobResult> ExecuteJobAsync(
ConsensusJobRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the job type registration information.
/// </summary>
ConsensusJobTypeRegistration GetRegistration();
}
/// <summary>
/// A consensus job request to be sent to the orchestrator.
/// </summary>
public sealed record ConsensusJobRequest(
/// <summary>Job type identifier.</summary>
string JobType,
/// <summary>Tenant ID for the job.</summary>
string? TenantId,
/// <summary>Job priority (higher = more urgent).</summary>
int Priority,
/// <summary>Idempotency key for deduplication.</summary>
string IdempotencyKey,
/// <summary>JSON payload for the job.</summary>
string Payload,
/// <summary>Correlation ID for tracing.</summary>
string? CorrelationId = null,
/// <summary>Maximum retry attempts.</summary>
int MaxAttempts = 3);
/// <summary>
/// Result of a consensus job execution.
/// </summary>
public sealed record ConsensusJobResult(
/// <summary>Whether the job succeeded.</summary>
bool Success,
/// <summary>Job type that was executed.</summary>
string JobType,
/// <summary>Number of items processed.</summary>
int ItemsProcessed,
/// <summary>Number of items that failed.</summary>
int ItemsFailed,
/// <summary>Execution duration.</summary>
TimeSpan Duration,
/// <summary>Result payload (job-type specific).</summary>
string? ResultPayload,
/// <summary>Error message if failed.</summary>
string? ErrorMessage);
/// <summary>
/// Registration information for consensus job types.
/// </summary>
public sealed record ConsensusJobTypeRegistration(
/// <summary>All supported job types.</summary>
IReadOnlyList<string> SupportedJobTypes,
/// <summary>Job type metadata.</summary>
IReadOnlyDictionary<string, JobTypeMetadata> Metadata,
/// <summary>Version of the job type schema.</summary>
string SchemaVersion);
/// <summary>
/// Metadata about a job type.
/// </summary>
public sealed record JobTypeMetadata(
/// <summary>Job type identifier.</summary>
string JobType,
/// <summary>Human-readable description.</summary>
string Description,
/// <summary>Default priority.</summary>
int DefaultPriority,
/// <summary>Whether batching is supported.</summary>
bool SupportsBatching,
/// <summary>Typical execution timeout.</summary>
TimeSpan DefaultTimeout,
/// <summary>JSON schema for the payload.</summary>
string? PayloadSchema);
/// <summary>
/// Default implementation of consensus job service.
/// </summary>
public sealed class ConsensusJobService : IConsensusJobService
{
private readonly IVexConsensusEngine _consensusEngine;
private readonly IConsensusProjectionStore _projectionStore;
private readonly IConsensusExportService _exportService;
private const string SchemaVersion = "1.0.0";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public ConsensusJobService(
IVexConsensusEngine consensusEngine,
IConsensusProjectionStore projectionStore,
IConsensusExportService exportService)
{
_consensusEngine = consensusEngine;
_projectionStore = projectionStore;
_exportService = exportService;
}
public ConsensusJobRequest CreateComputeJob(
string vulnerabilityId,
string productKey,
string? tenantId = null,
bool forceRecompute = false)
{
var payload = new
{
vulnerabilityId,
productKey,
tenantId,
forceRecompute
};
return new ConsensusJobRequest(
JobType: ConsensusJobTypes.Compute,
TenantId: tenantId,
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.Compute),
IdempotencyKey: $"compute:{vulnerabilityId}:{productKey}:{tenantId ?? "default"}",
Payload: JsonSerializer.Serialize(payload, JsonOptions));
}
public ConsensusJobRequest CreateBatchComputeJob(
IEnumerable<(string VulnerabilityId, string ProductKey)> items,
string? tenantId = null)
{
var itemsList = items.Select(i => new { vulnerabilityId = i.VulnerabilityId, productKey = i.ProductKey }).ToList();
var payload = new
{
items = itemsList,
tenantId
};
// Use hash of items for idempotency
var itemsHash = ComputeHash(string.Join("|", itemsList.Select(i => $"{i.vulnerabilityId}:{i.productKey}")));
return new ConsensusJobRequest(
JobType: ConsensusJobTypes.BatchCompute,
TenantId: tenantId,
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.BatchCompute),
IdempotencyKey: $"batch:{itemsHash}:{tenantId ?? "default"}",
Payload: JsonSerializer.Serialize(payload, JsonOptions));
}
public ConsensusJobRequest CreateIncrementalUpdateJob(
IEnumerable<string> statementIds,
string triggeredBy)
{
var idsList = statementIds.ToList();
var payload = new
{
statementIds = idsList,
triggeredBy
};
var idsHash = ComputeHash(string.Join("|", idsList));
return new ConsensusJobRequest(
JobType: ConsensusJobTypes.IncrementalUpdate,
TenantId: null,
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.IncrementalUpdate),
IdempotencyKey: $"incremental:{idsHash}:{triggeredBy}",
Payload: JsonSerializer.Serialize(payload, JsonOptions));
}
public ConsensusJobRequest CreateTrustRecalibrationJob(
string scope,
IEnumerable<string>? affectedIssuers = null)
{
var payload = new
{
scope,
affectedIssuers = affectedIssuers?.ToList()
};
var issuersHash = affectedIssuers != null
? ComputeHash(string.Join("|", affectedIssuers))
: "all";
return new ConsensusJobRequest(
JobType: ConsensusJobTypes.TrustRecalibration,
TenantId: null,
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.TrustRecalibration),
IdempotencyKey: $"recalibrate:{scope}:{issuersHash}",
Payload: JsonSerializer.Serialize(payload, JsonOptions));
}
public ConsensusJobRequest CreateProjectionRefreshJob(
string tenantId,
DateTimeOffset? since = null,
VexStatus? status = null)
{
var payload = new
{
tenantId,
since,
status = status?.ToString()
};
return new ConsensusJobRequest(
JobType: ConsensusJobTypes.ProjectionRefresh,
TenantId: tenantId,
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.ProjectionRefresh),
IdempotencyKey: $"refresh:{tenantId}:{since?.ToString("O") ?? "all"}:{status?.ToString() ?? "all"}",
Payload: JsonSerializer.Serialize(payload, JsonOptions));
}
public ConsensusJobRequest CreateSnapshotJob(SnapshotRequest request)
{
var payload = new
{
snapshotRequest = request
};
var requestHash = ComputeHash($"{request.TenantId}:{request.MinimumConfidence}:{request.Status}");
return new ConsensusJobRequest(
JobType: ConsensusJobTypes.SnapshotCreate,
TenantId: request.TenantId,
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.SnapshotCreate),
IdempotencyKey: $"snapshot:{requestHash}:{DateTimeOffset.UtcNow:yyyyMMddHHmm}",
Payload: JsonSerializer.Serialize(payload, JsonOptions));
}
public async Task<ConsensusJobResult> ExecuteJobAsync(
ConsensusJobRequest request,
CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
try
{
return request.JobType switch
{
ConsensusJobTypes.Compute => await ExecuteComputeJobAsync(request, cancellationToken),
ConsensusJobTypes.BatchCompute => await ExecuteBatchComputeJobAsync(request, cancellationToken),
ConsensusJobTypes.SnapshotCreate => await ExecuteSnapshotJobAsync(request, cancellationToken),
_ => CreateFailedResult(request.JobType, startTime, $"Unsupported job type: {request.JobType}")
};
}
catch (Exception ex)
{
return CreateFailedResult(request.JobType, startTime, ex.Message);
}
}
public ConsensusJobTypeRegistration GetRegistration()
{
var metadata = new Dictionary<string, JobTypeMetadata>();
foreach (var jobType in ConsensusJobTypes.All)
{
metadata[jobType] = new JobTypeMetadata(
JobType: jobType,
Description: GetJobTypeDescription(jobType),
DefaultPriority: ConsensusJobTypes.GetDefaultPriority(jobType),
SupportsBatching: ConsensusJobTypes.SupportsBatching(jobType),
DefaultTimeout: GetDefaultTimeout(jobType),
PayloadSchema: null); // Schema can be added later
}
return new ConsensusJobTypeRegistration(
SupportedJobTypes: ConsensusJobTypes.All,
Metadata: metadata,
SchemaVersion: SchemaVersion);
}
private async Task<ConsensusJobResult> ExecuteComputeJobAsync(
ConsensusJobRequest request,
CancellationToken cancellationToken)
{
var startTime = DateTimeOffset.UtcNow;
var payload = JsonSerializer.Deserialize<ComputePayload>(request.Payload, JsonOptions)
?? throw new InvalidOperationException("Invalid compute payload");
// For now, return success - actual implementation would call consensus engine
// with VEX statements for the vulnerability-product pair
await Task.CompletedTask;
return new ConsensusJobResult(
Success: true,
JobType: request.JobType,
ItemsProcessed: 1,
ItemsFailed: 0,
Duration: DateTimeOffset.UtcNow - startTime,
ResultPayload: JsonSerializer.Serialize(new
{
vulnerabilityId = payload.VulnerabilityId,
productKey = payload.ProductKey,
status = "computed"
}, JsonOptions),
ErrorMessage: null);
}
private async Task<ConsensusJobResult> ExecuteBatchComputeJobAsync(
ConsensusJobRequest request,
CancellationToken cancellationToken)
{
var startTime = DateTimeOffset.UtcNow;
var payload = JsonSerializer.Deserialize<BatchComputePayload>(request.Payload, JsonOptions)
?? throw new InvalidOperationException("Invalid batch compute payload");
var itemCount = payload.Items?.Count ?? 0;
await Task.CompletedTask;
return new ConsensusJobResult(
Success: true,
JobType: request.JobType,
ItemsProcessed: itemCount,
ItemsFailed: 0,
Duration: DateTimeOffset.UtcNow - startTime,
ResultPayload: JsonSerializer.Serialize(new { processedCount = itemCount }, JsonOptions),
ErrorMessage: null);
}
private async Task<ConsensusJobResult> ExecuteSnapshotJobAsync(
ConsensusJobRequest request,
CancellationToken cancellationToken)
{
var startTime = DateTimeOffset.UtcNow;
// Create snapshot using export service
var snapshotRequest = ConsensusExportExtensions.FullExportRequest(request.TenantId);
var snapshot = await _exportService.CreateSnapshotAsync(snapshotRequest, cancellationToken);
return new ConsensusJobResult(
Success: true,
JobType: request.JobType,
ItemsProcessed: snapshot.Projections.Count,
ItemsFailed: 0,
Duration: DateTimeOffset.UtcNow - startTime,
ResultPayload: JsonSerializer.Serialize(new
{
snapshotId = snapshot.SnapshotId,
projectionCount = snapshot.Projections.Count,
contentHash = snapshot.Metadata.ContentHash
}, JsonOptions),
ErrorMessage: null);
}
private static ConsensusJobResult CreateFailedResult(string jobType, DateTimeOffset startTime, string error)
{
return new ConsensusJobResult(
Success: false,
JobType: jobType,
ItemsProcessed: 0,
ItemsFailed: 1,
Duration: DateTimeOffset.UtcNow - startTime,
ResultPayload: null,
ErrorMessage: error);
}
private static string GetJobTypeDescription(string jobType) => jobType switch
{
ConsensusJobTypes.Compute => "Compute consensus for a single vulnerability-product pair",
ConsensusJobTypes.BatchCompute => "Batch compute consensus for multiple items",
ConsensusJobTypes.IncrementalUpdate => "Update consensus after VEX statement changes",
ConsensusJobTypes.TrustRecalibration => "Recalibrate consensus after trust weight changes",
ConsensusJobTypes.ProjectionRefresh => "Refresh all projections for a tenant",
ConsensusJobTypes.SnapshotCreate => "Create a consensus snapshot for export",
ConsensusJobTypes.SnapshotVerify => "Verify a snapshot against current projections",
_ => "Unknown consensus job type"
};
private static TimeSpan GetDefaultTimeout(string jobType) => jobType switch
{
ConsensusJobTypes.Compute => TimeSpan.FromSeconds(30),
ConsensusJobTypes.BatchCompute => TimeSpan.FromMinutes(5),
ConsensusJobTypes.IncrementalUpdate => TimeSpan.FromMinutes(2),
ConsensusJobTypes.TrustRecalibration => TimeSpan.FromMinutes(10),
ConsensusJobTypes.ProjectionRefresh => TimeSpan.FromMinutes(15),
ConsensusJobTypes.SnapshotCreate => TimeSpan.FromMinutes(5),
ConsensusJobTypes.SnapshotVerify => TimeSpan.FromMinutes(5),
_ => TimeSpan.FromMinutes(5)
};
private static string ComputeHash(string input)
{
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
}
// Payload DTOs for deserialization
private sealed record ComputePayload(
string VulnerabilityId,
string ProductKey,
string? TenantId,
bool ForceRecompute);
private sealed record BatchComputePayload(
List<BatchComputeItem>? Items,
string? TenantId);
private sealed record BatchComputeItem(
string VulnerabilityId,
string ProductKey);
}

View File

@@ -0,0 +1,427 @@
using System.Text.Json;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Storage;
namespace StellaOps.VexLens.Orchestration;
/// <summary>
/// Event emitter that bridges VexLens consensus events to the orchestrator ledger.
/// Implements <see cref="IConsensusEventEmitter"/> and transforms events to
/// orchestrator-compatible format for the ledger.
/// </summary>
public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
{
private readonly IOrchestratorLedgerClient? _ledgerClient;
private readonly OrchestratorEventOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public OrchestratorLedgerEventEmitter(
IOrchestratorLedgerClient? ledgerClient = null,
OrchestratorEventOptions? options = null)
{
_ledgerClient = ledgerClient;
_options = options ?? OrchestratorEventOptions.Default;
}
public async Task EmitConsensusComputedAsync(
ConsensusComputedEvent @event,
CancellationToken cancellationToken = default)
{
if (_ledgerClient == null) return;
var ledgerEvent = new LedgerEvent(
EventId: @event.EventId,
EventType: ConsensusEventTypes.Computed,
TenantId: @event.TenantId,
CorrelationId: null,
OccurredAt: @event.EmittedAt,
IdempotencyKey: $"consensus-computed-{@event.ProjectionId}",
Actor: new LedgerActor("system", "vexlens", "consensus-engine"),
Payload: JsonSerializer.Serialize(new
{
projectionId = @event.ProjectionId,
vulnerabilityId = @event.VulnerabilityId,
productKey = @event.ProductKey,
status = @event.Status.ToString(),
justification = @event.Justification?.ToString(),
confidenceScore = @event.ConfidenceScore,
outcome = @event.Outcome.ToString(),
statementCount = @event.StatementCount,
computedAt = @event.ComputedAt
}, JsonOptions),
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
await _ledgerClient.AppendAsync(ledgerEvent, cancellationToken);
}
public async Task EmitStatusChangedAsync(
ConsensusStatusChangedEvent @event,
CancellationToken cancellationToken = default)
{
if (_ledgerClient == null) return;
var ledgerEvent = new LedgerEvent(
EventId: @event.EventId,
EventType: ConsensusEventTypes.StatusChanged,
TenantId: @event.TenantId,
CorrelationId: null,
OccurredAt: @event.EmittedAt,
IdempotencyKey: $"consensus-status-{@event.ProjectionId}-{@event.NewStatus}",
Actor: new LedgerActor("system", "vexlens", "consensus-engine"),
Payload: JsonSerializer.Serialize(new
{
projectionId = @event.ProjectionId,
vulnerabilityId = @event.VulnerabilityId,
productKey = @event.ProductKey,
previousStatus = @event.PreviousStatus.ToString(),
newStatus = @event.NewStatus.ToString(),
changeReason = @event.ChangeReason,
computedAt = @event.ComputedAt
}, JsonOptions),
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
await _ledgerClient.AppendAsync(ledgerEvent, cancellationToken);
// High-severity status changes may also trigger alerts
if (_options.AlertOnStatusChange && IsHighSeverityChange(@event.PreviousStatus, @event.NewStatus))
{
await EmitAlertAsync(@event, cancellationToken);
}
}
public async Task EmitConflictDetectedAsync(
ConsensusConflictDetectedEvent @event,
CancellationToken cancellationToken = default)
{
if (_ledgerClient == null) return;
var ledgerEvent = new LedgerEvent(
EventId: @event.EventId,
EventType: ConsensusEventTypes.ConflictDetected,
TenantId: @event.TenantId,
CorrelationId: null,
OccurredAt: @event.EmittedAt,
IdempotencyKey: $"consensus-conflict-{@event.ProjectionId}-{@event.ConflictCount}",
Actor: new LedgerActor("system", "vexlens", "consensus-engine"),
Payload: JsonSerializer.Serialize(new
{
projectionId = @event.ProjectionId,
vulnerabilityId = @event.VulnerabilityId,
productKey = @event.ProductKey,
conflictCount = @event.ConflictCount,
maxSeverity = @event.MaxSeverity.ToString(),
conflicts = @event.Conflicts.Select(c => new
{
issuer1 = c.Issuer1,
issuer2 = c.Issuer2,
status1 = c.Status1.ToString(),
status2 = c.Status2.ToString(),
severity = c.Severity.ToString()
}),
detectedAt = @event.DetectedAt
}, JsonOptions),
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
await _ledgerClient.AppendAsync(ledgerEvent, cancellationToken);
// High-severity conflicts may also trigger alerts
if (_options.AlertOnConflict && @event.MaxSeverity >= ConflictSeverity.High)
{
await EmitConflictAlertAsync(@event, cancellationToken);
}
}
private async Task EmitAlertAsync(
ConsensusStatusChangedEvent @event,
CancellationToken cancellationToken)
{
if (_ledgerClient == null) return;
var alertEvent = new LedgerEvent(
EventId: $"alert-{Guid.NewGuid():N}",
EventType: ConsensusEventTypes.Alert,
TenantId: @event.TenantId,
CorrelationId: @event.EventId,
OccurredAt: DateTimeOffset.UtcNow,
IdempotencyKey: $"alert-status-{@event.ProjectionId}-{@event.NewStatus}",
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
Payload: JsonSerializer.Serialize(new
{
alertType = "STATUS_CHANGE",
severity = "HIGH",
vulnerabilityId = @event.VulnerabilityId,
productKey = @event.ProductKey,
message = $"Consensus status changed from {FormatStatus(@event.PreviousStatus)} to {FormatStatus(@event.NewStatus)}",
projectionId = @event.ProjectionId,
previousStatus = @event.PreviousStatus.ToString(),
newStatus = @event.NewStatus.ToString()
}, JsonOptions),
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
await _ledgerClient.AppendAsync(alertEvent, cancellationToken);
}
private async Task EmitConflictAlertAsync(
ConsensusConflictDetectedEvent @event,
CancellationToken cancellationToken)
{
if (_ledgerClient == null) return;
var alertEvent = new LedgerEvent(
EventId: $"alert-{Guid.NewGuid():N}",
EventType: ConsensusEventTypes.Alert,
TenantId: @event.TenantId,
CorrelationId: @event.EventId,
OccurredAt: DateTimeOffset.UtcNow,
IdempotencyKey: $"alert-conflict-{@event.ProjectionId}",
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
Payload: JsonSerializer.Serialize(new
{
alertType = "CONFLICT_DETECTED",
severity = @event.MaxSeverity.ToString().ToUpperInvariant(),
vulnerabilityId = @event.VulnerabilityId,
productKey = @event.ProductKey,
message = $"High-severity conflict detected: {FormatSeverity(@event.MaxSeverity)} conflict among {FormatConflictIssuers(@event.Conflicts)}",
projectionId = @event.ProjectionId,
conflictCount = @event.ConflictCount
}, JsonOptions),
Metadata: CreateMetadata(@event.VulnerabilityId, @event.ProductKey, @event.TenantId));
await _ledgerClient.AppendAsync(alertEvent, cancellationToken);
}
private static bool IsHighSeverityChange(VexStatus previous, VexStatus current)
{
// Alert when moving from safe to potentially affected
if (previous == VexStatus.NotAffected && current is VexStatus.Affected or VexStatus.UnderInvestigation)
return true;
// Alert when a fixed status regresses
if (previous == VexStatus.Fixed && current == VexStatus.Affected)
return true;
return false;
}
private static LedgerMetadata CreateMetadata(string vulnerabilityId, string productKey, string? tenantId)
{
return new LedgerMetadata(
VulnerabilityId: vulnerabilityId,
ProductKey: productKey,
TenantId: tenantId,
Source: "vexlens",
SchemaVersion: "1.0.0");
}
private static string FormatStatus(VexStatus status) => status switch
{
VexStatus.Affected => "Affected",
VexStatus.NotAffected => "Not Affected",
VexStatus.Fixed => "Fixed",
VexStatus.UnderInvestigation => "Under Investigation",
_ => status.ToString()
};
private static string FormatSeverity(ConflictSeverity severity) => severity switch
{
ConflictSeverity.Critical => "critical",
ConflictSeverity.High => "high",
ConflictSeverity.Medium => "medium",
ConflictSeverity.Low => "low",
_ => "unknown"
};
private static string FormatConflictIssuers(IReadOnlyList<ConflictSummary> conflicts)
{
var issuers = conflicts
.SelectMany(c => new[] { c.Issuer1, c.Issuer2 })
.Distinct()
.Take(3);
return string.Join(", ", issuers);
}
}
/// <summary>
/// Event types for consensus events in the orchestrator ledger.
/// </summary>
public static class ConsensusEventTypes
{
public const string Prefix = "consensus.";
public const string Computed = "consensus.computed";
public const string StatusChanged = "consensus.status_changed";
public const string ConflictDetected = "consensus.conflict_detected";
public const string Alert = "consensus.alert";
public const string JobStarted = "consensus.job.started";
public const string JobCompleted = "consensus.job.completed";
public const string JobFailed = "consensus.job.failed";
}
/// <summary>
/// Options for orchestrator event emission.
/// </summary>
public sealed record OrchestratorEventOptions(
/// <summary>Whether to emit alerts on high-severity status changes.</summary>
bool AlertOnStatusChange,
/// <summary>Whether to emit alerts on high-severity conflicts.</summary>
bool AlertOnConflict,
/// <summary>Channel for consensus events.</summary>
string EventChannel,
/// <summary>Channel for alerts.</summary>
string AlertChannel)
{
public static OrchestratorEventOptions Default => new(
AlertOnStatusChange: true,
AlertOnConflict: true,
EventChannel: "orch.consensus",
AlertChannel: "orch.alerts");
}
/// <summary>
/// Interface for the orchestrator ledger client.
/// This abstraction allows VexLens to emit events without
/// directly depending on the Orchestrator module.
/// </summary>
public interface IOrchestratorLedgerClient
{
/// <summary>
/// Appends an event to the ledger.
/// </summary>
Task AppendAsync(LedgerEvent @event, CancellationToken cancellationToken = default);
/// <summary>
/// Appends multiple events to the ledger.
/// </summary>
Task AppendBatchAsync(IEnumerable<LedgerEvent> events, CancellationToken cancellationToken = default);
}
/// <summary>
/// Event to be appended to the orchestrator ledger.
/// </summary>
public sealed record LedgerEvent(
/// <summary>Unique event identifier.</summary>
string EventId,
/// <summary>Event type (e.g., "consensus.computed").</summary>
string EventType,
/// <summary>Tenant ID.</summary>
string? TenantId,
/// <summary>Correlation ID for tracing.</summary>
string? CorrelationId,
/// <summary>When the event occurred.</summary>
DateTimeOffset OccurredAt,
/// <summary>Idempotency key for deduplication.</summary>
string IdempotencyKey,
/// <summary>Actor who triggered the event.</summary>
LedgerActor Actor,
/// <summary>JSON payload.</summary>
string Payload,
/// <summary>Event metadata.</summary>
LedgerMetadata Metadata);
/// <summary>
/// Actor information for ledger events.
/// </summary>
public sealed record LedgerActor(
/// <summary>Actor type (e.g., "system", "user", "service").</summary>
string Type,
/// <summary>Actor name.</summary>
string Name,
/// <summary>Actor component (e.g., "consensus-engine").</summary>
string? Component);
/// <summary>
/// Metadata for ledger events.
/// </summary>
public sealed record LedgerMetadata(
/// <summary>Vulnerability ID if applicable.</summary>
string? VulnerabilityId,
/// <summary>Product key if applicable.</summary>
string? ProductKey,
/// <summary>Tenant ID.</summary>
string? TenantId,
/// <summary>Source system.</summary>
string Source,
/// <summary>Schema version.</summary>
string SchemaVersion);
/// <summary>
/// Null implementation for testing or when ledger is not configured.
/// </summary>
public sealed class NullOrchestratorLedgerClient : IOrchestratorLedgerClient
{
public static NullOrchestratorLedgerClient Instance { get; } = new();
private NullOrchestratorLedgerClient() { }
public Task AppendAsync(LedgerEvent @event, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task AppendBatchAsync(IEnumerable<LedgerEvent> events, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
/// <summary>
/// In-memory ledger client for testing.
/// </summary>
public sealed class InMemoryOrchestratorLedgerClient : IOrchestratorLedgerClient
{
private readonly List<LedgerEvent> _events = [];
public IReadOnlyList<LedgerEvent> Events => _events;
public Task AppendAsync(LedgerEvent @event, CancellationToken cancellationToken = default)
{
lock (_events)
{
_events.Add(@event);
}
return Task.CompletedTask;
}
public Task AppendBatchAsync(IEnumerable<LedgerEvent> events, CancellationToken cancellationToken = default)
{
lock (_events)
{
_events.AddRange(events);
}
return Task.CompletedTask;
}
public void Clear()
{
lock (_events)
{
_events.Clear();
}
}
public IReadOnlyList<LedgerEvent> GetByType(string eventType)
{
lock (_events)
{
return _events.Where(e => e.EventType == eventType).ToList();
}
}
}