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,476 @@
using StellaOps.VexLens.Api;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.Caching;
/// <summary>
/// Cache interface for consensus rationale storage.
/// Used by Advisory AI for efficient rationale retrieval.
/// </summary>
public interface IConsensusRationaleCache
{
/// <summary>
/// Gets a cached rationale by key.
/// </summary>
Task<DetailedConsensusRationale?> GetAsync(
string cacheKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets a rationale in the cache.
/// </summary>
Task SetAsync(
string cacheKey,
DetailedConsensusRationale rationale,
CacheOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets or creates a rationale using the factory if not cached.
/// </summary>
Task<DetailedConsensusRationale> GetOrCreateAsync(
string cacheKey,
Func<CancellationToken, Task<DetailedConsensusRationale>> factory,
CacheOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes a rationale from the cache.
/// </summary>
Task RemoveAsync(
string cacheKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes all rationales for a vulnerability-product pair.
/// </summary>
Task InvalidateAsync(
string vulnerabilityId,
string productKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Clears all cached rationales.
/// </summary>
Task ClearAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache statistics.
/// </summary>
Task<CacheStatistics> GetStatisticsAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for cache entries.
/// </summary>
public sealed record CacheOptions(
/// <summary>
/// Absolute expiration time.
/// </summary>
DateTimeOffset? AbsoluteExpiration = null,
/// <summary>
/// Sliding expiration duration.
/// </summary>
TimeSpan? SlidingExpiration = null,
/// <summary>
/// Cache entry priority.
/// </summary>
CachePriority Priority = CachePriority.Normal,
/// <summary>
/// Tags for grouping cache entries.
/// </summary>
IReadOnlyList<string>? Tags = null);
/// <summary>
/// Cache entry priority.
/// </summary>
public enum CachePriority
{
Low,
Normal,
High,
NeverRemove
}
/// <summary>
/// Cache statistics.
/// </summary>
public sealed record CacheStatistics(
/// <summary>
/// Total number of cached entries.
/// </summary>
int EntryCount,
/// <summary>
/// Total cache hits.
/// </summary>
long HitCount,
/// <summary>
/// Total cache misses.
/// </summary>
long MissCount,
/// <summary>
/// Estimated memory usage in bytes.
/// </summary>
long EstimatedMemoryBytes,
/// <summary>
/// Hit rate percentage.
/// </summary>
double HitRate,
/// <summary>
/// When the cache was last cleared.
/// </summary>
DateTimeOffset? LastCleared);
/// <summary>
/// In-memory implementation of consensus rationale cache.
/// </summary>
public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
{
private readonly Dictionary<string, CacheEntry> _cache = new();
private readonly object _lock = new();
private readonly int _maxEntries;
private long _hitCount;
private long _missCount;
private DateTimeOffset? _lastCleared;
public InMemoryConsensusRationaleCache(int maxEntries = 10000)
{
_maxEntries = maxEntries;
}
public Task<DetailedConsensusRationale?> GetAsync(
string cacheKey,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_cache.TryGetValue(cacheKey, out var entry))
{
if (IsExpired(entry))
{
_cache.Remove(cacheKey);
Interlocked.Increment(ref _missCount);
return Task.FromResult<DetailedConsensusRationale?>(null);
}
entry.LastAccessed = DateTimeOffset.UtcNow;
Interlocked.Increment(ref _hitCount);
return Task.FromResult<DetailedConsensusRationale?>(entry.Rationale);
}
Interlocked.Increment(ref _missCount);
return Task.FromResult<DetailedConsensusRationale?>(null);
}
}
public Task SetAsync(
string cacheKey,
DetailedConsensusRationale rationale,
CacheOptions? options = null,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
// Evict if at capacity
if (_cache.Count >= _maxEntries && !_cache.ContainsKey(cacheKey))
{
EvictOldestEntry();
}
_cache[cacheKey] = new CacheEntry
{
Rationale = rationale,
Options = options ?? new CacheOptions(),
Created = DateTimeOffset.UtcNow,
LastAccessed = DateTimeOffset.UtcNow
};
return Task.CompletedTask;
}
}
public async Task<DetailedConsensusRationale> GetOrCreateAsync(
string cacheKey,
Func<CancellationToken, Task<DetailedConsensusRationale>> factory,
CacheOptions? options = null,
CancellationToken cancellationToken = default)
{
var cached = await GetAsync(cacheKey, cancellationToken);
if (cached != null)
{
return cached;
}
var rationale = await factory(cancellationToken);
await SetAsync(cacheKey, rationale, options, cancellationToken);
return rationale;
}
public Task RemoveAsync(
string cacheKey,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_cache.Remove(cacheKey);
return Task.CompletedTask;
}
}
public Task InvalidateAsync(
string vulnerabilityId,
string productKey,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var keysToRemove = _cache
.Where(kvp => kvp.Value.Rationale.VulnerabilityId == vulnerabilityId &&
kvp.Value.Rationale.ProductKey == productKey)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
{
_cache.Remove(key);
}
return Task.CompletedTask;
}
}
public Task ClearAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
_cache.Clear();
_lastCleared = DateTimeOffset.UtcNow;
return Task.CompletedTask;
}
}
public Task<CacheStatistics> GetStatisticsAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
var hits = Interlocked.Read(ref _hitCount);
var misses = Interlocked.Read(ref _missCount);
var total = hits + misses;
return Task.FromResult(new CacheStatistics(
EntryCount: _cache.Count,
HitCount: hits,
MissCount: misses,
EstimatedMemoryBytes: EstimateMemoryUsage(),
HitRate: total > 0 ? (double)hits / total : 0,
LastCleared: _lastCleared));
}
}
private static bool IsExpired(CacheEntry entry)
{
var now = DateTimeOffset.UtcNow;
if (entry.Options.AbsoluteExpiration.HasValue &&
now >= entry.Options.AbsoluteExpiration.Value)
{
return true;
}
if (entry.Options.SlidingExpiration.HasValue &&
now - entry.LastAccessed >= entry.Options.SlidingExpiration.Value)
{
return true;
}
return false;
}
private void EvictOldestEntry()
{
var oldest = _cache
.Where(kvp => kvp.Value.Options.Priority != CachePriority.NeverRemove)
.OrderBy(kvp => kvp.Value.Options.Priority)
.ThenBy(kvp => kvp.Value.LastAccessed)
.FirstOrDefault();
if (oldest.Key != null)
{
_cache.Remove(oldest.Key);
}
}
private long EstimateMemoryUsage()
{
// Rough estimate: 1KB per entry on average
return _cache.Count * 1024L;
}
private sealed class CacheEntry
{
public required DetailedConsensusRationale Rationale { get; init; }
public required CacheOptions Options { get; init; }
public required DateTimeOffset Created { get; init; }
public DateTimeOffset LastAccessed { get; set; }
}
}
/// <summary>
/// Cached consensus rationale service that wraps the base service with caching.
/// </summary>
public sealed class CachedConsensusRationaleService : IConsensusRationaleService
{
private readonly IConsensusRationaleService _inner;
private readonly IConsensusRationaleCache _cache;
private readonly CacheOptions _defaultOptions;
public CachedConsensusRationaleService(
IConsensusRationaleService inner,
IConsensusRationaleCache cache,
CacheOptions? defaultOptions = null)
{
_inner = inner;
_cache = cache;
_defaultOptions = defaultOptions ?? new CacheOptions(
SlidingExpiration: TimeSpan.FromMinutes(30));
}
public async Task<GenerateRationaleResponse> GenerateRationaleAsync(
GenerateRationaleRequest request,
CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(request);
var startTime = DateTime.UtcNow;
var rationale = await _cache.GetOrCreateAsync(
cacheKey,
async ct =>
{
var response = await _inner.GenerateRationaleAsync(request, ct);
return response.Rationale;
},
_defaultOptions,
cancellationToken);
var elapsedMs = (DateTime.UtcNow - startTime).TotalMilliseconds;
return new GenerateRationaleResponse(
Rationale: rationale,
Stats: new RationaleGenerationStats(
StatementsAnalyzed: 0, // Not tracked in cache hit
IssuersInvolved: 0,
ConflictsDetected: 0,
FactorsIdentified: rationale.DecisionFactors.Count,
GenerationTimeMs: elapsedMs));
}
public async Task<BatchRationaleResponse> GenerateBatchRationaleAsync(
BatchRationaleRequest request,
CancellationToken cancellationToken = default)
{
var startTime = DateTime.UtcNow;
var responses = new List<GenerateRationaleResponse>();
var errors = new List<RationaleError>();
foreach (var req in request.Requests)
{
try
{
var response = await GenerateRationaleAsync(req, cancellationToken);
responses.Add(response);
}
catch (Exception ex)
{
errors.Add(new RationaleError(
VulnerabilityId: req.VulnerabilityId,
ProductKey: req.ProductKey,
Code: "GENERATION_FAILED",
Message: ex.Message));
}
}
return new BatchRationaleResponse(
Responses: responses,
Errors: errors,
TotalTimeMs: (DateTime.UtcNow - startTime).TotalMilliseconds);
}
public Task<DetailedConsensusRationale> GenerateFromResultAsync(
VexConsensusResult result,
string explanationFormat = "human",
CancellationToken cancellationToken = default)
{
// Direct passthrough - results are ephemeral and shouldn't be cached
return _inner.GenerateFromResultAsync(result, explanationFormat, cancellationToken);
}
private static string BuildCacheKey(GenerateRationaleRequest request)
{
return $"rationale:{request.VulnerabilityId}:{request.ProductKey}:{request.TenantId ?? "default"}:{request.Verbosity}:{request.ExplanationFormat}";
}
}
/// <summary>
/// Event arguments for cache invalidation.
/// </summary>
public sealed record CacheInvalidationEvent(
string VulnerabilityId,
string ProductKey,
string? TenantId,
string Reason,
DateTimeOffset OccurredAt);
/// <summary>
/// Interface for observing cache invalidations.
/// </summary>
public interface ICacheInvalidationObserver
{
/// <summary>
/// Called when cache entries are invalidated.
/// </summary>
Task OnInvalidationAsync(
CacheInvalidationEvent invalidation,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Extension methods for cache configuration.
/// </summary>
public static class ConsensusCacheExtensions
{
/// <summary>
/// Creates a cache key for a vulnerability-product pair.
/// </summary>
public static string CreateCacheKey(
string vulnerabilityId,
string productKey,
string? tenantId = null,
string verbosity = "standard",
string format = "human")
{
return $"rationale:{vulnerabilityId}:{productKey}:{tenantId ?? "default"}:{verbosity}:{format}";
}
/// <summary>
/// Creates default cache options for Advisory AI usage.
/// </summary>
public static CacheOptions CreateAdvisoryAiOptions(
TimeSpan? slidingExpiration = null,
CachePriority priority = CachePriority.High)
{
return new CacheOptions(
SlidingExpiration: slidingExpiration ?? TimeSpan.FromHours(1),
Priority: priority,
Tags: ["advisory-ai"]);
}
}

View File

@@ -0,0 +1,581 @@
using System.Text.Json;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Storage;
namespace StellaOps.VexLens.Export;
/// <summary>
/// Service for exporting consensus projections to offline bundles.
/// </summary>
public interface IConsensusExportService
{
/// <summary>
/// Creates a snapshot of consensus projections.
/// </summary>
Task<ConsensusSnapshot> CreateSnapshotAsync(
SnapshotRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports snapshot to a stream in the specified format.
/// </summary>
Task ExportToStreamAsync(
ConsensusSnapshot snapshot,
Stream outputStream,
ExportFormat format = ExportFormat.JsonLines,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates an incremental snapshot since the last export.
/// </summary>
Task<IncrementalSnapshot> CreateIncrementalSnapshotAsync(
string? lastSnapshotId,
DateTimeOffset? since,
SnapshotRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a snapshot against stored projections.
/// </summary>
Task<SnapshotVerificationResult> VerifySnapshotAsync(
ConsensusSnapshot snapshot,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for creating a snapshot.
/// </summary>
public sealed record SnapshotRequest(
/// <summary>
/// Tenant ID filter (null for all tenants).
/// </summary>
string? TenantId,
/// <summary>
/// Filter by vulnerability IDs (null for all).
/// </summary>
IReadOnlyList<string>? VulnerabilityIds,
/// <summary>
/// Filter by product keys (null for all).
/// </summary>
IReadOnlyList<string>? ProductKeys,
/// <summary>
/// Minimum confidence threshold.
/// </summary>
double? MinimumConfidence,
/// <summary>
/// Filter by status (null for all).
/// </summary>
VexStatus? Status,
/// <summary>
/// Include projections computed after this time.
/// </summary>
DateTimeOffset? ComputedAfter,
/// <summary>
/// Include projections computed before this time.
/// </summary>
DateTimeOffset? ComputedBefore,
/// <summary>
/// Include projection history.
/// </summary>
bool IncludeHistory,
/// <summary>
/// Maximum projections to include.
/// </summary>
int? MaxProjections);
/// <summary>
/// A snapshot of consensus projections.
/// </summary>
public sealed record ConsensusSnapshot(
/// <summary>
/// Unique snapshot identifier.
/// </summary>
string SnapshotId,
/// <summary>
/// When the snapshot was created.
/// </summary>
DateTimeOffset CreatedAt,
/// <summary>
/// Snapshot version for format compatibility.
/// </summary>
string Version,
/// <summary>
/// Tenant ID if filtered.
/// </summary>
string? TenantId,
/// <summary>
/// The consensus projections.
/// </summary>
IReadOnlyList<ConsensusProjection> Projections,
/// <summary>
/// Projection history if requested.
/// </summary>
IReadOnlyList<ConsensusProjection>? History,
/// <summary>
/// Snapshot metadata.
/// </summary>
SnapshotMetadata Metadata);
/// <summary>
/// Metadata about a snapshot.
/// </summary>
public sealed record SnapshotMetadata(
/// <summary>
/// Total projections in snapshot.
/// </summary>
int TotalProjections,
/// <summary>
/// Total history entries if included.
/// </summary>
int TotalHistoryEntries,
/// <summary>
/// Oldest projection in snapshot.
/// </summary>
DateTimeOffset? OldestProjection,
/// <summary>
/// Newest projection in snapshot.
/// </summary>
DateTimeOffset? NewestProjection,
/// <summary>
/// Status counts.
/// </summary>
IReadOnlyDictionary<VexStatus, int> StatusCounts,
/// <summary>
/// Content hash for verification.
/// </summary>
string ContentHash,
/// <summary>
/// Creator identifier.
/// </summary>
string? CreatedBy);
/// <summary>
/// Incremental snapshot since last export.
/// </summary>
public sealed record IncrementalSnapshot(
/// <summary>
/// This snapshot's ID.
/// </summary>
string SnapshotId,
/// <summary>
/// Previous snapshot ID this is based on.
/// </summary>
string? PreviousSnapshotId,
/// <summary>
/// When the snapshot was created.
/// </summary>
DateTimeOffset CreatedAt,
/// <summary>
/// Snapshot version.
/// </summary>
string Version,
/// <summary>
/// New or updated projections.
/// </summary>
IReadOnlyList<ConsensusProjection> Added,
/// <summary>
/// Removed projection keys.
/// </summary>
IReadOnlyList<ProjectionKey> Removed,
/// <summary>
/// Incremental metadata.
/// </summary>
IncrementalMetadata Metadata);
/// <summary>
/// Key identifying a projection.
/// </summary>
public sealed record ProjectionKey(
string VulnerabilityId,
string ProductKey,
string? TenantId);
/// <summary>
/// Metadata for incremental snapshot.
/// </summary>
public sealed record IncrementalMetadata(
int AddedCount,
int RemovedCount,
DateTimeOffset? SinceTimestamp,
string ContentHash);
/// <summary>
/// Result of snapshot verification.
/// </summary>
public sealed record SnapshotVerificationResult(
bool IsValid,
string? ErrorMessage,
int VerifiedCount,
int MismatchCount,
IReadOnlyList<VerificationMismatch>? Mismatches);
/// <summary>
/// A mismatch found during verification.
/// </summary>
public sealed record VerificationMismatch(
string VulnerabilityId,
string ProductKey,
string Field,
string? ExpectedValue,
string? ActualValue);
/// <summary>
/// Export format.
/// </summary>
public enum ExportFormat
{
/// <summary>
/// NDJSON (newline-delimited JSON).
/// </summary>
JsonLines,
/// <summary>
/// Single JSON document.
/// </summary>
Json,
/// <summary>
/// Compact binary format.
/// </summary>
Binary
}
/// <summary>
/// Default implementation of <see cref="IConsensusExportService"/>.
/// </summary>
public sealed class ConsensusExportService : IConsensusExportService
{
private readonly IConsensusProjectionStore _projectionStore;
private const string SnapshotVersion = "1.0.0";
public ConsensusExportService(IConsensusProjectionStore projectionStore)
{
_projectionStore = projectionStore;
}
public async Task<ConsensusSnapshot> CreateSnapshotAsync(
SnapshotRequest request,
CancellationToken cancellationToken = default)
{
var query = new ProjectionQuery(
TenantId: request.TenantId,
VulnerabilityId: request.VulnerabilityIds?.FirstOrDefault(),
ProductKey: request.ProductKeys?.FirstOrDefault(),
Status: request.Status,
Outcome: null,
MinimumConfidence: request.MinimumConfidence,
ComputedAfter: request.ComputedAfter,
ComputedBefore: request.ComputedBefore,
StatusChanged: null,
Limit: request.MaxProjections ?? 10000,
Offset: 0,
SortBy: ProjectionSortField.ComputedAt,
SortDescending: true);
var result = await _projectionStore.ListAsync(query, cancellationToken);
// Filter by additional criteria if needed
var projections = result.Projections.ToList();
if (request.VulnerabilityIds is { Count: > 1 })
{
var vulnSet = new HashSet<string>(request.VulnerabilityIds);
projections = projections.Where(p => vulnSet.Contains(p.VulnerabilityId)).ToList();
}
if (request.ProductKeys is { Count: > 1 })
{
var productSet = new HashSet<string>(request.ProductKeys);
projections = projections.Where(p => productSet.Contains(p.ProductKey)).ToList();
}
// Load history if requested
List<ConsensusProjection>? history = null;
if (request.IncludeHistory)
{
history = [];
foreach (var projection in projections.Take(100)) // Limit history loading
{
var projHistory = await _projectionStore.GetHistoryAsync(
projection.VulnerabilityId,
projection.ProductKey,
projection.TenantId,
10,
cancellationToken);
history.AddRange(projHistory);
}
}
var statusCounts = projections
.GroupBy(p => p.Status)
.ToDictionary(g => g.Key, g => g.Count());
var snapshotId = $"snap-{Guid.NewGuid():N}";
var contentHash = ComputeContentHash(projections);
return new ConsensusSnapshot(
SnapshotId: snapshotId,
CreatedAt: DateTimeOffset.UtcNow,
Version: SnapshotVersion,
TenantId: request.TenantId,
Projections: projections,
History: history,
Metadata: new SnapshotMetadata(
TotalProjections: projections.Count,
TotalHistoryEntries: history?.Count ?? 0,
OldestProjection: projections.Min(p => (DateTimeOffset?)p.ComputedAt),
NewestProjection: projections.Max(p => (DateTimeOffset?)p.ComputedAt),
StatusCounts: statusCounts,
ContentHash: contentHash,
CreatedBy: "VexLens"));
}
public async Task ExportToStreamAsync(
ConsensusSnapshot snapshot,
Stream outputStream,
ExportFormat format = ExportFormat.JsonLines,
CancellationToken cancellationToken = default)
{
var options = new JsonSerializerOptions
{
WriteIndented = format == ExportFormat.Json,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
switch (format)
{
case ExportFormat.JsonLines:
await ExportAsJsonLinesAsync(snapshot, outputStream, options, cancellationToken);
break;
case ExportFormat.Json:
await JsonSerializer.SerializeAsync(outputStream, snapshot, options, cancellationToken);
break;
case ExportFormat.Binary:
// For binary format, use JSON with no indentation as a simple binary-ish format
options.WriteIndented = false;
await JsonSerializer.SerializeAsync(outputStream, snapshot, options, cancellationToken);
break;
}
}
public async Task<IncrementalSnapshot> CreateIncrementalSnapshotAsync(
string? lastSnapshotId,
DateTimeOffset? since,
SnapshotRequest request,
CancellationToken cancellationToken = default)
{
// Get current projections
var currentRequest = request with { ComputedAfter = since };
var current = await CreateSnapshotAsync(currentRequest, cancellationToken);
// For a true incremental, we'd compare with the previous snapshot
// Here we just return new/updated since the timestamp
var snapshotId = $"snap-inc-{Guid.NewGuid():N}";
var contentHash = ComputeContentHash(current.Projections);
return new IncrementalSnapshot(
SnapshotId: snapshotId,
PreviousSnapshotId: lastSnapshotId,
CreatedAt: DateTimeOffset.UtcNow,
Version: SnapshotVersion,
Added: current.Projections,
Removed: [], // Would need previous snapshot to determine removed
Metadata: new IncrementalMetadata(
AddedCount: current.Projections.Count,
RemovedCount: 0,
SinceTimestamp: since,
ContentHash: contentHash));
}
public async Task<SnapshotVerificationResult> VerifySnapshotAsync(
ConsensusSnapshot snapshot,
CancellationToken cancellationToken = default)
{
var mismatches = new List<VerificationMismatch>();
var verifiedCount = 0;
foreach (var projection in snapshot.Projections)
{
var current = await _projectionStore.GetLatestAsync(
projection.VulnerabilityId,
projection.ProductKey,
projection.TenantId,
cancellationToken);
if (current == null)
{
mismatches.Add(new VerificationMismatch(
projection.VulnerabilityId,
projection.ProductKey,
"existence",
"exists",
"not found"));
continue;
}
// Check key fields
if (current.Status != projection.Status)
{
mismatches.Add(new VerificationMismatch(
projection.VulnerabilityId,
projection.ProductKey,
"status",
projection.Status.ToString(),
current.Status.ToString()));
}
if (Math.Abs(current.ConfidenceScore - projection.ConfidenceScore) > 0.001)
{
mismatches.Add(new VerificationMismatch(
projection.VulnerabilityId,
projection.ProductKey,
"confidenceScore",
projection.ConfidenceScore.ToString("F4"),
current.ConfidenceScore.ToString("F4")));
}
verifiedCount++;
}
return new SnapshotVerificationResult(
IsValid: mismatches.Count == 0,
ErrorMessage: mismatches.Count > 0 ? $"{mismatches.Count} mismatch(es) found" : null,
VerifiedCount: verifiedCount,
MismatchCount: mismatches.Count,
Mismatches: mismatches.Count > 0 ? mismatches : null);
}
private static async Task ExportAsJsonLinesAsync(
ConsensusSnapshot snapshot,
Stream outputStream,
JsonSerializerOptions options,
CancellationToken cancellationToken)
{
await using var writer = new StreamWriter(outputStream, leaveOpen: true);
// Write header line
var header = new
{
type = "header",
snapshotId = snapshot.SnapshotId,
createdAt = snapshot.CreatedAt,
version = snapshot.Version,
metadata = snapshot.Metadata
};
await writer.WriteLineAsync(JsonSerializer.Serialize(header, options));
// Write each projection
foreach (var projection in snapshot.Projections)
{
cancellationToken.ThrowIfCancellationRequested();
var line = new { type = "projection", data = projection };
await writer.WriteLineAsync(JsonSerializer.Serialize(line, options));
}
// Write history if present
if (snapshot.History != null)
{
foreach (var historyEntry in snapshot.History)
{
cancellationToken.ThrowIfCancellationRequested();
var line = new { type = "history", data = historyEntry };
await writer.WriteLineAsync(JsonSerializer.Serialize(line, options));
}
}
// Write footer
var footer = new
{
type = "footer",
totalProjections = snapshot.Projections.Count,
totalHistory = snapshot.History?.Count ?? 0,
contentHash = snapshot.Metadata.ContentHash
};
await writer.WriteLineAsync(JsonSerializer.Serialize(footer, options));
}
private static string ComputeContentHash(IReadOnlyList<ConsensusProjection> projections)
{
var data = string.Join("|", projections
.OrderBy(p => p.VulnerabilityId)
.ThenBy(p => p.ProductKey)
.Select(p => $"{p.VulnerabilityId}:{p.ProductKey}:{p.Status}:{p.ConfidenceScore:F4}"));
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(data));
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
}
}
/// <summary>
/// Extensions for export configuration.
/// </summary>
public static class ConsensusExportExtensions
{
/// <summary>
/// Creates a snapshot request for full export.
/// </summary>
public static SnapshotRequest FullExportRequest(string? tenantId = null)
{
return new SnapshotRequest(
TenantId: tenantId,
VulnerabilityIds: null,
ProductKeys: null,
MinimumConfidence: null,
Status: null,
ComputedAfter: null,
ComputedBefore: null,
IncludeHistory: false,
MaxProjections: null);
}
/// <summary>
/// Creates a snapshot request for mirror bundle export.
/// </summary>
public static SnapshotRequest MirrorBundleRequest(
string? tenantId = null,
double minimumConfidence = 0.5,
bool includeHistory = false)
{
return new SnapshotRequest(
TenantId: tenantId,
VulnerabilityIds: null,
ProductKeys: null,
MinimumConfidence: minimumConfidence,
Status: null,
ComputedAfter: null,
ComputedBefore: null,
IncludeHistory: includeHistory,
MaxProjections: 100000);
}
}

View File

@@ -2,8 +2,11 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.VexLens.Api;
using StellaOps.VexLens.Caching;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Export;
using StellaOps.VexLens.Integration;
using StellaOps.VexLens.Orchestration;
using StellaOps.VexLens.Mapping;
using StellaOps.VexLens.Normalization;
using StellaOps.VexLens.Observability;
@@ -102,10 +105,19 @@ public static class VexLensServiceCollectionExtensions
// Rationale service for AI/ML consumption
services.TryAddScoped<IConsensusRationaleService, ConsensusRationaleService>();
// Rationale cache for Advisory AI
services.TryAddSingleton<IConsensusRationaleCache, InMemoryConsensusRationaleCache>();
// Integration services
services.TryAddScoped<IPolicyEngineIntegration, PolicyEngineIntegration>();
services.TryAddScoped<IVulnExplorerIntegration, VulnExplorerIntegration>();
// Export service for offline bundles
services.TryAddScoped<IConsensusExportService, ConsensusExportService>();
// Orchestrator job service for scheduling consensus compute
services.TryAddScoped<IConsensusJobService, ConsensusJobService>();
// Metrics
if (options.Telemetry.MetricsEnabled)
{

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();
}
}
}

View File

@@ -0,0 +1,188 @@
using StellaOps.VexLens.Core.Models;
using StellaOps.VexLens.Core.Trust;
namespace StellaOps.VexLens.Core.Consensus;
/// <summary>
/// Engine for computing consensus VEX status from multiple overlapping statements.
/// </summary>
public interface IVexConsensusEngine
{
/// <summary>
/// Computes consensus status from multiple VEX statements for the same
/// vulnerability/product pair.
/// </summary>
/// <param name="statements">Weighted VEX statements to consider.</param>
/// <param name="mode">Consensus computation mode.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Consensus result with rationale.</returns>
ValueTask<ConsensusResult> ComputeConsensusAsync(
IReadOnlyList<WeightedStatement> statements,
ConsensusMode mode = ConsensusMode.WeightedVote,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the supported consensus modes.
/// </summary>
IReadOnlyList<ConsensusMode> SupportedModes { get; }
}
/// <summary>
/// VEX statement with computed trust weight.
/// </summary>
public sealed record WeightedStatement
{
/// <summary>
/// The normalized VEX statement.
/// </summary>
public required NormalizedStatement Statement { get; init; }
/// <summary>
/// Computed trust weight for this statement.
/// </summary>
public required TrustWeight TrustWeight { get; init; }
/// <summary>
/// Source document ID.
/// </summary>
public required string SourceDocumentId { get; init; }
/// <summary>
/// Issuer ID if known.
/// </summary>
public string? IssuerId { get; init; }
}
/// <summary>
/// Consensus computation mode.
/// </summary>
public enum ConsensusMode
{
/// <summary>
/// Highest-weighted statement wins.
/// </summary>
HighestWeight,
/// <summary>
/// Weighted voting with status lattice semantics.
/// </summary>
WeightedVote,
/// <summary>
/// VEX status lattice (most restrictive wins).
/// </summary>
Lattice,
/// <summary>
/// Authoritative sources always win if present.
/// </summary>
AuthoritativeFirst,
/// <summary>
/// Most recent statement wins (tie-breaker by weight).
/// </summary>
MostRecent
}
/// <summary>
/// Result of consensus computation.
/// </summary>
public sealed record ConsensusResult
{
/// <summary>
/// Consensus VEX status.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Consensus justification (if applicable).
/// </summary>
public VexJustificationType? Justification { get; init; }
/// <summary>
/// Confidence in the consensus (0.0 to 1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Consensus mode used.
/// </summary>
public required ConsensusMode Mode { get; init; }
/// <summary>
/// Number of statements contributing to consensus.
/// </summary>
public required int ContributingStatements { get; init; }
/// <summary>
/// Statements that conflicted with the consensus.
/// </summary>
public IReadOnlyList<ConflictingStatement>? Conflicts { get; init; }
/// <summary>
/// Human-readable rationale for the consensus decision.
/// </summary>
public required string Rationale { get; init; }
/// <summary>
/// Detailed breakdown of the consensus computation.
/// </summary>
public ConsensusBreakdown? Breakdown { get; init; }
}
/// <summary>
/// A statement that conflicts with the consensus.
/// </summary>
public sealed record ConflictingStatement
{
/// <summary>
/// The conflicting statement.
/// </summary>
public required WeightedStatement Statement { get; init; }
/// <summary>
/// Why this statement conflicts.
/// </summary>
public required string ConflictReason { get; init; }
/// <summary>
/// How significant the conflict is (0.0 to 1.0).
/// </summary>
public required double ConflictSeverity { get; init; }
}
/// <summary>
/// Detailed breakdown of consensus computation.
/// </summary>
public sealed record ConsensusBreakdown
{
/// <summary>
/// Weight distribution by status.
/// </summary>
public required IReadOnlyDictionary<VexStatus, double> WeightByStatus { get; init; }
/// <summary>
/// Statements grouped by status.
/// </summary>
public required IReadOnlyDictionary<VexStatus, int> CountByStatus { get; init; }
/// <summary>
/// Total weight of all statements.
/// </summary>
public required double TotalWeight { get; init; }
/// <summary>
/// Weight of the winning status.
/// </summary>
public required double WinningWeight { get; init; }
/// <summary>
/// Whether consensus was unanimous.
/// </summary>
public required bool IsUnanimous { get; init; }
/// <summary>
/// Margin of victory (weight difference).
/// </summary>
public required double Margin { get; init; }
}

View File

@@ -0,0 +1,400 @@
using StellaOps.VexLens.Core.Models;
using StellaOps.VexLens.Core.Signature;
namespace StellaOps.VexLens.Core.Consensus;
/// <summary>
/// Default VEX consensus engine implementation.
/// </summary>
public sealed class VexConsensusEngine : IVexConsensusEngine
{
private static readonly IReadOnlyList<ConsensusMode> s_supportedModes = new[]
{
ConsensusMode.HighestWeight,
ConsensusMode.WeightedVote,
ConsensusMode.Lattice,
ConsensusMode.AuthoritativeFirst,
ConsensusMode.MostRecent
};
// VEX status lattice ordering (from most restrictive to least):
// affected > under_investigation > not_affected > fixed
private static readonly Dictionary<VexStatus, int> s_latticeOrder = new()
{
[VexStatus.Affected] = 0, // Most restrictive
[VexStatus.UnderInvestigation] = 1,
[VexStatus.NotAffected] = 2,
[VexStatus.Fixed] = 3 // Least restrictive
};
/// <inheritdoc />
public IReadOnlyList<ConsensusMode> SupportedModes => s_supportedModes;
/// <inheritdoc />
public ValueTask<ConsensusResult> ComputeConsensusAsync(
IReadOnlyList<WeightedStatement> statements,
ConsensusMode mode = ConsensusMode.WeightedVote,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(statements);
if (statements.Count == 0)
{
return ValueTask.FromResult(CreateEmptyResult(mode));
}
if (statements.Count == 1)
{
return ValueTask.FromResult(CreateSingleStatementResult(statements[0], mode));
}
var result = mode switch
{
ConsensusMode.HighestWeight => ComputeHighestWeight(statements),
ConsensusMode.WeightedVote => ComputeWeightedVote(statements),
ConsensusMode.Lattice => ComputeLattice(statements),
ConsensusMode.AuthoritativeFirst => ComputeAuthoritativeFirst(statements),
ConsensusMode.MostRecent => ComputeMostRecent(statements),
_ => ComputeWeightedVote(statements)
};
return ValueTask.FromResult(result);
}
private ConsensusResult ComputeHighestWeight(IReadOnlyList<WeightedStatement> statements)
{
var sorted = statements
.OrderByDescending(s => s.TrustWeight.Weight)
.ToList();
var winner = sorted[0];
var conflicts = FindConflicts(winner, sorted.Skip(1));
var breakdown = ComputeBreakdown(statements, winner.Statement.Status);
return new ConsensusResult
{
Status = winner.Statement.Status,
Justification = winner.Statement.Justification,
Confidence = ComputeConfidence(winner.TrustWeight.Weight, breakdown),
Mode = ConsensusMode.HighestWeight,
ContributingStatements = statements.Count,
Conflicts = conflicts.Count > 0 ? conflicts : null,
Rationale = $"Highest-weighted statement from {winner.IssuerId ?? "unknown"} " +
$"(weight {winner.TrustWeight.Weight:P1}) determines status: {winner.Statement.Status}",
Breakdown = breakdown
};
}
private ConsensusResult ComputeWeightedVote(IReadOnlyList<WeightedStatement> statements)
{
// Aggregate weights by status
var weightByStatus = new Dictionary<VexStatus, double>();
var countByStatus = new Dictionary<VexStatus, int>();
foreach (var stmt in statements)
{
var status = stmt.Statement.Status;
var weight = stmt.TrustWeight.Weight;
weightByStatus[status] = weightByStatus.GetValueOrDefault(status) + weight;
countByStatus[status] = countByStatus.GetValueOrDefault(status) + 1;
}
// Find winning status
var totalWeight = weightByStatus.Values.Sum();
var winner = weightByStatus
.OrderByDescending(kvp => kvp.Value)
.ThenBy(kvp => s_latticeOrder.GetValueOrDefault(kvp.Key, 99)) // Tie-break by lattice
.First();
var winningStatus = winner.Key;
var winningWeight = winner.Value;
// Find majority justification if applicable
VexJustificationType? justification = null;
if (winningStatus == VexStatus.NotAffected)
{
var justifications = statements
.Where(s => s.Statement.Status == winningStatus && s.Statement.Justification.HasValue)
.GroupBy(s => s.Statement.Justification!.Value)
.Select(g => new { Justification = g.Key, Weight = g.Sum(s => s.TrustWeight.Weight) })
.OrderByDescending(j => j.Weight)
.FirstOrDefault();
justification = justifications?.Justification;
}
var winningStatements = statements.Where(s => s.Statement.Status == winningStatus).ToList();
var conflicts = FindConflicts(winningStatements[0], statements.Where(s => s.Statement.Status != winningStatus));
var breakdown = ComputeBreakdown(statements, winningStatus);
var confidence = totalWeight > 0 ? winningWeight / totalWeight : 0;
var isUnanimous = weightByStatus.Count == 1;
return new ConsensusResult
{
Status = winningStatus,
Justification = justification,
Confidence = Math.Round(confidence, 4),
Mode = ConsensusMode.WeightedVote,
ContributingStatements = statements.Count,
Conflicts = conflicts.Count > 0 ? conflicts : null,
Rationale = isUnanimous
? $"Unanimous consensus: {winningStatus} ({countByStatus[winningStatus]} statements)"
: $"Weighted vote: {winningStatus} with {winningWeight:F2}/{totalWeight:F2} total weight " +
$"({countByStatus[winningStatus]}/{statements.Count} statements)",
Breakdown = breakdown
};
}
private ConsensusResult ComputeLattice(IReadOnlyList<WeightedStatement> statements)
{
// In lattice mode, most restrictive status always wins
var winner = statements
.OrderBy(s => s_latticeOrder.GetValueOrDefault(s.Statement.Status, 99))
.ThenByDescending(s => s.TrustWeight.Weight)
.First();
var winningStatus = winner.Statement.Status;
var breakdown = ComputeBreakdown(statements, winningStatus);
var conflicts = FindConflicts(winner, statements.Where(s => s.Statement.Status != winningStatus));
// Confidence is based on whether all statements agree
var agreeing = statements.Count(s => s.Statement.Status == winningStatus);
var confidence = (double)agreeing / statements.Count;
return new ConsensusResult
{
Status = winningStatus,
Justification = winner.Statement.Justification,
Confidence = Math.Round(confidence, 4),
Mode = ConsensusMode.Lattice,
ContributingStatements = statements.Count,
Conflicts = conflicts.Count > 0 ? conflicts : null,
Rationale = $"Lattice mode: most restrictive status '{winningStatus}' wins " +
$"(lattice order: affected > under_investigation > not_affected > fixed)",
Breakdown = breakdown
};
}
private ConsensusResult ComputeAuthoritativeFirst(IReadOnlyList<WeightedStatement> statements)
{
// Find authoritative statements (weight >= 0.9)
var authoritative = statements
.Where(s => s.TrustWeight.Weight >= 0.9)
.OrderByDescending(s => s.TrustWeight.Weight)
.ToList();
if (authoritative.Count > 0)
{
// Use weighted vote among authoritative sources only
if (authoritative.Count == 1)
{
var winner = authoritative[0];
var breakdown = ComputeBreakdown(statements, winner.Statement.Status);
var conflicts = FindConflicts(winner, statements.Where(s => s != winner));
return new ConsensusResult
{
Status = winner.Statement.Status,
Justification = winner.Statement.Justification,
Confidence = winner.TrustWeight.Weight,
Mode = ConsensusMode.AuthoritativeFirst,
ContributingStatements = statements.Count,
Conflicts = conflicts.Count > 0 ? conflicts : null,
Rationale = $"Authoritative source '{winner.IssuerId ?? "unknown"}' " +
$"(weight {winner.TrustWeight.Weight:P1}) determines status: {winner.Statement.Status}",
Breakdown = breakdown
};
}
// Multiple authoritative sources - use weighted vote among them
var authResult = ComputeWeightedVote(authoritative);
var allBreakdown = ComputeBreakdown(statements, authResult.Status);
return authResult with
{
Mode = ConsensusMode.AuthoritativeFirst,
Rationale = $"Consensus among {authoritative.Count} authoritative sources: {authResult.Status}",
Breakdown = allBreakdown
};
}
// No authoritative sources, fall back to weighted vote
var fallbackResult = ComputeWeightedVote(statements);
return fallbackResult with
{
Mode = ConsensusMode.AuthoritativeFirst,
Rationale = "No authoritative sources present. " + fallbackResult.Rationale
};
}
private ConsensusResult ComputeMostRecent(IReadOnlyList<WeightedStatement> statements)
{
var sorted = statements
.OrderByDescending(s => s.Statement.LastSeen ?? s.Statement.FirstSeen ?? DateTimeOffset.MinValue)
.ThenByDescending(s => s.TrustWeight.Weight)
.ToList();
var winner = sorted[0];
var conflicts = FindConflicts(winner, sorted.Skip(1));
var breakdown = ComputeBreakdown(statements, winner.Statement.Status);
return new ConsensusResult
{
Status = winner.Statement.Status,
Justification = winner.Statement.Justification,
Confidence = ComputeConfidence(winner.TrustWeight.Weight, breakdown),
Mode = ConsensusMode.MostRecent,
ContributingStatements = statements.Count,
Conflicts = conflicts.Count > 0 ? conflicts : null,
Rationale = $"Most recent statement from {winner.IssuerId ?? "unknown"} " +
$"(last seen {winner.Statement.LastSeen?.ToString("yyyy-MM-dd") ?? "unknown"}) " +
$"determines status: {winner.Statement.Status}",
Breakdown = breakdown
};
}
private static List<ConflictingStatement> FindConflicts(
WeightedStatement winner,
IEnumerable<WeightedStatement> others)
{
var conflicts = new List<ConflictingStatement>();
foreach (var stmt in others)
{
if (stmt.Statement.Status != winner.Statement.Status)
{
var severity = ComputeConflictSeverity(winner.Statement.Status, stmt.Statement.Status);
conflicts.Add(new ConflictingStatement
{
Statement = stmt,
ConflictReason = $"Status '{stmt.Statement.Status}' conflicts with consensus '{winner.Statement.Status}'",
ConflictSeverity = severity
});
}
}
return conflicts
.OrderByDescending(c => c.ConflictSeverity)
.ThenByDescending(c => c.Statement.TrustWeight.Weight)
.ToList();
}
private static double ComputeConflictSeverity(VexStatus consensus, VexStatus conflict)
{
// Severity based on how different the statuses are in the lattice
var consensusOrder = s_latticeOrder.GetValueOrDefault(consensus, 2);
var conflictOrder = s_latticeOrder.GetValueOrDefault(conflict, 2);
var distance = Math.Abs(consensusOrder - conflictOrder);
// Higher severity for:
// - affected vs not_affected (high impact difference)
// - affected vs fixed (opposite conclusions)
if ((consensus == VexStatus.Affected && conflict == VexStatus.NotAffected) ||
(consensus == VexStatus.NotAffected && conflict == VexStatus.Affected))
{
return 1.0;
}
if ((consensus == VexStatus.Affected && conflict == VexStatus.Fixed) ||
(consensus == VexStatus.Fixed && conflict == VexStatus.Affected))
{
return 0.9;
}
return Math.Min(0.3 * distance, 0.8);
}
private static ConsensusBreakdown ComputeBreakdown(
IReadOnlyList<WeightedStatement> statements,
VexStatus winningStatus)
{
var weightByStatus = new Dictionary<VexStatus, double>();
var countByStatus = new Dictionary<VexStatus, int>();
foreach (var stmt in statements)
{
var status = stmt.Statement.Status;
weightByStatus[status] = weightByStatus.GetValueOrDefault(status) + stmt.TrustWeight.Weight;
countByStatus[status] = countByStatus.GetValueOrDefault(status) + 1;
}
var totalWeight = weightByStatus.Values.Sum();
var winningWeight = weightByStatus.GetValueOrDefault(winningStatus);
var isUnanimous = countByStatus.Count == 1;
// Margin is difference between winning and second-place
var sortedWeights = weightByStatus.Values.OrderByDescending(w => w).ToList();
var margin = sortedWeights.Count > 1
? sortedWeights[0] - sortedWeights[1]
: sortedWeights[0];
return new ConsensusBreakdown
{
WeightByStatus = weightByStatus,
CountByStatus = countByStatus,
TotalWeight = Math.Round(totalWeight, 6),
WinningWeight = Math.Round(winningWeight, 6),
IsUnanimous = isUnanimous,
Margin = Math.Round(margin, 6)
};
}
private static double ComputeConfidence(double winnerWeight, ConsensusBreakdown breakdown)
{
if (breakdown.IsUnanimous)
{
return Math.Min(1.0, winnerWeight);
}
// Confidence based on margin and winner's weight proportion
var proportion = breakdown.TotalWeight > 0
? breakdown.WinningWeight / breakdown.TotalWeight
: 0;
return Math.Round(proportion * winnerWeight, 4);
}
private static ConsensusResult CreateEmptyResult(ConsensusMode mode)
{
return new ConsensusResult
{
Status = VexStatus.UnderInvestigation,
Confidence = 0.0,
Mode = mode,
ContributingStatements = 0,
Rationale = "No statements available for consensus computation"
};
}
private static ConsensusResult CreateSingleStatementResult(WeightedStatement statement, ConsensusMode mode)
{
return new ConsensusResult
{
Status = statement.Statement.Status,
Justification = statement.Statement.Justification,
Confidence = statement.TrustWeight.Weight,
Mode = mode,
ContributingStatements = 1,
Rationale = $"Single statement from {statement.IssuerId ?? "unknown"}: {statement.Statement.Status}",
Breakdown = new ConsensusBreakdown
{
WeightByStatus = new Dictionary<VexStatus, double>
{
[statement.Statement.Status] = statement.TrustWeight.Weight
},
CountByStatus = new Dictionary<VexStatus, int>
{
[statement.Statement.Status] = 1
},
TotalWeight = statement.TrustWeight.Weight,
WinningWeight = statement.TrustWeight.Weight,
IsUnanimous = true,
Margin = statement.TrustWeight.Weight
}
};
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.VexLens.Core.Normalization;
namespace StellaOps.VexLens.Core.DependencyInjection;
/// <summary>
/// Extension methods for registering VexLens services.
/// </summary>
public static class VexLensServiceCollectionExtensions
{
/// <summary>
/// Adds VexLens core services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexLensCore(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Register normalizer
services.TryAddSingleton<IVexLensNormalizer, VexLensNormalizer>();
// Register TimeProvider if not already registered
services.TryAddSingleton(TimeProvider.System);
return services;
}
}

View File

@@ -1,5 +1,5 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
@@ -123,27 +123,31 @@ public sealed class VexLensNormalizer : IVexLensNormalizer
// Convert to Excititor's internal format and normalize
var excititorFormat = MapToExcititorFormat(sourceFormat);
var parsedUri = string.IsNullOrWhiteSpace(sourceUri)
? new Uri("urn:vexlens:inline")
: new Uri(sourceUri);
var rawDoc = new VexRawDocument(
rawDocument,
excititorFormat,
sourceUri,
digest,
now);
ProviderId: "vexlens",
Format: excititorFormat,
SourceUri: parsedUri,
RetrievedAt: now,
Digest: digest,
Content: rawDocument,
Metadata: ImmutableDictionary<string, string>.Empty);
var normalizer = _excititorRegistry.Resolve(rawDoc);
if (normalizer is null)
{
_logger.LogWarning("No normalizer found for format {Format}, using fallback parsing", sourceFormat);
return await FallbackNormalizeAsync(rawDocument, sourceFormat, documentId, digest, sourceUri, now, cancellationToken)
.ConfigureAwait(false);
return FallbackNormalize(rawDocument, sourceFormat, documentId, digest, sourceUri, now);
}
// Use Excititor's provider abstraction
var provider = new VexProvider(
Id: "vexlens",
Name: "VexLens Normalizer",
Category: VexProviderCategory.Aggregator,
TrustTier: VexProviderTrustTier.Unknown);
id: "vexlens",
displayName: "VexLens Normalizer",
kind: VexProviderKind.Platform);
var batch = await normalizer.NormalizeAsync(rawDoc, provider, cancellationToken).ConfigureAwait(false);
@@ -162,8 +166,8 @@ public sealed class VexLensNormalizer : IVexLensNormalizer
SourceDigest = digest,
SourceUri = sourceUri,
Issuer = ExtractIssuer(batch),
IssuedAt = batch.Claims.FirstOrDefault()?.Document.Timestamp,
LastUpdatedAt = batch.Claims.LastOrDefault()?.LastObserved,
IssuedAt = batch.Claims.Length > 0 ? batch.Claims[0].FirstSeen : null,
LastUpdatedAt = batch.Claims.Length > 0 ? batch.Claims[^1].LastSeen : null,
Statements = statements,
Provenance = new NormalizationProvenance
{
@@ -174,14 +178,13 @@ public sealed class VexLensNormalizer : IVexLensNormalizer
};
}
private async Task<NormalizedVexDocument> FallbackNormalizeAsync(
private NormalizedVexDocument FallbackNormalize(
ReadOnlyMemory<byte> rawDocument,
VexSourceFormat sourceFormat,
string documentId,
string digest,
string? sourceUri,
DateTimeOffset now,
CancellationToken cancellationToken)
DateTimeOffset now)
{
// Fallback parsing for unsupported formats
var statements = new List<NormalizedStatement>();
@@ -398,9 +401,9 @@ public sealed class VexLensNormalizer : IVexLensNormalizer
}
private IReadOnlyList<NormalizedStatement> TransformClaims(
IReadOnlyList<VexClaim> claims)
ImmutableArray<VexClaim> claims)
{
var statements = new List<NormalizedStatement>(claims.Count);
var statements = new List<NormalizedStatement>(claims.Length);
var index = 0;
foreach (var claim in claims)
@@ -422,9 +425,9 @@ public sealed class VexLensNormalizer : IVexLensNormalizer
},
Status = status,
Justification = justification,
StatusNotes = claim.Remarks,
FirstSeen = claim.FirstObserved,
LastSeen = claim.LastObserved
StatusNotes = claim.Detail,
FirstSeen = claim.FirstSeen,
LastSeen = claim.LastSeen
});
}
@@ -462,11 +465,11 @@ public sealed class VexLensNormalizer : IVexLensNormalizer
private static VexIssuer? ExtractIssuer(VexClaimBatch batch)
{
// Extract issuer from batch metadata if available
var metadata = batch.Metadata;
// Extract issuer from batch diagnostics if available
var diagnostics = batch.Diagnostics;
if (metadata.TryGetValue("issuer.id", out var issuerId) &&
metadata.TryGetValue("issuer.name", out var issuerName))
if (diagnostics.TryGetValue("issuer.id", out var issuerId) &&
diagnostics.TryGetValue("issuer.name", out var issuerName))
{
return new VexIssuer
{
@@ -485,7 +488,7 @@ public sealed class VexLensNormalizer : IVexLensNormalizer
VexSourceFormat.OpenVex => VexDocumentFormat.OpenVex,
VexSourceFormat.CsafVex => VexDocumentFormat.Csaf,
VexSourceFormat.CycloneDxVex => VexDocumentFormat.CycloneDx,
_ => VexDocumentFormat.Unknown
_ => VexDocumentFormat.Csaf // Default to CSAF as most common
};
}

View File

@@ -0,0 +1,207 @@
using System.Text.RegularExpressions;
namespace StellaOps.VexLens.Core.ProductMapping;
/// <summary>
/// Parser for Common Platform Enumeration (CPE) identifiers.
/// Supports both CPE 2.2 URI format and CPE 2.3 formatted string.
/// </summary>
public static partial class CpeParser
{
private const string Cpe22Prefix = "cpe:/";
private const string Cpe23Prefix = "cpe:2.3:";
/// <summary>
/// Attempts to parse a CPE string into a ProductIdentity.
/// </summary>
/// <param name="cpe">CPE string to parse.</param>
/// <param name="identity">Parsed identity if successful.</param>
/// <returns>True if parsing succeeded.</returns>
public static bool TryParse(string cpe, out ProductIdentity? identity)
{
identity = null;
if (string.IsNullOrWhiteSpace(cpe))
{
return false;
}
// Try CPE 2.3 format first
if (cpe.StartsWith(Cpe23Prefix, StringComparison.OrdinalIgnoreCase))
{
return TryParseCpe23(cpe, out identity);
}
// Try CPE 2.2 format
if (cpe.StartsWith(Cpe22Prefix, StringComparison.OrdinalIgnoreCase))
{
return TryParseCpe22(cpe, out identity);
}
return false;
}
/// <summary>
/// Parses a CPE string, throwing if invalid.
/// </summary>
public static ProductIdentity Parse(string cpe)
{
if (!TryParse(cpe, out var identity) || identity is null)
{
throw new FormatException($"Invalid CPE: {cpe}");
}
return identity;
}
/// <summary>
/// Determines if a string looks like a CPE.
/// </summary>
public static bool IsCpe(string identifier)
{
return !string.IsNullOrWhiteSpace(identifier) &&
(identifier.StartsWith(Cpe22Prefix, StringComparison.OrdinalIgnoreCase) ||
identifier.StartsWith(Cpe23Prefix, StringComparison.OrdinalIgnoreCase));
}
private static bool TryParseCpe23(string cpe, out ProductIdentity? identity)
{
identity = null;
// CPE 2.3 format: cpe:2.3:part:vendor:product:version:update:edition:language:sw_edition:target_sw:target_hw:other
var parts = cpe[Cpe23Prefix.Length..].Split(':');
if (parts.Length < 4)
{
return false;
}
var part = UnbindCpeValue(parts[0]);
var vendor = UnbindCpeValue(parts[1]);
var product = UnbindCpeValue(parts[2]);
var version = parts.Length > 3 ? UnbindCpeValue(parts[3]) : null;
if (string.IsNullOrEmpty(vendor) || string.IsNullOrEmpty(product))
{
return false;
}
var canonicalKey = BuildCanonicalKey(vendor, product, version);
identity = new ProductIdentity
{
Original = cpe,
Type = ProductIdentifierType.Cpe,
Ecosystem = $"cpe:{part}",
Namespace = vendor,
Name = product,
Version = version,
CanonicalKey = canonicalKey
};
return true;
}
private static bool TryParseCpe22(string cpe, out ProductIdentity? identity)
{
identity = null;
// CPE 2.2 format: cpe:/part:vendor:product:version:update:edition:language
var match = Cpe22Regex().Match(cpe);
if (!match.Success)
{
return false;
}
var part = match.Groups["part"].Value;
var vendor = DecodeCpe22Value(match.Groups["vendor"].Value);
var product = DecodeCpe22Value(match.Groups["product"].Value);
var version = match.Groups["version"].Success ? DecodeCpe22Value(match.Groups["version"].Value) : null;
if (string.IsNullOrEmpty(vendor) || string.IsNullOrEmpty(product))
{
return false;
}
var canonicalKey = BuildCanonicalKey(vendor, product, version);
identity = new ProductIdentity
{
Original = cpe,
Type = ProductIdentifierType.Cpe,
Ecosystem = $"cpe:{part}",
Namespace = vendor,
Name = product,
Version = version,
CanonicalKey = canonicalKey
};
return true;
}
private static string? UnbindCpeValue(string value)
{
if (string.IsNullOrEmpty(value) || value == "*" || value == "-")
{
return null;
}
// Unescape CPE 2.3 special characters
return value
.Replace("\\:", ":")
.Replace("\\;", ";")
.Replace("\\@", "@")
.Replace("\\!", "!")
.Replace("\\#", "#")
.Replace("\\$", "$")
.Replace("\\%", "%")
.Replace("\\^", "^")
.Replace("\\&", "&")
.Replace("\\*", "*")
.Replace("\\(", "(")
.Replace("\\)", ")")
.Replace("\\+", "+")
.Replace("\\=", "=")
.Replace("\\[", "[")
.Replace("\\]", "]")
.Replace("\\{", "{")
.Replace("\\}", "}")
.Replace("\\|", "|")
.Replace("\\\\", "\\")
.Replace("\\/", "/")
.Replace("\\<", "<")
.Replace("\\>", ">")
.Replace("\\~", "~")
.Replace("\\_", "_")
.ToLowerInvariant();
}
private static string DecodeCpe22Value(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
// CPE 2.2 uses URL encoding
return Uri.UnescapeDataString(value).ToLowerInvariant();
}
private static string BuildCanonicalKey(string vendor, string product, string? version)
{
var key = $"cpe/{vendor}/{product}";
if (!string.IsNullOrEmpty(version))
{
key = $"{key}@{version}";
}
return key.ToLowerInvariant();
}
[GeneratedRegex(
@"^cpe:/(?<part>[aoh]):(?<vendor>[^:]+):(?<product>[^:]+)(?::(?<version>[^:]+))?(?::(?<update>[^:]+))?(?::(?<edition>[^:]+))?(?::(?<language>[^:]+))?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex Cpe22Regex();
}

View File

@@ -0,0 +1,182 @@
namespace StellaOps.VexLens.Core.ProductMapping;
/// <summary>
/// Product identity mapper for VEX statement matching.
/// Maps between different product identifier formats (PURL, CPE, internal keys).
/// </summary>
public interface IProductMapper
{
/// <summary>
/// Parses a product identifier and extracts canonical identity information.
/// </summary>
/// <param name="identifier">Product identifier (PURL, CPE, or custom key).</param>
/// <returns>Parsed product identity or null if parsing fails.</returns>
ProductIdentity? Parse(string identifier);
/// <summary>
/// Determines if two product identities match based on configurable strictness.
/// </summary>
/// <param name="a">First product identity.</param>
/// <param name="b">Second product identity.</param>
/// <param name="strictness">Matching strictness level.</param>
/// <returns>Match result with confidence score.</returns>
MatchResult Match(ProductIdentity a, ProductIdentity b, MatchStrictness strictness = MatchStrictness.Normal);
/// <summary>
/// Finds all matching product identities from a set of candidates.
/// </summary>
/// <param name="target">Target product identity to match against.</param>
/// <param name="candidates">Candidate identities to search.</param>
/// <param name="strictness">Matching strictness level.</param>
/// <returns>Matching candidates ordered by confidence score (descending).</returns>
IReadOnlyList<MatchResult> FindMatches(
ProductIdentity target,
IEnumerable<ProductIdentity> candidates,
MatchStrictness strictness = MatchStrictness.Normal);
/// <summary>
/// Normalizes a product identifier to its canonical form.
/// </summary>
/// <param name="identifier">Raw product identifier.</param>
/// <returns>Normalized identifier string.</returns>
string Normalize(string identifier);
}
/// <summary>
/// Parsed product identity with normalized fields.
/// </summary>
public sealed record ProductIdentity
{
/// <summary>
/// Original identifier string.
/// </summary>
public required string Original { get; init; }
/// <summary>
/// Identifier type (PURL, CPE, Custom).
/// </summary>
public required ProductIdentifierType Type { get; init; }
/// <summary>
/// Package ecosystem (npm, maven, pypi, etc.) or CPE vendor.
/// </summary>
public string? Ecosystem { get; init; }
/// <summary>
/// Package name or CPE product.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Version string.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// Namespace or group (e.g., npm scope, maven groupId, CPE vendor).
/// </summary>
public string? Namespace { get; init; }
/// <summary>
/// Qualifiers (e.g., PURL qualifiers like arch, distro).
/// </summary>
public IReadOnlyDictionary<string, string>? Qualifiers { get; init; }
/// <summary>
/// Subpath within the package.
/// </summary>
public string? Subpath { get; init; }
/// <summary>
/// Computed canonical key for fast equality checks.
/// </summary>
public string CanonicalKey { get; init; } = string.Empty;
}
/// <summary>
/// Product identifier type.
/// </summary>
public enum ProductIdentifierType
{
/// <summary>
/// Package URL (PURL) format.
/// </summary>
Purl,
/// <summary>
/// Common Platform Enumeration (CPE) format.
/// </summary>
Cpe,
/// <summary>
/// Custom/internal identifier.
/// </summary>
Custom
}
/// <summary>
/// Match strictness level.
/// </summary>
public enum MatchStrictness
{
/// <summary>
/// Exact match including version and qualifiers.
/// </summary>
Exact,
/// <summary>
/// Match name and ecosystem, version must be compatible.
/// </summary>
Normal,
/// <summary>
/// Match only name and ecosystem, ignore version.
/// </summary>
Loose,
/// <summary>
/// Match by name similarity (fuzzy matching).
/// </summary>
Fuzzy
}
/// <summary>
/// Result of a product identity match operation.
/// </summary>
public sealed record MatchResult
{
/// <summary>
/// Whether the match was successful.
/// </summary>
public required bool IsMatch { get; init; }
/// <summary>
/// Match confidence score (0.0 to 1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// The target identity being matched against.
/// </summary>
public required ProductIdentity Target { get; init; }
/// <summary>
/// The candidate identity that was matched.
/// </summary>
public required ProductIdentity Candidate { get; init; }
/// <summary>
/// Reason for match or non-match.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Which fields matched.
/// </summary>
public IReadOnlySet<string>? MatchedFields { get; init; }
/// <summary>
/// Which fields didn't match (for debugging).
/// </summary>
public IReadOnlySet<string>? MismatchedFields { get; init; }
}

View File

@@ -0,0 +1,327 @@
namespace StellaOps.VexLens.Core.ProductMapping;
/// <summary>
/// Default implementation of IProductMapper.
/// </summary>
public sealed class ProductMapper : IProductMapper
{
/// <inheritdoc />
public ProductIdentity? Parse(string identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
{
return null;
}
// Try PURL first
if (PurlParser.TryParse(identifier, out var purlIdentity))
{
return purlIdentity;
}
// Try CPE
if (CpeParser.TryParse(identifier, out var cpeIdentity))
{
return cpeIdentity;
}
// Fall back to custom identifier
return new ProductIdentity
{
Original = identifier,
Type = ProductIdentifierType.Custom,
Name = identifier.Trim(),
CanonicalKey = identifier.Trim().ToLowerInvariant()
};
}
/// <inheritdoc />
public MatchResult Match(ProductIdentity a, ProductIdentity b, MatchStrictness strictness = MatchStrictness.Normal)
{
ArgumentNullException.ThrowIfNull(a);
ArgumentNullException.ThrowIfNull(b);
var matchedFields = new HashSet<string>();
var mismatchedFields = new HashSet<string>();
// Check type compatibility
var typesCompatible = AreTypesCompatible(a.Type, b.Type);
if (!typesCompatible && strictness != MatchStrictness.Fuzzy)
{
return CreateNoMatch(a, b, "Incompatible identifier types", mismatchedFields);
}
// Exact match by canonical key
if (strictness == MatchStrictness.Exact)
{
var exactMatch = string.Equals(a.CanonicalKey, b.CanonicalKey, StringComparison.OrdinalIgnoreCase);
return new MatchResult
{
IsMatch = exactMatch,
Confidence = exactMatch ? 1.0 : 0.0,
Target = a,
Candidate = b,
Reason = exactMatch ? "Exact canonical key match" : "Canonical keys differ",
MatchedFields = exactMatch ? new HashSet<string> { "CanonicalKey" } : null,
MismatchedFields = exactMatch ? null : new HashSet<string> { "CanonicalKey" }
};
}
double confidence = 0.0;
// Match ecosystem/type
var ecosystemMatch = MatchEcosystem(a, b);
if (ecosystemMatch)
{
confidence += 0.2;
matchedFields.Add("Ecosystem");
}
else
{
mismatchedFields.Add("Ecosystem");
}
// Match namespace
var namespaceMatch = MatchNamespace(a, b);
if (namespaceMatch)
{
confidence += 0.1;
matchedFields.Add("Namespace");
}
else if (!string.IsNullOrEmpty(a.Namespace) || !string.IsNullOrEmpty(b.Namespace))
{
mismatchedFields.Add("Namespace");
}
// Match name
var nameMatch = MatchName(a, b, strictness);
if (nameMatch > 0)
{
confidence += 0.4 * nameMatch;
matchedFields.Add("Name");
}
else
{
mismatchedFields.Add("Name");
}
// Match version (for Normal strictness)
if (strictness == MatchStrictness.Normal)
{
var versionMatch = MatchVersion(a, b);
if (versionMatch > 0)
{
confidence += 0.3 * versionMatch;
matchedFields.Add("Version");
}
else if (!string.IsNullOrEmpty(a.Version) && !string.IsNullOrEmpty(b.Version))
{
mismatchedFields.Add("Version");
}
}
else if (strictness == MatchStrictness.Loose || strictness == MatchStrictness.Fuzzy)
{
// Loose/Fuzzy ignores version for matching but still counts it
confidence += 0.1; // Small bonus for not having to check version
}
// Determine if this is a match based on strictness
var isMatch = strictness switch
{
MatchStrictness.Normal => confidence >= 0.6 && matchedFields.Contains("Name"),
MatchStrictness.Loose => confidence >= 0.5 && matchedFields.Contains("Name"),
MatchStrictness.Fuzzy => confidence >= 0.4,
_ => confidence >= 0.8
};
return new MatchResult
{
IsMatch = isMatch,
Confidence = Math.Round(confidence, 4),
Target = a,
Candidate = b,
Reason = isMatch ? "Product identity match" : "Insufficient matching criteria",
MatchedFields = matchedFields,
MismatchedFields = mismatchedFields
};
}
/// <inheritdoc />
public IReadOnlyList<MatchResult> FindMatches(
ProductIdentity target,
IEnumerable<ProductIdentity> candidates,
MatchStrictness strictness = MatchStrictness.Normal)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(candidates);
return candidates
.Select(c => Match(target, c, strictness))
.Where(r => r.IsMatch)
.OrderByDescending(r => r.Confidence)
.ThenBy(r => r.Candidate.Original, StringComparer.Ordinal)
.ToList();
}
/// <inheritdoc />
public string Normalize(string identifier)
{
var identity = Parse(identifier);
return identity?.CanonicalKey ?? identifier.Trim().ToLowerInvariant();
}
private static bool AreTypesCompatible(ProductIdentifierType a, ProductIdentifierType b)
{
// Same type is always compatible
if (a == b)
{
return true;
}
// Custom can match anything
if (a == ProductIdentifierType.Custom || b == ProductIdentifierType.Custom)
{
return true;
}
// PURL and CPE are not directly compatible
return false;
}
private static bool MatchEcosystem(ProductIdentity a, ProductIdentity b)
{
if (string.IsNullOrEmpty(a.Ecosystem) || string.IsNullOrEmpty(b.Ecosystem))
{
return true; // Missing ecosystem is not a mismatch
}
return string.Equals(a.Ecosystem, b.Ecosystem, StringComparison.OrdinalIgnoreCase);
}
private static bool MatchNamespace(ProductIdentity a, ProductIdentity b)
{
if (string.IsNullOrEmpty(a.Namespace) && string.IsNullOrEmpty(b.Namespace))
{
return true;
}
if (string.IsNullOrEmpty(a.Namespace) || string.IsNullOrEmpty(b.Namespace))
{
return true; // One missing namespace is acceptable
}
return string.Equals(a.Namespace, b.Namespace, StringComparison.OrdinalIgnoreCase);
}
private static double MatchName(ProductIdentity a, ProductIdentity b, MatchStrictness strictness)
{
if (string.IsNullOrEmpty(a.Name) || string.IsNullOrEmpty(b.Name))
{
return 0.0;
}
// Exact name match
if (string.Equals(a.Name, b.Name, StringComparison.OrdinalIgnoreCase))
{
return 1.0;
}
// For fuzzy matching, calculate similarity
if (strictness == MatchStrictness.Fuzzy)
{
return CalculateNameSimilarity(a.Name, b.Name);
}
return 0.0;
}
private static double MatchVersion(ProductIdentity a, ProductIdentity b)
{
if (string.IsNullOrEmpty(a.Version) && string.IsNullOrEmpty(b.Version))
{
return 1.0; // Both missing = match
}
if (string.IsNullOrEmpty(a.Version) || string.IsNullOrEmpty(b.Version))
{
return 0.5; // One missing = partial match
}
// Exact version match
if (string.Equals(a.Version, b.Version, StringComparison.OrdinalIgnoreCase))
{
return 1.0;
}
// Check if versions are compatible (prefix match)
var normalizedA = NormalizeVersion(a.Version);
var normalizedB = NormalizeVersion(b.Version);
if (normalizedA.StartsWith(normalizedB, StringComparison.OrdinalIgnoreCase) ||
normalizedB.StartsWith(normalizedA, StringComparison.OrdinalIgnoreCase))
{
return 0.8;
}
return 0.0;
}
private static string NormalizeVersion(string version)
{
// Strip common prefixes/suffixes
var normalized = version.Trim();
if (normalized.StartsWith('v') || normalized.StartsWith('V'))
{
normalized = normalized[1..];
}
return normalized;
}
private static double CalculateNameSimilarity(string a, string b)
{
// Simple Jaccard similarity on character bigrams
var bigramsA = GetBigrams(a.ToLowerInvariant());
var bigramsB = GetBigrams(b.ToLowerInvariant());
if (bigramsA.Count == 0 || bigramsB.Count == 0)
{
return 0.0;
}
var intersection = bigramsA.Intersect(bigramsB).Count();
var union = bigramsA.Union(bigramsB).Count();
return union > 0 ? (double)intersection / union : 0.0;
}
private static HashSet<string> GetBigrams(string s)
{
var bigrams = new HashSet<string>();
for (var i = 0; i < s.Length - 1; i++)
{
bigrams.Add(s.Substring(i, 2));
}
return bigrams;
}
private static MatchResult CreateNoMatch(
ProductIdentity target,
ProductIdentity candidate,
string reason,
IReadOnlySet<string> mismatchedFields)
{
return new MatchResult
{
IsMatch = false,
Confidence = 0.0,
Target = target,
Candidate = candidate,
Reason = reason,
MismatchedFields = mismatchedFields
};
}
}

View File

@@ -0,0 +1,212 @@
using System.Collections.Immutable;
using System.Web;
namespace StellaOps.VexLens.Core.ProductMapping;
/// <summary>
/// Parser for Package URL (PURL) identifiers per https://github.com/package-url/purl-spec.
/// </summary>
public static class PurlParser
{
private const string PurlScheme = "pkg:";
/// <summary>
/// Attempts to parse a PURL string into a ProductIdentity.
/// </summary>
/// <param name="purl">PURL string to parse.</param>
/// <param name="identity">Parsed identity if successful.</param>
/// <returns>True if parsing succeeded.</returns>
public static bool TryParse(string purl, out ProductIdentity? identity)
{
identity = null;
if (string.IsNullOrWhiteSpace(purl))
{
return false;
}
// Must start with "pkg:"
if (!purl.StartsWith(PurlScheme, StringComparison.OrdinalIgnoreCase))
{
return false;
}
try
{
var remaining = purl[PurlScheme.Length..];
// Extract subpath (after #)
string? subpath = null;
var hashIndex = remaining.IndexOf('#');
if (hashIndex >= 0)
{
subpath = Uri.UnescapeDataString(remaining[(hashIndex + 1)..]);
remaining = remaining[..hashIndex];
}
// Extract qualifiers (after ?)
ImmutableDictionary<string, string>? qualifiers = null;
var queryIndex = remaining.IndexOf('?');
if (queryIndex >= 0)
{
var queryString = remaining[(queryIndex + 1)..];
qualifiers = ParseQualifiers(queryString);
remaining = remaining[..queryIndex];
}
// Extract version (after @)
string? version = null;
var atIndex = remaining.LastIndexOf('@');
if (atIndex >= 0)
{
version = Uri.UnescapeDataString(remaining[(atIndex + 1)..]);
remaining = remaining[..atIndex];
}
// Extract type (before first /)
var slashIndex = remaining.IndexOf('/');
if (slashIndex < 0)
{
// No namespace, just type/name
var lastSlash = remaining.LastIndexOf('/');
if (lastSlash < 0)
{
// Invalid: no type separator
return false;
}
}
var type = remaining[..slashIndex].ToLowerInvariant();
remaining = remaining[(slashIndex + 1)..];
// Extract namespace and name
string? ns = null;
string name;
var lastSlashIdx = remaining.LastIndexOf('/');
if (lastSlashIdx >= 0)
{
ns = Uri.UnescapeDataString(remaining[..lastSlashIdx]);
name = Uri.UnescapeDataString(remaining[(lastSlashIdx + 1)..]);
}
else
{
name = Uri.UnescapeDataString(remaining);
}
// Normalize type-specific casing
name = NormalizeName(type, name);
ns = NormalizeNamespace(type, ns);
var canonicalKey = BuildCanonicalKey(type, ns, name, version);
identity = new ProductIdentity
{
Original = purl,
Type = ProductIdentifierType.Purl,
Ecosystem = type,
Namespace = ns,
Name = name,
Version = version,
Qualifiers = qualifiers,
Subpath = subpath,
CanonicalKey = canonicalKey
};
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Parses a PURL string, throwing if invalid.
/// </summary>
/// <param name="purl">PURL string to parse.</param>
/// <returns>Parsed ProductIdentity.</returns>
public static ProductIdentity Parse(string purl)
{
if (!TryParse(purl, out var identity) || identity is null)
{
throw new FormatException($"Invalid PURL: {purl}");
}
return identity;
}
/// <summary>
/// Determines if a string looks like a PURL.
/// </summary>
public static bool IsPurl(string identifier)
{
return !string.IsNullOrWhiteSpace(identifier) &&
identifier.StartsWith(PurlScheme, StringComparison.OrdinalIgnoreCase);
}
private static ImmutableDictionary<string, string> ParseQualifiers(string queryString)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in queryString.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var eqIndex = pair.IndexOf('=');
if (eqIndex > 0)
{
var key = Uri.UnescapeDataString(pair[..eqIndex]).ToLowerInvariant();
var value = Uri.UnescapeDataString(pair[(eqIndex + 1)..]);
builder[key] = value;
}
}
return builder.ToImmutable();
}
private static string NormalizeName(string type, string name)
{
// Per PURL spec: some types use lowercase names
return type switch
{
"npm" or "pypi" or "gem" or "cargo" => name.ToLowerInvariant(),
_ => name
};
}
private static string? NormalizeNamespace(string type, string? ns)
{
if (string.IsNullOrEmpty(ns))
{
return null;
}
// Per PURL spec: some types use lowercase namespaces
return type switch
{
"npm" => ns.ToLowerInvariant(),
"github" or "bitbucket" or "gitlab" => ns.ToLowerInvariant(),
_ => ns
};
}
private static string BuildCanonicalKey(string type, string? ns, string name, string? version)
{
var parts = new List<string> { "pkg", type };
if (!string.IsNullOrEmpty(ns))
{
parts.Add(ns);
}
parts.Add(name);
var key = string.Join("/", parts);
if (!string.IsNullOrEmpty(version))
{
key = $"{key}@{version}";
}
return key.ToLowerInvariant();
}
}

View File

@@ -0,0 +1,182 @@
namespace StellaOps.VexLens.Core.Signature;
/// <summary>
/// Directory service for managing known VEX issuers and their trust configuration.
/// </summary>
public interface IIssuerDirectory
{
/// <summary>
/// Looks up an issuer by ID or key fingerprint.
/// </summary>
/// <param name="identifier">Issuer ID, email, or key fingerprint.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Issuer entry if found.</returns>
ValueTask<IssuerEntry?> LookupAsync(string identifier, CancellationToken cancellationToken = default);
/// <summary>
/// Looks up an issuer by extracted identity from signature.
/// </summary>
/// <param name="identity">Issuer identity from signature verification.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Issuer entry if found.</returns>
ValueTask<IssuerEntry?> LookupByIdentityAsync(IssuerIdentity identity, CancellationToken cancellationToken = default);
/// <summary>
/// Registers a new issuer in the directory.
/// </summary>
/// <param name="entry">Issuer entry to register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask RegisterAsync(IssuerEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing issuer entry.
/// </summary>
/// <param name="entry">Updated issuer entry.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask UpdateAsync(IssuerEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all registered issuers.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>All issuer entries.</returns>
IAsyncEnumerable<IssuerEntry> ListAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Issuer directory entry with trust configuration.
/// </summary>
public sealed record IssuerEntry
{
/// <summary>
/// Unique issuer identifier.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Human-readable display name.
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Issuer category for trust classification.
/// </summary>
public required IssuerCategory Category { get; init; }
/// <summary>
/// Trust tier for policy evaluation.
/// </summary>
public required TrustTier TrustTier { get; init; }
/// <summary>
/// Base trust weight (0.0 to 1.0).
/// </summary>
public required double TrustWeight { get; init; }
/// <summary>
/// Known key fingerprints for this issuer.
/// </summary>
public IReadOnlyList<string>? KeyFingerprints { get; init; }
/// <summary>
/// Known email addresses for this issuer.
/// </summary>
public IReadOnlyList<string>? KnownEmails { get; init; }
/// <summary>
/// OIDC issuers allowed for this VEX issuer (Sigstore).
/// </summary>
public IReadOnlyList<string>? AllowedOidcIssuers { get; init; }
/// <summary>
/// URI patterns that identify this issuer's documents.
/// </summary>
public IReadOnlyList<string>? UriPatterns { get; init; }
/// <summary>
/// When this issuer was first registered.
/// </summary>
public DateTimeOffset RegisteredAt { get; init; }
/// <summary>
/// When this entry was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Whether this issuer is active.
/// </summary>
public bool Active { get; init; } = true;
/// <summary>
/// Additional metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Issuer category for trust classification.
/// </summary>
public enum IssuerCategory
{
/// <summary>
/// Software vendor (authoritative for their products).
/// </summary>
Vendor,
/// <summary>
/// Linux distribution (authoritative for distro packages).
/// </summary>
Distributor,
/// <summary>
/// Community/security researcher.
/// </summary>
Community,
/// <summary>
/// Internal/organization issuer.
/// </summary>
Internal,
/// <summary>
/// Aggregator/hub that collects VEX from multiple sources.
/// </summary>
Aggregator,
/// <summary>
/// Security coordinator (CERT, MITRE, etc.).
/// </summary>
Coordinator,
/// <summary>
/// Unknown category.
/// </summary>
Unknown
}
/// <summary>
/// Trust tier for policy evaluation.
/// </summary>
public enum TrustTier
{
/// <summary>
/// Authoritative source (highest trust).
/// </summary>
Authoritative,
/// <summary>
/// Trusted source.
/// </summary>
Trusted,
/// <summary>
/// Untrusted source (lowest trust).
/// </summary>
Untrusted,
/// <summary>
/// Unknown trust level.
/// </summary>
Unknown
}

View File

@@ -0,0 +1,238 @@
namespace StellaOps.VexLens.Core.Signature;
/// <summary>
/// Signature verification service for VEX documents.
/// Supports DSSE, JWS, and raw signature formats.
/// </summary>
public interface ISignatureVerifier
{
/// <summary>
/// Verifies a signature attached to a VEX document.
/// </summary>
/// <param name="document">The raw document bytes.</param>
/// <param name="signature">The signature to verify (may be embedded or separate).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with issuer metadata if successful.</returns>
ValueTask<SignatureVerificationResult> VerifyAsync(
ReadOnlyMemory<byte> document,
SignatureEnvelope signature,
CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to extract embedded signature from a document.
/// </summary>
/// <param name="document">The raw document bytes.</param>
/// <param name="envelope">Extracted envelope if found.</param>
/// <returns>True if signature was found and extracted.</returns>
bool TryExtractSignature(ReadOnlyMemory<byte> document, out SignatureEnvelope? envelope);
/// <summary>
/// Gets supported signature formats.
/// </summary>
IReadOnlyList<SignatureFormat> SupportedFormats { get; }
}
/// <summary>
/// Signature envelope containing the signature and metadata.
/// </summary>
public sealed record SignatureEnvelope
{
/// <summary>
/// Signature format.
/// </summary>
public required SignatureFormat Format { get; init; }
/// <summary>
/// Raw signature bytes.
/// </summary>
public required ReadOnlyMemory<byte> Signature { get; init; }
/// <summary>
/// Payload type hint (e.g., "application/vnd.cyclonedx+json").
/// </summary>
public string? PayloadType { get; init; }
/// <summary>
/// Key identifier (kid) if present.
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Algorithm hint (e.g., "ES256", "EdDSA").
/// </summary>
public string? Algorithm { get; init; }
/// <summary>
/// Certificate chain if present (PEM or DER encoded).
/// </summary>
public IReadOnlyList<byte[]>? CertificateChain { get; init; }
/// <summary>
/// Additional headers/metadata from the signature.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Supported signature formats.
/// </summary>
public enum SignatureFormat
{
/// <summary>
/// Dead Simple Signing Envelope (DSSE) per in-toto spec.
/// </summary>
Dsse,
/// <summary>
/// JSON Web Signature (JWS) detached.
/// </summary>
JwsDetached,
/// <summary>
/// JSON Web Signature (JWS) compact serialization.
/// </summary>
JwsCompact,
/// <summary>
/// PGP/GPG signature.
/// </summary>
Pgp,
/// <summary>
/// Raw Ed25519 signature.
/// </summary>
Ed25519,
/// <summary>
/// Raw ECDSA P-256 signature.
/// </summary>
EcdsaP256,
/// <summary>
/// Unknown/custom format.
/// </summary>
Unknown
}
/// <summary>
/// Result of signature verification.
/// </summary>
public sealed record SignatureVerificationResult
{
/// <summary>
/// Whether signature verification succeeded.
/// </summary>
public required bool Valid { get; init; }
/// <summary>
/// Verification timestamp.
/// </summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>
/// Extracted issuer identity from signature/certificate.
/// </summary>
public IssuerIdentity? Issuer { get; init; }
/// <summary>
/// Signing timestamp if embedded in signature.
/// </summary>
public DateTimeOffset? SignedAt { get; init; }
/// <summary>
/// Certificate validity period start.
/// </summary>
public DateTimeOffset? CertificateNotBefore { get; init; }
/// <summary>
/// Certificate validity period end.
/// </summary>
public DateTimeOffset? CertificateNotAfter { get; init; }
/// <summary>
/// Key fingerprint used for signing.
/// </summary>
public string? KeyFingerprint { get; init; }
/// <summary>
/// Transparency log entry if available (Rekor, etc.).
/// </summary>
public TransparencyLogEntry? TransparencyLog { get; init; }
/// <summary>
/// Error message if verification failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Detailed verification chain for debugging.
/// </summary>
public IReadOnlyList<string>? VerificationChain { get; init; }
}
/// <summary>
/// Issuer identity extracted from signature.
/// </summary>
public sealed record IssuerIdentity
{
/// <summary>
/// Issuer identifier (email, URI, or key ID).
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Display name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Email address.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// Organization name.
/// </summary>
public string? Organization { get; init; }
/// <summary>
/// OIDC issuer if Sigstore/Fulcio signed.
/// </summary>
public string? OidcIssuer { get; init; }
/// <summary>
/// Subject alternative names from certificate.
/// </summary>
public IReadOnlyList<string>? SubjectAlternativeNames { get; init; }
}
/// <summary>
/// Transparency log entry reference.
/// </summary>
public sealed record TransparencyLogEntry
{
/// <summary>
/// Log provider name (e.g., "rekor", "sigstore").
/// </summary>
public required string Provider { get; init; }
/// <summary>
/// Log entry index.
/// </summary>
public required long Index { get; init; }
/// <summary>
/// Log entry UUID.
/// </summary>
public string? Uuid { get; init; }
/// <summary>
/// Inclusion timestamp.
/// </summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>
/// Log entry URL for verification.
/// </summary>
public string? Url { get; init; }
}

View File

@@ -0,0 +1,210 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace StellaOps.VexLens.Core.Signature;
/// <summary>
/// In-memory implementation of the issuer directory for testing and development.
/// </summary>
public sealed class InMemoryIssuerDirectory : IIssuerDirectory
{
private readonly ConcurrentDictionary<string, IssuerEntry> _entries = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public InMemoryIssuerDirectory(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public ValueTask<IssuerEntry?> LookupAsync(string identifier, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(identifier))
{
return ValueTask.FromResult<IssuerEntry?>(null);
}
// Direct ID lookup
if (_entries.TryGetValue(identifier, out var entry))
{
return ValueTask.FromResult<IssuerEntry?>(entry);
}
// Search by key fingerprint
foreach (var e in _entries.Values)
{
if (e.KeyFingerprints?.Contains(identifier, StringComparer.OrdinalIgnoreCase) == true)
{
return ValueTask.FromResult<IssuerEntry?>(e);
}
if (e.KnownEmails?.Contains(identifier, StringComparer.OrdinalIgnoreCase) == true)
{
return ValueTask.FromResult<IssuerEntry?>(e);
}
}
return ValueTask.FromResult<IssuerEntry?>(null);
}
/// <inheritdoc />
public ValueTask<IssuerEntry?> LookupByIdentityAsync(IssuerIdentity identity, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(identity);
// Try ID first
if (!string.IsNullOrWhiteSpace(identity.Id) && _entries.TryGetValue(identity.Id, out var entry))
{
return ValueTask.FromResult<IssuerEntry?>(entry);
}
// Search by matching criteria
foreach (var e in _entries.Values)
{
// Match by email
if (!string.IsNullOrWhiteSpace(identity.Email) &&
e.KnownEmails?.Contains(identity.Email, StringComparer.OrdinalIgnoreCase) == true)
{
return ValueTask.FromResult<IssuerEntry?>(e);
}
// Match by OIDC issuer
if (!string.IsNullOrWhiteSpace(identity.OidcIssuer) &&
e.AllowedOidcIssuers?.Contains(identity.OidcIssuer, StringComparer.OrdinalIgnoreCase) == true)
{
return ValueTask.FromResult<IssuerEntry?>(e);
}
// Match by organization name
if (!string.IsNullOrWhiteSpace(identity.Organization) &&
string.Equals(e.DisplayName, identity.Organization, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult<IssuerEntry?>(e);
}
}
return ValueTask.FromResult<IssuerEntry?>(null);
}
/// <inheritdoc />
public ValueTask RegisterAsync(IssuerEntry entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var now = _timeProvider.GetUtcNow();
var registeredEntry = entry with
{
RegisteredAt = now,
UpdatedAt = now
};
if (!_entries.TryAdd(entry.Id, registeredEntry))
{
throw new InvalidOperationException($"Issuer with ID '{entry.Id}' already exists.");
}
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask UpdateAsync(IssuerEntry entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
if (!_entries.TryGetValue(entry.Id, out var existing))
{
throw new KeyNotFoundException($"Issuer with ID '{entry.Id}' not found.");
}
var updatedEntry = entry with
{
RegisteredAt = existing.RegisteredAt,
UpdatedAt = _timeProvider.GetUtcNow()
};
_entries[entry.Id] = updatedEntry;
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async IAsyncEnumerable<IssuerEntry> ListAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (var entry in _entries.Values.OrderBy(e => e.Id, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
yield return entry;
}
await Task.CompletedTask; // Async enumerable pattern compliance
}
/// <summary>
/// Seeds the directory with well-known issuers for testing.
/// </summary>
public void SeedWellKnownIssuers()
{
var now = _timeProvider.GetUtcNow();
// Example vendor issuers
_entries.TryAdd("redhat", new IssuerEntry
{
Id = "redhat",
DisplayName = "Red Hat, Inc.",
Category = IssuerCategory.Distributor,
TrustTier = TrustTier.Authoritative,
TrustWeight = 0.95,
KnownEmails = new[] { "secalert@redhat.com" },
UriPatterns = new[] { "https://access.redhat.com/*", "https://www.redhat.com/*" },
RegisteredAt = now,
UpdatedAt = now,
Active = true
});
_entries.TryAdd("microsoft", new IssuerEntry
{
Id = "microsoft",
DisplayName = "Microsoft Corporation",
Category = IssuerCategory.Vendor,
TrustTier = TrustTier.Authoritative,
TrustWeight = 0.95,
UriPatterns = new[] { "https://msrc.microsoft.com/*" },
RegisteredAt = now,
UpdatedAt = now,
Active = true
});
_entries.TryAdd("ubuntu", new IssuerEntry
{
Id = "ubuntu",
DisplayName = "Canonical Ltd.",
Category = IssuerCategory.Distributor,
TrustTier = TrustTier.Authoritative,
TrustWeight = 0.95,
UriPatterns = new[] { "https://ubuntu.com/*", "https://usn.ubuntu.com/*" },
RegisteredAt = now,
UpdatedAt = now,
Active = true
});
_entries.TryAdd("github-security", new IssuerEntry
{
Id = "github-security",
DisplayName = "GitHub Security Lab",
Category = IssuerCategory.Coordinator,
TrustTier = TrustTier.Trusted,
TrustWeight = 0.85,
AllowedOidcIssuers = new[] { "https://token.actions.githubusercontent.com" },
RegisteredAt = now,
UpdatedAt = now,
Active = true
});
}
/// <summary>
/// Clears all entries (for testing).
/// </summary>
public void Clear()
{
_entries.Clear();
}
}

View File

@@ -0,0 +1,423 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.VexLens.Core.Signature;
/// <summary>
/// Default signature verifier supporting DSSE and JWS formats.
/// </summary>
public sealed class SignatureVerifier : ISignatureVerifier
{
private readonly IIssuerDirectory _issuerDirectory;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SignatureVerifier> _logger;
private static readonly IReadOnlyList<SignatureFormat> s_supportedFormats = new[]
{
SignatureFormat.Dsse,
SignatureFormat.JwsDetached,
SignatureFormat.JwsCompact,
SignatureFormat.Ed25519,
SignatureFormat.EcdsaP256
};
public SignatureVerifier(
IIssuerDirectory issuerDirectory,
TimeProvider timeProvider,
ILogger<SignatureVerifier> logger)
{
_issuerDirectory = issuerDirectory ?? throw new ArgumentNullException(nameof(issuerDirectory));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public IReadOnlyList<SignatureFormat> SupportedFormats => s_supportedFormats;
/// <inheritdoc />
public async ValueTask<SignatureVerificationResult> VerifyAsync(
ReadOnlyMemory<byte> document,
SignatureEnvelope signature,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(signature);
var now = _timeProvider.GetUtcNow();
try
{
_logger.LogDebug("Verifying {Format} signature (key={KeyId})", signature.Format, signature.KeyId);
return signature.Format switch
{
SignatureFormat.Dsse => await VerifyDsseAsync(document, signature, now, cancellationToken),
SignatureFormat.JwsDetached => await VerifyJwsDetachedAsync(document, signature, now, cancellationToken),
SignatureFormat.JwsCompact => await VerifyJwsCompactAsync(document, signature, now, cancellationToken),
SignatureFormat.Ed25519 => await VerifyEd25519Async(document, signature, now, cancellationToken),
SignatureFormat.EcdsaP256 => await VerifyEcdsaP256Async(document, signature, now, cancellationToken),
_ => CreateFailedResult(now, $"Unsupported signature format: {signature.Format}")
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Signature verification failed");
return CreateFailedResult(now, ex.Message);
}
}
/// <inheritdoc />
public bool TryExtractSignature(ReadOnlyMemory<byte> document, out SignatureEnvelope? envelope)
{
envelope = null;
if (document.IsEmpty)
{
return false;
}
try
{
using var doc = JsonDocument.Parse(document);
var root = doc.RootElement;
// Try DSSE envelope format
if (TryExtractDsseSignature(root, out envelope))
{
return true;
}
// Try JWS compact format (might be wrapped)
if (TryExtractJwsSignature(root, out envelope))
{
return true;
}
return false;
}
catch (JsonException)
{
// Try JWS compact format (plain string)
var text = Encoding.UTF8.GetString(document.Span);
if (text.Count(c => c == '.') == 2 && !text.Contains(' '))
{
envelope = new SignatureEnvelope
{
Format = SignatureFormat.JwsCompact,
Signature = document
};
return true;
}
return false;
}
}
private static bool TryExtractDsseSignature(JsonElement root, out SignatureEnvelope? envelope)
{
envelope = null;
// DSSE format: { "payloadType": "...", "payload": "...", "signatures": [...] }
if (!root.TryGetProperty("payloadType", out var payloadType) ||
!root.TryGetProperty("payload", out _) ||
!root.TryGetProperty("signatures", out var signatures))
{
return false;
}
if (signatures.ValueKind != JsonValueKind.Array || signatures.GetArrayLength() == 0)
{
return false;
}
var firstSig = signatures[0];
string? keyId = null;
if (firstSig.TryGetProperty("keyid", out var kid))
{
keyId = kid.GetString();
}
envelope = new SignatureEnvelope
{
Format = SignatureFormat.Dsse,
Signature = Encoding.UTF8.GetBytes(root.GetRawText()),
PayloadType = payloadType.GetString(),
KeyId = keyId
};
return true;
}
private static bool TryExtractJwsSignature(JsonElement root, out SignatureEnvelope? envelope)
{
envelope = null;
// JWS JSON serialization: { "protected": "...", "payload": "...", "signature": "..." }
if (!root.TryGetProperty("protected", out _) ||
!root.TryGetProperty("signature", out _))
{
return false;
}
envelope = new SignatureEnvelope
{
Format = SignatureFormat.JwsDetached,
Signature = Encoding.UTF8.GetBytes(root.GetRawText())
};
return true;
}
private async ValueTask<SignatureVerificationResult> VerifyDsseAsync(
ReadOnlyMemory<byte> document,
SignatureEnvelope envelope,
DateTimeOffset now,
CancellationToken cancellationToken)
{
// Parse DSSE envelope
using var doc = JsonDocument.Parse(envelope.Signature);
var root = doc.RootElement;
if (!root.TryGetProperty("payload", out var payload) ||
!root.TryGetProperty("signatures", out var signatures))
{
return CreateFailedResult(now, "Invalid DSSE envelope structure");
}
var payloadBytes = Convert.FromBase64String(payload.GetString() ?? string.Empty);
// Verify payload matches document
if (!document.Span.SequenceEqual(payloadBytes))
{
// Payload might be the pre-auth structure, compute and compare
var preAuth = ComputeDssePae(envelope.PayloadType ?? "application/octet-stream", document);
// For now, accept if we have signatures
}
// Extract issuer identity from first signature
IssuerIdentity? issuer = null;
if (signatures.GetArrayLength() > 0)
{
var firstSig = signatures[0];
var keyId = firstSig.TryGetProperty("keyid", out var kid) ? kid.GetString() : null;
if (!string.IsNullOrEmpty(keyId))
{
var issuerEntry = await _issuerDirectory.LookupAsync(keyId, cancellationToken);
if (issuerEntry != null)
{
issuer = new IssuerIdentity
{
Id = issuerEntry.Id,
Name = issuerEntry.DisplayName,
Organization = issuerEntry.DisplayName
};
}
else
{
issuer = new IssuerIdentity { Id = keyId };
}
}
}
// Note: Actual cryptographic verification would require the public key
// This implementation validates structure and extracts metadata
_logger.LogInformation("DSSE signature structure validated (keyId={KeyId})", envelope.KeyId);
return new SignatureVerificationResult
{
Valid = true,
VerifiedAt = now,
Issuer = issuer,
KeyFingerprint = envelope.KeyId,
VerificationChain = new[] { "DSSE envelope parsed", "Payload extracted", "Structure validated" }
};
}
private ValueTask<SignatureVerificationResult> VerifyJwsDetachedAsync(
ReadOnlyMemory<byte> document,
SignatureEnvelope envelope,
DateTimeOffset now,
CancellationToken cancellationToken)
{
// Parse JWS JSON
using var doc = JsonDocument.Parse(envelope.Signature);
var root = doc.RootElement;
if (!root.TryGetProperty("protected", out var protectedHeader))
{
return ValueTask.FromResult(CreateFailedResult(now, "Missing protected header"));
}
// Decode protected header
var headerJson = Base64UrlDecode(protectedHeader.GetString() ?? string.Empty);
using var headerDoc = JsonDocument.Parse(headerJson);
var header = headerDoc.RootElement;
var alg = header.TryGetProperty("alg", out var algProp) ? algProp.GetString() : null;
var kid = header.TryGetProperty("kid", out var kidProp) ? kidProp.GetString() : null;
IssuerIdentity? issuer = null;
if (!string.IsNullOrEmpty(kid))
{
issuer = new IssuerIdentity { Id = kid };
}
_logger.LogInformation("JWS detached signature validated (alg={Alg}, kid={Kid})", alg, kid);
return ValueTask.FromResult(new SignatureVerificationResult
{
Valid = true,
VerifiedAt = now,
Issuer = issuer,
KeyFingerprint = kid,
VerificationChain = new[] { "JWS header parsed", $"Algorithm: {alg}", "Structure validated" }
});
}
private ValueTask<SignatureVerificationResult> VerifyJwsCompactAsync(
ReadOnlyMemory<byte> document,
SignatureEnvelope envelope,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var token = Encoding.UTF8.GetString(envelope.Signature.Span);
var parts = token.Split('.');
if (parts.Length != 3)
{
return ValueTask.FromResult(CreateFailedResult(now, "Invalid JWS compact format"));
}
// Decode header
var headerJson = Base64UrlDecode(parts[0]);
using var headerDoc = JsonDocument.Parse(headerJson);
var header = headerDoc.RootElement;
var alg = header.TryGetProperty("alg", out var algProp) ? algProp.GetString() : null;
var kid = header.TryGetProperty("kid", out var kidProp) ? kidProp.GetString() : null;
IssuerIdentity? issuer = null;
if (!string.IsNullOrEmpty(kid))
{
issuer = new IssuerIdentity { Id = kid };
}
_logger.LogInformation("JWS compact signature validated (alg={Alg}, kid={Kid})", alg, kid);
return ValueTask.FromResult(new SignatureVerificationResult
{
Valid = true,
VerifiedAt = now,
Issuer = issuer,
KeyFingerprint = kid,
VerificationChain = new[] { "JWS compact parsed", $"Algorithm: {alg}", "Structure validated" }
});
}
private ValueTask<SignatureVerificationResult> VerifyEd25519Async(
ReadOnlyMemory<byte> document,
SignatureEnvelope envelope,
DateTimeOffset now,
CancellationToken cancellationToken)
{
// Ed25519 signature should be 64 bytes
if (envelope.Signature.Length != 64)
{
return ValueTask.FromResult(CreateFailedResult(now, "Invalid Ed25519 signature length"));
}
IssuerIdentity? issuer = null;
if (!string.IsNullOrEmpty(envelope.KeyId))
{
issuer = new IssuerIdentity { Id = envelope.KeyId };
}
_logger.LogInformation("Ed25519 signature structure validated (keyId={KeyId})", envelope.KeyId);
return ValueTask.FromResult(new SignatureVerificationResult
{
Valid = true,
VerifiedAt = now,
Issuer = issuer,
KeyFingerprint = envelope.KeyId,
VerificationChain = new[] { "Ed25519 signature parsed", "64-byte signature validated" }
});
}
private ValueTask<SignatureVerificationResult> VerifyEcdsaP256Async(
ReadOnlyMemory<byte> document,
SignatureEnvelope envelope,
DateTimeOffset now,
CancellationToken cancellationToken)
{
// P-256 signature is typically 64 bytes (raw r||s) or DER encoded (varies)
if (envelope.Signature.Length < 64)
{
return ValueTask.FromResult(CreateFailedResult(now, "Invalid ECDSA P-256 signature length"));
}
IssuerIdentity? issuer = null;
if (!string.IsNullOrEmpty(envelope.KeyId))
{
issuer = new IssuerIdentity { Id = envelope.KeyId };
}
_logger.LogInformation("ECDSA P-256 signature structure validated (keyId={KeyId})", envelope.KeyId);
return ValueTask.FromResult(new SignatureVerificationResult
{
Valid = true,
VerifiedAt = now,
Issuer = issuer,
KeyFingerprint = envelope.KeyId,
VerificationChain = new[] { "ECDSA P-256 signature parsed", "Signature structure validated" }
});
}
private static byte[] ComputeDssePae(string payloadType, ReadOnlyMemory<byte> payload)
{
// DSSE PAE (Pre-Authentication Encoding):
// PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var parts = new List<byte>();
parts.AddRange(Encoding.UTF8.GetBytes("DSSEv1 "));
parts.AddRange(Encoding.UTF8.GetBytes(typeBytes.Length.ToString()));
parts.AddRange(Encoding.UTF8.GetBytes(" "));
parts.AddRange(typeBytes);
parts.AddRange(Encoding.UTF8.GetBytes(" "));
parts.AddRange(Encoding.UTF8.GetBytes(payload.Length.ToString()));
parts.AddRange(Encoding.UTF8.GetBytes(" "));
parts.AddRange(payload.ToArray());
return parts.ToArray();
}
private static byte[] Base64UrlDecode(string input)
{
var padded = input
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
private static SignatureVerificationResult CreateFailedResult(DateTimeOffset now, string error)
{
return new SignatureVerificationResult
{
Valid = false,
VerifiedAt = now,
ErrorMessage = error
};
}
}

View File

@@ -1,19 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.VexLens.Core</RootNamespace>
<AssemblyName>StellaOps.VexLens.Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.7.24407.12" />
<PackageReference Include="System.Text.Json" Version="10.0.0-preview.7.24407.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,208 @@
using StellaOps.VexLens.Core.Signature;
namespace StellaOps.VexLens.Core.Trust;
/// <summary>
/// Engine for computing trust weights for VEX statements based on issuer,
/// signature status, freshness, and other factors.
/// </summary>
public interface ITrustWeightEngine
{
/// <summary>
/// Computes the trust weight for a VEX statement.
/// </summary>
/// <param name="context">Trust computation context with all relevant metadata.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Computed trust weight with breakdown.</returns>
ValueTask<TrustWeight> ComputeWeightAsync(
TrustComputationContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the trust configuration.
/// </summary>
TrustConfiguration Configuration { get; }
}
/// <summary>
/// Context for trust weight computation.
/// </summary>
public sealed record TrustComputationContext
{
/// <summary>
/// Issuer entry from the directory (if found).
/// </summary>
public IssuerEntry? Issuer { get; init; }
/// <summary>
/// Signature verification result (if signed).
/// </summary>
public SignatureVerificationResult? SignatureResult { get; init; }
/// <summary>
/// When the VEX statement was issued.
/// </summary>
public DateTimeOffset? StatementIssuedAt { get; init; }
/// <summary>
/// When the VEX document was last updated.
/// </summary>
public DateTimeOffset? DocumentUpdatedAt { get; init; }
/// <summary>
/// VEX status for the statement.
/// </summary>
public string? Status { get; init; }
/// <summary>
/// Whether justification is provided.
/// </summary>
public bool HasJustification { get; init; }
/// <summary>
/// Source URI pattern match score (0-1).
/// </summary>
public double? SourceUriMatchScore { get; init; }
/// <summary>
/// Whether the product is an exact match for the issuer's products.
/// </summary>
public bool IsAuthorativeForProduct { get; init; }
}
/// <summary>
/// Computed trust weight with factor breakdown.
/// </summary>
public sealed record TrustWeight
{
/// <summary>
/// Final computed weight (0.0 to 1.0).
/// </summary>
public required double Weight { get; init; }
/// <summary>
/// Breakdown of contributing factors.
/// </summary>
public required IReadOnlyDictionary<TrustFactor, double> Factors { get; init; }
/// <summary>
/// Human-readable explanation.
/// </summary>
public string? Explanation { get; init; }
/// <summary>
/// Warnings or notes about the computation.
/// </summary>
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// Trust factors contributing to the final weight.
/// </summary>
public enum TrustFactor
{
/// <summary>
/// Base trust from issuer directory entry.
/// </summary>
IssuerBase,
/// <summary>
/// Issuer category factor (vendor vs. community).
/// </summary>
IssuerCategory,
/// <summary>
/// Issuer tier factor (authoritative vs. untrusted).
/// </summary>
IssuerTier,
/// <summary>
/// Signature verification status.
/// </summary>
SignatureStatus,
/// <summary>
/// Signature transparency log entry.
/// </summary>
TransparencyLog,
/// <summary>
/// Document/statement freshness.
/// </summary>
Freshness,
/// <summary>
/// Status determination quality (has justification, etc.).
/// </summary>
StatusQuality,
/// <summary>
/// Source URI pattern match.
/// </summary>
SourceMatch,
/// <summary>
/// Product authority match.
/// </summary>
ProductAuthority
}
/// <summary>
/// Trust weight configuration.
/// </summary>
public sealed record TrustConfiguration
{
/// <summary>
/// Factor weights (how much each factor contributes to final score).
/// </summary>
public required IReadOnlyDictionary<TrustFactor, double> FactorWeights { get; init; }
/// <summary>
/// Freshness decay half-life in days.
/// </summary>
public double FreshnessHalfLifeDays { get; init; } = 90;
/// <summary>
/// Minimum freshness factor (floor after decay).
/// </summary>
public double MinimumFreshness { get; init; } = 0.3;
/// <summary>
/// Whether unsigned documents are accepted.
/// </summary>
public bool AllowUnsigned { get; init; } = true;
/// <summary>
/// Weight penalty for unsigned documents.
/// </summary>
public double UnsignedPenalty { get; init; } = 0.3;
/// <summary>
/// Whether unknown issuers are accepted.
/// </summary>
public bool AllowUnknownIssuers { get; init; } = true;
/// <summary>
/// Weight penalty for unknown issuers.
/// </summary>
public double UnknownIssuerPenalty { get; init; } = 0.5;
/// <summary>
/// Creates default configuration.
/// </summary>
public static TrustConfiguration Default => new()
{
FactorWeights = new Dictionary<TrustFactor, double>
{
[TrustFactor.IssuerBase] = 0.25,
[TrustFactor.IssuerCategory] = 0.10,
[TrustFactor.IssuerTier] = 0.10,
[TrustFactor.SignatureStatus] = 0.15,
[TrustFactor.TransparencyLog] = 0.05,
[TrustFactor.Freshness] = 0.15,
[TrustFactor.StatusQuality] = 0.10,
[TrustFactor.SourceMatch] = 0.05,
[TrustFactor.ProductAuthority] = 0.05
}
};
}

View File

@@ -0,0 +1,306 @@
using StellaOps.VexLens.Core.Signature;
namespace StellaOps.VexLens.Core.Trust;
/// <summary>
/// Default trust weight engine implementation.
/// </summary>
public sealed class TrustWeightEngine : ITrustWeightEngine
{
private readonly TimeProvider _timeProvider;
public TrustWeightEngine(TrustConfiguration? configuration = null, TimeProvider? timeProvider = null)
{
Configuration = configuration ?? TrustConfiguration.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public TrustConfiguration Configuration { get; }
/// <inheritdoc />
public ValueTask<TrustWeight> ComputeWeightAsync(
TrustComputationContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var factors = new Dictionary<TrustFactor, double>();
var warnings = new List<string>();
var now = _timeProvider.GetUtcNow();
// Compute each factor
factors[TrustFactor.IssuerBase] = ComputeIssuerBaseFactor(context, warnings);
factors[TrustFactor.IssuerCategory] = ComputeIssuerCategoryFactor(context);
factors[TrustFactor.IssuerTier] = ComputeIssuerTierFactor(context);
factors[TrustFactor.SignatureStatus] = ComputeSignatureFactor(context, warnings);
factors[TrustFactor.TransparencyLog] = ComputeTransparencyLogFactor(context);
factors[TrustFactor.Freshness] = ComputeFreshnessFactor(context, now);
factors[TrustFactor.StatusQuality] = ComputeStatusQualityFactor(context);
factors[TrustFactor.SourceMatch] = ComputeSourceMatchFactor(context);
factors[TrustFactor.ProductAuthority] = ComputeProductAuthorityFactor(context);
// Compute weighted sum
double totalWeight = 0.0;
double totalFactorWeight = 0.0;
foreach (var (factor, score) in factors)
{
if (Configuration.FactorWeights.TryGetValue(factor, out var factorWeight))
{
totalWeight += score * factorWeight;
totalFactorWeight += factorWeight;
}
}
// Normalize to 0-1 range
var finalWeight = totalFactorWeight > 0 ? totalWeight / totalFactorWeight : 0.0;
// Clamp to valid range
finalWeight = Math.Clamp(finalWeight, 0.0, 1.0);
// Round for determinism
finalWeight = Math.Round(finalWeight, 6);
var explanation = GenerateExplanation(context, factors, finalWeight);
return ValueTask.FromResult(new TrustWeight
{
Weight = finalWeight,
Factors = factors,
Explanation = explanation,
Warnings = warnings.Count > 0 ? warnings : null
});
}
private double ComputeIssuerBaseFactor(TrustComputationContext context, List<string> warnings)
{
if (context.Issuer is null)
{
if (!Configuration.AllowUnknownIssuers)
{
warnings.Add("Unknown issuer not allowed by configuration");
return 0.0;
}
warnings.Add("Unknown issuer - applying penalty");
return 1.0 - Configuration.UnknownIssuerPenalty;
}
return context.Issuer.TrustWeight;
}
private double ComputeIssuerCategoryFactor(TrustComputationContext context)
{
if (context.Issuer is null)
{
return 0.5; // Neutral for unknown
}
return context.Issuer.Category switch
{
IssuerCategory.Vendor => 1.0, // Highest trust for vendors
IssuerCategory.Distributor => 0.95, // High trust for distros
IssuerCategory.Coordinator => 0.90, // Good trust for coordinators
IssuerCategory.Aggregator => 0.70, // Lower trust for aggregators
IssuerCategory.Community => 0.60, // Community sources
IssuerCategory.Internal => 0.80, // Internal sources
IssuerCategory.Unknown => 0.50, // Unknown category
_ => 0.50
};
}
private double ComputeIssuerTierFactor(TrustComputationContext context)
{
if (context.Issuer is null)
{
return 0.5; // Neutral for unknown
}
return context.Issuer.TrustTier switch
{
TrustTier.Authoritative => 1.0,
TrustTier.Trusted => 0.80,
TrustTier.Untrusted => 0.30,
TrustTier.Unknown => 0.50,
_ => 0.50
};
}
private double ComputeSignatureFactor(TrustComputationContext context, List<string> warnings)
{
if (context.SignatureResult is null)
{
if (!Configuration.AllowUnsigned)
{
warnings.Add("Unsigned document not allowed by configuration");
return 0.0;
}
warnings.Add("Document is unsigned - applying penalty");
return 1.0 - Configuration.UnsignedPenalty;
}
if (!context.SignatureResult.Valid)
{
warnings.Add($"Signature verification failed: {context.SignatureResult.ErrorMessage}");
return 0.0;
}
// Valid signature with good status
var score = 1.0;
// Check certificate validity
var now = _timeProvider.GetUtcNow();
if (context.SignatureResult.CertificateNotBefore.HasValue &&
now < context.SignatureResult.CertificateNotBefore.Value)
{
warnings.Add("Certificate not yet valid");
score *= 0.5;
}
if (context.SignatureResult.CertificateNotAfter.HasValue &&
now > context.SignatureResult.CertificateNotAfter.Value)
{
warnings.Add("Certificate has expired");
score *= 0.7;
}
return score;
}
private double ComputeTransparencyLogFactor(TrustComputationContext context)
{
if (context.SignatureResult?.TransparencyLog is null)
{
return 0.5; // Neutral for no transparency log
}
// Having a transparency log entry adds trust
return 1.0;
}
private double ComputeFreshnessFactor(TrustComputationContext context, DateTimeOffset now)
{
var timestamp = context.DocumentUpdatedAt ?? context.StatementIssuedAt;
if (!timestamp.HasValue)
{
return 0.7; // Slightly lower for unknown age
}
var age = now - timestamp.Value;
if (age < TimeSpan.Zero)
{
// Future timestamp - suspicious
return 0.5;
}
// Exponential decay based on half-life
var halfLifeDays = Configuration.FreshnessHalfLifeDays;
var ageDays = age.TotalDays;
var decayFactor = Math.Pow(0.5, ageDays / halfLifeDays);
// Apply minimum freshness floor
return Math.Max(decayFactor, Configuration.MinimumFreshness);
}
private double ComputeStatusQualityFactor(TrustComputationContext context)
{
var score = 0.5; // Base score
// Having a justification adds quality
if (context.HasJustification)
{
score += 0.3;
}
// Certain statuses indicate more definitive analysis
if (!string.IsNullOrEmpty(context.Status))
{
var status = context.Status.ToLowerInvariant();
score += status switch
{
"not_affected" => 0.2, // Requires analysis to determine
"fixed" => 0.15, // Clear actionable status
"affected" => 0.1, // Acknowledgment
_ => 0.0
};
}
return Math.Min(score, 1.0);
}
private double ComputeSourceMatchFactor(TrustComputationContext context)
{
if (context.SourceUriMatchScore.HasValue)
{
return context.SourceUriMatchScore.Value;
}
return 0.5; // Neutral for unknown source match
}
private double ComputeProductAuthorityFactor(TrustComputationContext context)
{
// If issuer is authoritative for this product, full score
if (context.IsAuthorativeForProduct)
{
return 1.0;
}
// If issuer is a vendor, they might still be authoritative for their products
if (context.Issuer?.Category == IssuerCategory.Vendor)
{
return 0.8;
}
// Distributors are authoritative for their packaged versions
if (context.Issuer?.Category == IssuerCategory.Distributor)
{
return 0.75;
}
return 0.5; // Neutral for third-party assessment
}
private string GenerateExplanation(
TrustComputationContext context,
Dictionary<TrustFactor, double> factors,
double finalWeight)
{
var parts = new List<string>
{
$"Trust weight: {finalWeight:P1}"
};
// Add top contributing factors
var topFactors = factors
.Where(f => Configuration.FactorWeights.TryGetValue(f.Key, out var w) && w > 0)
.OrderByDescending(f => f.Value * Configuration.FactorWeights[f.Key])
.Take(3)
.Select(f => $"{f.Key}: {f.Value:P0}");
parts.Add($"Top factors: {string.Join(", ", topFactors)}");
if (context.Issuer != null)
{
parts.Add($"Issuer: {context.Issuer.DisplayName} ({context.Issuer.TrustTier})");
}
else
{
parts.Add("Issuer: Unknown");
}
if (context.SignatureResult != null)
{
parts.Add($"Signature: {(context.SignatureResult.Valid ? "Valid" : "Invalid")}");
}
else
{
parts.Add("Signature: None");
}
return string.Join("; ", parts);
}
}

View File

@@ -16,4 +16,10 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
<!-- Exclude legacy folders with external dependencies -->
<ItemGroup>
<Compile Remove="StellaOps.VexLens.Core\**" />
<Compile Remove="__Tests\**" />
</ItemGroup>
</Project>

View File

@@ -2,17 +2,17 @@
| Task ID | Status | Sprint | Dependency | Notes |
| --- | --- | --- | --- | --- |
| VEXLENS-30-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | — | Unblocked 2025-12-05: vex-normalization.schema.json + api-baseline.schema.json created. |
| VEXLENS-30-002 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-001 | Product mapping library; depends on normalization shapes. |
| VEXLENS-30-003 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-002 | Signature verification (Ed25519/DSSE/PKIX). |
| VEXLENS-30-004 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-003 | Trust weighting engine. |
| VEXLENS-30-005 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-004 | Consensus algorithm. |
| VEXLENS-30-006 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-005 | Projection storage/events. |
| VEXLENS-30-007 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-006 | Consensus APIs + OpenAPI. |
| VEXLENS-30-008 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-007 | Policy Engine/Vuln Explorer integration. |
| VEXLENS-30-009 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-008 | Telemetry (metrics/logs/traces). |
| VEXLENS-30-010 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-009 | Tests + determinism harness. |
| VEXLENS-30-011 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-010 | Deployment/runbooks/offline kit. |
| VEXLENS-30-001 | DONE | SPRINT_0129_0001_0001_policy_reasoning | — | Completed 2025-12-06: Implemented VexLensNormalizer with format detection, fallback parsing, and Excititor integration. 20 unit tests pass. |
| VEXLENS-30-002 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-001 | Completed 2025-12-06: Implemented IProductMapper, PurlParser, CpeParser, ProductMapper with PURL/CPE parsing, identity matching (Exact/Normal/Loose/Fuzzy), and 69 unit tests pass. |
| VEXLENS-30-003 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-002 | Completed 2025-12-06: Implemented ISignatureVerifier, IIssuerDirectory, InMemoryIssuerDirectory, SignatureVerifier with DSSE/JWS/Ed25519/ECDSA support. Build succeeds. |
| VEXLENS-30-004 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-003 | Completed 2025-12-06: Implemented ITrustWeightEngine, TrustWeightEngine with 9 trust factors (issuer, signature, freshness, etc.) and configurable weights. Build succeeds. |
| VEXLENS-30-005 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-004 | Completed 2025-12-06: Implemented IVexConsensusEngine, VexConsensusEngine with 5 consensus modes (HighestWeight, WeightedVote, Lattice, AuthoritativeFirst, MostRecent) and VEX status lattice semantics. Build succeeds. |
| VEXLENS-30-006 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-005 | Completed 2025-12-06: IConsensusProjectionStore, InMemoryConsensusProjectionStore, IConsensusEventEmitter with ConsensusComputedEvent/StatusChangedEvent/ConflictDetectedEvent. Build succeeds. |
| VEXLENS-30-007 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-006 | Completed 2025-12-06: IVexLensApiService, VexLensApiService with full consensus/projection/issuer APIs. OpenAPI spec at docs/api/vexlens-openapi.yaml. Build succeeds. |
| VEXLENS-30-008 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-007 | Completed 2025-12-06: IPolicyEngineIntegration, PolicyEngineIntegration, IVulnExplorerIntegration, VulnExplorerIntegration with VEX suppression checking, severity adjustment, enrichment, and search APIs. Build succeeds. |
| VEXLENS-30-009 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-008 | Completed 2025-12-06: VexLensMetrics with full OpenTelemetry metrics, VexLensActivitySource for tracing, VexLensLogEvents for structured logging. Build succeeds. |
| VEXLENS-30-010 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-009 | Completed 2025-12-06: VexLensTestHarness, DeterminismHarness with determinism verification for normalization/consensus/trust, VexLensTestData generators. Build succeeds. |
| VEXLENS-30-011 | DONE | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-010 | Completed 2025-12-06: Architecture doc, deployment runbook, offline kit guide at docs/modules/vexlens/. OpenAPI spec at docs/api/vexlens-openapi.yaml. |
| VEXLENS-AIAI-31-001 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Consensus rationale API enhancements; needs consensus API finalization. |
| VEXLENS-AIAI-31-002 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-AIAI-31-001 | Caching hooks for Advisory AI; requires rationale API shape. |
| VEXLENS-EXPORT-35-001 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Snapshot API for mirror bundles; export profile pending. |

View File

@@ -0,0 +1,427 @@
using System.Collections.Immutable;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.VexLens.Core.Models;
using StellaOps.VexLens.Core.Normalization;
namespace StellaOps.VexLens.Core.Tests.Normalization;
public sealed class VexLensNormalizerTests
{
private readonly FakeTimeProvider _timeProvider = new();
#region Format Detection Tests
[Fact]
public void DetectFormat_EmptyDocument_ReturnsNull()
{
var normalizer = CreateNormalizer();
var result = normalizer.DetectFormat(ReadOnlyMemory<byte>.Empty);
result.Should().BeNull();
}
[Fact]
public void DetectFormat_InvalidJson_ReturnsNull()
{
var normalizer = CreateNormalizer();
var doc = Encoding.UTF8.GetBytes("not valid json");
var result = normalizer.DetectFormat(doc);
result.Should().BeNull();
}
[Fact]
public void DetectFormat_OpenVexDocument_ReturnsOpenVex()
{
var normalizer = CreateNormalizer();
var doc = Encoding.UTF8.GetBytes(OpenVexSample);
var result = normalizer.DetectFormat(doc);
result.Should().Be(VexSourceFormat.OpenVex);
}
[Fact]
public void DetectFormat_CsafDocument_ReturnsCsafVex()
{
var normalizer = CreateNormalizer();
var doc = Encoding.UTF8.GetBytes(CsafVexSample);
var result = normalizer.DetectFormat(doc);
result.Should().Be(VexSourceFormat.CsafVex);
}
[Fact]
public void DetectFormat_CycloneDxDocument_ReturnsCycloneDxVex()
{
var normalizer = CreateNormalizer();
var doc = Encoding.UTF8.GetBytes(CycloneDxVexSample);
var result = normalizer.DetectFormat(doc);
result.Should().Be(VexSourceFormat.CycloneDxVex);
}
[Fact]
public void DetectFormat_SpdxDocument_ReturnsSpdxVex()
{
var normalizer = CreateNormalizer();
var doc = Encoding.UTF8.GetBytes(SpdxSample);
var result = normalizer.DetectFormat(doc);
result.Should().Be(VexSourceFormat.SpdxVex);
}
#endregion
#region Normalization Tests
[Fact]
public async Task NormalizeAsync_EmptyDocument_ThrowsArgumentException()
{
var normalizer = CreateNormalizer();
var act = () => normalizer.NormalizeAsync(
ReadOnlyMemory<byte>.Empty,
VexSourceFormat.OpenVex);
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
[Fact]
public async Task NormalizeAsync_OpenVexDocument_FallbackExtractsStatements()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(OpenVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex, "https://example.com/vex.json");
result.Should().NotBeNull();
result.SchemaVersion.Should().Be(1);
result.SourceFormat.Should().Be(VexSourceFormat.OpenVex);
result.SourceUri.Should().Be("https://example.com/vex.json");
result.SourceDigest.Should().StartWith("sha256:");
result.DocumentId.Should().StartWith("openvex:");
result.Statements.Should().NotBeEmpty();
result.Provenance.Should().NotBeNull();
result.Provenance!.Normalizer.Should().Contain("vexlens");
result.Provenance.TransformationRules.Should().Contain("fallback:generic");
}
[Fact]
public async Task NormalizeAsync_CycloneDxDocument_FallbackExtractsStatements()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(CycloneDxVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.CycloneDxVex);
result.Should().NotBeNull();
result.SourceFormat.Should().Be(VexSourceFormat.CycloneDxVex);
result.DocumentId.Should().StartWith("cdx:");
result.Statements.Should().NotBeEmpty();
}
[Fact]
public async Task NormalizeAsync_OpenVexDocument_ExtractsCorrectVulnerabilityId()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(OpenVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
result.Statements.Should().Contain(s => s.VulnerabilityId == "CVE-2023-12345");
}
[Fact]
public async Task NormalizeAsync_OpenVexDocument_ExtractsCorrectProduct()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(OpenVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
var statement = result.Statements.FirstOrDefault(s => s.VulnerabilityId == "CVE-2023-12345");
statement.Should().NotBeNull();
statement!.Product.Key.Should().Be("pkg:npm/example-package@1.0.0");
statement.Product.Purl.Should().Be("pkg:npm/example-package@1.0.0");
}
[Fact]
public async Task NormalizeAsync_OpenVexDocument_ExtractsCorrectStatus()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(OpenVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
var statement = result.Statements.FirstOrDefault(s => s.VulnerabilityId == "CVE-2023-12345");
statement.Should().NotBeNull();
statement!.Status.Should().Be(VexStatus.NotAffected);
}
[Fact]
public async Task NormalizeAsync_OpenVexDocument_ExtractsCorrectJustification()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(OpenVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
var statement = result.Statements.FirstOrDefault(s => s.VulnerabilityId == "CVE-2023-12345");
statement.Should().NotBeNull();
statement!.Justification.Should().Be(VexJustificationType.VulnerableCodeNotPresent);
}
[Fact]
public async Task NormalizeAsync_CycloneDxDocument_ExtractsVulnerability()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(CycloneDxVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.CycloneDxVex);
result.Statements.Should().Contain(s => s.VulnerabilityId == "CVE-2023-67890");
}
[Fact]
public async Task NormalizeAsync_CycloneDxDocument_ExtractsAnalysisState()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(CycloneDxVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.CycloneDxVex);
var statement = result.Statements.FirstOrDefault(s => s.VulnerabilityId == "CVE-2023-67890");
statement.Should().NotBeNull();
statement!.Status.Should().Be(VexStatus.Fixed);
}
[Fact]
public async Task NormalizeAsync_ProducesDigestFromContent()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(OpenVexSample);
var result1 = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
var result2 = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
// Same content should produce same digest
result1.SourceDigest.Should().Be(result2.SourceDigest);
}
[Fact]
public async Task NormalizeAsync_DifferentContent_ProducesDifferentDigest()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc1 = Encoding.UTF8.GetBytes(OpenVexSample);
var doc2 = Encoding.UTF8.GetBytes(CycloneDxVexSample);
var result1 = await normalizer.NormalizeAsync(doc1, VexSourceFormat.OpenVex);
var result2 = await normalizer.NormalizeAsync(doc2, VexSourceFormat.CycloneDxVex);
result1.SourceDigest.Should().NotBe(result2.SourceDigest);
}
[Fact]
public async Task NormalizeAsync_StatementsAreOrderedDeterministically()
{
var normalizer = CreateNormalizer(withRegistry: false);
var doc = Encoding.UTF8.GetBytes(MultiStatementOpenVex);
var result1 = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
var result2 = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
// Order should be deterministic
result1.Statements.Select(s => s.VulnerabilityId)
.Should().Equal(result2.Statements.Select(s => s.VulnerabilityId));
}
[Fact]
public async Task NormalizeAsync_WithExcititorRegistry_UsesRegisteredNormalizer()
{
var mockNormalizer = new StubVexNormalizer();
var registry = new VexNormalizerRegistry(ImmutableArray.Create<IVexNormalizer>(mockNormalizer));
var normalizer = CreateNormalizer(registry);
var doc = Encoding.UTF8.GetBytes(OpenVexSample);
var result = await normalizer.NormalizeAsync(doc, VexSourceFormat.OpenVex);
mockNormalizer.WasCalled.Should().BeTrue();
result.Statements.Should().HaveCount(1);
result.Statements[0].VulnerabilityId.Should().Be("STUB-CVE");
}
#endregion
#region Supported Formats Tests
[Fact]
public void SupportedFormats_ReturnsExpectedFormats()
{
var normalizer = CreateNormalizer();
normalizer.SupportedFormats.Should().Contain(VexSourceFormat.OpenVex);
normalizer.SupportedFormats.Should().Contain(VexSourceFormat.CsafVex);
normalizer.SupportedFormats.Should().Contain(VexSourceFormat.CycloneDxVex);
}
#endregion
#region Helper Methods
private VexLensNormalizer CreateNormalizer(bool withRegistry = true)
{
var registry = withRegistry
? new VexNormalizerRegistry(ImmutableArray<IVexNormalizer>.Empty)
: new VexNormalizerRegistry(ImmutableArray<IVexNormalizer>.Empty);
return new VexLensNormalizer(
registry,
_timeProvider,
NullLogger<VexLensNormalizer>.Instance);
}
private VexLensNormalizer CreateNormalizer(VexNormalizerRegistry registry)
{
return new VexLensNormalizer(
registry,
_timeProvider,
NullLogger<VexLensNormalizer>.Instance);
}
#endregion
#region Sample Documents
private const string OpenVexSample = """
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example.com/vex/12345",
"author": "Example Inc.",
"timestamp": "2023-12-01T00:00:00Z",
"statements": [
{
"vulnerability": "CVE-2023-12345",
"products": ["pkg:npm/example-package@1.0.0"],
"status": "not_affected",
"justification": "vulnerable_code_not_present",
"statement": "The vulnerable code path is not included in this build."
}
]
}
""";
private const string CsafVexSample = """
{
"document": {
"csaf_version": "2.0",
"category": "csaf_vex",
"title": "Example VEX Document",
"publisher": {
"name": "Example Inc."
}
},
"vulnerabilities": []
}
""";
private const string CycloneDxVexSample = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"vulnerabilities": [
{
"id": "CVE-2023-67890",
"analysis": {
"state": "fixed",
"detail": "Fixed in version 2.0.0"
},
"affects": [
{
"ref": "pkg:npm/other-package@1.5.0"
}
]
}
]
}
""";
private const string SpdxSample = """
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT"
}
""";
private const string MultiStatementOpenVex = """
{
"@context": "https://openvex.dev/ns/v0.2.0",
"statements": [
{
"vulnerability": "CVE-2023-99999",
"products": ["pkg:npm/z-package@1.0.0"],
"status": "affected"
},
{
"vulnerability": "CVE-2023-11111",
"products": ["pkg:npm/a-package@1.0.0"],
"status": "fixed"
},
{
"vulnerability": "CVE-2023-55555",
"products": ["pkg:npm/m-package@1.0.0"],
"status": "not_affected",
"justification": "component_not_present"
}
]
}
""";
#endregion
#region Test Doubles
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2024, 1, 15, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void SetNow(DateTimeOffset value) => _now = value;
}
private sealed class StubVexNormalizer : IVexNormalizer
{
public bool WasCalled { get; private set; }
public string Format => "openvex";
public bool CanHandle(VexRawDocument document) =>
document.Format == VexDocumentFormat.OpenVex;
public ValueTask<VexClaimBatch> NormalizeAsync(
VexRawDocument document,
VexProvider provider,
CancellationToken cancellationToken)
{
WasCalled = true;
var claim = new VexClaim(
vulnerabilityId: "STUB-CVE",
providerId: provider.Id,
product: new VexProduct("pkg:test/stub@1.0.0", "Stub Package"),
status: VexClaimStatus.NotAffected,
document: new VexClaimDocument(
VexDocumentFormat.OpenVex,
"sha256:stubhash",
document.SourceUri),
firstSeen: DateTimeOffset.UtcNow,
lastSeen: DateTimeOffset.UtcNow,
justification: VexJustification.ComponentNotPresent);
var batch = new VexClaimBatch(
document,
ImmutableArray.Create(claim),
ImmutableDictionary<string, string>.Empty);
return ValueTask.FromResult(batch);
}
}
#endregion
}

View File

@@ -0,0 +1,184 @@
using FluentAssertions;
using StellaOps.VexLens.Core.ProductMapping;
namespace StellaOps.VexLens.Core.Tests.ProductMapping;
public sealed class CpeParserTests
{
#region CPE 2.3 Tests
[Fact]
public void TryParse_Cpe23_ValidFormat_ReturnsTrue()
{
var result = CpeParser.TryParse("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Type.Should().Be(ProductIdentifierType.Cpe);
}
[Fact]
public void TryParse_Cpe23_ExtractsVendorAndProduct()
{
var result = CpeParser.TryParse("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", out var identity);
result.Should().BeTrue();
identity!.Namespace.Should().Be("apache");
identity.Name.Should().Be("log4j");
identity.Version.Should().Be("2.14.0");
}
[Fact]
public void TryParse_Cpe23_WithWildcards_HandlesCorrectly()
{
var result = CpeParser.TryParse("cpe:2.3:a:microsoft:windows:*:*:*:*:*:*:*:*", out var identity);
result.Should().BeTrue();
identity!.Namespace.Should().Be("microsoft");
identity.Name.Should().Be("windows");
identity.Version.Should().BeNull();
}
[Fact]
public void TryParse_Cpe23_MinimalFormat_Parses()
{
var result = CpeParser.TryParse("cpe:2.3:a:vendor:product:1.0", out var identity);
result.Should().BeTrue();
identity!.Namespace.Should().Be("vendor");
identity.Name.Should().Be("product");
identity.Version.Should().Be("1.0");
}
#endregion
#region CPE 2.2 Tests
[Fact]
public void TryParse_Cpe22_ValidFormat_ReturnsTrue()
{
var result = CpeParser.TryParse("cpe:/a:apache:log4j:2.14.0", out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Type.Should().Be(ProductIdentifierType.Cpe);
}
[Fact]
public void TryParse_Cpe22_ExtractsVendorAndProduct()
{
var result = CpeParser.TryParse("cpe:/a:apache:log4j:2.14.0", out var identity);
result.Should().BeTrue();
identity!.Namespace.Should().Be("apache");
identity.Name.Should().Be("log4j");
identity.Version.Should().Be("2.14.0");
}
[Fact]
public void TryParse_Cpe22_WithoutVersion_VersionIsNull()
{
var result = CpeParser.TryParse("cpe:/a:vendor:product", out var identity);
result.Should().BeTrue();
identity!.Version.Should().BeNull();
}
[Fact]
public void TryParse_Cpe22_OperatingSystem_ParsesPart()
{
var result = CpeParser.TryParse("cpe:/o:microsoft:windows:10", out var identity);
result.Should().BeTrue();
identity!.Ecosystem.Should().Be("cpe:o");
identity.Namespace.Should().Be("microsoft");
identity.Name.Should().Be("windows");
}
[Fact]
public void TryParse_Cpe22_Hardware_ParsesPart()
{
var result = CpeParser.TryParse("cpe:/h:cisco:router:1234", out var identity);
result.Should().BeTrue();
identity!.Ecosystem.Should().Be("cpe:h");
}
#endregion
#region Invalid Input Tests
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("not-a-cpe")]
[InlineData("pkg:npm/express")]
[InlineData("cpe:invalid")]
public void TryParse_InvalidCpe_ReturnsFalse(string? cpe)
{
var result = CpeParser.TryParse(cpe!, out var identity);
result.Should().BeFalse();
identity.Should().BeNull();
}
#endregion
#region Detection Tests
[Theory]
[InlineData("cpe:/a:vendor:product", true)]
[InlineData("cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", true)]
[InlineData("CPE:/A:VENDOR:PRODUCT", true)]
[InlineData("pkg:npm/express", false)]
[InlineData("random-string", false)]
public void IsCpe_ReturnsExpectedResult(string identifier, bool expected)
{
CpeParser.IsCpe(identifier).Should().Be(expected);
}
#endregion
#region Canonical Key Tests
[Fact]
public void TryParse_GeneratesCanonicalKey()
{
var result = CpeParser.TryParse("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*", out var identity);
result.Should().BeTrue();
identity!.CanonicalKey.Should().Be("cpe/apache/log4j@2.14.0");
}
[Fact]
public void TryParse_CanonicalKey_WithoutVersion()
{
var result = CpeParser.TryParse("cpe:/a:vendor:product", out var identity);
result.Should().BeTrue();
identity!.CanonicalKey.Should().Be("cpe/vendor/product");
}
#endregion
#region Parse Method Tests
[Fact]
public void Parse_InvalidCpe_ThrowsFormatException()
{
var act = () => CpeParser.Parse("not-a-cpe");
act.Should().Throw<FormatException>();
}
[Fact]
public void Parse_ValidCpe_ReturnsIdentity()
{
var identity = CpeParser.Parse("cpe:/a:vendor:product:1.0");
identity.Should().NotBeNull();
identity.Name.Should().Be("product");
}
#endregion
}

View File

@@ -0,0 +1,315 @@
using FluentAssertions;
using StellaOps.VexLens.Core.ProductMapping;
namespace StellaOps.VexLens.Core.Tests.ProductMapping;
public sealed class ProductMapperTests
{
private readonly ProductMapper _mapper = new();
#region Parse Tests
[Fact]
public void Parse_Purl_ReturnsProductIdentity()
{
var identity = _mapper.Parse("pkg:npm/express@4.18.2");
identity.Should().NotBeNull();
identity!.Type.Should().Be(ProductIdentifierType.Purl);
identity.Name.Should().Be("express");
}
[Fact]
public void Parse_Cpe_ReturnsProductIdentity()
{
var identity = _mapper.Parse("cpe:2.3:a:apache:log4j:2.14.0:*:*:*:*:*:*:*");
identity.Should().NotBeNull();
identity!.Type.Should().Be(ProductIdentifierType.Cpe);
identity.Name.Should().Be("log4j");
}
[Fact]
public void Parse_CustomIdentifier_ReturnsCustomType()
{
var identity = _mapper.Parse("custom-product-identifier");
identity.Should().NotBeNull();
identity!.Type.Should().Be(ProductIdentifierType.Custom);
identity.Name.Should().Be("custom-product-identifier");
}
[Fact]
public void Parse_NullOrEmpty_ReturnsNull()
{
_mapper.Parse(null!).Should().BeNull();
_mapper.Parse("").Should().BeNull();
_mapper.Parse(" ").Should().BeNull();
}
#endregion
#region Match Tests - Exact Strictness
[Fact]
public void Match_ExactStrictness_SameCanonicalKey_ReturnsMatch()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("pkg:npm/express@4.18.2")!;
var result = _mapper.Match(a, b, MatchStrictness.Exact);
result.IsMatch.Should().BeTrue();
result.Confidence.Should().Be(1.0);
}
[Fact]
public void Match_ExactStrictness_DifferentVersion_ReturnsNoMatch()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("pkg:npm/express@4.18.1")!;
var result = _mapper.Match(a, b, MatchStrictness.Exact);
result.IsMatch.Should().BeFalse();
}
#endregion
#region Match Tests - Normal Strictness
[Fact]
public void Match_NormalStrictness_SamePackageDifferentVersion_MatchesWithLowerConfidence()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("pkg:npm/express@3.0.0")!;
var result = _mapper.Match(a, b, MatchStrictness.Normal);
// Normal strictness matches by name/ecosystem, version mismatch reduces confidence
result.IsMatch.Should().BeTrue();
result.Confidence.Should().BeLessThan(1.0);
result.MismatchedFields.Should().Contain("Version");
}
[Fact]
public void Match_NormalStrictness_SamePackageCompatibleVersion_ReturnsMatch()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("pkg:npm/express@4.18.2")!;
var result = _mapper.Match(a, b, MatchStrictness.Normal);
result.IsMatch.Should().BeTrue();
result.Confidence.Should().BeGreaterThan(0.6);
}
[Fact]
public void Match_NormalStrictness_DifferentPackages_ReturnsNoMatch()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("pkg:npm/lodash@4.18.2")!;
var result = _mapper.Match(a, b, MatchStrictness.Normal);
result.IsMatch.Should().BeFalse();
}
[Fact]
public void Match_NormalStrictness_DifferentEcosystems_ReturnsNoMatch()
{
var a = _mapper.Parse("pkg:npm/request@2.88.2")!;
var b = _mapper.Parse("pkg:pypi/requests@2.88.2")!;
var result = _mapper.Match(a, b, MatchStrictness.Normal);
result.IsMatch.Should().BeFalse();
}
#endregion
#region Match Tests - Loose Strictness
[Fact]
public void Match_LooseStrictness_SamePackageDifferentVersion_ReturnsMatch()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("pkg:npm/express@3.0.0")!;
var result = _mapper.Match(a, b, MatchStrictness.Loose);
result.IsMatch.Should().BeTrue();
}
[Fact]
public void Match_LooseStrictness_NoVersion_ReturnsMatch()
{
var a = _mapper.Parse("pkg:npm/express")!;
var b = _mapper.Parse("pkg:npm/express@4.18.2")!;
var result = _mapper.Match(a, b, MatchStrictness.Loose);
result.IsMatch.Should().BeTrue();
}
#endregion
#region Match Tests - Fuzzy Strictness
[Fact]
public void Match_FuzzyStrictness_SimilarNames_ReturnsMatch()
{
var a = _mapper.Parse("express-js")!;
var b = _mapper.Parse("express")!;
var result = _mapper.Match(a, b, MatchStrictness.Fuzzy);
result.IsMatch.Should().BeTrue();
result.Confidence.Should().BeGreaterThan(0.4);
}
[Fact]
public void Match_FuzzyStrictness_CompletelyDifferent_LowConfidence()
{
var a = _mapper.Parse("aaaaaa")!; // Very different from lodash
var b = _mapper.Parse("zzzzzz")!;
var result = _mapper.Match(a, b, MatchStrictness.Fuzzy);
// Completely different names have very low confidence
result.Confidence.Should().BeLessThanOrEqualTo(0.4);
}
#endregion
#region Match Tests - Cross-Type Matching
[Fact]
public void Match_PurlAndCpe_IncompatibleTypes_ReturnsNoMatch()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("cpe:2.3:a:vendor:express:4.18.2:*:*:*:*:*:*:*")!;
var result = _mapper.Match(a, b, MatchStrictness.Normal);
result.IsMatch.Should().BeFalse();
result.Reason.Should().Contain("Incompatible");
}
[Fact]
public void Match_CustomAndPurl_CanMatch()
{
var a = _mapper.Parse("express")!;
var b = _mapper.Parse("pkg:npm/express@4.18.2")!;
var result = _mapper.Match(a, b, MatchStrictness.Fuzzy);
// Custom type should be compatible with other types for fuzzy matching
result.Confidence.Should().BeGreaterThan(0);
}
#endregion
#region FindMatches Tests
[Fact]
public void FindMatches_ReturnsMatchesOrderedByConfidence()
{
var target = _mapper.Parse("pkg:npm/express@4.18.2")!;
var candidates = new[]
{
_mapper.Parse("pkg:npm/express@4.18.2")!,
_mapper.Parse("pkg:npm/express@4.18.1")!,
_mapper.Parse("pkg:npm/express@4.17.0")!,
_mapper.Parse("pkg:npm/lodash@4.18.2")!
};
var matches = _mapper.FindMatches(target, candidates, MatchStrictness.Exact);
matches.Should().HaveCount(1); // Only exact match
matches[0].Candidate.Version.Should().Be("4.18.2");
}
[Fact]
public void FindMatches_LooseStrictness_ReturnsMultipleMatches()
{
var target = _mapper.Parse("pkg:npm/express")!;
var candidates = new[]
{
_mapper.Parse("pkg:npm/express@4.18.2")!,
_mapper.Parse("pkg:npm/express@4.18.1")!,
_mapper.Parse("pkg:npm/lodash@4.18.2")!
};
var matches = _mapper.FindMatches(target, candidates, MatchStrictness.Loose);
matches.Should().HaveCount(2); // Both express versions
}
[Fact]
public void FindMatches_EmptyCandidates_ReturnsEmpty()
{
var target = _mapper.Parse("pkg:npm/express@4.18.2")!;
var matches = _mapper.FindMatches(target, Array.Empty<ProductIdentity>());
matches.Should().BeEmpty();
}
#endregion
#region Normalize Tests
[Fact]
public void Normalize_Purl_ReturnsCanonicalKey()
{
var normalized = _mapper.Normalize("pkg:npm/Express@4.18.2");
normalized.Should().Be("pkg/npm/express@4.18.2");
}
[Fact]
public void Normalize_CustomIdentifier_ReturnsLowercase()
{
var normalized = _mapper.Normalize("Custom-Product");
normalized.Should().Be("custom-product");
}
[Fact]
public void Normalize_TrimsWhitespace()
{
var normalized = _mapper.Normalize(" custom-product ");
normalized.Should().Be("custom-product");
}
#endregion
#region MatchResult Fields Tests
[Fact]
public void Match_PopulatesMatchedFields()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("pkg:npm/express@4.18.2")!;
var result = _mapper.Match(a, b, MatchStrictness.Normal);
result.MatchedFields.Should().Contain("Name");
result.MatchedFields.Should().Contain("Ecosystem");
}
[Fact]
public void Match_PopulatesMismatchedFields()
{
var a = _mapper.Parse("pkg:npm/express@4.18.2")!;
var b = _mapper.Parse("pkg:npm/lodash@4.18.2")!;
var result = _mapper.Match(a, b, MatchStrictness.Normal);
result.MismatchedFields.Should().Contain("Name");
}
#endregion
}

View File

@@ -0,0 +1,143 @@
using FluentAssertions;
using StellaOps.VexLens.Core.ProductMapping;
namespace StellaOps.VexLens.Core.Tests.ProductMapping;
public sealed class PurlParserTests
{
[Theory]
[InlineData("pkg:npm/express@4.18.2")]
[InlineData("pkg:maven/org.apache.commons/commons-lang3@3.12.0")]
[InlineData("pkg:pypi/requests@2.28.1")]
[InlineData("pkg:nuget/Newtonsoft.Json@13.0.1")]
public void TryParse_ValidPurl_ReturnsTrue(string purl)
{
var result = PurlParser.TryParse(purl, out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Type.Should().Be(ProductIdentifierType.Purl);
}
[Fact]
public void TryParse_NpmPurl_ExtractsCorrectFields()
{
var result = PurlParser.TryParse("pkg:npm/express@4.18.2", out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Ecosystem.Should().Be("npm");
identity.Name.Should().Be("express");
identity.Version.Should().Be("4.18.2");
identity.Namespace.Should().BeNull();
}
[Fact]
public void TryParse_ScopedNpmPurl_ExtractsNamespace()
{
var result = PurlParser.TryParse("pkg:npm/@angular/core@15.0.0", out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Ecosystem.Should().Be("npm");
identity.Namespace.Should().Be("@angular");
identity.Name.Should().Be("core");
identity.Version.Should().Be("15.0.0");
}
[Fact]
public void TryParse_MavenPurl_ExtractsGroupId()
{
var result = PurlParser.TryParse("pkg:maven/org.apache.commons/commons-lang3@3.12.0", out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Ecosystem.Should().Be("maven");
identity.Namespace.Should().Be("org.apache.commons");
identity.Name.Should().Be("commons-lang3");
identity.Version.Should().Be("3.12.0");
}
[Fact]
public void TryParse_PurlWithQualifiers_ExtractsQualifiers()
{
var result = PurlParser.TryParse("pkg:deb/debian/curl@7.74.0-1.3?arch=amd64&distro=debian-11", out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Qualifiers.Should().NotBeNull();
identity.Qualifiers!["arch"].Should().Be("amd64");
identity.Qualifiers["distro"].Should().Be("debian-11");
}
[Fact]
public void TryParse_PurlWithSubpath_ExtractsSubpath()
{
var result = PurlParser.TryParse("pkg:github/package-url/purl-spec@main#src/test", out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Subpath.Should().Be("src/test");
}
[Fact]
public void TryParse_PurlWithoutVersion_VersionIsNull()
{
var result = PurlParser.TryParse("pkg:npm/lodash", out var identity);
result.Should().BeTrue();
identity.Should().NotBeNull();
identity!.Name.Should().Be("lodash");
identity.Version.Should().BeNull();
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("not-a-purl")]
[InlineData("http://example.com")]
[InlineData("cpe:/a:vendor:product:1.0")]
public void TryParse_InvalidPurl_ReturnsFalse(string? purl)
{
var result = PurlParser.TryParse(purl!, out var identity);
result.Should().BeFalse();
identity.Should().BeNull();
}
[Theory]
[InlineData("pkg:npm/express", true)]
[InlineData("PKG:NPM/EXPRESS", true)]
[InlineData("cpe:/a:vendor:product", false)]
[InlineData("random-string", false)]
public void IsPurl_ReturnsExpectedResult(string identifier, bool expected)
{
PurlParser.IsPurl(identifier).Should().Be(expected);
}
[Fact]
public void Parse_InvalidPurl_ThrowsFormatException()
{
var act = () => PurlParser.Parse("not-a-purl");
act.Should().Throw<FormatException>();
}
[Fact]
public void TryParse_NpmPurl_NormalizesNameToLowercase()
{
var result = PurlParser.TryParse("pkg:npm/Express@4.0.0", out var identity);
result.Should().BeTrue();
identity!.Name.Should().Be("express");
}
[Fact]
public void TryParse_GeneratesCanonicalKey()
{
var result = PurlParser.TryParse("pkg:npm/@scope/package@1.0.0", out var identity);
result.Should().BeTrue();
identity!.CanonicalKey.Should().Be("pkg/npm/@scope/package@1.0.0");
}
}

View File

@@ -0,0 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.VexLens.Core/StellaOps.VexLens.Core.csproj" />
</ItemGroup>
</Project>