consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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
};
}

View File

@@ -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;
}
}

View 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);

View 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;
}
}

View File

@@ -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);

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

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

View File

@@ -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
};
}
}

View File

@@ -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; } = [];
}

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -0,0 +1,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);
}

View File

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

View File

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

View File

@@ -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 sideeffect 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);
}

View File

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

View File

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

View File

@@ -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>

View 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. |