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:
@@ -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"]);
|
||||
}
|
||||
}
|
||||
581
src/VexLens/StellaOps.VexLens/Export/IConsensusExportService.cs
Normal file
581
src/VexLens/StellaOps.VexLens/Export/IConsensusExportService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
119
src/VexLens/StellaOps.VexLens/Orchestration/ConsensusJobTypes.cs
Normal file
119
src/VexLens/StellaOps.VexLens/Orchestration/ConsensusJobTypes.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user