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