This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

@@ -0,0 +1,223 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Text.Json;
namespace StellaOps.RiskEngine.Core.Providers;
/// <summary>
/// Loads EPSS data from offline risk bundles.
/// </summary>
public interface IEpssBundleLoader
{
/// <summary>
/// Loads EPSS data from a risk bundle archive.
/// </summary>
Task<EpssLoadResult> LoadFromBundleAsync(
string bundlePath,
CancellationToken cancellationToken = default);
/// <summary>
/// Loads EPSS data from an extracted bundle directory.
/// </summary>
Task<EpssLoadResult> LoadFromDirectoryAsync(
string directoryPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Loads EPSS data from a snapshot file (gzip-compressed JSON).
/// </summary>
Task<EpssLoadResult> LoadFromSnapshotAsync(
string snapshotPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Loads EPSS data from a stream (gzip-compressed JSON).
/// </summary>
Task<EpssLoadResult> LoadFromStreamAsync(
Stream stream,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of loading EPSS data from a bundle.
/// </summary>
public sealed record EpssLoadResult(
IEpssSource Source,
DateOnly ModelDate,
int RecordCount,
DateTimeOffset? FetchedAt);
/// <summary>
/// Default implementation of EPSS bundle loader.
/// </summary>
public sealed class EpssBundleLoader : IEpssBundleLoader
{
private const string EpssProviderPath = "providers/first-epss/snapshot";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public async Task<EpssLoadResult> LoadFromBundleAsync(
string bundlePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
if (!File.Exists(bundlePath))
throw new FileNotFoundException($"Bundle file not found: {bundlePath}", bundlePath);
await using var fileStream = new FileStream(
bundlePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
using var tarReader = new TarReader(gzipStream);
while (await tarReader.GetNextEntryAsync(copyData: false, cancellationToken).ConfigureAwait(false) is { } entry)
{
if (entry.Name.Equals(EpssProviderPath, StringComparison.OrdinalIgnoreCase))
{
if (entry.DataStream is null)
throw new InvalidOperationException("EPSS snapshot entry has no data stream.");
// Copy to memory since tar streams can't seek
using var memoryStream = new MemoryStream();
await entry.DataStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
memoryStream.Position = 0;
return await LoadFromStreamAsync(memoryStream, cancellationToken).ConfigureAwait(false);
}
}
throw new InvalidOperationException($"EPSS provider not found in bundle at path: {EpssProviderPath}");
}
public async Task<EpssLoadResult> LoadFromDirectoryAsync(
string directoryPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directoryPath);
var snapshotPath = Path.Combine(directoryPath, "providers", "first-epss", "snapshot");
if (!File.Exists(snapshotPath))
{
// Try alternate naming
snapshotPath = Path.Combine(directoryPath, "providers", "epss", "snapshot");
}
if (!File.Exists(snapshotPath))
throw new FileNotFoundException($"EPSS snapshot not found in bundle directory: {directoryPath}");
return await LoadFromSnapshotAsync(snapshotPath, cancellationToken).ConfigureAwait(false);
}
public async Task<EpssLoadResult> LoadFromSnapshotAsync(
string snapshotPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotPath);
if (!File.Exists(snapshotPath))
throw new FileNotFoundException($"EPSS snapshot file not found: {snapshotPath}", snapshotPath);
await using var fileStream = new FileStream(
snapshotPath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous);
return await LoadFromStreamAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
public async Task<EpssLoadResult> LoadFromStreamAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
// Try to decompress (may already be decompressed JSON)
Stream dataStream;
try
{
// Check for gzip magic bytes
var header = new byte[2];
var bytesRead = await stream.ReadAsync(header.AsMemory(0, 2), cancellationToken).ConfigureAwait(false);
stream.Position = 0;
if (bytesRead >= 2 && header[0] == 0x1f && header[1] == 0x8b)
{
// Gzip compressed
dataStream = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true);
}
else
{
// Plain JSON
dataStream = stream;
}
}
catch
{
// Assume plain JSON on error
stream.Position = 0;
dataStream = stream;
}
await using (dataStream.ConfigureAwait(false))
{
var bundleData = await JsonSerializer.DeserializeAsync<EpssBundleData>(
dataStream, JsonOptions, cancellationToken).ConfigureAwait(false);
if (bundleData is null)
throw new InvalidOperationException("Failed to deserialize EPSS bundle data.");
// Build dictionary for InMemoryEpssSource
var scores = new Dictionary<string, EpssData>(
bundleData.Scores.Count,
StringComparer.OrdinalIgnoreCase);
foreach (var score in bundleData.Scores)
{
if (string.IsNullOrWhiteSpace(score.Cve))
continue;
var cve = score.Cve.ToUpperInvariant();
scores[cve] = new EpssData(score.Score, score.Percentile, bundleData.ModelDate.ToDateTime(TimeOnly.MinValue));
}
var source = new InMemoryEpssSource(scores);
return new EpssLoadResult(
source,
bundleData.ModelDate,
scores.Count,
bundleData.FetchedAt);
}
}
}
/// <summary>
/// Extension methods for EPSS bundle operations.
/// </summary>
public static class EpssBundleExtensions
{
/// <summary>
/// Creates an EPSS source from a risk bundle file.
/// </summary>
public static async Task<IEpssSource> CreateEpssSourceFromBundleAsync(
string bundlePath,
CancellationToken cancellationToken = default)
{
var loader = new EpssBundleLoader();
var result = await loader.LoadFromBundleAsync(bundlePath, cancellationToken).ConfigureAwait(false);
return result.Source;
}
/// <summary>
/// Creates an EPSS source from an extracted bundle directory.
/// </summary>
public static async Task<IEpssSource> CreateEpssSourceFromDirectoryAsync(
string directoryPath,
CancellationToken cancellationToken = default)
{
var loader = new EpssBundleLoader();
var result = await loader.LoadFromDirectoryAsync(directoryPath, cancellationToken).ConfigureAwait(false);
return result.Source;
}
}

View File

@@ -0,0 +1,222 @@
using System.Globalization;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.RiskEngine.Core.Providers;
/// <summary>
/// Fetches EPSS data from FIRST.org and saves to a local file.
/// Used for building offline risk bundles.
/// </summary>
public interface IEpssFetcher
{
/// <summary>
/// Downloads EPSS data from FIRST.org and saves to the specified path.
/// Returns metadata about the downloaded data.
/// </summary>
Task<EpssFetchResult> FetchAndSaveAsync(
string outputPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the date of the latest EPSS model without downloading full data.
/// </summary>
Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an EPSS fetch operation.
/// </summary>
public sealed record EpssFetchResult(
DateOnly ModelDate,
int RecordCount,
string Sha256,
long FileSizeBytes,
DateTimeOffset FetchedAt);
/// <summary>
/// EPSS fetcher that downloads from FIRST.org API.
/// </summary>
public sealed class EpssFetcher : IEpssFetcher, IDisposable
{
private const string EpssApiBaseUrl = "https://api.first.org/data/v1/epss";
private const int PageSize = 10000;
private const int MaxConcurrentRequests = 3;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient httpClient;
private readonly TimeProvider timeProvider;
private bool disposed;
public EpssFetcher(HttpClient httpClient, TimeProvider? timeProvider = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<EpssFetchResult> FetchAndSaveAsync(
string outputPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
var allScores = new List<EpssRecord>();
DateOnly? modelDate = null;
var offset = 0;
var hasMore = true;
// Fetch all pages
while (hasMore)
{
cancellationToken.ThrowIfCancellationRequested();
var url = $"{EpssApiBaseUrl}?envelope=true&pretty=false&limit={PageSize}&offset={offset}";
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var apiResponse = await JsonSerializer.DeserializeAsync<EpssApiResponse>(
stream, JsonOptions, cancellationToken).ConfigureAwait(false);
if (apiResponse?.Data is null || apiResponse.Data.Count == 0)
{
hasMore = false;
continue;
}
// Extract model date from first record
if (modelDate is null && apiResponse.Data.Count > 0)
{
modelDate = ParseModelDate(apiResponse.Data[0].Date);
}
allScores.AddRange(apiResponse.Data);
offset += apiResponse.Data.Count;
hasMore = apiResponse.Total.HasValue && offset < apiResponse.Total.Value;
}
if (allScores.Count == 0)
{
throw new InvalidOperationException("No EPSS data retrieved from API.");
}
// Ensure output directory exists
var dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
// Write to compressed JSON file
var bundleData = new EpssBundleData
{
ModelDate = modelDate ?? DateOnly.FromDateTime(timeProvider.GetUtcNow().DateTime),
FetchedAt = timeProvider.GetUtcNow(),
RecordCount = allScores.Count,
Scores = allScores
.DistinctBy(s => s.Cve, StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s.Cve, StringComparer.OrdinalIgnoreCase)
.Select(s => new EpssBundleScore(s.Cve, s.Epss, s.Percentile))
.ToList()
};
// Write as compressed JSON
await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None);
await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize);
await JsonSerializer.SerializeAsync(gzipStream, bundleData, JsonOptions, cancellationToken).ConfigureAwait(false);
await gzipStream.FlushAsync(cancellationToken).ConfigureAwait(false);
// Compute hash
fileStream.Position = 0;
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hashBytes = await sha256.ComputeHashAsync(
new FileStream(outputPath, FileMode.Open, FileAccess.Read, FileShare.Read),
cancellationToken).ConfigureAwait(false);
var hash = Convert.ToHexStringLower(hashBytes);
var fileInfo = new FileInfo(outputPath);
return new EpssFetchResult(
bundleData.ModelDate,
bundleData.Scores.Count,
hash,
fileInfo.Length,
bundleData.FetchedAt);
}
public async Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default)
{
var url = $"{EpssApiBaseUrl}?envelope=true&pretty=false&limit=1";
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
return null;
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var apiResponse = await JsonSerializer.DeserializeAsync<EpssApiResponse>(
stream, JsonOptions, cancellationToken).ConfigureAwait(false);
if (apiResponse?.Data is null || apiResponse.Data.Count == 0)
return null;
return ParseModelDate(apiResponse.Data[0].Date);
}
private static DateOnly ParseModelDate(string? dateStr)
{
if (string.IsNullOrWhiteSpace(dateStr))
return DateOnly.FromDateTime(DateTime.UtcNow);
if (DateOnly.TryParseExact(dateStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
return date;
return DateOnly.FromDateTime(DateTime.UtcNow);
}
public void Dispose()
{
if (!disposed)
{
// HttpClient is typically managed by DI, don't dispose
disposed = true;
}
}
// API response models
private sealed record EpssApiResponse(
[property: JsonPropertyName("status")] string? Status,
[property: JsonPropertyName("status-code")] int? StatusCode,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("total")] int? Total,
[property: JsonPropertyName("offset")] int? Offset,
[property: JsonPropertyName("limit")] int? Limit,
[property: JsonPropertyName("data")] List<EpssRecord>? Data);
private sealed record EpssRecord(
[property: JsonPropertyName("cve")] string Cve,
[property: JsonPropertyName("epss")] double Epss,
[property: JsonPropertyName("percentile")] double Percentile,
[property: JsonPropertyName("date")] string? Date);
}
/// <summary>
/// Data structure for EPSS bundle files.
/// </summary>
public sealed record EpssBundleData
{
public DateOnly ModelDate { get; init; }
public DateTimeOffset FetchedAt { get; init; }
public int RecordCount { get; init; }
public required List<EpssBundleScore> Scores { get; init; }
}
/// <summary>
/// Individual EPSS score in a bundle.
/// </summary>
public sealed record EpssBundleScore(string Cve, double Score, double Percentile);

View File

@@ -0,0 +1,124 @@
using StellaOps.RiskEngine.Core.Contracts;
namespace StellaOps.RiskEngine.Core.Providers;
/// <summary>
/// Risk provider that derives score from EPSS (Exploit Prediction Scoring System).
/// Uses probability score directly as risk score since EPSS is already 0.0-1.0.
/// Optionally applies percentile-based thresholds for bonus adjustments.
/// </summary>
public sealed class EpssProvider : IRiskScoreProvider
{
public const string ProviderName = "epss";
private readonly IEpssSource epss;
public EpssProvider(IEpssSource epss)
{
this.epss = epss ?? throw new ArgumentNullException(nameof(epss));
}
public string Name => ProviderName;
public async Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var epssData = await epss.GetEpssAsync(request.Subject, cancellationToken).ConfigureAwait(false);
if (epssData is null)
return 0d; // Unknown = no additional risk signal
// EPSS score is already a probability (0.0-1.0)
// Use it directly as risk score
var score = Math.Clamp(epssData.Score, 0d, 1d);
return Math.Round(score, 6, MidpointRounding.ToEven);
}
}
/// <summary>
/// Combined risk provider using CVSS, KEV, and EPSS signals.
/// Formula: clamp01((cvss/10) + kevBonus + epssBonus)
///
/// KEV bonus: 0.2 if vulnerability is in CISA KEV catalog
/// EPSS bonus (percentile-based):
/// - 99th percentile or above: +0.10
/// - 90th percentile or above: +0.05
/// - 50th percentile or above: +0.02
/// - Below 50th percentile: +0.00
/// </summary>
public sealed class CvssKevEpssProvider : IRiskScoreProvider
{
public const string ProviderName = "cvss-kev-epss";
/// <summary>
/// EPSS bonus thresholds based on percentile ranking.
/// Higher percentile = higher probability of exploitation = higher bonus.
/// </summary>
public static readonly IReadOnlyList<(double Percentile, double Bonus)> EpssThresholds =
[
(0.99, 0.10), // Top 1% = significant additional risk
(0.90, 0.05), // Top 10% = moderate additional risk
(0.50, 0.02), // Top 50% = slight additional risk
];
private const double KevBonus = 0.2;
private readonly ICvssSource cvss;
private readonly IKevSource kev;
private readonly IEpssSource epss;
public CvssKevEpssProvider(ICvssSource cvss, IKevSource kev, IEpssSource epss)
{
this.cvss = cvss ?? throw new ArgumentNullException(nameof(cvss));
this.kev = kev ?? throw new ArgumentNullException(nameof(kev));
this.epss = epss ?? throw new ArgumentNullException(nameof(epss));
}
public string Name => ProviderName;
public async Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
// Fetch all signals in parallel
var cvssTask = cvss.GetCvssAsync(request.Subject, cancellationToken);
var kevTask = kev.IsKevAsync(request.Subject, cancellationToken);
var epssTask = epss.GetEpssAsync(request.Subject, cancellationToken);
await Task.WhenAll(cvssTask, kevTask, epssTask).ConfigureAwait(false);
var cvssScore = Math.Clamp(cvssTask.Result ?? 0d, 0d, 10d);
var kevFlag = kevTask.Result ?? false;
var epssData = epssTask.Result;
// Base score from CVSS (normalized to 0-1)
var baseScore = cvssScore / 10d;
// KEV bonus: 20% if in CISA KEV catalog
var kevBonusValue = kevFlag ? KevBonus : 0d;
// EPSS bonus based on percentile thresholds
var epssBonusValue = ComputeEpssBonus(epssData?.Percentile);
// Combined score
var raw = baseScore + kevBonusValue + epssBonusValue;
return Math.Round(Math.Min(1d, raw), 6, MidpointRounding.ToEven);
}
private static double ComputeEpssBonus(double? percentile)
{
if (percentile is null)
return 0d;
var p = percentile.Value;
foreach (var (threshold, bonus) in EpssThresholds)
{
if (p >= threshold)
return bonus;
}
return 0d;
}
}

View File

@@ -0,0 +1,51 @@
namespace StellaOps.RiskEngine.Core.Providers;
/// <summary>
/// EPSS (Exploit Prediction Scoring System) data from FIRST.org.
/// Contains the probability score (0.0-1.0) and percentile ranking.
/// </summary>
public sealed record EpssData(double Score, double Percentile, DateTimeOffset? ModelVersion = null);
/// <summary>
/// Source for EPSS data. Returns probability score and percentile for a CVE.
/// </summary>
public interface IEpssSource
{
/// <summary>
/// Returns EPSS data for the given CVE identifier, or null if unknown.
/// Score is probability of exploitation in next 30 days (0.0-1.0).
/// Percentile indicates relative ranking among all CVEs (0.0-1.0).
/// </summary>
Task<EpssData?> GetEpssAsync(string cveId, CancellationToken cancellationToken);
}
/// <summary>
/// Null-object EPSS source returning no data (treat as unknown).
/// </summary>
public sealed class NullEpssSource : IEpssSource
{
public Task<EpssData?> GetEpssAsync(string cveId, CancellationToken cancellationToken) =>
Task.FromResult<EpssData?>(null);
}
/// <summary>
/// In-memory EPSS source for testing and offline operation.
/// </summary>
public sealed class InMemoryEpssSource : IEpssSource
{
private readonly IReadOnlyDictionary<string, EpssData> data;
public InMemoryEpssSource(IReadOnlyDictionary<string, EpssData> data)
{
this.data = data ?? throw new ArgumentNullException(nameof(data));
}
public Task<EpssData?> GetEpssAsync(string cveId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(cveId))
return Task.FromResult<EpssData?>(null);
data.TryGetValue(cveId.ToUpperInvariant(), out var result);
return Task.FromResult(result);
}
}

View File

@@ -0,0 +1,228 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.RiskEngine.Core.Providers;
namespace StellaOps.RiskEngine.Tests;
public sealed class EpssBundleTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
[Fact]
public async Task LoadFromStreamAsync_WithValidGzipJson_ReturnsEpssSource()
{
// Arrange
var bundleData = new EpssBundleData
{
ModelDate = new DateOnly(2025, 12, 14),
FetchedAt = new DateTimeOffset(2025, 12, 14, 10, 0, 0, TimeSpan.Zero),
RecordCount = 3,
Scores =
[
new EpssBundleScore("CVE-2024-1234", 0.95, 0.99),
new EpssBundleScore("CVE-2024-5678", 0.50, 0.75),
new EpssBundleScore("CVE-2024-9012", 0.10, 0.25)
]
};
using var compressedStream = CreateGzipJsonStream(bundleData);
var loader = new EpssBundleLoader();
// Act
var result = await loader.LoadFromStreamAsync(compressedStream);
// Assert
result.Should().NotBeNull();
result.ModelDate.Should().Be(new DateOnly(2025, 12, 14));
result.RecordCount.Should().Be(3);
result.Source.Should().NotBeNull();
}
[Fact]
public async Task LoadFromStreamAsync_WithPlainJson_ReturnsEpssSource()
{
// Arrange
var bundleData = new EpssBundleData
{
ModelDate = new DateOnly(2025, 12, 14),
FetchedAt = new DateTimeOffset(2025, 12, 14, 10, 0, 0, TimeSpan.Zero),
RecordCount = 2,
Scores =
[
new EpssBundleScore("CVE-2024-1111", 0.80, 0.90),
new EpssBundleScore("CVE-2024-2222", 0.30, 0.50)
]
};
var json = JsonSerializer.Serialize(bundleData, JsonOptions);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var loader = new EpssBundleLoader();
// Act
var result = await loader.LoadFromStreamAsync(stream);
// Assert
result.Should().NotBeNull();
result.RecordCount.Should().Be(2);
}
[Fact]
public async Task LoadedSource_ReturnsCorrectScores()
{
// Arrange
var bundleData = new EpssBundleData
{
ModelDate = new DateOnly(2025, 12, 14),
FetchedAt = DateTimeOffset.UtcNow,
RecordCount = 2,
Scores =
[
new EpssBundleScore("CVE-2024-99999", 0.95123, 0.99456),
new EpssBundleScore("CVE-2024-00001", 0.00123, 0.00456)
]
};
using var compressedStream = CreateGzipJsonStream(bundleData);
var loader = new EpssBundleLoader();
var result = await loader.LoadFromStreamAsync(compressedStream);
// Act
var highScore = await result.Source.GetEpssAsync("CVE-2024-99999", CancellationToken.None);
var lowScore = await result.Source.GetEpssAsync("CVE-2024-00001", CancellationToken.None);
var unknownScore = await result.Source.GetEpssAsync("CVE-9999-99999", CancellationToken.None);
// Assert
highScore.Should().NotBeNull();
highScore!.Score.Should().BeApproximately(0.95123, 0.00001);
highScore.Percentile.Should().BeApproximately(0.99456, 0.00001);
lowScore.Should().NotBeNull();
lowScore!.Score.Should().BeApproximately(0.00123, 0.00001);
unknownScore.Should().BeNull();
}
[Fact]
public async Task LoadedSource_IsCaseInsensitive()
{
// Arrange
var bundleData = new EpssBundleData
{
ModelDate = new DateOnly(2025, 12, 14),
FetchedAt = DateTimeOffset.UtcNow,
RecordCount = 1,
Scores = [new EpssBundleScore("CVE-2024-12345", 0.75, 0.85)]
};
using var compressedStream = CreateGzipJsonStream(bundleData);
var loader = new EpssBundleLoader();
var result = await loader.LoadFromStreamAsync(compressedStream);
// Act & Assert - various case combinations
var upper = await result.Source.GetEpssAsync("CVE-2024-12345", CancellationToken.None);
var lower = await result.Source.GetEpssAsync("cve-2024-12345", CancellationToken.None);
var mixed = await result.Source.GetEpssAsync("Cve-2024-12345", CancellationToken.None);
upper.Should().NotBeNull();
lower.Should().NotBeNull();
mixed.Should().NotBeNull();
upper!.Score.Should().Be(lower!.Score);
upper.Score.Should().Be(mixed!.Score);
}
[Fact]
public async Task LoadFromStreamAsync_WithEmptyScores_ReturnsEmptySource()
{
// Arrange
var bundleData = new EpssBundleData
{
ModelDate = new DateOnly(2025, 12, 14),
FetchedAt = DateTimeOffset.UtcNow,
RecordCount = 0,
Scores = []
};
using var compressedStream = CreateGzipJsonStream(bundleData);
var loader = new EpssBundleLoader();
// Act
var result = await loader.LoadFromStreamAsync(compressedStream);
// Assert
result.Should().NotBeNull();
result.RecordCount.Should().Be(0);
var score = await result.Source.GetEpssAsync("CVE-2024-12345", CancellationToken.None);
score.Should().BeNull();
}
[Fact]
public async Task LoadFromStreamAsync_WithDuplicates_DeduplicatesScores()
{
// Arrange - bundle loader should handle duplicates gracefully
var bundleData = new EpssBundleData
{
ModelDate = new DateOnly(2025, 12, 14),
FetchedAt = DateTimeOffset.UtcNow,
RecordCount = 3,
Scores =
[
new EpssBundleScore("CVE-2024-12345", 0.50, 0.60),
new EpssBundleScore("CVE-2024-12345", 0.55, 0.65), // Duplicate - later one wins
new EpssBundleScore("CVE-2024-67890", 0.30, 0.40)
]
};
using var compressedStream = CreateGzipJsonStream(bundleData);
var loader = new EpssBundleLoader();
// Act
var result = await loader.LoadFromStreamAsync(compressedStream);
// Assert - duplicates are handled (last one wins or first one, implementation dependent)
result.Should().NotBeNull();
var score = await result.Source.GetEpssAsync("CVE-2024-12345", CancellationToken.None);
score.Should().NotBeNull();
}
[Fact]
public void LoadFromSnapshotAsync_WithMissingFile_ThrowsFileNotFoundException()
{
// Arrange
var loader = new EpssBundleLoader();
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "snapshot");
// Act & Assert
var act = async () => await loader.LoadFromSnapshotAsync(nonExistentPath);
act.Should().ThrowAsync<FileNotFoundException>();
}
[Fact]
public void LoadFromBundleAsync_WithMissingFile_ThrowsFileNotFoundException()
{
// Arrange
var loader = new EpssBundleLoader();
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".tar.gz");
// Act & Assert
var act = async () => await loader.LoadFromBundleAsync(nonExistentPath);
act.Should().ThrowAsync<FileNotFoundException>();
}
private static MemoryStream CreateGzipJsonStream(EpssBundleData data)
{
var memoryStream = new MemoryStream();
using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Fastest, leaveOpen: true))
{
JsonSerializer.Serialize(gzipStream, data, JsonOptions);
}
memoryStream.Position = 0;
return memoryStream;
}
}

View File

@@ -62,6 +62,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
<PackageReference Include="xunit.v3" Version="3.0.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
<PackageReference Include="FluentAssertions" Version="8.1.0"/>

View File

@@ -261,6 +261,161 @@ public class RiskScoreWorkerTests
Assert.Equal(results.Select(r => r.Score), snapshot.Select(r => r.Score));
}
[Fact]
public async Task EpssProviderReturnsScoreDirectly()
{
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0002"] = new EpssData(0.75, 0.95)
});
var provider = new EpssProvider(epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(EpssProvider.ProviderName, "CVE-2025-0002", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0.75d, result.Score);
}
[Fact]
public async Task EpssProviderReturnsZeroForUnknown()
{
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>());
var provider = new EpssProvider(epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(EpssProvider.ProviderName, "CVE-9999-0000", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0d, result.Score);
}
[Fact]
public async Task CvssKevEpssProviderCombinesAllSignals()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0003"] = 7.5
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>
{
["CVE-2025-0003"] = true
});
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0003"] = new EpssData(0.85, 0.99) // 99th percentile = +0.10
});
var provider = new CvssKevEpssProvider(cvssSource, kevSource, epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-2025-0003", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// 0.75 (cvss/10) + 0.2 (kev) + 0.10 (epss 99th) = 1.05 → clamped to 1.0
Assert.Equal(1.0d, result.Score);
}
[Fact]
public async Task CvssKevEpssProviderApplies90thPercentileBonus()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0004"] = 5.0
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>());
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0004"] = new EpssData(0.35, 0.92) // 90th percentile = +0.05
});
var provider = new CvssKevEpssProvider(cvssSource, kevSource, epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-2025-0004", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// 0.5 (cvss/10) + 0.0 (no kev) + 0.05 (epss 90th) = 0.55
Assert.Equal(0.55d, result.Score);
}
[Fact]
public async Task CvssKevEpssProviderApplies50thPercentileBonus()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0005"] = 4.0
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>());
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0005"] = new EpssData(0.15, 0.60) // 50th percentile = +0.02
});
var provider = new CvssKevEpssProvider(cvssSource, kevSource, epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-2025-0005", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// 0.4 (cvss/10) + 0.0 (no kev) + 0.02 (epss 50th) = 0.42
Assert.Equal(0.42d, result.Score);
}
[Fact]
public async Task CvssKevEpssProviderNoBonusBelowThreshold()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0006"] = 6.0
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>());
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0006"] = new EpssData(0.05, 0.30) // Below 50th percentile = +0.0
});
var provider = new CvssKevEpssProvider(cvssSource, kevSource, epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-2025-0006", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// 0.6 (cvss/10) + 0.0 (no kev) + 0.0 (epss below 50th) = 0.6
Assert.Equal(0.6d, result.Score);
}
private sealed class FakeCvssSource : ICvssSource
{
private readonly IReadOnlyDictionary<string, double> data;
@@ -277,6 +432,14 @@ public class RiskScoreWorkerTests
Task.FromResult<bool?>(data.TryGetValue(subject, out var value) ? value : null);
}
private sealed class FakeEpssSource : IEpssSource
{
private readonly IReadOnlyDictionary<string, EpssData> data;
public FakeEpssSource(IReadOnlyDictionary<string, EpssData> data) => this.data = data;
public Task<EpssData?> GetEpssAsync(string cveId, CancellationToken cancellationToken) =>
Task.FromResult<EpssData?>(data.TryGetValue(cveId, out var value) ? value : null);
}
private sealed class DeterministicProvider : IRiskScoreProvider
{
public DeterministicProvider(string name, double weight)