consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
namespace StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Exploit maturity level taxonomy.
|
||||
/// Ordered from least mature to most mature exploitation.
|
||||
/// </summary>
|
||||
public enum ExploitMaturityLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// No known exploitation. No public exploit code, no reports.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Theoretical exploitation. Vulnerability documented but no proof of concept.
|
||||
/// </summary>
|
||||
Theoretical = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Proof of concept exists. Public exploit code available but not weaponized.
|
||||
/// </summary>
|
||||
ProofOfConcept = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Active exploitation in the wild. Reports of exploitation but not widespread.
|
||||
/// </summary>
|
||||
Active = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Weaponized. Exploit kits, malware, or ransomware using this vulnerability.
|
||||
/// CISA KEV or similar authoritative source confirms active exploitation.
|
||||
/// </summary>
|
||||
Weaponized = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence source for exploit maturity signal.
|
||||
/// </summary>
|
||||
public enum MaturityEvidenceSource
|
||||
{
|
||||
/// <summary>EPSS probability score.</summary>
|
||||
Epss,
|
||||
|
||||
/// <summary>CISA Known Exploited Vulnerabilities catalog.</summary>
|
||||
Kev,
|
||||
|
||||
/// <summary>In-the-wild exploitation report (e.g., threat intel feed).</summary>
|
||||
InTheWild,
|
||||
|
||||
/// <summary>Exploit-DB or similar public exploit database.</summary>
|
||||
ExploitDb,
|
||||
|
||||
/// <summary>Nuclei or other vulnerability scanner templates.</summary>
|
||||
ScannerTemplate,
|
||||
|
||||
/// <summary>Internal or user-provided override.</summary>
|
||||
Override
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual maturity signal from a specific source.
|
||||
/// </summary>
|
||||
public sealed record MaturitySignal(
|
||||
MaturityEvidenceSource Source,
|
||||
ExploitMaturityLevel Level,
|
||||
double Confidence,
|
||||
string? Evidence,
|
||||
DateTimeOffset? ObservedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Result of exploit maturity assessment.
|
||||
/// </summary>
|
||||
public sealed record ExploitMaturityResult(
|
||||
string CveId,
|
||||
ExploitMaturityLevel Level,
|
||||
double Confidence,
|
||||
IReadOnlyList<MaturitySignal> Signals,
|
||||
string Rationale,
|
||||
DateTimeOffset AssessedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Historical maturity state for lifecycle tracking.
|
||||
/// </summary>
|
||||
public sealed record MaturityHistoryEntry(
|
||||
ExploitMaturityLevel Level,
|
||||
double Confidence,
|
||||
DateTimeOffset Timestamp,
|
||||
string ChangeReason);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a risk score computation.
|
||||
/// </summary>
|
||||
public sealed record RiskScoreResult(
|
||||
Guid JobId,
|
||||
string Provider,
|
||||
string Subject,
|
||||
double Score,
|
||||
bool Success,
|
||||
string? Error,
|
||||
IReadOnlyDictionary<string, double> Signals,
|
||||
DateTimeOffset CompletedAtUtc);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Input for a risk score computation. Subject is an opaque asset/id; Signals are deterministic numeric factors.
|
||||
/// </summary>
|
||||
public sealed record ScoreRequest(
|
||||
string Provider,
|
||||
string Subject,
|
||||
IReadOnlyDictionary<string, double> Signals);
|
||||
|
||||
/// <summary>
|
||||
/// Job envelope carried through the queue.
|
||||
/// </summary>
|
||||
public sealed record RiskScoreJob(Guid JobId, ScoreRequest Request);
|
||||
@@ -0,0 +1,59 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Risk provider that derives score from CVSS base score and KEV flag.
|
||||
/// Score formula: clamp01((cvss/10) + kevBonus), where kevBonus = 0.2 if KEV, else 0.
|
||||
/// </summary>
|
||||
public sealed class CvssKevProvider : IRiskScoreProvider
|
||||
{
|
||||
public const string ProviderName = "cvss-kev";
|
||||
|
||||
private readonly ICvssSource cvss;
|
||||
private readonly IKevSource kev;
|
||||
|
||||
public CvssKevProvider(ICvssSource cvss, IKevSource kev)
|
||||
{
|
||||
this.cvss = cvss ?? throw new ArgumentNullException(nameof(cvss));
|
||||
this.kev = kev ?? throw new ArgumentNullException(nameof(kev));
|
||||
}
|
||||
|
||||
public string Name => ProviderName;
|
||||
|
||||
public async Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var cvssScore = request.Signals.TryGetValue("Cvss", out var inlineCvss)
|
||||
? inlineCvss
|
||||
: await cvss.GetCvssAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? 0d;
|
||||
cvssScore = Math.Clamp(cvssScore, 0d, 10d);
|
||||
|
||||
var kevFlag = TryGetKevFlag(request, out var inlineKev)
|
||||
? inlineKev
|
||||
: await kev.IsKevAsync(request.Subject, cancellationToken).ConfigureAwait(false) ?? false;
|
||||
|
||||
var kevBonus = kevFlag ? 0.2d : 0d;
|
||||
var raw = (cvssScore / 10d) + kevBonus;
|
||||
return Math.Round(Math.Min(1d, raw), 6, MidpointRounding.ToEven);
|
||||
}
|
||||
|
||||
private static bool TryGetKevFlag(ScoreRequest request, out bool kevFlag)
|
||||
{
|
||||
if (request.Signals.TryGetValue("Kev", out var kev))
|
||||
{
|
||||
kevFlag = kev >= 1d;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.Signals.TryGetValue("IsKev", out var isKev))
|
||||
{
|
||||
kevFlag = isKev >= 1d;
|
||||
return true;
|
||||
}
|
||||
|
||||
kevFlag = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Default provider that clamps each signal to [0,1] and averages the result.
|
||||
/// Deterministic and side-effect free.
|
||||
/// </summary>
|
||||
public sealed class DefaultTransformsProvider : IRiskScoreProvider
|
||||
{
|
||||
public const string ProviderName = "default-transforms";
|
||||
|
||||
public string Name => ProviderName;
|
||||
|
||||
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.Signals.Count == 0)
|
||||
{
|
||||
return Task.FromResult(0d);
|
||||
}
|
||||
|
||||
var sum = 0d;
|
||||
foreach (var kvp in request.Signals.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
sum += Clamp01(kvp.Value);
|
||||
}
|
||||
|
||||
var average = sum / request.Signals.Count;
|
||||
return Task.FromResult(Math.Round(average, 6, MidpointRounding.ToEven));
|
||||
}
|
||||
|
||||
private static double Clamp01(double value) =>
|
||||
value switch
|
||||
{
|
||||
< 0d => 0d,
|
||||
> 1d => 1d,
|
||||
_ => value
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
223
src/Findings/StellaOps.RiskEngine.Core/Providers/EpssFetcher.cs
Normal file
223
src/Findings/StellaOps.RiskEngine.Core/Providers/EpssFetcher.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
|
||||
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);
|
||||
179
src/Findings/StellaOps.RiskEngine.Core/Providers/EpssProvider.cs
Normal file
179
src/Findings/StellaOps.RiskEngine.Core/Providers/EpssProvider.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
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 signalScore = TryGetSignalScore(request);
|
||||
var epssData = signalScore.HasValue
|
||||
? new EpssData(signalScore.Value, request.Signals.TryGetValue("EpssPercentile", out var percentile) ? percentile : 0d)
|
||||
: 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);
|
||||
}
|
||||
|
||||
private static double? TryGetSignalScore(ScoreRequest request)
|
||||
{
|
||||
if (request.Signals.TryGetValue("EpssScore", out var epssScore))
|
||||
{
|
||||
return epssScore;
|
||||
}
|
||||
|
||||
if (request.Signals.TryGetValue("Epss", out var epss))
|
||||
{
|
||||
return epss;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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; explicit request signals take precedence.
|
||||
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 = request.Signals.TryGetValue("Cvss", out var inlineCvss)
|
||||
? inlineCvss
|
||||
: cvssTask.Result ?? 0d;
|
||||
cvssScore = Math.Clamp(cvssScore, 0d, 10d);
|
||||
|
||||
var kevFlag = TryGetKevFlag(request, out var inlineKev)
|
||||
? inlineKev
|
||||
: kevTask.Result ?? false;
|
||||
|
||||
var epssScore = request.Signals.TryGetValue("EpssScore", out var inlineEpssScore)
|
||||
? inlineEpssScore
|
||||
: (request.Signals.TryGetValue("Epss", out var inlineEpss) ? inlineEpss : epssTask.Result?.Score);
|
||||
|
||||
var epssPercentile = request.Signals.TryGetValue("EpssPercentile", out var inlinePercentile)
|
||||
? inlinePercentile
|
||||
: epssTask.Result?.Percentile;
|
||||
|
||||
// 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(epssPercentile);
|
||||
|
||||
// If CVSS+KEV are absent, fall back to raw EPSS score contribution.
|
||||
var epssBase = Math.Clamp(epssScore ?? 0d, 0d, 1d);
|
||||
|
||||
// Combined score
|
||||
var raw = baseScore + kevBonusValue + epssBonusValue;
|
||||
if (baseScore <= 0d && !kevFlag)
|
||||
{
|
||||
raw = epssBase + 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;
|
||||
}
|
||||
|
||||
private static bool TryGetKevFlag(ScoreRequest request, out bool kevFlag)
|
||||
{
|
||||
if (request.Signals.TryGetValue("Kev", out var kev))
|
||||
{
|
||||
kevFlag = kev >= 1d;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.Signals.TryGetValue("IsKev", out var isKev))
|
||||
{
|
||||
kevFlag = isKev >= 1d;
|
||||
return true;
|
||||
}
|
||||
|
||||
kevFlag = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Consolidates EPSS, KEV, and in-the-wild signals into a unified exploit maturity level.
|
||||
/// Implements deterministic maturity assessment for risk prioritization.
|
||||
/// </summary>
|
||||
public sealed class ExploitMaturityService : IExploitMaturityService
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.RiskEngine");
|
||||
private static readonly Histogram<double> AssessmentDuration = Meter.CreateHistogram<double>(
|
||||
"stellaops_exploit_maturity_assessment_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Time to assess exploit maturity");
|
||||
private static readonly Counter<long> AssessmentCount = Meter.CreateCounter<long>(
|
||||
"stellaops_exploit_maturity_assessments_total",
|
||||
unit: "assessments",
|
||||
description: "Total exploit maturity assessments");
|
||||
|
||||
/// <summary>
|
||||
/// EPSS thresholds for maturity level mapping.
|
||||
/// Based on EPSS percentile/probability research.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<(double Score, ExploitMaturityLevel Level)> EpssThresholds =
|
||||
[
|
||||
(0.80, ExploitMaturityLevel.Weaponized), // Very high exploitation probability
|
||||
(0.40, ExploitMaturityLevel.Active), // High exploitation probability
|
||||
(0.10, ExploitMaturityLevel.ProofOfConcept), // Moderate exploitation probability
|
||||
(0.01, ExploitMaturityLevel.Theoretical), // Low exploitation probability
|
||||
];
|
||||
|
||||
private readonly IEpssSource _epss;
|
||||
private readonly IKevSource _kev;
|
||||
private readonly IInTheWildSource? _inTheWild;
|
||||
private readonly ILogger<ExploitMaturityService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ExploitMaturityService(
|
||||
IEpssSource epss,
|
||||
IKevSource kev,
|
||||
IInTheWildSource? inTheWild = null,
|
||||
ILogger<ExploitMaturityService>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_epss = epss ?? throw new ArgumentNullException(nameof(epss));
|
||||
_kev = kev ?? throw new ArgumentNullException(nameof(kev));
|
||||
_inTheWild = inTheWild;
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<ExploitMaturityService>.Instance;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExploitMaturityResult> AssessMaturityAsync(string cveId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
|
||||
var startTime = _timeProvider.GetTimestamp();
|
||||
var signals = new List<MaturitySignal>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch all signals in parallel
|
||||
var epssTask = _epss.GetEpssAsync(cveId, cancellationToken);
|
||||
var kevTask = _kev.IsKevAsync(cveId, cancellationToken);
|
||||
var inTheWildTask = _inTheWild?.IsExploitedInTheWildAsync(cveId, cancellationToken)
|
||||
?? Task.FromResult<InTheWildResult?>(null);
|
||||
|
||||
await Task.WhenAll(epssTask, kevTask, inTheWildTask).ConfigureAwait(false);
|
||||
|
||||
// Process EPSS signal
|
||||
var epssData = epssTask.Result;
|
||||
if (epssData is not null)
|
||||
{
|
||||
var epssLevel = MapEpssToMaturityLevel(epssData.Score);
|
||||
signals.Add(new MaturitySignal(
|
||||
Source: MaturityEvidenceSource.Epss,
|
||||
Level: epssLevel,
|
||||
Confidence: ComputeEpssConfidence(epssData.Score, epssData.Percentile),
|
||||
Evidence: $"EPSS score: {epssData.Score:F4}, percentile: {epssData.Percentile:P1}",
|
||||
ObservedAt: now));
|
||||
}
|
||||
|
||||
// Process KEV signal
|
||||
var isKev = kevTask.Result ?? false;
|
||||
if (isKev)
|
||||
{
|
||||
signals.Add(new MaturitySignal(
|
||||
Source: MaturityEvidenceSource.Kev,
|
||||
Level: ExploitMaturityLevel.Weaponized,
|
||||
Confidence: 0.95, // KEV is authoritative
|
||||
Evidence: "Listed in CISA Known Exploited Vulnerabilities catalog",
|
||||
ObservedAt: now));
|
||||
}
|
||||
|
||||
// Process in-the-wild signal
|
||||
var inTheWildData = inTheWildTask.Result;
|
||||
if (inTheWildData?.IsExploited == true)
|
||||
{
|
||||
signals.Add(new MaturitySignal(
|
||||
Source: MaturityEvidenceSource.InTheWild,
|
||||
Level: ExploitMaturityLevel.Active,
|
||||
Confidence: inTheWildData.Confidence,
|
||||
Evidence: inTheWildData.Evidence,
|
||||
ObservedAt: inTheWildData.ObservedAt ?? now));
|
||||
}
|
||||
|
||||
// Compute final maturity level
|
||||
var (level, confidence, rationale) = ComputeFinalMaturity(signals);
|
||||
|
||||
// Record metrics
|
||||
var duration = _timeProvider.GetElapsedTime(startTime).TotalMilliseconds;
|
||||
AssessmentDuration.Record(duration, new KeyValuePair<string, object?>("cve", cveId));
|
||||
AssessmentCount.Add(1, new KeyValuePair<string, object?>("level", level.ToString()));
|
||||
|
||||
_logger.LogDebug(
|
||||
"Assessed exploit maturity for {CveId}: {Level} (confidence={Confidence:F2})",
|
||||
cveId, level, confidence);
|
||||
|
||||
return new ExploitMaturityResult(
|
||||
CveId: cveId,
|
||||
Level: level,
|
||||
Confidence: confidence,
|
||||
Signals: signals,
|
||||
Rationale: rationale,
|
||||
AssessedAt: now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to assess exploit maturity for {CveId}", cveId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ExploitMaturityLevel?> GetMaturityLevelAsync(string cveId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// For now, always compute fresh. A production implementation would cache results.
|
||||
return AssessMaturityAsync(cveId, cancellationToken)
|
||||
.ContinueWith(t => (ExploitMaturityLevel?)t.Result.Level, TaskContinuationOptions.OnlyOnRanToCompletion);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<MaturityHistoryEntry>> GetMaturityHistoryAsync(string cveId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// History tracking requires persistence; return empty for now.
|
||||
// A production implementation would store and retrieve historical entries.
|
||||
return Task.FromResult<IReadOnlyList<MaturityHistoryEntry>>(Array.Empty<MaturityHistoryEntry>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps EPSS score to a maturity level.
|
||||
/// </summary>
|
||||
private static ExploitMaturityLevel MapEpssToMaturityLevel(double epssScore)
|
||||
{
|
||||
foreach (var (threshold, level) in EpssThresholds)
|
||||
{
|
||||
if (epssScore >= threshold)
|
||||
return level;
|
||||
}
|
||||
return ExploitMaturityLevel.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes confidence based on EPSS score and percentile.
|
||||
/// Higher percentile = higher confidence in the signal.
|
||||
/// </summary>
|
||||
private static double ComputeEpssConfidence(double score, double percentile)
|
||||
{
|
||||
// Confidence based on percentile (more extreme = more confident)
|
||||
// Percentile 0.99 -> confidence 0.9
|
||||
// Percentile 0.50 -> confidence 0.5
|
||||
// Percentile 0.10 -> confidence 0.4
|
||||
var baseConfidence = percentile >= 0.90 ? 0.9
|
||||
: percentile >= 0.50 ? 0.6 + (percentile - 0.50) * 0.75
|
||||
: 0.4 + percentile * 0.2;
|
||||
|
||||
return Math.Clamp(baseConfidence, 0.0, 1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes final maturity level from all signals using max-level with weighted confidence.
|
||||
/// </summary>
|
||||
private static (ExploitMaturityLevel Level, double Confidence, string Rationale) ComputeFinalMaturity(
|
||||
IReadOnlyList<MaturitySignal> signals)
|
||||
{
|
||||
if (signals.Count == 0)
|
||||
{
|
||||
return (ExploitMaturityLevel.Unknown, 0.0, "No exploit maturity signals available");
|
||||
}
|
||||
|
||||
// Take the highest maturity level across all signals
|
||||
var maxLevel = signals.Max(s => s.Level);
|
||||
var maxLevelSignals = signals.Where(s => s.Level == maxLevel).ToList();
|
||||
|
||||
// Weighted average confidence for signals at max level
|
||||
var totalConfidence = maxLevelSignals.Sum(s => s.Confidence);
|
||||
var avgConfidence = totalConfidence / maxLevelSignals.Count;
|
||||
|
||||
// Build rationale
|
||||
var sources = string.Join(", ", maxLevelSignals.Select(s => s.Source.ToString()));
|
||||
var rationale = $"Maturity level {maxLevel} determined by {sources} ({maxLevelSignals.Count} signal(s))";
|
||||
|
||||
return (maxLevel, avgConfidence, rationale);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for in-the-wild exploitation data source.
|
||||
/// </summary>
|
||||
public interface IInTheWildSource
|
||||
{
|
||||
Task<InTheWildResult?> IsExploitedInTheWildAsync(string cveId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from in-the-wild exploitation check.
|
||||
/// </summary>
|
||||
public sealed record InTheWildResult(
|
||||
bool IsExploited,
|
||||
double Confidence,
|
||||
string? Evidence,
|
||||
DateTimeOffset? ObservedAt);
|
||||
@@ -0,0 +1,252 @@
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_007_RISK
|
||||
// Task: FCR-005 - FixChain Attestation Client Implementation
|
||||
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers.FixChain;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-based client for querying FixChain attestations from the Attestor service.
|
||||
/// </summary>
|
||||
internal sealed class FixChainAttestationClient : IFixChainAttestationClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly FixChainClientOptions _options;
|
||||
private readonly ILogger<FixChainAttestationClient> _logger;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public FixChainAttestationClient(
|
||||
HttpClient httpClient,
|
||||
IMemoryCache cache,
|
||||
IOptions<FixChainClientOptions> options,
|
||||
ILogger<FixChainAttestationClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FixChainAttestationData?> GetFixChainAsync(
|
||||
string cveId,
|
||||
string binarySha256,
|
||||
string? componentPurl = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(binarySha256);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Try cache first
|
||||
var cacheKey = BuildCacheKey(cveId, binarySha256);
|
||||
if (_cache.TryGetValue(cacheKey, out FixChainAttestationData? cached))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for FixChain attestation: {CveId}/{Binary}", cveId, binarySha256[..8]);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Query attestor service
|
||||
try
|
||||
{
|
||||
var url = BuildUrl(cveId, binarySha256, componentPurl);
|
||||
_logger.LogDebug("Querying FixChain attestation: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("No FixChain attestation found for {CveId}/{Binary}", cveId, binarySha256[..8]);
|
||||
CacheNotFound(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var dto = await response.Content.ReadFromJsonAsync<FixChainAttestationDto>(JsonOptions, ct);
|
||||
if (dto is null)
|
||||
{
|
||||
_logger.LogWarning("Null response from attestor service");
|
||||
return null;
|
||||
}
|
||||
|
||||
var data = MapToData(dto);
|
||||
CacheResult(cacheKey, data);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Retrieved FixChain attestation: {CveId}, verdict={Verdict}, confidence={Confidence:F2}",
|
||||
cveId, data.Verdict.Status, data.Verdict.Confidence);
|
||||
|
||||
return data;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query attestor service for {CveId}", cveId);
|
||||
return null;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse attestor response for {CveId}", cveId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<FixChainAttestationData>> GetForComponentAsync(
|
||||
string componentPurl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentPurl);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var encodedPurl = Uri.EscapeDataString(componentPurl);
|
||||
var url = $"/api/v1/attestations/fixchain/components/{encodedPurl}";
|
||||
|
||||
_logger.LogDebug("Querying FixChain attestations for component: {Purl}", componentPurl);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var dtos = await response.Content.ReadFromJsonAsync<FixChainAttestationDto[]>(JsonOptions, ct);
|
||||
if (dtos is null || dtos.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return [.. dtos.Select(MapToData)];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query attestor service for component {Purl}", componentPurl);
|
||||
return [];
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse attestor response for component {Purl}", componentPurl);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildUrl(string cveId, string binarySha256, string? componentPurl)
|
||||
{
|
||||
var encodedCve = Uri.EscapeDataString(cveId);
|
||||
var url = $"/api/v1/attestations/fixchain/{encodedCve}/{binarySha256}";
|
||||
|
||||
if (!string.IsNullOrEmpty(componentPurl))
|
||||
{
|
||||
url += $"?purl={Uri.EscapeDataString(componentPurl)}";
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string cveId, string binarySha256)
|
||||
=> $"fixchain:{cveId}:{binarySha256}";
|
||||
|
||||
private void CacheResult(string cacheKey, FixChainAttestationData data)
|
||||
{
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _options.CacheTtl,
|
||||
Size = 1
|
||||
};
|
||||
_cache.Set(cacheKey, data, cacheOptions);
|
||||
}
|
||||
|
||||
private void CacheNotFound(string cacheKey)
|
||||
{
|
||||
// Cache negative result for shorter duration
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _options.NegativeCacheTtl,
|
||||
Size = 1
|
||||
};
|
||||
_cache.Set(cacheKey, (FixChainAttestationData?)null, cacheOptions);
|
||||
}
|
||||
|
||||
private static FixChainAttestationData MapToData(FixChainAttestationDto dto)
|
||||
{
|
||||
return new FixChainAttestationData
|
||||
{
|
||||
ContentDigest = dto.ContentDigest,
|
||||
CveId = dto.CveId,
|
||||
ComponentPurl = dto.ComponentPurl ?? dto.Component ?? string.Empty,
|
||||
BinarySha256 = dto.BinarySha256,
|
||||
Verdict = new FixChainVerdictData
|
||||
{
|
||||
Status = dto.VerdictStatus,
|
||||
Confidence = dto.Confidence,
|
||||
Rationale = dto.Rationale ?? []
|
||||
},
|
||||
GoldenSetId = dto.GoldenSetId,
|
||||
VerifiedAt = dto.VerifiedAt ?? dto.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for the FixChain attestation client.
|
||||
/// </summary>
|
||||
public sealed record FixChainClientOptions
|
||||
{
|
||||
/// <summary>Base URL for the attestor service.</summary>
|
||||
public string AttestorBaseUrl { get; init; } = "http://localhost:5000";
|
||||
|
||||
/// <summary>Cache TTL for successful lookups.</summary>
|
||||
public TimeSpan CacheTtl { get; init; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>Cache TTL for negative (not found) lookups.</summary>
|
||||
public TimeSpan NegativeCacheTtl { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>HTTP timeout.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for deserializing attestor API response.
|
||||
/// </summary>
|
||||
internal sealed class FixChainAttestationDto
|
||||
{
|
||||
public string ContentDigest { get; init; } = string.Empty;
|
||||
public string CveId { get; init; } = string.Empty;
|
||||
public string? Component { get; init; }
|
||||
public string? ComponentPurl { get; init; }
|
||||
public string BinarySha256 { get; init; } = string.Empty;
|
||||
public string VerdictStatus { get; init; } = string.Empty;
|
||||
public decimal Confidence { get; init; }
|
||||
public ImmutableArray<string>? Rationale { get; init; }
|
||||
public string? GoldenSetId { get; init; }
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_007_RISK
|
||||
// Task: FCR-006 - Risk Factor Display Model
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers.FixChain;
|
||||
|
||||
/// <summary>
|
||||
/// Display model for risk factors in UI and reports.
|
||||
/// </summary>
|
||||
public sealed record RiskFactorDisplay
|
||||
{
|
||||
/// <summary>Factor type identifier.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Human-readable label.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>Display value.</summary>
|
||||
public required string Value { get; init; }
|
||||
|
||||
/// <summary>Impact value.</summary>
|
||||
public required double Impact { get; init; }
|
||||
|
||||
/// <summary>Impact direction: "increase", "decrease", "neutral".</summary>
|
||||
public required string ImpactDirection { get; init; }
|
||||
|
||||
/// <summary>Reference to evidence (attestation URI, etc).</summary>
|
||||
public string? EvidenceRef { get; init; }
|
||||
|
||||
/// <summary>Tooltip text.</summary>
|
||||
public string? Tooltip { get; init; }
|
||||
|
||||
/// <summary>Additional details.</summary>
|
||||
public ImmutableDictionary<string, string>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for converting FixChain risk factors to display models.
|
||||
/// </summary>
|
||||
public static class FixChainRiskDisplayExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a FixChainRiskFactor to a display model for UI rendering.
|
||||
/// </summary>
|
||||
public static RiskFactorDisplay ToDisplay(this FixChainRiskFactor factor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factor);
|
||||
|
||||
var impactPercent = Math.Abs(factor.RiskModifier) * 100;
|
||||
var confidenceDisplay = factor.Confidence.ToString("P0", CultureInfo.InvariantCulture);
|
||||
|
||||
var value = factor.Verdict switch
|
||||
{
|
||||
FixChainVerdictStatus.Fixed => $"Fixed ({confidenceDisplay} confidence)",
|
||||
FixChainVerdictStatus.Partial => $"Partial fix ({confidenceDisplay} confidence)",
|
||||
FixChainVerdictStatus.Inconclusive => "Inconclusive",
|
||||
FixChainVerdictStatus.StillVulnerable => "Still Vulnerable",
|
||||
FixChainVerdictStatus.NotVerified => "Not Verified",
|
||||
_ => "Unknown"
|
||||
};
|
||||
|
||||
var impactDirection = factor.RiskModifier < 0 ? "decrease" : "neutral";
|
||||
|
||||
var details = new Dictionary<string, string>
|
||||
{
|
||||
["verdict"] = factor.Verdict.ToString(),
|
||||
["confidence"] = factor.Confidence.ToString("P2", CultureInfo.InvariantCulture),
|
||||
["verified_at"] = factor.VerifiedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
["risk_modifier"] = factor.RiskModifier.ToString("P0", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (factor.GoldenSetId is not null)
|
||||
{
|
||||
details["golden_set_id"] = factor.GoldenSetId;
|
||||
}
|
||||
|
||||
return new RiskFactorDisplay
|
||||
{
|
||||
Type = factor.FactorType,
|
||||
Label = "Fix Verification",
|
||||
Value = value,
|
||||
Impact = factor.RiskModifier,
|
||||
ImpactDirection = impactDirection,
|
||||
EvidenceRef = factor.AttestationRef,
|
||||
Tooltip = factor.Rationale.Length > 0
|
||||
? string.Join("; ", factor.Rationale)
|
||||
: null,
|
||||
Details = details.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a FixVerificationStatus to a display model.
|
||||
/// </summary>
|
||||
public static RiskFactorDisplay ToDisplay(
|
||||
this FixVerificationStatus status,
|
||||
IFixChainRiskProvider provider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
var factor = provider.CreateRiskFactor(status);
|
||||
return factor.ToDisplay();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a summary string for the fix verification status.
|
||||
/// </summary>
|
||||
public static string ToSummary(this FixChainRiskFactor factor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factor);
|
||||
|
||||
return factor.Verdict switch
|
||||
{
|
||||
FixChainVerdictStatus.Fixed =>
|
||||
$"[FIXED] {factor.Confidence:P0} confidence, risk reduced by {Math.Abs(factor.RiskModifier):P0}",
|
||||
FixChainVerdictStatus.Partial =>
|
||||
$"[PARTIAL] {factor.Confidence:P0} confidence, risk reduced by {Math.Abs(factor.RiskModifier):P0}",
|
||||
FixChainVerdictStatus.Inconclusive =>
|
||||
"[INCONCLUSIVE] Cannot determine fix status",
|
||||
FixChainVerdictStatus.StillVulnerable =>
|
||||
"[VULNERABLE] Vulnerability still present",
|
||||
_ =>
|
||||
"[NOT VERIFIED] No fix verification performed"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a badge style for UI rendering.
|
||||
/// </summary>
|
||||
public static FixChainBadge ToBadge(this FixChainRiskFactor factor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factor);
|
||||
|
||||
return factor.Verdict switch
|
||||
{
|
||||
FixChainVerdictStatus.Fixed => new FixChainBadge
|
||||
{
|
||||
Status = "Fixed",
|
||||
Color = "green",
|
||||
Icon = "check-circle",
|
||||
Confidence = factor.Confidence,
|
||||
Tooltip = $"Verified fix ({factor.Confidence:P0} confidence)"
|
||||
},
|
||||
FixChainVerdictStatus.Partial => new FixChainBadge
|
||||
{
|
||||
Status = "Partial",
|
||||
Color = "yellow",
|
||||
Icon = "alert-circle",
|
||||
Confidence = factor.Confidence,
|
||||
Tooltip = $"Partial fix ({factor.Confidence:P0} confidence)"
|
||||
},
|
||||
FixChainVerdictStatus.Inconclusive => new FixChainBadge
|
||||
{
|
||||
Status = "Inconclusive",
|
||||
Color = "gray",
|
||||
Icon = "help-circle",
|
||||
Confidence = factor.Confidence,
|
||||
Tooltip = "Cannot determine fix status"
|
||||
},
|
||||
FixChainVerdictStatus.StillVulnerable => new FixChainBadge
|
||||
{
|
||||
Status = "Vulnerable",
|
||||
Color = "red",
|
||||
Icon = "x-circle",
|
||||
Confidence = factor.Confidence,
|
||||
Tooltip = "Vulnerability still present"
|
||||
},
|
||||
_ => new FixChainBadge
|
||||
{
|
||||
Status = "Unverified",
|
||||
Color = "gray",
|
||||
Icon = "question",
|
||||
Confidence = 0,
|
||||
Tooltip = "No fix verification performed"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Badge information for UI rendering.
|
||||
/// </summary>
|
||||
public sealed record FixChainBadge
|
||||
{
|
||||
/// <summary>Status text.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Color for the badge (e.g., "green", "red", "yellow", "gray").</summary>
|
||||
public required string Color { get; init; }
|
||||
|
||||
/// <summary>Icon name.</summary>
|
||||
public required string Icon { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0-1).</summary>
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
/// <summary>Tooltip text.</summary>
|
||||
public string? Tooltip { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_007_RISK
|
||||
// Task: FCR-007 - Metrics and Observability
|
||||
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers.FixChain;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry metrics for FixChain risk integration.
|
||||
/// </summary>
|
||||
public static class FixChainRiskMetrics
|
||||
{
|
||||
/// <summary>Meter name for FixChain risk metrics.</summary>
|
||||
public const string MeterName = "StellaOps.RiskEngine.FixChain";
|
||||
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
|
||||
/// <summary>Total FixChain attestation lookups.</summary>
|
||||
public static readonly Counter<long> LookupsTotal = Meter.CreateCounter<long>(
|
||||
"risk_fixchain_lookups_total",
|
||||
unit: "{lookups}",
|
||||
description: "Total number of FixChain attestation lookups");
|
||||
|
||||
/// <summary>FixChain attestations found (cache hits + remote hits).</summary>
|
||||
public static readonly Counter<long> HitsTotal = Meter.CreateCounter<long>(
|
||||
"risk_fixchain_hits_total",
|
||||
unit: "{hits}",
|
||||
description: "Total number of FixChain attestations found");
|
||||
|
||||
/// <summary>FixChain lookups that did not find an attestation.</summary>
|
||||
public static readonly Counter<long> MissesTotal = Meter.CreateCounter<long>(
|
||||
"risk_fixchain_misses_total",
|
||||
unit: "{misses}",
|
||||
description: "Total number of FixChain lookups that did not find an attestation");
|
||||
|
||||
/// <summary>Cache hits for FixChain lookups.</summary>
|
||||
public static readonly Counter<long> CacheHitsTotal = Meter.CreateCounter<long>(
|
||||
"risk_fixchain_cache_hits_total",
|
||||
unit: "{hits}",
|
||||
description: "Total number of FixChain lookups served from cache");
|
||||
|
||||
/// <summary>Lookup duration histogram.</summary>
|
||||
public static readonly Histogram<double> LookupDuration = Meter.CreateHistogram<double>(
|
||||
"risk_fixchain_lookup_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of FixChain attestation lookups");
|
||||
|
||||
/// <summary>Risk adjustments applied from FixChain.</summary>
|
||||
public static readonly Counter<long> AdjustmentsTotal = Meter.CreateCounter<long>(
|
||||
"risk_fixchain_adjustments_total",
|
||||
unit: "{adjustments}",
|
||||
description: "Total number of risk adjustments applied from FixChain verdicts");
|
||||
|
||||
/// <summary>Risk reduction percentage distribution.</summary>
|
||||
public static readonly Histogram<double> ReductionPercent = Meter.CreateHistogram<double>(
|
||||
"risk_fixchain_reduction_percent",
|
||||
unit: "%",
|
||||
description: "Distribution of risk reduction percentages from FixChain");
|
||||
|
||||
/// <summary>Errors during FixChain lookup.</summary>
|
||||
public static readonly Counter<long> ErrorsTotal = Meter.CreateCounter<long>(
|
||||
"risk_fixchain_errors_total",
|
||||
unit: "{errors}",
|
||||
description: "Total number of errors during FixChain attestation lookups");
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful lookup.
|
||||
/// </summary>
|
||||
public static void RecordLookup(
|
||||
bool found,
|
||||
bool fromCache,
|
||||
double durationSeconds,
|
||||
string? verdict = null)
|
||||
{
|
||||
LookupsTotal.Add(1);
|
||||
LookupDuration.Record(durationSeconds);
|
||||
|
||||
if (found)
|
||||
{
|
||||
HitsTotal.Add(1, new KeyValuePair<string, object?>("verdict", verdict ?? "unknown"));
|
||||
}
|
||||
else
|
||||
{
|
||||
MissesTotal.Add(1);
|
||||
}
|
||||
|
||||
if (fromCache)
|
||||
{
|
||||
CacheHitsTotal.Add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a risk adjustment.
|
||||
/// </summary>
|
||||
public static void RecordAdjustment(
|
||||
FixChainVerdictStatus verdict,
|
||||
decimal confidence,
|
||||
double reductionPercent)
|
||||
{
|
||||
AdjustmentsTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("verdict", verdict.ToString()),
|
||||
new KeyValuePair<string, object?>("confidence_tier", GetConfidenceTier(confidence)));
|
||||
|
||||
ReductionPercent.Record(reductionPercent * 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a lookup error.
|
||||
/// </summary>
|
||||
public static void RecordError(string errorType)
|
||||
{
|
||||
ErrorsTotal.Add(1, new KeyValuePair<string, object?>("error_type", errorType));
|
||||
}
|
||||
|
||||
private static string GetConfidenceTier(decimal confidence)
|
||||
{
|
||||
return confidence switch
|
||||
{
|
||||
>= 0.95m => "high",
|
||||
>= 0.85m => "medium",
|
||||
>= 0.70m => "low",
|
||||
_ => "very_low"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for recording metrics in the risk provider.
|
||||
/// </summary>
|
||||
public static class FixChainRiskMetricsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Records metrics for a fix verification result.
|
||||
/// </summary>
|
||||
public static void RecordMetrics(
|
||||
this FixChainRiskFactor factor,
|
||||
double lookupDurationSeconds,
|
||||
bool fromCache)
|
||||
{
|
||||
FixChainRiskMetrics.RecordLookup(
|
||||
found: true,
|
||||
fromCache: fromCache,
|
||||
durationSeconds: lookupDurationSeconds,
|
||||
verdict: factor.Verdict.ToString());
|
||||
|
||||
FixChainRiskMetrics.RecordAdjustment(
|
||||
verdict: factor.Verdict,
|
||||
confidence: factor.Confidence,
|
||||
reductionPercent: Math.Abs(factor.RiskModifier));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a missed lookup.
|
||||
/// </summary>
|
||||
public static void RecordMiss(double lookupDurationSeconds, bool fromCache)
|
||||
{
|
||||
FixChainRiskMetrics.RecordLookup(
|
||||
found: false,
|
||||
fromCache: fromCache,
|
||||
durationSeconds: lookupDurationSeconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_007_RISK
|
||||
// Task: FCR-001 through FCR-005 - FixChain Risk Provider
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers.FixChain;
|
||||
|
||||
/// <summary>
|
||||
/// Risk score provider that adjusts risk based on FixChain attestation verdicts.
|
||||
/// </summary>
|
||||
public interface IFixChainRiskProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the fix verification status for a vulnerability on a specific binary.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="binarySha256">Binary SHA-256 digest.</param>
|
||||
/// <param name="componentPurl">Optional component PURL.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Fix verification status or null if not available.</returns>
|
||||
Task<FixVerificationStatus?> GetFixStatusAsync(
|
||||
string cveId,
|
||||
string binarySha256,
|
||||
string? componentPurl = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the risk adjustment factor based on fix verification.
|
||||
/// </summary>
|
||||
/// <param name="status">Fix verification status.</param>
|
||||
/// <returns>Risk adjustment factor (0.0 = no risk, 1.0 = full risk).</returns>
|
||||
double ComputeRiskAdjustment(FixVerificationStatus status);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a risk factor from fix verification status.
|
||||
/// </summary>
|
||||
/// <param name="status">Fix verification status.</param>
|
||||
/// <returns>Risk factor for inclusion in risk calculation.</returns>
|
||||
FixChainRiskFactor CreateRiskFactor(FixVerificationStatus status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix verification status from a FixChain attestation.
|
||||
/// </summary>
|
||||
public sealed record FixVerificationStatus
|
||||
{
|
||||
/// <summary>Verdict status: fixed, partial, not_fixed, inconclusive.</summary>
|
||||
public required string Verdict { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0 - 1.0).</summary>
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
/// <summary>When the verification was performed.</summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>Source attestation digest.</summary>
|
||||
public required string AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>Rationale items.</summary>
|
||||
public IReadOnlyList<string> Rationale { get; init; } = [];
|
||||
|
||||
/// <summary>Golden set ID used for verification.</summary>
|
||||
public string? GoldenSetId { get; init; }
|
||||
|
||||
/// <summary>Component PURL.</summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for FixChain risk adjustment.
|
||||
/// </summary>
|
||||
public sealed record FixChainRiskOptions
|
||||
{
|
||||
/// <summary>Whether fix chain risk adjustment is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>Risk reduction for "fixed" verdict at 100% confidence.</summary>
|
||||
public double FixedReduction { get; init; } = 0.90;
|
||||
|
||||
/// <summary>Risk reduction for "partial" verdict at 100% confidence.</summary>
|
||||
public double PartialReduction { get; init; } = 0.50;
|
||||
|
||||
/// <summary>Minimum confidence threshold to apply any reduction.</summary>
|
||||
public decimal MinConfidenceThreshold { get; init; } = 0.60m;
|
||||
|
||||
/// <summary>High confidence threshold for maximum reduction.</summary>
|
||||
public decimal HighConfidenceThreshold { get; init; } = 0.95m;
|
||||
|
||||
/// <summary>Medium confidence threshold.</summary>
|
||||
public decimal MediumConfidenceThreshold { get; init; } = 0.85m;
|
||||
|
||||
/// <summary>Low confidence threshold.</summary>
|
||||
public decimal LowConfidenceThreshold { get; init; } = 0.70m;
|
||||
|
||||
/// <summary>Maximum risk reduction allowed.</summary>
|
||||
public double MaxRiskReduction { get; init; } = 0.90;
|
||||
|
||||
/// <summary>Maximum age (hours) for cached fix status.</summary>
|
||||
public int CacheMaxAgeHours { get; init; } = 24;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk factor from FixChain verification.
|
||||
/// </summary>
|
||||
public sealed record FixChainRiskFactor
|
||||
{
|
||||
/// <summary>Factor type identifier.</summary>
|
||||
public string FactorType => "fix_chain_verification";
|
||||
|
||||
/// <summary>Verdict status.</summary>
|
||||
public required FixChainVerdictStatus Verdict { get; init; }
|
||||
|
||||
/// <summary>Confidence score.</summary>
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
/// <summary>Risk modifier (-1.0 to 0.0 for reduction).</summary>
|
||||
public required double RiskModifier { get; init; }
|
||||
|
||||
/// <summary>Reference to attestation.</summary>
|
||||
public required string AttestationRef { get; init; }
|
||||
|
||||
/// <summary>Human-readable rationale.</summary>
|
||||
public ImmutableArray<string> Rationale { get; init; } = [];
|
||||
|
||||
/// <summary>Golden set ID.</summary>
|
||||
public string? GoldenSetId { get; init; }
|
||||
|
||||
/// <summary>When the verification was performed.</summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdict status for risk calculation.
|
||||
/// </summary>
|
||||
public enum FixChainVerdictStatus
|
||||
{
|
||||
/// <summary>Fix verified.</summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>Partial fix.</summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>Verdict inconclusive.</summary>
|
||||
Inconclusive,
|
||||
|
||||
/// <summary>Still vulnerable.</summary>
|
||||
StillVulnerable,
|
||||
|
||||
/// <summary>Not verified.</summary>
|
||||
NotVerified
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk provider that adjusts scores based on FixChain attestation verdicts.
|
||||
/// </summary>
|
||||
public sealed class FixChainRiskProvider : IRiskScoreProvider, IFixChainRiskProvider
|
||||
{
|
||||
private readonly FixChainRiskOptions _options;
|
||||
private readonly IFixChainAttestationClient? _attestationClient;
|
||||
private readonly ILogger<FixChainRiskProvider> _logger;
|
||||
|
||||
/// <summary>Signal name for fix verification confidence.</summary>
|
||||
public const string SignalFixConfidence = "fixchain.confidence";
|
||||
|
||||
/// <summary>Signal name for fix verification status (encoded).</summary>
|
||||
public const string SignalFixStatus = "fixchain.status";
|
||||
|
||||
public FixChainRiskProvider()
|
||||
: this(new FixChainRiskOptions(), null, NullLogger<FixChainRiskProvider>.Instance)
|
||||
{ }
|
||||
|
||||
public FixChainRiskProvider(FixChainRiskOptions options)
|
||||
: this(options, null, NullLogger<FixChainRiskProvider>.Instance)
|
||||
{ }
|
||||
|
||||
public FixChainRiskProvider(
|
||||
FixChainRiskOptions options,
|
||||
IFixChainAttestationClient? attestationClient,
|
||||
ILogger<FixChainRiskProvider> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_attestationClient = attestationClient;
|
||||
_logger = logger ?? NullLogger<FixChainRiskProvider>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "fixchain";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult(1.0);
|
||||
}
|
||||
|
||||
// Extract fix signals if present
|
||||
if (!request.Signals.TryGetValue(SignalFixConfidence, out var confidence))
|
||||
{
|
||||
// No fix verification data - return neutral score (1.0 = full risk retained)
|
||||
return Task.FromResult(1.0);
|
||||
}
|
||||
|
||||
if (!request.Signals.TryGetValue(SignalFixStatus, out var statusCode))
|
||||
{
|
||||
return Task.FromResult(1.0);
|
||||
}
|
||||
|
||||
// Decode status
|
||||
var status = DecodeStatus(statusCode);
|
||||
var adjustment = ComputeRiskAdjustmentInternal(status, (decimal)confidence);
|
||||
|
||||
return Task.FromResult(adjustment);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FixVerificationStatus?> GetFixStatusAsync(
|
||||
string cveId,
|
||||
string binarySha256,
|
||||
string? componentPurl = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_attestationClient is null)
|
||||
{
|
||||
_logger.LogDebug("No attestation client configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
var attestation = await _attestationClient.GetFixChainAsync(
|
||||
cveId, binarySha256, componentPurl, ct);
|
||||
|
||||
if (attestation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FixVerificationStatus
|
||||
{
|
||||
Verdict = attestation.Verdict.Status,
|
||||
Confidence = attestation.Verdict.Confidence,
|
||||
VerifiedAt = attestation.VerifiedAt,
|
||||
AttestationDigest = attestation.ContentDigest,
|
||||
Rationale = attestation.Verdict.Rationale.ToArray(),
|
||||
GoldenSetId = attestation.GoldenSetId,
|
||||
ComponentPurl = attestation.ComponentPurl
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public double ComputeRiskAdjustment(FixVerificationStatus status)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
return ComputeRiskAdjustmentInternal(status.Verdict, status.Confidence);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public FixChainRiskFactor CreateRiskFactor(FixVerificationStatus status)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
|
||||
var adjustment = ComputeRiskAdjustmentInternal(status.Verdict, status.Confidence);
|
||||
var modifier = adjustment - 1.0; // Convert to modifier (-0.9 to 0.0)
|
||||
|
||||
return new FixChainRiskFactor
|
||||
{
|
||||
Verdict = MapVerdictStatus(status.Verdict),
|
||||
Confidence = status.Confidence,
|
||||
RiskModifier = modifier,
|
||||
AttestationRef = $"fixchain://{status.AttestationDigest}",
|
||||
Rationale = [.. status.Rationale],
|
||||
GoldenSetId = status.GoldenSetId,
|
||||
VerifiedAt = status.VerifiedAt
|
||||
};
|
||||
}
|
||||
|
||||
private double ComputeRiskAdjustmentInternal(string verdict, decimal confidence)
|
||||
{
|
||||
// Below minimum confidence threshold, no adjustment
|
||||
if (confidence < _options.MinConfidenceThreshold)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Scale confidence contribution
|
||||
var confidenceScale = (double)((confidence - _options.MinConfidenceThreshold) /
|
||||
(1.0m - _options.MinConfidenceThreshold));
|
||||
|
||||
var adjustment = verdict.ToLowerInvariant() switch
|
||||
{
|
||||
"fixed" => 1.0 - (_options.FixedReduction * confidenceScale),
|
||||
"partial" => 1.0 - (_options.PartialReduction * confidenceScale),
|
||||
"not_fixed" => 1.0, // No reduction
|
||||
"inconclusive" => 1.0, // No reduction
|
||||
_ => 1.0
|
||||
};
|
||||
|
||||
// Ensure minimum risk is retained
|
||||
var minRisk = 1.0 - _options.MaxRiskReduction;
|
||||
return Math.Max(adjustment, minRisk);
|
||||
}
|
||||
|
||||
private static string DecodeStatus(double statusCode)
|
||||
{
|
||||
// Status codes: 1=fixed, 2=partial, 3=not_fixed, 4=inconclusive
|
||||
return statusCode switch
|
||||
{
|
||||
1.0 => "fixed",
|
||||
2.0 => "partial",
|
||||
3.0 => "not_fixed",
|
||||
4.0 => "inconclusive",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private static FixChainVerdictStatus MapVerdictStatus(string verdict)
|
||||
{
|
||||
return verdict.ToLowerInvariant() switch
|
||||
{
|
||||
"fixed" => FixChainVerdictStatus.Fixed,
|
||||
"partial" => FixChainVerdictStatus.Partial,
|
||||
"not_fixed" => FixChainVerdictStatus.StillVulnerable,
|
||||
"inconclusive" => FixChainVerdictStatus.Inconclusive,
|
||||
_ => FixChainVerdictStatus.NotVerified
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a verdict string as a numeric status code for signal transport.
|
||||
/// </summary>
|
||||
public static double EncodeStatus(string verdict)
|
||||
{
|
||||
return verdict.ToLowerInvariant() switch
|
||||
{
|
||||
"fixed" => 1.0,
|
||||
"partial" => 2.0,
|
||||
"not_fixed" => 3.0,
|
||||
"inconclusive" => 4.0,
|
||||
_ => 0.0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_007_RISK
|
||||
// Task: FCR-005 - FixChain Attestation Client
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers.FixChain;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for querying FixChain attestations from the attestation store.
|
||||
/// </summary>
|
||||
public interface IFixChainAttestationClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the FixChain attestation for a CVE/binary combination.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="binarySha256">Binary SHA-256 digest.</param>
|
||||
/// <param name="componentPurl">Optional component PURL.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Attestation info if found.</returns>
|
||||
Task<FixChainAttestationData?> GetFixChainAsync(
|
||||
string cveId,
|
||||
string binarySha256,
|
||||
string? componentPurl = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all FixChain attestations for a component.
|
||||
/// </summary>
|
||||
/// <param name="componentPurl">Component PURL.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>All attestations for the component.</returns>
|
||||
Task<ImmutableArray<FixChainAttestationData>> GetForComponentAsync(
|
||||
string componentPurl,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data about a FixChain attestation for risk calculation.
|
||||
/// </summary>
|
||||
public sealed record FixChainAttestationData
|
||||
{
|
||||
/// <summary>Content digest of the attestation.</summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>CVE identifier.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Component PURL.</summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>Binary SHA-256 digest.</summary>
|
||||
public required string BinarySha256 { get; init; }
|
||||
|
||||
/// <summary>Verdict information.</summary>
|
||||
public required FixChainVerdictData Verdict { get; init; }
|
||||
|
||||
/// <summary>Golden set ID used for verification.</summary>
|
||||
public string? GoldenSetId { get; init; }
|
||||
|
||||
/// <summary>When the verification was performed.</summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdict data from a FixChain attestation.
|
||||
/// </summary>
|
||||
public sealed record FixChainVerdictData
|
||||
{
|
||||
/// <summary>Verdict status: fixed, partial, not_fixed, inconclusive.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0 - 1.0).</summary>
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
/// <summary>Rationale items explaining the verdict.</summary>
|
||||
public ImmutableArray<string> Rationale { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Combines fix availability, asset criticality, and internet exposure into a bounded score.
|
||||
/// Formula: clamp01(0.5 * FixAvailability + 0.3 * Criticality + 0.2 * Exposure).
|
||||
/// Inputs are expected in [0,1]; missing keys default to 0.
|
||||
/// </summary>
|
||||
public sealed class FixExposureProvider : IRiskScoreProvider
|
||||
{
|
||||
public const string ProviderName = "fix-exposure";
|
||||
|
||||
public string Name => ProviderName;
|
||||
|
||||
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var fix = Get(request, "FixAvailability");
|
||||
var crit = Get(request, "Criticality");
|
||||
var exposure = Get(request, "Exposure");
|
||||
|
||||
var weighted = (0.5 * fix) + (0.3 * crit) + (0.2 * exposure);
|
||||
var score = Math.Round(Math.Clamp(weighted, 0d, 1d), 6, MidpointRounding.ToEven);
|
||||
return Task.FromResult(score);
|
||||
}
|
||||
|
||||
private static double Get(ScoreRequest request, string key) =>
|
||||
request.Signals.TryGetValue(key, out var value)
|
||||
? Math.Clamp(value, 0d, 1d)
|
||||
: 0d;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
public interface ICvssSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns CVSS base score (0-10) for the subject, or null if unknown.
|
||||
/// </summary>
|
||||
Task<double?> GetCvssAsync(string subject, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IKevSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the subject is marked as known exploited (KEV), false otherwise, null if unknown.
|
||||
/// </summary>
|
||||
Task<bool?> IsKevAsync(string subject, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-object CVSS source returning no score (treat as unknown).
|
||||
/// </summary>
|
||||
public sealed class NullCvssSource : ICvssSource
|
||||
{
|
||||
public Task<double?> GetCvssAsync(string subject, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<double?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-object KEV source returning false.
|
||||
/// </summary>
|
||||
public sealed class NullKevSource : IKevSource
|
||||
{
|
||||
public Task<bool?> IsKevAsync(string subject, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<bool?>(false);
|
||||
}
|
||||
@@ -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,33 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for exploit maturity mapping service.
|
||||
/// </summary>
|
||||
public interface IExploitMaturityService
|
||||
{
|
||||
/// <summary>
|
||||
/// Assesses exploit maturity for a CVE by consolidating all available signals.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Exploit maturity assessment result.</returns>
|
||||
Task<ExploitMaturityResult> AssessMaturityAsync(string cveId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current maturity level for a CVE.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Current maturity level or null if not assessed.</returns>
|
||||
Task<ExploitMaturityLevel?> GetMaturityLevelAsync(string cveId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets maturity lifecycle history for a CVE.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>History of maturity changes.</returns>
|
||||
Task<IReadOnlyList<MaturityHistoryEntry>> GetMaturityHistoryAsync(string cveId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a risk score for a request. Implementations must be deterministic for identical inputs.
|
||||
/// </summary>
|
||||
public interface IRiskScoreProvider
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IRiskScoreProviderRegistry
|
||||
{
|
||||
bool TryGet(string name, out IRiskScoreProvider provider);
|
||||
|
||||
IReadOnlyCollection<string> ProviderNames { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory provider registry.
|
||||
/// </summary>
|
||||
public sealed class RiskScoreProviderRegistry : IRiskScoreProviderRegistry
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IRiskScoreProvider> providers;
|
||||
|
||||
public RiskScoreProviderRegistry(IEnumerable<IRiskScoreProvider> providers)
|
||||
{
|
||||
var map = new Dictionary<string, IRiskScoreProvider>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
map[provider.Name] = provider;
|
||||
}
|
||||
|
||||
this.providers = map;
|
||||
}
|
||||
|
||||
public bool TryGet(string name, out IRiskScoreProvider provider) =>
|
||||
providers.TryGetValue(name, out provider!);
|
||||
|
||||
public IReadOnlyCollection<string> ProviderNames => providers.Keys.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// VEX gate provider that short-circuits scoring when a denial is present.
|
||||
/// Signals are ignored when <c>HasDenial</c> is true.
|
||||
/// </summary>
|
||||
public sealed class VexGateProvider : IRiskScoreProvider
|
||||
{
|
||||
public const string ProviderName = "vex-gate";
|
||||
|
||||
public string Name => ProviderName;
|
||||
|
||||
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var hasDenial = request.Signals.TryGetValue("HasDenial", out var denialFlag) && denialFlag >= 1;
|
||||
if (hasDenial)
|
||||
{
|
||||
return Task.FromResult(0d);
|
||||
}
|
||||
|
||||
// Fall back to simple max of remaining signals (if any)
|
||||
var max = request.Signals
|
||||
.Where(kvp => !string.Equals(kvp.Key, "HasDenial", StringComparison.Ordinal))
|
||||
.Select(kvp => kvp.Value)
|
||||
.DefaultIfEmpty(0d)
|
||||
.Max();
|
||||
|
||||
var score = Math.Clamp(max, 0d, 1d);
|
||||
return Task.FromResult(Math.Round(score, 6, MidpointRounding.ToEven));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists risk score results for later retrieval/ledger projection.
|
||||
/// Implementations must be deterministic and side‑effect free for identical inputs.
|
||||
/// </summary>
|
||||
public interface IRiskScoreResultStore
|
||||
{
|
||||
Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read a previously persisted result by job identifier.
|
||||
/// Implementations must be deterministic and side-effect free.
|
||||
/// </summary>
|
||||
bool TryGet(Guid jobId, out RiskScoreResult result);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic FIFO queue for risk score jobs.
|
||||
/// </summary>
|
||||
public sealed class RiskScoreQueue
|
||||
{
|
||||
private readonly Channel<RiskScoreJob> channel;
|
||||
|
||||
public RiskScoreQueue(int? capacity = null)
|
||||
{
|
||||
if (capacity.HasValue)
|
||||
{
|
||||
var options = new BoundedChannelOptions(capacity.Value)
|
||||
{
|
||||
AllowSynchronousContinuations = false,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
FullMode = BoundedChannelFullMode.Wait
|
||||
};
|
||||
|
||||
channel = Channel.CreateBounded<RiskScoreJob>(options);
|
||||
}
|
||||
else
|
||||
{
|
||||
var options = new UnboundedChannelOptions
|
||||
{
|
||||
AllowSynchronousContinuations = false,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
};
|
||||
|
||||
channel = Channel.CreateUnbounded<RiskScoreJob>(options);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask EnqueueAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var job = new RiskScoreJob(Guid.NewGuid(), request);
|
||||
return channel.Writer.WriteAsync(job, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a request and returns the assigned job id for later retrieval.
|
||||
/// </summary>
|
||||
public async ValueTask<Guid> EnqueueWithIdAsync(ScoreRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var job = new RiskScoreJob(Guid.NewGuid(), request);
|
||||
await channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
return job.JobId;
|
||||
}
|
||||
|
||||
public ValueTask<RiskScoreJob> DequeueAsync(CancellationToken cancellationToken) =>
|
||||
channel.Reader.ReadAsync(cancellationToken);
|
||||
|
||||
public bool TryDequeue([NotNullWhen(true)] out RiskScoreJob? job) => channel.Reader.TryRead(out job);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using StellaOps.RiskEngine.Core.Contracts;
|
||||
using StellaOps.RiskEngine.Core.Providers;
|
||||
|
||||
namespace StellaOps.RiskEngine.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single-reader worker that pulls jobs from the queue and executes providers deterministically.
|
||||
/// </summary>
|
||||
public sealed class RiskScoreWorker
|
||||
{
|
||||
private readonly RiskScoreQueue queue;
|
||||
private readonly IRiskScoreProviderRegistry registry;
|
||||
private readonly IRiskScoreResultStore? resultStore;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public RiskScoreWorker(
|
||||
RiskScoreQueue queue,
|
||||
IRiskScoreProviderRegistry registry,
|
||||
IRiskScoreResultStore? resultStore = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.resultStore = resultStore;
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<RiskScoreResult> ProcessNextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await queue.DequeueAsync(cancellationToken).ConfigureAwait(false);
|
||||
var request = job.Request;
|
||||
|
||||
RiskScoreResult Build(double score, bool success, string? error) =>
|
||||
new(
|
||||
job.JobId,
|
||||
request.Provider,
|
||||
request.Subject,
|
||||
Score: score,
|
||||
Success: success,
|
||||
Error: error,
|
||||
request.Signals,
|
||||
CompletedAtUtc: timeProvider.GetUtcNow());
|
||||
|
||||
if (!registry.TryGet(request.Provider, out var provider))
|
||||
{
|
||||
var missing = Build(0d, false, "Provider not registered");
|
||||
await PersistAsync(missing, cancellationToken).ConfigureAwait(false);
|
||||
return missing;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var score = await provider.ScoreAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var success = Build(score, true, null);
|
||||
await PersistAsync(success, cancellationToken).ConfigureAwait(false);
|
||||
return success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failure = Build(0d, false, ex.Message);
|
||||
await PersistAsync(failure, cancellationToken).ConfigureAwait(false);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RiskScoreResult>> ProcessBatchAsync(int expectedCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (expectedCount < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(expectedCount));
|
||||
}
|
||||
|
||||
var results = new List<RiskScoreResult>(expectedCount);
|
||||
for (var i = 0; i < expectedCount; i++)
|
||||
{
|
||||
results.Add(await ProcessNextAsync(cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private Task PersistAsync(RiskScoreResult result, CancellationToken cancellationToken) =>
|
||||
resultStore is null
|
||||
? Task.CompletedTask
|
||||
: resultStore.SaveAsync(result, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
8
src/Findings/StellaOps.RiskEngine.Core/TASKS.md
Normal file
8
src/Findings/StellaOps.RiskEngine.Core/TASKS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# StellaOps.RiskEngine.Core Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Core/StellaOps.RiskEngine.Core.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
Reference in New Issue
Block a user