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

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,138 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using static StellaOps.Localization.T;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers;
using StellaOps.RiskEngine.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
namespace StellaOps.RiskEngine.WebService.Endpoints;
/// <summary>
/// Minimal API endpoints for exploit maturity assessment.
/// </summary>
public static class ExploitMaturityEndpoints
{
/// <summary>
/// Maps exploit maturity endpoints to the application.
/// </summary>
public static IEndpointRouteBuilder MapExploitMaturityEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/exploit-maturity")
.WithTags("ExploitMaturity")
.RequireAuthorization(RiskEnginePolicies.Read)
.RequireTenant();
// GET /exploit-maturity/{cveId} - Assess exploit maturity for a CVE
group.MapGet("/{cveId}", async (
string cveId,
[FromServices] IExploitMaturityService service,
CancellationToken ct) =>
{
try
{
var result = await service.AssessMaturityAsync(cveId, ct).ConfigureAwait(false);
return Results.Ok(result);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.WithName("GetExploitMaturity")
.WithSummary("Assess exploit maturity for a CVE")
.WithDescription(_t("riskengine.exploit_maturity.assess_description"))
.Produces<ExploitMaturityResult>()
.ProducesProblem(400);
// GET /exploit-maturity/{cveId}/level - Get just the maturity level
group.MapGet("/{cveId}/level", async (
string cveId,
[FromServices] IExploitMaturityService service,
CancellationToken ct) =>
{
try
{
var level = await service.GetMaturityLevelAsync(cveId, ct).ConfigureAwait(false);
return level.HasValue
? Results.Ok(new { cveId, level = level.Value.ToString() })
: Results.NotFound(new { cveId, error = _t("riskengine.error.maturity_level_undetermined") });
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.WithName("GetExploitMaturityLevel")
.WithSummary("Get exploit maturity level for a CVE")
.WithDescription(_t("riskengine.exploit_maturity.get_level_description"));
// GET /exploit-maturity/{cveId}/history - Get maturity history
group.MapGet("/{cveId}/history", async (
string cveId,
[FromServices] IExploitMaturityService service,
CancellationToken ct) =>
{
try
{
var history = await service.GetMaturityHistoryAsync(cveId, ct).ConfigureAwait(false);
return Results.Ok(new { cveId, entries = history });
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.WithName("GetExploitMaturityHistory")
.WithSummary("Get exploit maturity history for a CVE")
.WithDescription(_t("riskengine.exploit_maturity.get_history_description"));
// POST /exploit-maturity/batch - Batch assess multiple CVEs
group.MapPost("/batch", async (
BatchMaturityRequest request,
[FromServices] IExploitMaturityService service,
CancellationToken ct) =>
{
if (request.CveIds is null || request.CveIds.Count == 0)
{
return Results.BadRequest(new { error = _t("riskengine.error.cve_ids_required") });
}
var results = new List<ExploitMaturityResult>();
var errors = new List<BatchError>();
foreach (var cveId in request.CveIds.Distinct())
{
try
{
var result = await service.AssessMaturityAsync(cveId, ct).ConfigureAwait(false);
results.Add(result);
}
catch (ArgumentException ex)
{
errors.Add(new BatchError(cveId, ex.Message));
}
}
return Results.Ok(new { results, errors });
})
.WithName("BatchAssessExploitMaturity")
.WithSummary("Batch assess exploit maturity for multiple CVEs")
.WithDescription(_t("riskengine.exploit_maturity.batch_assess_description"))
.RequireAuthorization(RiskEnginePolicies.Operate);
return app;
}
}
/// <summary>
/// Request for batch maturity assessment.
/// </summary>
public sealed record BatchMaturityRequest(IReadOnlyList<string>? CveIds);
/// <summary>
/// Error entry in batch response.
/// </summary>
public sealed record BatchError(string CveId, string Error);

View File

@@ -0,0 +1,263 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Localization;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers;
using StellaOps.RiskEngine.Core.Services;
using StellaOps.RiskEngine.Infrastructure.Stores;
using StellaOps.RiskEngine.WebService.Endpoints;
using StellaOps.RiskEngine.WebService.Security;
using StellaOps.Router.AspNet;
using System.Linq;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<RiskScoreQueue>();
var storageDriver = ResolveStorageDriver(builder.Configuration, "RiskEngine");
RegisterResultStore(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), storageDriver);
builder.Services.AddSingleton<IRiskScoreProviderRegistry>(_ =>
new RiskScoreProviderRegistry(new IRiskScoreProvider[]
{
new DefaultTransformsProvider(),
new CvssKevProvider(new NullCvssSource(), new NullKevSource()),
new EpssProvider(new NullEpssSource()),
new CvssKevEpssProvider(new NullCvssSource(), new NullKevSource(), new NullEpssSource()),
new VexGateProvider(),
new FixExposureProvider()
}));
// Exploit Maturity Service registration
builder.Services.AddSingleton<IEpssSource, NullEpssSource>();
builder.Services.AddSingleton<IKevSource, NullKevSource>();
builder.Services.AddSingleton<IExploitMaturityService, ExploitMaturityService>();
// Authentication and authorization
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(RiskEnginePolicies.Read, StellaOpsScopes.RiskEngineRead);
options.AddStellaOpsScopePolicy(RiskEnginePolicies.Operate, StellaOpsScopes.RiskEngineOperate);
});
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "riskengine",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.TryAddStellaOpsLocalBinding("riskengine");
var app = builder.Build();
app.LogStellaOpsLocalHostname("riskengine");
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.TryUseStellaRouter(routerEnabled);
await app.LoadTranslationsAsync();
// Map exploit maturity endpoints
app.MapExploitMaturityEndpoints();
app.MapGet("/risk-scores/providers", (IRiskScoreProviderRegistry registry) =>
Results.Ok(new { providers = registry.ProviderNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase) }))
.WithName("ListRiskScoreProviders")
.WithDescription("Returns the sorted list of registered risk score provider names. Use this to discover which scoring strategies are available before submitting job or simulation requests.")
.RequireAuthorization(RiskEnginePolicies.Read)
.RequireTenant();
app.MapPost("/risk-scores/jobs", async (
ScoreRequest request,
[FromServices] RiskScoreQueue queue,
[FromServices] IRiskScoreProviderRegistry registry,
[FromServices] IRiskScoreResultStore store,
CancellationToken ct) =>
{
var normalized = new ScoreRequest(
request.Provider,
request.Subject,
request.Signals ?? new Dictionary<string, double>());
var jobId = await queue.EnqueueWithIdAsync(normalized, ct).ConfigureAwait(false);
var worker = new RiskScoreWorker(queue, registry, store, TimeProvider.System);
var result = await worker.ProcessNextAsync(ct).ConfigureAwait(false);
return Results.Accepted($"/risk-scores/jobs/{jobId}", new { jobId, result });
})
.WithName("CreateRiskScoreJob")
.WithDescription("Enqueues a risk scoring job for the specified subject and provider, immediately executes it synchronously, and returns a 202 Accepted response with the job ID and computed result. The provider must be registered or the job will fail with an error in the result payload.")
.RequireAuthorization(RiskEnginePolicies.Operate)
.RequireTenant();
app.MapGet("/risk-scores/jobs/{jobId:guid}", (
Guid jobId,
[FromServices] IRiskScoreResultStore store) =>
store.TryGet(jobId, out var result)
? Results.Ok(result)
: Results.NotFound())
.WithName("GetRiskScoreJob")
.WithDescription("Returns the stored risk score result for the specified job ID. Returns 404 if the job ID is not found in the result store, which may occur if the store has been cleared or the ID is invalid.")
.RequireAuthorization(RiskEnginePolicies.Read)
.RequireTenant();
app.MapPost("/risk-scores/simulations", async (
IReadOnlyCollection<ScoreRequest> requests,
[FromServices] IRiskScoreProviderRegistry registry,
CancellationToken ct) =>
{
var results = await EvaluateAsync(requests, registry, ct).ConfigureAwait(false);
return Results.Ok(new { results });
})
.WithName("RunRiskScoreSimulation")
.WithDescription("Evaluates a collection of risk score requests against the registered providers and returns the full result list. Unlike the job endpoint, simulations do not persist results. Requests for unregistered providers are returned with a failure flag and error message.")
.RequireAuthorization(RiskEnginePolicies.Operate)
.RequireTenant();
app.MapPost("/risk-scores/simulations/summary", async (
IReadOnlyCollection<ScoreRequest> requests,
[FromServices] IRiskScoreProviderRegistry registry,
CancellationToken ct) =>
{
var results = await EvaluateAsync(requests, registry, ct).ConfigureAwait(false);
var scores = results.Select(r => r.Score).ToArray();
var summary = new
{
averageScore = scores.Length == 0 ? 0d : scores.Average(),
minScore = scores.Length == 0 ? 0d : scores.Min(),
maxScore = scores.Length == 0 ? 0d : scores.Max(),
topMovers = results
.OrderByDescending(r => r.Score)
.ThenBy(r => r.Subject, StringComparer.Ordinal)
.Take(3)
.ToArray()
};
return Results.Ok(new { summary, results });
})
.WithName("GetRiskScoreSimulationSummary")
.WithDescription("Evaluates a collection of risk score requests and returns both the full result list and an aggregate summary including average, minimum, and maximum scores plus the top-three highest-scoring subjects. Use this variant when a dashboard-style overview is required alongside per-subject detail.")
.RequireAuthorization(RiskEnginePolicies.Operate)
.RequireTenant();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.RunAsync().ConfigureAwait(false);
static void RegisterResultStore(IServiceCollection services, IConfiguration configuration, bool isDevelopment, string storageDriver)
{
if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase))
{
var connectionString = ResolvePostgresConnectionString(configuration, "RiskEngine");
if (string.IsNullOrWhiteSpace(connectionString))
{
if (!isDevelopment)
{
throw new InvalidOperationException(
"RiskEngine requires PostgreSQL connection settings in non-development mode. " +
"Set ConnectionStrings:Default or RiskEngine:Storage:Postgres:ConnectionString.");
}
services.AddSingleton<IRiskScoreResultStore, InMemoryRiskScoreResultStore>();
return;
}
services.AddSingleton<IRiskScoreResultStore>(_ => new PostgresRiskScoreResultStore(connectionString));
return;
}
if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase))
{
services.AddSingleton<IRiskScoreResultStore, InMemoryRiskScoreResultStore>();
return;
}
throw new InvalidOperationException(
$"Unsupported RiskEngine storage driver '{storageDriver}'. Allowed values: postgres, inmemory.");
}
static string ResolveStorageDriver(IConfiguration configuration, string serviceName)
{
return FirstNonEmpty(
configuration["Storage:Driver"],
configuration[$"{serviceName}:Storage:Driver"])
?? "postgres";
}
static string? ResolvePostgresConnectionString(IConfiguration configuration, string serviceName)
{
return FirstNonEmpty(
configuration[$"{serviceName}:Storage:Postgres:ConnectionString"],
configuration["Storage:Postgres:ConnectionString"],
configuration[$"Postgres:{serviceName}:ConnectionString"],
configuration[$"ConnectionStrings:{serviceName}"],
configuration["ConnectionStrings:Default"]);
}
static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
static async Task<List<RiskScoreResult>> EvaluateAsync(
IReadOnlyCollection<ScoreRequest> requests,
IRiskScoreProviderRegistry registry,
CancellationToken ct)
{
var results = new List<RiskScoreResult>(requests.Count);
foreach (var req in requests)
{
var normalized = new ScoreRequest(
req.Provider,
req.Subject,
req.Signals ?? new Dictionary<string, double>());
if (!registry.TryGet(normalized.Provider, out var provider))
{
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, 0d, false, "Provider not registered", normalized.Signals, TimeProvider.System.GetUtcNow()));
continue;
}
try
{
var score = await provider.ScoreAsync(normalized, ct).ConfigureAwait(false);
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, score, true, null, normalized.Signals, TimeProvider.System.GetUtcNow()));
}
catch (Exception ex)
{
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, 0d, false, ex.Message, normalized.Signals, TimeProvider.System.GetUtcNow()));
}
}
return results;
}

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:10161",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:10160;http://localhost:10161",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
}
}
}
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.RiskEngine.WebService.Security;
/// <summary>
/// Named authorization policy constants for the Risk Engine service.
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
/// </summary>
internal static class RiskEnginePolicies
{
/// <summary>Policy for querying risk score providers and job results. Requires risk-engine:read scope.</summary>
public const string Read = "RiskEngine.Read";
/// <summary>Policy for submitting risk score jobs and simulations. Requires risk-engine:operate scope.</summary>
public const string Operate = "RiskEngine.Operate";
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.RiskEngine.Core\StellaOps.RiskEngine.Core.csproj"/>
<ProjectReference Include="..\__Libraries\StellaOps.RiskEngine.Infrastructure\StellaOps.RiskEngine.Infrastructure.csproj"/>
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj"/>
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj"/>
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj"/>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@StellaOps.RiskEngine.WebService_HostAddress = http://localhost:5115
GET {{StellaOps.RiskEngine.WebService_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,9 @@
# StellaOps.RiskEngine.WebService 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.WebService/StellaOps.RiskEngine.WebService.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-005 | DONE | Production result-store wiring switched from in-memory to Postgres by storage-driver contract, with explicit in-memory fallback. |

View File

@@ -0,0 +1,11 @@
{
"_meta": { "locale": "en-US", "namespace": "riskengine", "version": "1.0" },
"riskengine.exploit_maturity.assess_description": "Returns a unified exploit maturity assessment for the specified CVE by aggregating EPSS probability, KEV catalog membership, and in-the-wild exploitation signals. The result includes the overall maturity level, per-provider signal breakdown, and a composite confidence score.",
"riskengine.exploit_maturity.get_level_description": "Returns only the resolved maturity level enum value for the specified CVE without the full per-provider signal breakdown. Use this lightweight variant when only the top-level classification is needed. Returns 404 if the maturity level could not be determined.",
"riskengine.exploit_maturity.get_history_description": "Returns the chronological history of maturity level assessments for the specified CVE, ordered from oldest to newest. Each entry records the maturity level, the contributing signals, and the timestamp of assessment. Useful for tracking escalation from theoretical to active exploitation.",
"riskengine.exploit_maturity.batch_assess_description": "Submits a list of CVE IDs for bulk exploit maturity assessment and returns results for all successfully evaluated CVEs plus a separate errors array for any that could not be resolved. Duplicate CVE IDs are deduplicated before evaluation.",
"riskengine.error.maturity_level_undetermined": "Maturity level could not be determined",
"riskengine.error.cve_ids_required": "CveIds list is required"
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,10 @@
using StellaOps.RiskEngine.Worker;
using StellaOps.Worker.Health;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddHostedService<Worker>();
builder.Services.AddWorkerHealthChecks();
var app = builder.Build();
app.MapWorkerHealthEndpoints();
app.Run();

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"StellaOps.RiskEngine.Worker": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>dotnet-StellaOps.RiskEngine.Worker-b973483d-c33b-47fb-a20f-e2669c244427</UserSecretsId>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
<!-- Microsoft.Extensions.Hosting is provided by Sdk.Worker -->
<ItemGroup>
<ProjectReference Include="..\StellaOps.RiskEngine.Core\StellaOps.RiskEngine.Core.csproj"/>
<ProjectReference Include="../__Libraries/StellaOps.RiskEngine.Infrastructure/StellaOps.RiskEngine.Infrastructure.csproj"/>
<ProjectReference Include="../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.RiskEngine.Worker 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.Worker/StellaOps.RiskEngine.Worker.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,16 @@
namespace StellaOps.RiskEngine.Worker;
public class Worker(ILogger<Worker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
await Task.Delay(1000, stoppingToken);
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,40 @@
# Vulnerability Explorer API Guild Charter (Epic 6)
## Mission
Expose policy-aware vulnerability listing, detail, simulation, workflow, and export APIs backed by the Findings Ledger and evidence services. Provide deterministic, RBAC-enforced endpoints that power Console, CLI, and automation workflows.
## Scope
- Service under `src/VulnExplorer/StellaOps.VulnExplorer.Api` (query engine, workflow endpoints, simulation bridge, export orchestrator).
- Integration with Findings Ledger, Policy Engine, Conseiller, Excitor, SBOM Service, Scheduler, and Authority.
- Evidence bundle assembly and signing hand-off.
## Principles
1. **Policy-driven** All responses reference the requested policy version and include rationale metadata.
2. **Immutable facts** APIs read advisory/VEX/inventory evidence; they never mutate or overwrite source documents.
3. **Audit-ready** Every workflow action records ledger events and exposes provenance (IDs, timestamps, actors).
4. **Deterministic & efficient** Query results stable under fixed inputs; pagination and grouping honor budgets.
5. **Secure** RBAC/ABAC enforced server-side; exports signed; attachments served via scoped URLs.
## Collaboration
- Coordinate schemas with Findings Ledger, Console, CLI, and Docs; publish OpenAPI + JSON schemas.
- Work with DevOps/Observability for performance dashboards and SLOs.
## Tooling
- .NET 10 preview minimal API with async streaming for exports.
- PostgreSQL projections from Findings Ledger; Redis for query caching as needed.
- Integration with Policy Engine batch eval and simulation endpoints.
## Definition of Done
- Endpoints documented (OpenAPI), tested (unit/integration/perf), and budget-enforced.
- Telemetry/alerts configured; CI covers determinism.
- Evidence bundle signing verified; docs updated with compliance checklist.
## Required Reading
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -0,0 +1,336 @@
// <copyright file="IVexOverrideAttestorClient.cs" company="StellaOps">
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-002)
// </copyright>
using StellaOps.VulnExplorer.Api.Models;
using System.Text.Json.Serialization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.VulnExplorer.Api.Data;
/// <summary>
/// Client for creating signed VEX override attestations via Attestor.
/// </summary>
public interface IVexOverrideAttestorClient
{
/// <summary>
/// Creates a signed DSSE attestation for a VEX override decision.
/// </summary>
Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies an existing VEX override attestation.
/// </summary>
Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to create a VEX override attestation.
/// </summary>
public sealed record VexOverrideAttestationRequest
{
/// <summary>
/// Vulnerability ID being overridden.
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public required string VulnerabilityId { get; init; }
/// <summary>
/// Subject the override applies to.
/// </summary>
[JsonPropertyName("subject")]
public required SubjectRefDto Subject { get; init; }
/// <summary>
/// VEX status being set.
/// </summary>
[JsonPropertyName("status")]
public required VexStatus Status { get; init; }
/// <summary>
/// Justification type.
/// </summary>
[JsonPropertyName("justificationType")]
public required VexJustificationType JustificationType { get; init; }
/// <summary>
/// Justification text.
/// </summary>
[JsonPropertyName("justificationText")]
public string? JustificationText { get; init; }
/// <summary>
/// Evidence references supporting the decision.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<EvidenceRefDto>? EvidenceRefs { get; init; }
/// <summary>
/// Scope of the override.
/// </summary>
[JsonPropertyName("scope")]
public VexScopeDto? Scope { get; init; }
/// <summary>
/// Validity period.
/// </summary>
[JsonPropertyName("validFor")]
public ValidForDto? ValidFor { get; init; }
/// <summary>
/// Actor creating the override.
/// </summary>
[JsonPropertyName("createdBy")]
public required ActorRefDto CreatedBy { get; init; }
/// <summary>
/// Whether to anchor to Rekor.
/// </summary>
[JsonPropertyName("anchorToRekor")]
public bool AnchorToRekor { get; init; }
/// <summary>
/// Signing key ID (null = default).
/// </summary>
[JsonPropertyName("signingKeyId")]
public string? SigningKeyId { get; init; }
/// <summary>
/// Storage destination for the attestation.
/// </summary>
[JsonPropertyName("storageDestination")]
public string? StorageDestination { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("additionalMetadata")]
public IReadOnlyDictionary<string, string>? AdditionalMetadata { get; init; }
}
/// <summary>
/// Result of creating a VEX override attestation.
/// </summary>
public sealed record VexOverrideAttestationResult
{
/// <summary>
/// Whether the attestation was successfully created.
/// </summary>
[JsonPropertyName("success")]
public required bool Success { get; init; }
/// <summary>
/// Created attestation details (if successful).
/// </summary>
[JsonPropertyName("attestation")]
public VexOverrideAttestationDto? Attestation { get; init; }
/// <summary>
/// Error message (if failed).
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// Error code (if failed).
/// </summary>
[JsonPropertyName("errorCode")]
public string? ErrorCode { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static VexOverrideAttestationResult Ok(VexOverrideAttestationDto attestation) => new()
{
Success = true,
Attestation = attestation
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static VexOverrideAttestationResult Fail(string error, string? errorCode = null) => new()
{
Success = false,
Error = error,
ErrorCode = errorCode
};
}
/// <summary>
/// HTTP client implementation for VEX override attestations.
/// </summary>
public sealed class HttpVexOverrideAttestorClient : IVexOverrideAttestorClient
{
private readonly HttpClient _httpClient;
private readonly TimeProvider _timeProvider;
private readonly ILogger<HttpVexOverrideAttestorClient> _logger;
public HttpVexOverrideAttestorClient(
HttpClient httpClient,
TimeProvider timeProvider,
ILogger<HttpVexOverrideAttestorClient> logger)
{
_httpClient = httpClient;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/attestations/vex-override",
request,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning(
"Failed to create VEX override attestation: {StatusCode} - {Error}",
response.StatusCode, errorBody);
return VexOverrideAttestationResult.Fail(
$"Attestor returned {response.StatusCode}: {errorBody}",
response.StatusCode.ToString());
}
var result = await response.Content.ReadFromJsonAsync<VexOverrideAttestationDto>(
cancellationToken: cancellationToken);
if (result is null)
{
return VexOverrideAttestationResult.Fail("Empty response from Attestor");
}
return VexOverrideAttestationResult.Ok(result);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error creating VEX override attestation");
return VexOverrideAttestationResult.Fail($"HTTP error: {ex.Message}", "HTTP_ERROR");
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "Timeout creating VEX override attestation");
return VexOverrideAttestationResult.Fail("Request timed out", "TIMEOUT");
}
}
public async Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync(
$"/api/v1/attestations/{envelopeDigest}/verify",
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: $"Attestor returned {response.StatusCode}");
}
var result = await response.Content.ReadFromJsonAsync<AttestationVerificationStatusDto>(
cancellationToken: cancellationToken);
return result ?? new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: "Empty response from Attestor");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Error verifying attestation {Digest}", envelopeDigest);
return new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: ex.Message);
}
}
}
/// <summary>
/// Stub implementation for offline/testing scenarios.
/// </summary>
public sealed class StubVexOverrideAttestorClient : IVexOverrideAttestorClient
{
private readonly TimeProvider _timeProvider;
public StubVexOverrideAttestorClient(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default)
{
// In offline mode, return deterministic placeholder attestation metadata.
var now = _timeProvider.GetUtcNow();
var material = string.Join("|",
request.VulnerabilityId,
request.Subject.Name,
request.Status,
request.JustificationType,
request.CreatedBy.Id,
request.AnchorToRekor.ToString());
var digestBytes = SHA256.HashData(Encoding.UTF8.GetBytes(material));
var digestHex = Convert.ToHexString(digestBytes).ToLowerInvariant();
var rekorEntryId = request.AnchorToRekor ? $"rekor-local-{digestHex[..16]}" : null;
long? rekorLogIndex = request.AnchorToRekor
? Math.Abs(BitConverter.ToInt32(digestBytes, 0))
: null;
var attestation = new VexOverrideAttestationDto(
EnvelopeDigest: $"sha256:{digestHex}",
PredicateType: "https://stellaops.dev/predicates/vex-override@v1",
RekorLogIndex: rekorLogIndex,
RekorEntryId: rekorEntryId,
StorageRef: "offline-queue",
AttestationCreatedAt: now,
Verified: request.AnchorToRekor,
VerificationStatus: request.AnchorToRekor
? new AttestationVerificationStatusDto(
SignatureValid: true,
RekorVerified: true,
VerifiedAt: now,
ErrorMessage: null)
: null);
return Task.FromResult(VexOverrideAttestationResult.Ok(attestation));
}
public Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: "Offline mode - verification unavailable"));
}
}

View File

@@ -0,0 +1,100 @@
using StellaOps.VulnExplorer.Api.Models;
namespace StellaOps.VulnExplorer.Api.Data;
internal static class SampleData
{
private static readonly VulnSummary[] summaries =
{
new(
Id: "vuln-0001",
Severity: "HIGH",
Score: 8.2,
Kev: true,
Exploitability: "known",
FixAvailable: true,
CveIds: new[] { "CVE-2025-0001" },
Purls: new[] { "pkg:maven/org.example/app@1.2.3" },
PolicyVersion: "policy-main",
RationaleId: "rat-0001"),
new(
Id: "vuln-0002",
Severity: "MEDIUM",
Score: 5.4,
Kev: false,
Exploitability: "unknown",
FixAvailable: false,
CveIds: new[] { "CVE-2024-2222" },
Purls: new[] { "pkg:npm/foo@4.5.6" },
PolicyVersion: "policy-main",
RationaleId: "rat-0002")
};
private static readonly VulnDetail[] details =
{
new(
Id: "vuln-0001",
Severity: "HIGH",
Score: 8.2,
Kev: true,
Exploitability: "known",
FixAvailable: true,
CveIds: summaries[0].CveIds,
Purls: summaries[0].Purls,
Summary: "Example vulnerable library with RCE.",
AffectedPackages: new[]
{
new PackageAffect("pkg:maven/org.example/app", new[] { "1.2.3" })
},
AdvisoryRefs: new[]
{
new AdvisoryRef("https://example.com/advisory/0001", "Upstream advisory")
},
Rationale: new PolicyRationale("rat-0001", "High severity RCE with known exploit; fix available"),
Paths: new[] { "/src/app/Program.cs", "/src/lib/utils/net.cs" },
Evidence: new[]
{
new EvidenceRef("sbom", "sbom-0001", "Inventory evidence"),
new EvidenceRef("vex", "vex-0001", "Vendor statement")
},
FirstSeen: DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
LastSeen: DateTimeOffset.Parse("2025-11-01T00:00:00Z"),
PolicyVersion: summaries[0].PolicyVersion,
RationaleId: summaries[0].RationaleId,
Provenance: new EvidenceProvenance("ledger-1", "evidence-1")),
new(
Id: "vuln-0002",
Severity: "MEDIUM",
Score: 5.4,
Kev: false,
Exploitability: "unknown",
FixAvailable: false,
CveIds: summaries[1].CveIds,
Purls: summaries[1].Purls,
Summary: "Prototype pollution risk.",
AffectedPackages: new[]
{
new PackageAffect("pkg:npm/foo", new[] { "4.5.6" })
},
AdvisoryRefs: Array.Empty<AdvisoryRef>(),
Rationale: new PolicyRationale("rat-0002", "Medium severity; no exploit observed; fix unavailable"),
Paths: new[] { "/app/node_modules/foo/index.js" },
Evidence: new[]
{
new EvidenceRef("sbom", "sbom-0002", "Inventory evidence")
},
FirstSeen: DateTimeOffset.Parse("2024-06-10T00:00:00Z"),
LastSeen: DateTimeOffset.Parse("2025-08-15T00:00:00Z"),
PolicyVersion: summaries[1].PolicyVersion,
RationaleId: summaries[1].RationaleId,
Provenance: new EvidenceProvenance("ledger-2", "evidence-2"))
};
public static IReadOnlyList<VulnSummary> Summaries => summaries;
public static bool TryGetDetail(string id, out VulnDetail? detail)
{
detail = details.FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.Ordinal));
return detail is not null;
}
}

View File

@@ -0,0 +1,233 @@
using System.Collections.Concurrent;
using StellaOps.VulnExplorer.Api.Models;
using StellaOps.VulnExplorer.WebService.Contracts;
namespace StellaOps.VulnExplorer.Api.Data;
public sealed record CreateFixVerificationRequest(
string CveId,
string ComponentPurl,
string? ArtifactDigest);
public sealed record UpdateFixVerificationRequest(string Verdict);
public sealed record CreateAuditBundleRequest(
string Tenant,
IReadOnlyList<Guid>? DecisionIds);
public sealed record AuditBundleResponse(
string BundleId,
string Tenant,
DateTimeOffset CreatedAt,
IReadOnlyList<VexDecisionDto> Decisions,
IReadOnlyList<string> EvidenceRefs);
public sealed record FixVerificationTransition(
string From,
string To,
DateTimeOffset ChangedAt);
public sealed record FixVerificationRecord(
string CveId,
string ComponentPurl,
string? ArtifactDigest,
string Verdict,
IReadOnlyList<FixVerificationTransition> Transitions,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed class FixVerificationStore
{
private readonly ConcurrentDictionary<string, FixVerificationRecord> records = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
public FixVerificationStore(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public FixVerificationRecord Create(CreateFixVerificationRequest request)
{
var now = timeProvider.GetUtcNow();
var created = new FixVerificationRecord(
CveId: request.CveId,
ComponentPurl: request.ComponentPurl,
ArtifactDigest: request.ArtifactDigest,
Verdict: "pending",
Transitions: [],
CreatedAt: now,
UpdatedAt: now);
records[request.CveId] = created;
return created;
}
public FixVerificationRecord? Update(string cveId, string verdict)
{
if (!records.TryGetValue(cveId, out var existing))
{
return null;
}
var now = timeProvider.GetUtcNow();
var transitions = existing.Transitions.ToList();
transitions.Add(new FixVerificationTransition(existing.Verdict, verdict, now));
var updated = existing with
{
Verdict = verdict,
Transitions = transitions.ToArray(),
UpdatedAt = now
};
records[cveId] = updated;
return updated;
}
}
public sealed class AuditBundleStore
{
private int sequence;
private readonly TimeProvider timeProvider;
public AuditBundleStore(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public AuditBundleResponse Create(string tenant, IReadOnlyList<VexDecisionDto> decisions)
{
var next = Interlocked.Increment(ref sequence);
var createdAt = timeProvider.GetUtcNow();
var evidenceRefs = decisions
.SelectMany(x => x.EvidenceRefs ?? Array.Empty<EvidenceRefDto>())
.Select(x => x.Url.ToString())
.OrderBy(x => x, StringComparer.Ordinal)
.Distinct(StringComparer.Ordinal)
.ToArray();
return new AuditBundleResponse(
BundleId: $"bundle-{next:D6}",
Tenant: tenant,
CreatedAt: createdAt,
Decisions: decisions.OrderBy(x => x.Id).ToArray(),
EvidenceRefs: evidenceRefs);
}
}
public sealed class EvidenceSubgraphStore
{
private readonly TimeProvider timeProvider;
public EvidenceSubgraphStore(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public EvidenceSubgraphResponse Build(string vulnId)
{
var observedAt = timeProvider.GetUtcNow();
return new EvidenceSubgraphResponse
{
FindingId = $"finding-{vulnId}",
VulnId = vulnId,
Root = new EvidenceNode
{
Id = "artifact:registry.example/app@sha256:abc123",
Type = EvidenceNodeType.Artifact,
Label = "registry.example/app:1.2.3",
IsExpandable = true,
Status = EvidenceNodeStatus.Warning
},
Edges = new[]
{
new EvidenceEdge
{
SourceId = "artifact:registry.example/app@sha256:abc123",
TargetId = "reachability:path:main",
Relationship = "has_reachability_path",
IsReachable = true,
Weight = 0.93,
Citation = new EvidenceCitation
{
Source = "reachability-analysis",
SourceUrl = "urn:stellaops:reachability:path:main",
ObservedAt = observedAt,
Confidence = 0.93,
EvidenceHash = "sha256:reachability-main",
IsVerified = true
}
},
new EvidenceEdge
{
SourceId = "artifact:registry.example/app@sha256:abc123",
TargetId = "binary-diff:patch:current",
Relationship = "references_binary_diff",
IsReachable = false,
Weight = 0.81,
Citation = new EvidenceCitation
{
Source = "binary-diff",
SourceUrl = "urn:stellaops:binary-diff:patch:current",
ObservedAt = observedAt,
Confidence = 0.81,
EvidenceHash = "sha256:binary-diff-current",
IsVerified = true
}
},
new EvidenceEdge
{
SourceId = "artifact:registry.example/app@sha256:abc123",
TargetId = "proof-chain:rekor:entry",
Relationship = "anchored_by_proof_chain",
IsReachable = false,
Weight = 1.0,
Citation = new EvidenceCitation
{
Source = "proof-chain",
SourceUrl = "urn:stellaops:proof-chain:rekor:entry",
ObservedAt = observedAt,
Confidence = 1.0,
EvidenceHash = "sha256:proof-chain-entry",
IsVerified = true
}
}
},
Verdict = new VerdictSummary
{
Decision = "review",
Explanation = "Reachability exists but binary diff and proof-chain evidence indicate active remediation progress.",
KeyFactors = new[] { "reachable-path", "binary-diff", "proof-chain" },
ConfidenceScore = 0.84,
AppliedPolicies = new[] { "policy.vuln.reachability", "policy.vuln.proof-chain" },
ComputedAt = observedAt
},
AvailableActions = new[]
{
new TriageAction
{
ActionId = "apply-internal-vex",
Type = TriageActionType.ApplyInternalVex,
Label = "Apply Internal VEX",
RequiresConfirmation = false
},
new TriageAction
{
ActionId = "schedule-patch",
Type = TriageActionType.SchedulePatch,
Label = "Schedule Patch",
RequiresConfirmation = true
}
},
Metadata = new EvidenceMetadata
{
CollectedAt = observedAt,
NodeCount = 4,
EdgeCount = 3,
IsTruncated = false,
MaxDepth = 3,
Sources = new[] { "reachability-analysis", "binary-diff", "proof-chain" }
}
};
}
}

View File

@@ -0,0 +1,244 @@
using StellaOps.Determinism;
using StellaOps.VulnExplorer.Api.Models;
using System.Collections.Concurrent;
namespace StellaOps.VulnExplorer.Api.Data;
/// <summary>
/// In-memory VEX decision store for development/testing.
/// Production would use PostgreSQL repository.
/// </summary>
public sealed class VexDecisionStore
{
private readonly ConcurrentDictionary<Guid, VexDecisionDto> _decisions = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly IVexOverrideAttestorClient? _attestorClient;
public VexDecisionStore(
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null,
IVexOverrideAttestorClient? attestorClient = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_attestorClient = attestorClient;
}
public VexDecisionDto Create(CreateVexDecisionRequest request, string userId, string userDisplayName)
{
var id = _guidProvider.NewGuid();
var now = _timeProvider.GetUtcNow();
var decision = new VexDecisionDto(
Id: id,
VulnerabilityId: request.VulnerabilityId,
Subject: request.Subject,
Status: request.Status,
JustificationType: request.JustificationType,
JustificationText: request.JustificationText,
EvidenceRefs: request.EvidenceRefs,
Scope: request.Scope,
ValidFor: request.ValidFor,
AttestationRef: null, // Will be set when attestation is generated
SignedOverride: null, // Will be set when attestation is generated (VEX-OVR-002)
SupersedesDecisionId: request.SupersedesDecisionId,
CreatedBy: new ActorRefDto(userId, userDisplayName),
CreatedAt: now,
UpdatedAt: null);
_decisions[id] = decision;
return decision;
}
public VexDecisionDto? Update(Guid id, UpdateVexDecisionRequest request)
{
if (!_decisions.TryGetValue(id, out var existing))
{
return null;
}
var updated = existing with
{
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
UpdatedAt = _timeProvider.GetUtcNow()
};
_decisions[id] = updated;
return updated;
}
public VexDecisionDto? Get(Guid id) =>
_decisions.TryGetValue(id, out var decision) ? decision : null;
public IReadOnlyList<VexDecisionDto> Query(
string? vulnerabilityId = null,
string? subjectName = null,
VexStatus? status = null,
int skip = 0,
int take = 50)
{
IEnumerable<VexDecisionDto> query = _decisions.Values;
if (vulnerabilityId is not null)
{
query = query.Where(d => string.Equals(d.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase));
}
if (subjectName is not null)
{
query = query.Where(d => d.Subject.Name.Contains(subjectName, StringComparison.OrdinalIgnoreCase));
}
if (status is not null)
{
query = query.Where(d => d.Status == status);
}
// Deterministic ordering: createdAt desc, id asc
return query
.OrderByDescending(d => d.CreatedAt)
.ThenBy(d => d.Id)
.Skip(skip)
.Take(take)
.ToArray();
}
public int Count() => _decisions.Count;
// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-002)
/// <summary>
/// Creates a VEX decision with a signed attestation.
/// </summary>
public async Task<(VexDecisionDto Decision, VexOverrideAttestationResult? AttestationResult)> CreateWithAttestationAsync(
CreateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken cancellationToken = default)
{
var id = _guidProvider.NewGuid();
var now = _timeProvider.GetUtcNow();
VexOverrideAttestationDto? signedOverride = null;
VexOverrideAttestationResult? attestationResult = null;
// Create attestation if requested and client is available
if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
{
var attestationRequest = new VexOverrideAttestationRequest
{
VulnerabilityId = request.VulnerabilityId,
Subject = request.Subject,
Status = request.Status,
JustificationType = request.JustificationType,
JustificationText = request.JustificationText,
EvidenceRefs = request.EvidenceRefs,
Scope = request.Scope,
ValidFor = request.ValidFor,
CreatedBy = new ActorRefDto(userId, userDisplayName),
AnchorToRekor = request.AttestationOptions.AnchorToRekor,
SigningKeyId = request.AttestationOptions.SigningKeyId,
StorageDestination = request.AttestationOptions.StorageDestination,
AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
};
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
if (attestationResult.Success && attestationResult.Attestation is not null)
{
signedOverride = attestationResult.Attestation;
}
}
var decision = new VexDecisionDto(
Id: id,
VulnerabilityId: request.VulnerabilityId,
Subject: request.Subject,
Status: request.Status,
JustificationType: request.JustificationType,
JustificationText: request.JustificationText,
EvidenceRefs: request.EvidenceRefs,
Scope: request.Scope,
ValidFor: request.ValidFor,
AttestationRef: null,
SignedOverride: signedOverride,
SupersedesDecisionId: request.SupersedesDecisionId,
CreatedBy: new ActorRefDto(userId, userDisplayName),
CreatedAt: now,
UpdatedAt: null);
_decisions[id] = decision;
return (decision, attestationResult);
}
/// <summary>
/// Updates a VEX decision and optionally creates a new attestation.
/// </summary>
public async Task<(VexDecisionDto? Decision, VexOverrideAttestationResult? AttestationResult)> UpdateWithAttestationAsync(
Guid id,
UpdateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken cancellationToken = default)
{
if (!_decisions.TryGetValue(id, out var existing))
{
return (null, null);
}
VexOverrideAttestationDto? signedOverride = existing.SignedOverride;
VexOverrideAttestationResult? attestationResult = null;
// Create new attestation if requested
if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
{
var attestationRequest = new VexOverrideAttestationRequest
{
VulnerabilityId = existing.VulnerabilityId,
Subject = existing.Subject,
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
CreatedBy = new ActorRefDto(userId, userDisplayName),
AnchorToRekor = request.AttestationOptions.AnchorToRekor,
SigningKeyId = request.AttestationOptions.SigningKeyId,
StorageDestination = request.AttestationOptions.StorageDestination,
AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
};
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
if (attestationResult.Success && attestationResult.Attestation is not null)
{
signedOverride = attestationResult.Attestation;
}
}
var updated = existing with
{
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
SignedOverride = signedOverride,
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
UpdatedAt = _timeProvider.GetUtcNow()
};
_decisions[id] = updated;
return (updated, attestationResult);
}
}

View File

@@ -0,0 +1,108 @@
namespace StellaOps.VulnExplorer.Api.Models;
/// <summary>
/// In-toto style attestation for vulnerability scan results.
/// Based on docs/schemas/attestation-vuln-scan.schema.json
/// </summary>
public sealed record VulnScanAttestationDto(
string Type,
string PredicateType,
IReadOnlyList<AttestationSubjectDto> Subject,
VulnScanPredicateDto Predicate,
AttestationMetaDto AttestationMeta);
/// <summary>
/// Subject of an attestation (artifact that was scanned).
/// </summary>
public sealed record AttestationSubjectDto(
string Name,
IReadOnlyDictionary<string, string> Digest);
/// <summary>
/// Vulnerability scan result predicate.
/// </summary>
public sealed record VulnScanPredicateDto(
ScannerInfoDto Scanner,
ScannerDbInfoDto? ScannerDb,
DateTimeOffset ScanStartedAt,
DateTimeOffset ScanCompletedAt,
SeverityCountsDto SeverityCounts,
FindingReportDto FindingReport);
/// <summary>
/// Scanner information.
/// </summary>
public sealed record ScannerInfoDto(
string Name,
string Version);
/// <summary>
/// Vulnerability database information.
/// </summary>
public sealed record ScannerDbInfoDto(
DateTimeOffset? LastUpdatedAt);
/// <summary>
/// Count of findings by severity.
/// </summary>
public sealed record SeverityCountsDto(
int Critical,
int High,
int Medium,
int Low);
/// <summary>
/// Reference to the full findings report.
/// </summary>
public sealed record FindingReportDto(
string MediaType,
string Location,
IReadOnlyDictionary<string, string> Digest);
/// <summary>
/// Attestation metadata including signer info.
/// </summary>
public sealed record AttestationMetaDto(
string StatementId,
DateTimeOffset CreatedAt,
AttestationSignerDto Signer);
/// <summary>
/// Entity that signed an attestation.
/// </summary>
public sealed record AttestationSignerDto(
string Name,
string KeyId);
/// <summary>
/// Response for listing attestations.
/// </summary>
public sealed record AttestationListResponse(
IReadOnlyList<AttestationSummaryDto> Items,
string? NextPageToken);
/// <summary>
/// Summary view of an attestation for listing.
/// </summary>
public sealed record AttestationSummaryDto(
string Id,
AttestationType Type,
string SubjectName,
IReadOnlyDictionary<string, string> SubjectDigest,
string PredicateType,
DateTimeOffset CreatedAt,
string? SignerName,
string? SignerKeyId,
bool Verified);
/// <summary>
/// Attestation type enumeration.
/// </summary>
public enum AttestationType
{
VulnScan,
Sbom,
Vex,
PolicyEval,
Other
}

View File

@@ -0,0 +1,209 @@
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_009_FE
// Task: FVU-001 - Fix Verification API Models
namespace StellaOps.VulnExplorer.Api.Models;
/// <summary>
/// Fix verification status response for frontend display.
/// </summary>
public sealed record FixVerificationResponse
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component PURL.</summary>
public required string ComponentPurl { get; init; }
/// <summary>Whether a FixChain attestation exists.</summary>
public required bool HasAttestation { get; init; }
/// <summary>Verdict status: fixed, partial, not_fixed, inconclusive, none.</summary>
public required string Verdict { get; init; }
/// <summary>Confidence score (0.0 - 1.0).</summary>
public required decimal Confidence { get; init; }
/// <summary>Human-readable verdict label.</summary>
public required string VerdictLabel { get; init; }
/// <summary>Golden set reference.</summary>
public FixVerificationGoldenSetRef? GoldenSet { get; init; }
/// <summary>Analysis results summary.</summary>
public FixVerificationAnalysis? Analysis { get; init; }
/// <summary>Risk impact from fix verification.</summary>
public FixVerificationRiskImpact? RiskImpact { get; init; }
/// <summary>Evidence chain references.</summary>
public FixVerificationEvidenceChain? EvidenceChain { get; init; }
/// <summary>When the verification was performed.</summary>
public DateTimeOffset? VerifiedAt { get; init; }
/// <summary>Rationale items.</summary>
public IReadOnlyList<string> Rationale { get; init; } = [];
}
/// <summary>
/// Golden set reference for UI display.
/// </summary>
public sealed record FixVerificationGoldenSetRef
{
/// <summary>Golden set ID (typically CVE ID).</summary>
public required string Id { get; init; }
/// <summary>Content digest.</summary>
public required string Digest { get; init; }
/// <summary>Reviewer/approver.</summary>
public string? ReviewedBy { get; init; }
/// <summary>When reviewed.</summary>
public DateTimeOffset? ReviewedAt { get; init; }
}
/// <summary>
/// Analysis results for UI display.
/// </summary>
public sealed record FixVerificationAnalysis
{
/// <summary>Function-level changes.</summary>
public IReadOnlyList<FunctionChangeResult> Functions { get; init; } = [];
/// <summary>Reachability changes.</summary>
public ReachabilityChangeResult? Reachability { get; init; }
}
/// <summary>
/// Function-level change result.
/// </summary>
public sealed record FunctionChangeResult
{
/// <summary>Function name.</summary>
public required string FunctionName { get; init; }
/// <summary>Change status: modified, removed, unchanged.</summary>
public required string Status { get; init; }
/// <summary>Status icon for UI.</summary>
public required string StatusIcon { get; init; }
/// <summary>Human-readable details.</summary>
public required string Details { get; init; }
/// <summary>Child items (edges, sinks).</summary>
public IReadOnlyList<FunctionChangeChild> Children { get; init; } = [];
}
/// <summary>
/// Child item of a function change (edge or sink).
/// </summary>
public sealed record FunctionChangeChild
{
/// <summary>Name (edge identifier or sink name).</summary>
public required string Name { get; init; }
/// <summary>Change status.</summary>
public required string Status { get; init; }
/// <summary>Status icon.</summary>
public required string StatusIcon { get; init; }
/// <summary>Details.</summary>
public required string Details { get; init; }
}
/// <summary>
/// Reachability change result.
/// </summary>
public sealed record ReachabilityChangeResult
{
/// <summary>Pre-patch path count.</summary>
public required int PrePatchPaths { get; init; }
/// <summary>Post-patch path count.</summary>
public required int PostPatchPaths { get; init; }
/// <summary>Whether all paths were eliminated.</summary>
public required bool AllPathsEliminated { get; init; }
/// <summary>Summary text.</summary>
public required string Summary { get; init; }
}
/// <summary>
/// Risk impact from fix verification.
/// </summary>
public sealed record FixVerificationRiskImpact
{
/// <summary>Base risk score before fix adjustment.</summary>
public required decimal BaseScore { get; init; }
/// <summary>Base severity label.</summary>
public required string BaseSeverity { get; init; }
/// <summary>Fix adjustment percentage (negative = reduction).</summary>
public required decimal AdjustmentPercent { get; init; }
/// <summary>Final risk score after adjustment.</summary>
public required decimal FinalScore { get; init; }
/// <summary>Final severity label.</summary>
public required string FinalSeverity { get; init; }
/// <summary>Progress bar value (0-100).</summary>
public required int ProgressValue { get; init; }
}
/// <summary>
/// Evidence chain for audit trail.
/// </summary>
public sealed record FixVerificationEvidenceChain
{
/// <summary>SBOM reference.</summary>
public EvidenceChainItem? Sbom { get; init; }
/// <summary>Golden set reference.</summary>
public EvidenceChainItem? GoldenSet { get; init; }
/// <summary>Diff report reference.</summary>
public EvidenceChainItem? DiffReport { get; init; }
/// <summary>FixChain attestation reference.</summary>
public EvidenceChainItem? Attestation { get; init; }
}
/// <summary>
/// Individual evidence chain item.
/// </summary>
public sealed record EvidenceChainItem
{
/// <summary>Item label.</summary>
public required string Label { get; init; }
/// <summary>Content digest (truncated for display).</summary>
public required string DigestShort { get; init; }
/// <summary>Full content digest.</summary>
public required string DigestFull { get; init; }
/// <summary>Download URL.</summary>
public string? DownloadUrl { get; init; }
}
/// <summary>
/// Request to verify a fix.
/// </summary>
public sealed record FixVerificationRequest
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component PURL.</summary>
public required string ComponentPurl { get; init; }
/// <summary>Image or binary digest.</summary>
public string? ArtifactDigest { get; init; }
}

View File

@@ -0,0 +1,220 @@
namespace StellaOps.VulnExplorer.Api.Models;
/// <summary>
/// VEX-style statement attached to a finding + subject, representing a vulnerability exploitability decision.
/// Based on docs/schemas/vex-decision.schema.json
/// </summary>
public sealed record VexDecisionDto(
Guid Id,
string VulnerabilityId,
SubjectRefDto Subject,
VexStatus Status,
VexJustificationType JustificationType,
string? JustificationText,
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
VexScopeDto? Scope,
ValidForDto? ValidFor,
AttestationRefDto? AttestationRef,
VexOverrideAttestationDto? SignedOverride,
Guid? SupersedesDecisionId,
ActorRefDto CreatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
/// <summary>
/// Signed VEX override attestation details.
/// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-001)
/// </summary>
public sealed record VexOverrideAttestationDto(
/// <summary>DSSE envelope digest (sha256:hex).</summary>
string EnvelopeDigest,
/// <summary>Predicate type for the attestation.</summary>
string PredicateType,
/// <summary>Rekor transparency log index (null if not anchored).</summary>
long? RekorLogIndex,
/// <summary>Rekor entry ID (null if not anchored).</summary>
string? RekorEntryId,
/// <summary>Attestation storage location/reference.</summary>
string? StorageRef,
/// <summary>Timestamp when attestation was created.</summary>
DateTimeOffset AttestationCreatedAt,
/// <summary>Whether the attestation has been verified.</summary>
bool Verified,
/// <summary>Verification status details.</summary>
AttestationVerificationStatusDto? VerificationStatus);
/// <summary>
/// Attestation verification status details.
/// </summary>
public sealed record AttestationVerificationStatusDto(
/// <summary>Whether signature was valid.</summary>
bool SignatureValid,
/// <summary>Whether Rekor inclusion was verified.</summary>
bool? RekorVerified,
/// <summary>Timestamp when verification was performed.</summary>
DateTimeOffset? VerifiedAt,
/// <summary>Error message if verification failed.</summary>
string? ErrorMessage);
/// <summary>
/// Reference to an artifact or SBOM component that a VEX decision applies to.
/// </summary>
public sealed record SubjectRefDto(
SubjectType Type,
string Name,
IReadOnlyDictionary<string, string> Digest,
string? SbomNodeId = null);
/// <summary>
/// Reference to evidence supporting a VEX decision (PR, ticket, doc, commit).
/// </summary>
public sealed record EvidenceRefDto(
EvidenceType Type,
Uri Url,
string? Title = null);
/// <summary>
/// Scope definition for VEX decisions (environments and projects where decision applies).
/// </summary>
public sealed record VexScopeDto(
IReadOnlyList<string>? Environments,
IReadOnlyList<string>? Projects);
/// <summary>
/// Validity window for VEX decisions.
/// </summary>
public sealed record ValidForDto(
DateTimeOffset? NotBefore,
DateTimeOffset? NotAfter);
/// <summary>
/// Reference to a signed attestation.
/// </summary>
public sealed record AttestationRefDto(
string? Id,
IReadOnlyDictionary<string, string>? Digest,
string? Storage);
/// <summary>
/// Reference to an actor (user) who created a decision.
/// </summary>
public sealed record ActorRefDto(
string Id,
string DisplayName);
/// <summary>
/// VEX status following OpenVEX semantics.
/// </summary>
public enum VexStatus
{
NotAffected,
AffectedMitigated,
AffectedUnmitigated,
Fixed
}
/// <summary>
/// Subject type enumeration for VEX decisions.
/// </summary>
public enum SubjectType
{
Image,
Repo,
SbomComponent,
Other
}
/// <summary>
/// Evidence type enumeration.
/// </summary>
public enum EvidenceType
{
Pr,
Ticket,
Doc,
Commit,
Other
}
/// <summary>
/// Justification type inspired by CSAF/VEX specifications.
/// </summary>
public enum VexJustificationType
{
CodeNotPresent,
CodeNotReachable,
VulnerableCodeNotInExecutePath,
ConfigurationNotAffected,
OsNotAffected,
RuntimeMitigationPresent,
CompensatingControls,
AcceptedBusinessRisk,
Other
}
/// <summary>
/// Request to create a new VEX decision.
/// </summary>
public sealed record CreateVexDecisionRequest(
string VulnerabilityId,
SubjectRefDto Subject,
VexStatus Status,
VexJustificationType JustificationType,
string? JustificationText,
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
VexScopeDto? Scope,
ValidForDto? ValidFor,
Guid? SupersedesDecisionId,
/// <summary>Attestation options for signed override.</summary>
AttestationRequestOptions? AttestationOptions);
/// <summary>
/// Options for creating a signed attestation with the VEX decision.
/// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-001)
/// </summary>
public sealed record AttestationRequestOptions(
/// <summary>Whether to create a signed attestation (required in strict mode).</summary>
bool CreateAttestation,
/// <summary>Whether to anchor the attestation to Rekor transparency log.</summary>
bool AnchorToRekor = false,
/// <summary>Key ID to use for signing (null = default).</summary>
string? SigningKeyId = null,
/// <summary>Storage destination for the attestation.</summary>
string? StorageDestination = null,
/// <summary>Additional metadata to include in the attestation.</summary>
IReadOnlyDictionary<string, string>? AdditionalMetadata = null);
/// <summary>
/// Request to update an existing VEX decision.
/// </summary>
public sealed record UpdateVexDecisionRequest(
VexStatus? Status,
VexJustificationType? JustificationType,
string? JustificationText,
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
VexScopeDto? Scope,
ValidForDto? ValidFor,
Guid? SupersedesDecisionId,
/// <summary>Attestation options for signed override update.</summary>
AttestationRequestOptions? AttestationOptions);
/// <summary>
/// Response for listing VEX decisions.
/// </summary>
public sealed record VexDecisionListResponse(
IReadOnlyList<VexDecisionDto> Items,
string? NextPageToken);

View File

@@ -0,0 +1,46 @@
namespace StellaOps.VulnExplorer.Api.Models;
public sealed record VulnSummary(
string Id,
string Severity,
double Score,
bool Kev,
string Exploitability,
bool FixAvailable,
IReadOnlyList<string> CveIds,
IReadOnlyList<string> Purls,
string PolicyVersion,
string RationaleId);
public sealed record VulnDetail(
string Id,
string Severity,
double Score,
bool Kev,
string Exploitability,
bool FixAvailable,
IReadOnlyList<string> CveIds,
IReadOnlyList<string> Purls,
string Summary,
IReadOnlyList<PackageAffect> AffectedPackages,
IReadOnlyList<AdvisoryRef> AdvisoryRefs,
PolicyRationale Rationale,
IReadOnlyList<string> Paths,
IReadOnlyList<EvidenceRef> Evidence,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen,
string PolicyVersion,
string RationaleId,
EvidenceProvenance Provenance);
public sealed record PackageAffect(string Purl, IReadOnlyList<string> Versions);
public sealed record AdvisoryRef(string Url, string Title);
public sealed record EvidenceRef(string Kind, string Reference, string? Title = null);
public sealed record EvidenceProvenance(string LedgerEntryId, string EvidenceBundleId);
public sealed record PolicyRationale(string Id, string Summary);
public sealed record VulnListResponse(IReadOnlyList<VulnSummary> Items, string? NextPageToken);

View File

@@ -0,0 +1,409 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.VulnExplorer.Api.Data;
using StellaOps.VulnExplorer.Api.Models;
using StellaOps.VulnExplorer.Api.Security;
using StellaOps.VulnExplorer.WebService.Contracts;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Localization;
using static StellaOps.Localization.T;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Configure JSON serialization with enum string converter
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddSingleton<IVexOverrideAttestorClient, StubVexOverrideAttestorClient>();
builder.Services.AddSingleton<VexDecisionStore>(sp =>
new VexDecisionStore(attestorClient: sp.GetRequiredService<IVexOverrideAttestorClient>()));
builder.Services.AddSingleton<FixVerificationStore>();
builder.Services.AddSingleton<AuditBundleStore>();
builder.Services.AddSingleton<EvidenceSubgraphStore>();
// Authentication and authorization
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(VulnExplorerPolicies.View, StellaOpsScopes.VulnView);
options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Investigate, StellaOpsScopes.VulnInvestigate);
options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Operate, StellaOpsScopes.VulnOperate);
options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Audit, StellaOpsScopes.VulnAudit);
});
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "vulnexplorer",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.TryAddStellaOpsLocalBinding("vulnexplorer");
var app = builder.Build();
app.LogStellaOpsLocalHostname("vulnexplorer");
app.UseSwagger();
app.UseSwaggerUI();
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.TryUseStellaRouter(routerEnabled);
app.MapGet("/v1/vulns", ([AsParameters] VulnFilter filter) =>
{
if (string.IsNullOrWhiteSpace(filter.Tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
var data = ApplyFilter(SampleData.Summaries, filter);
var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200);
var offset = ParsePageToken(filter.PageToken);
var page = data.Skip(offset).Take(pageSize).ToArray();
var nextOffset = offset + page.Length;
var next = nextOffset < data.Count ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
var response = new VulnListResponse(page, next);
return Results.Ok(response);
})
.WithName("ListVulns")
.WithDescription(_t("vulnexplorer.vuln.list_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
app.MapGet("/v1/vulns/{id}", ([FromHeader(Name = "x-stella-tenant")] string? tenant, string id) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
return SampleData.TryGetDetail(id, out var detail) && detail is not null
? Results.Ok(detail)
: Results.NotFound();
})
.WithName("GetVuln")
.WithDescription(_t("vulnexplorer.vuln.get_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
// ============================================================================
// VEX Decision Endpoints (API-VEX-06-001, API-VEX-06-002, API-VEX-06-003)
// ============================================================================
app.MapPost("/v1/vex-decisions", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
[FromHeader(Name = "x-stella-user-id")] string? userId,
[FromHeader(Name = "x-stella-user-name")] string? userName,
[FromBody] CreateVexDecisionRequest request,
VexDecisionStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.VulnerabilityId))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.vulnerability_id_required") });
}
if (request.Subject is null)
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.subject_required") });
}
var effectiveUserId = userId ?? "anonymous";
var effectiveUserName = userName ?? "Anonymous User";
VexDecisionDto decision;
if (request.AttestationOptions?.CreateAttestation == true)
{
var result = await store.CreateWithAttestationAsync(
request,
effectiveUserId,
effectiveUserName,
cancellationToken);
decision = result.Decision;
}
else
{
decision = store.Create(request, effectiveUserId, effectiveUserName);
}
return Results.Created($"/v1/vex-decisions/{decision.Id}", decision);
})
.WithName("CreateVexDecision")
.WithDescription(_t("vulnexplorer.vex_decision.create_description"))
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
app.MapPatch("/v1/vex-decisions/{id:guid}", (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
Guid id,
[FromBody] UpdateVexDecisionRequest request,
VexDecisionStore store) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
var updated = store.Update(id, request);
return updated is not null
? Results.Ok(updated)
: Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) });
})
.WithName("UpdateVexDecision")
.WithDescription(_t("vulnexplorer.vex_decision.update_description"))
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDecisionStore store) =>
{
if (string.IsNullOrWhiteSpace(filter.Tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200);
var offset = ParsePageToken(filter.PageToken);
var decisions = store.Query(
vulnerabilityId: filter.VulnerabilityId,
subjectName: filter.Subject,
status: filter.Status,
skip: offset,
take: pageSize);
var nextOffset = offset + decisions.Count;
var next = nextOffset < store.Count() ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
return Results.Ok(new VexDecisionListResponse(decisions, next));
})
.WithName("ListVexDecisions")
.WithDescription(_t("vulnexplorer.vex_decision.list_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
app.MapGet("/v1/vex-decisions/{id:guid}", (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
Guid id,
VexDecisionStore store) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
var decision = store.Get(id);
return decision is not null
? Results.Ok(decision)
: Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) });
})
.WithName("GetVexDecision")
.WithDescription(_t("vulnexplorer.vex_decision.get_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
app.MapGet("/v1/evidence-subgraph/{vulnId}", (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
string vulnId,
EvidenceSubgraphStore store) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(vulnId))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.vuln_id_required") });
}
EvidenceSubgraphResponse response = store.Build(vulnId);
return Results.Ok(response);
})
.WithName("GetEvidenceSubgraph")
.WithDescription(_t("vulnexplorer.evidence_subgraph.get_description"))
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
app.MapPost("/v1/fix-verifications", (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
[FromBody] CreateFixVerificationRequest request,
FixVerificationStore store) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.CveId) || string.IsNullOrWhiteSpace(request.ComponentPurl))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.cve_id_and_purl_required") });
}
var created = store.Create(request);
return Results.Created($"/v1/fix-verifications/{created.CveId}", created);
})
.WithName("CreateFixVerification")
.WithDescription(_t("vulnexplorer.fix_verification.create_description"))
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
app.MapPatch("/v1/fix-verifications/{cveId}", (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
string cveId,
[FromBody] UpdateFixVerificationRequest request,
FixVerificationStore store) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.Verdict))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.verdict_required") });
}
var updated = store.Update(cveId, request.Verdict);
return updated is not null
? Results.Ok(updated)
: Results.NotFound(new { error = _t("vulnexplorer.error.fix_verification_not_found", cveId) });
})
.WithName("UpdateFixVerification")
.WithDescription(_t("vulnexplorer.fix_verification.update_description"))
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
app.MapPost("/v1/audit-bundles", (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
[FromBody] CreateAuditBundleRequest request,
VexDecisionStore decisions,
AuditBundleStore bundles) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
if (request.DecisionIds is null || request.DecisionIds.Count == 0)
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.decision_ids_required") });
}
var selected = request.DecisionIds
.Select(id => decisions.Get(id))
.Where(x => x is not null)
.Cast<VexDecisionDto>()
.ToArray();
if (selected.Length == 0)
{
return Results.NotFound(new { error = _t("vulnexplorer.error.no_decisions_found") });
}
var bundle = bundles.Create(tenant, selected);
return Results.Created($"/v1/audit-bundles/{bundle.BundleId}", bundle);
})
.WithName("CreateAuditBundle")
.WithDescription(_t("vulnexplorer.audit_bundle.create_description"))
.RequireAuthorization(VulnExplorerPolicies.Audit)
.RequireTenant();
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.LoadTranslationsAsync();
app.Run();
static int ParsePageToken(string? token) =>
int.TryParse(token, out var offset) && offset >= 0 ? offset : 0;
static IReadOnlyList<VulnSummary> ApplyFilter(IReadOnlyList<VulnSummary> source, VulnFilter filter)
{
IEnumerable<VulnSummary> query = source;
if (filter.Cve is { Length: > 0 })
{
var set = filter.Cve.ToHashSet(StringComparer.OrdinalIgnoreCase);
query = query.Where(v => v.CveIds.Any(set.Contains));
}
if (filter.Purl is { Length: > 0 })
{
var set = filter.Purl.ToHashSet(StringComparer.OrdinalIgnoreCase);
query = query.Where(v => v.Purls.Any(set.Contains));
}
if (filter.Severity is { Length: > 0 })
{
var set = filter.Severity.ToHashSet(StringComparer.OrdinalIgnoreCase);
query = query.Where(v => set.Contains(v.Severity));
}
if (filter.Exploitability is not null)
{
query = query.Where(v => string.Equals(v.Exploitability, filter.Exploitability, StringComparison.OrdinalIgnoreCase));
}
if (filter.FixAvailable is not null)
{
query = query.Where(v => v.FixAvailable == filter.FixAvailable);
}
// deterministic ordering: score desc, id asc
query = query
.OrderByDescending(v => v.Score)
.ThenBy(v => v.Id, StringComparer.Ordinal);
return query.ToArray();
}
public record VulnFilter(
[FromHeader(Name = "x-stella-tenant")] string? Tenant,
[FromQuery(Name = "policyVersion")] string? PolicyVersion,
[FromQuery(Name = "pageSize")] int? PageSize,
[FromQuery(Name = "pageToken")] string? PageToken,
[FromQuery(Name = "cve")] string[]? Cve,
[FromQuery(Name = "purl")] string[]? Purl,
[FromQuery(Name = "severity")] string[]? Severity,
[FromQuery(Name = "exploitability")] string? Exploitability,
[FromQuery(Name = "fixAvailable")] bool? FixAvailable);
public record VexDecisionFilter(
[FromHeader(Name = "x-stella-tenant")] string? Tenant,
[FromQuery(Name = "vulnerabilityId")] string? VulnerabilityId,
[FromQuery(Name = "subject")] string? Subject,
[FromQuery(Name = "status")] VexStatus? Status,
[FromQuery(Name = "pageSize")] int? PageSize,
[FromQuery(Name = "pageToken")] string? PageToken);
// Program class public for WebApplicationFactory<Program>
public partial class Program;

View File

@@ -0,0 +1,14 @@
{
"profiles": {
"StellaOps.VulnExplorer.Api": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
},
"applicationUrl": "https://localhost:10130;http://localhost:10131"
}
}
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.VulnExplorer.Api.Security;
/// <summary>
/// Named authorization policy constants for the VulnExplorer service.
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
/// </summary>
internal static class VulnExplorerPolicies
{
/// <summary>Policy for viewing vulnerability findings, reports, and dashboards. Requires vuln:view scope.</summary>
public const string View = "VulnExplorer.View";
/// <summary>Policy for triage actions (assign, comment, annotate). Requires vuln:investigate scope.</summary>
public const string Investigate = "VulnExplorer.Investigate";
/// <summary>Policy for state-changing operations (VEX decisions, fix verifications). Requires vuln:operate scope.</summary>
public const string Operate = "VulnExplorer.Operate";
/// <summary>Policy for audit export and immutable ledger access. Requires vuln:audit scope.</summary>
public const string Audit = "VulnExplorer.Audit";
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.VulnExplorer.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<ItemGroup>
<Compile Include="../StellaOps.VulnExplorer.WebService/Contracts/EvidenceSubgraphContracts.cs" Link="Contracts/EvidenceSubgraphContracts.cs" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.VulnExplorer.Api 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/VulnExplorer/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,29 @@
{
"_meta": { "locale": "en-US", "namespace": "vulnexplorer", "version": "1.0" },
"vulnexplorer.vuln.list_description": "Returns a paginated list of vulnerability summaries for the tenant, optionally filtered by CVE IDs, PURLs, severity levels, exploitability, and fix availability. Results are ordered by score descending then ID ascending. Requires x-stella-tenant header.",
"vulnexplorer.vuln.get_description": "Returns the full vulnerability detail record for a specific vulnerability ID including CVE IDs, affected components, severity score, exploitability assessment, and fix availability. Returns 404 if not found. Requires x-stella-tenant header.",
"vulnexplorer.vex_decision.create_description": "Creates a new VEX decision record for a vulnerability and subject artifact, recording the analyst verdict, justification, and optional attestation options. Optionally creates a signed VEX attestation if attestationOptions.createAttestation is true. Returns 201 Created with the VEX decision. Requires x-stella-tenant, x-stella-user-id, and x-stella-user-name headers.",
"vulnexplorer.vex_decision.update_description": "Partially updates an existing VEX decision record by ID, allowing the analyst to revise the status, justification, or other mutable fields. Returns 200 with the updated decision or 404 if the decision is not found. Requires x-stella-tenant header.",
"vulnexplorer.vex_decision.list_description": "Returns a paginated list of VEX decisions for the tenant, optionally filtered by vulnerability ID, subject artifact name, and decision status. Results are returned in stable order with a page token for continuation. Requires x-stella-tenant header.",
"vulnexplorer.vex_decision.get_description": "Returns the full VEX decision record for a specific decision ID including vulnerability reference, subject artifact, analyst verdict, justification, timestamps, and attestation reference if present. Returns 404 if the decision is not found. Requires x-stella-tenant header.",
"vulnexplorer.evidence_subgraph.get_description": "Returns the evidence subgraph for a specific vulnerability ID, linking together all related VEX decisions, fix verifications, audit bundles, and attestations that form the traceability chain for the vulnerability disposition. Requires x-stella-tenant header.",
"vulnexplorer.fix_verification.create_description": "Creates a new fix verification record linking a CVE ID to a component PURL to track the verification status of an applied fix. Returns 201 Created with the verification record. Requires x-stella-tenant header and both cveId and componentPurl in the request body.",
"vulnexplorer.fix_verification.update_description": "Updates the verdict for an existing fix verification record, recording the confirmed verification outcome for a CVE fix. Returns 200 with the updated record or 404 if the fix verification is not found. Requires x-stella-tenant header and verdict in the request body.",
"vulnexplorer.audit_bundle.create_description": "Creates an immutable audit bundle aggregating a set of VEX decisions by their IDs into a single exportable evidence record for compliance and audit purposes. Returns 201 Created with the bundle ID and included decisions. Returns 404 if none of the requested decision IDs are found. Requires x-stella-tenant header.",
"vulnexplorer.error.tenant_required": "x-stella-tenant required",
"vulnexplorer.error.vulnerability_id_required": "vulnerabilityId is required",
"vulnexplorer.error.subject_required": "subject is required",
"vulnexplorer.error.vuln_id_required": "vulnId is required",
"vulnexplorer.error.cve_id_and_purl_required": "cveId and componentPurl are required",
"vulnexplorer.error.verdict_required": "verdict is required",
"vulnexplorer.error.decision_ids_required": "decisionIds is required",
"vulnexplorer.error.no_decisions_found": "No decisions found for requested decisionIds",
"vulnexplorer.error.vex_decision_not_found": "VEX decision {0} not found",
"vulnexplorer.error.fix_verification_not_found": "Fix verification {0} not found"
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,514 @@
// <copyright file="EvidenceSubgraphContracts.cs" company="StellaOps">
// SPDX-License-Identifier: BUSL-1.1
// </copyright>
namespace StellaOps.VulnExplorer.WebService.Contracts;
using System.Text.Json.Serialization;
/// <summary>
/// Response containing the evidence subgraph for a finding.
/// </summary>
public sealed record EvidenceSubgraphResponse
{
/// <summary>
/// Finding identifier this subgraph explains.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Vulnerability identifier (CVE ID or similar).
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Root node of the evidence graph (typically the artifact).
/// </summary>
public required EvidenceNode Root { get; init; }
/// <summary>
/// All edges in the evidence graph.
/// </summary>
public required IReadOnlyList<EvidenceEdge> Edges { get; init; }
/// <summary>
/// Summary verdict for this finding.
/// </summary>
public required VerdictSummary Verdict { get; init; }
/// <summary>
/// Available triage actions for this finding.
/// </summary>
public required IReadOnlyList<TriageAction> AvailableActions { get; init; }
/// <summary>
/// Optional metadata about the evidence collection.
/// </summary>
public EvidenceMetadata? Metadata { get; init; }
}
/// <summary>
/// Node in the evidence graph.
/// </summary>
public sealed record EvidenceNode
{
/// <summary>
/// Unique identifier for this node.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Type of evidence this node represents.
/// </summary>
public required EvidenceNodeType Type { get; init; }
/// <summary>
/// Human-readable label for display.
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Optional longer description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Type-specific metadata.
/// </summary>
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
/// <summary>
/// Child nodes (for expandable tree view).
/// </summary>
public IReadOnlyList<EvidenceNode>? Children { get; init; }
/// <summary>
/// Whether this node is expandable (has or can load children).
/// </summary>
public bool IsExpandable { get; init; }
/// <summary>
/// Status indicator for this node (pass/fail/info).
/// </summary>
public EvidenceNodeStatus Status { get; init; } = EvidenceNodeStatus.Info;
}
/// <summary>
/// Type of evidence node.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EvidenceNodeType
{
/// <summary>Container image or artifact.</summary>
Artifact,
/// <summary>Software package (PURL).</summary>
Package,
/// <summary>Code symbol (function, class, method).</summary>
Symbol,
/// <summary>Call path from entry point to vulnerable code.</summary>
CallPath,
/// <summary>VEX claim from vendor or internal team.</summary>
VexClaim,
/// <summary>Policy rule that affected the verdict.</summary>
PolicyRule,
/// <summary>External advisory source (NVD, vendor, etc.).</summary>
AdvisorySource,
/// <summary>Scanner evidence (binary analysis, SBOM, etc.).</summary>
ScannerEvidence,
/// <summary>Runtime observation (eBPF, traces).</summary>
RuntimeObservation,
/// <summary>Configuration or environment context.</summary>
Configuration,
}
/// <summary>
/// Status indicator for evidence nodes.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EvidenceNodeStatus
{
/// <summary>Informational, no pass/fail.</summary>
Info,
/// <summary>Positive indicator (mitigating factor).</summary>
Pass,
/// <summary>Negative indicator (risk factor).</summary>
Fail,
/// <summary>Needs review or additional information.</summary>
Warning,
/// <summary>Unknown or incomplete data.</summary>
Unknown,
}
/// <summary>
/// Edge connecting two evidence nodes.
/// </summary>
public sealed record EvidenceEdge
{
/// <summary>
/// Source node identifier.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Target node identifier.
/// </summary>
public required string TargetId { get; init; }
/// <summary>
/// Type of relationship (contains, calls, claims, references, etc.).
/// </summary>
public required string Relationship { get; init; }
/// <summary>
/// Citation linking to source evidence.
/// </summary>
public required EvidenceCitation Citation { get; init; }
/// <summary>
/// Whether this edge represents a reachable path.
/// </summary>
public bool IsReachable { get; init; }
/// <summary>
/// Strength of the relationship (for visualization).
/// </summary>
public double? Weight { get; init; }
}
/// <summary>
/// Citation linking to source evidence.
/// </summary>
public sealed record EvidenceCitation
{
/// <summary>
/// Source type (scanner, vex:vendor, advisory:nvd, etc.).
/// </summary>
public required string Source { get; init; }
/// <summary>
/// URL to the source evidence (if available).
/// </summary>
public required string SourceUrl { get; init; }
/// <summary>
/// When this evidence was observed/collected.
/// </summary>
public required DateTimeOffset ObservedAt { get; init; }
/// <summary>
/// Confidence score (0.0-1.0) if applicable.
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// Hash of the evidence (for verification).
/// </summary>
public string? EvidenceHash { get; init; }
/// <summary>
/// Whether this citation is verified/signed.
/// </summary>
public bool IsVerified { get; init; }
}
/// <summary>
/// Summary verdict for a finding.
/// </summary>
public sealed record VerdictSummary
{
/// <summary>
/// Decision outcome (allow, deny, review).
/// </summary>
public required string Decision { get; init; }
/// <summary>
/// Human-readable explanation paragraph.
/// </summary>
public required string Explanation { get; init; }
/// <summary>
/// Key factors that influenced the decision.
/// </summary>
public required IReadOnlyList<string> KeyFactors { get; init; }
/// <summary>
/// Overall confidence score (0.0-1.0).
/// </summary>
public required double ConfidenceScore { get; init; }
/// <summary>
/// Policy rule IDs that affected this verdict.
/// </summary>
public IReadOnlyList<string>? AppliedPolicies { get; init; }
/// <summary>
/// Timestamp when this verdict was computed.
/// </summary>
public DateTimeOffset? ComputedAt { get; init; }
}
/// <summary>
/// Available triage action.
/// </summary>
public sealed record TriageAction
{
/// <summary>
/// Unique action identifier.
/// </summary>
public required string ActionId { get; init; }
/// <summary>
/// Type of action.
/// </summary>
public required TriageActionType Type { get; init; }
/// <summary>
/// Display label.
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Optional description of what this action does.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Whether this action requires confirmation dialog.
/// </summary>
public bool RequiresConfirmation { get; init; }
/// <summary>
/// Whether this action is currently available.
/// </summary>
public bool IsEnabled { get; init; } = true;
/// <summary>
/// Reason if the action is disabled.
/// </summary>
public string? DisabledReason { get; init; }
/// <summary>
/// Additional parameters for the action.
/// </summary>
public IReadOnlyDictionary<string, object>? Parameters { get; init; }
}
/// <summary>
/// Type of triage action.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TriageActionType
{
/// <summary>Accept vendor's VEX claim.</summary>
AcceptVendorVex,
/// <summary>Request additional evidence.</summary>
RequestEvidence,
/// <summary>Open diff view to previous version.</summary>
OpenDiff,
/// <summary>Create time-boxed policy exception.</summary>
CreateException,
/// <summary>Mark as false positive.</summary>
MarkFalsePositive,
/// <summary>Escalate to security team.</summary>
EscalateToSecurityTeam,
/// <summary>Apply internal VEX claim.</summary>
ApplyInternalVex,
/// <summary>Schedule for patching.</summary>
SchedulePatch,
/// <summary>Suppress finding.</summary>
Suppress,
}
/// <summary>
/// Metadata about evidence collection.
/// </summary>
public sealed record EvidenceMetadata
{
/// <summary>
/// When the evidence was collected.
/// </summary>
public DateTimeOffset CollectedAt { get; init; }
/// <summary>
/// Number of nodes in the graph.
/// </summary>
public int NodeCount { get; init; }
/// <summary>
/// Number of edges in the graph.
/// </summary>
public int EdgeCount { get; init; }
/// <summary>
/// Whether the graph is complete or truncated.
/// </summary>
public bool IsTruncated { get; init; }
/// <summary>
/// Maximum depth of the tree.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Sources of evidence included.
/// </summary>
public IReadOnlyList<string>? Sources { get; init; }
}
/// <summary>
/// Request to execute a triage action.
/// </summary>
public sealed record ExecuteTriageActionRequest
{
/// <summary>
/// Finding to apply action to.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Action to execute.
/// </summary>
public required string ActionId { get; init; }
/// <summary>
/// Optional parameters for the action.
/// </summary>
public IReadOnlyDictionary<string, object>? Parameters { get; init; }
/// <summary>
/// User comment/justification.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// Response from triage action execution.
/// </summary>
public sealed record ExecuteTriageActionResponse
{
/// <summary>
/// Whether the action succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Result message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Updated verdict after action (if changed).
/// </summary>
public VerdictSummary? UpdatedVerdict { get; init; }
/// <summary>
/// Next recommended action (if any).
/// </summary>
public TriageAction? NextAction { get; init; }
}
/// <summary>
/// Filters for finding triage.
/// </summary>
public sealed record TriageFilters
{
/// <summary>
/// Reachability filter.
/// </summary>
public ReachabilityFilter Reachability { get; init; } = ReachabilityFilter.Reachable;
/// <summary>
/// Patch status filter.
/// </summary>
public PatchStatusFilter PatchStatus { get; init; } = PatchStatusFilter.Unpatched;
/// <summary>
/// VEX status filter.
/// </summary>
public VexStatusFilter VexStatus { get; init; } = VexStatusFilter.Unvexed;
/// <summary>
/// Severity levels to include.
/// </summary>
public IReadOnlyList<string> Severity { get; init; } = new[] { "critical", "high" };
/// <summary>
/// Whether to show suppressed findings.
/// </summary>
public bool ShowSuppressed { get; init; }
}
/// <summary>
/// Reachability filter options.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ReachabilityFilter
{
/// <summary>Show all findings.</summary>
All,
/// <summary>Show only reachable findings.</summary>
Reachable,
/// <summary>Show only unreachable findings.</summary>
Unreachable,
/// <summary>Show findings with unknown reachability.</summary>
Unknown,
}
/// <summary>
/// Patch status filter options.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PatchStatusFilter
{
/// <summary>Show all findings.</summary>
All,
/// <summary>Show only findings with available patches.</summary>
Patched,
/// <summary>Show only findings without available patches.</summary>
Unpatched,
}
/// <summary>
/// VEX status filter options.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum VexStatusFilter
{
/// <summary>Show all findings.</summary>
All,
/// <summary>Show only findings with VEX claims.</summary>
Vexed,
/// <summary>Show only findings without VEX claims.</summary>
Unvexed,
/// <summary>Show findings with conflicting VEX claims.</summary>
Conflicting,
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.RiskEngine.Infrastructure;
public class Class1
{
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.RiskEngine.Core\StellaOps.RiskEngine.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,32 @@
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Services;
using System.Collections.Concurrent;
namespace StellaOps.RiskEngine.Infrastructure.Stores;
/// <summary>
/// Deterministic in-memory store for risk score results.
/// Used for offline/ephemeral runs and testing until ledger integration lands.
/// </summary>
public sealed class InMemoryRiskScoreResultStore : IRiskScoreResultStore
{
private readonly ConcurrentDictionary<Guid, RiskScoreResult> results = new();
private readonly ConcurrentQueue<Guid> order = new();
public Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (results.TryAdd(result.JobId, result))
{
order.Enqueue(result.JobId);
}
return Task.CompletedTask;
}
public IReadOnlyList<RiskScoreResult> Snapshot() =>
order.Select(id => results[id]).ToArray();
public bool TryGet(Guid jobId, out RiskScoreResult result) =>
results.TryGetValue(jobId, out result!);
}

View File

@@ -0,0 +1,195 @@
using Npgsql;
using NpgsqlTypes;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Services;
using System.Text.Json;
namespace StellaOps.RiskEngine.Infrastructure.Stores;
/// <summary>
/// PostgreSQL-backed risk score result store for durable production retrieval.
/// </summary>
public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsyncDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly NpgsqlDataSource _dataSource;
private readonly object _initGate = new();
private bool _tableInitialized;
public PostgresRiskScoreResultStore(string connectionString)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
_dataSource = NpgsqlDataSource.Create(connectionString);
}
public async Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO riskengine.risk_score_results (
job_id,
provider,
subject,
score,
success,
error,
signals,
completed_at
) VALUES (
@job_id,
@provider,
@subject,
@score,
@success,
@error,
@signals,
@completed_at
)
ON CONFLICT (job_id) DO UPDATE SET
provider = EXCLUDED.provider,
subject = EXCLUDED.subject,
score = EXCLUDED.score,
success = EXCLUDED.success,
error = EXCLUDED.error,
signals = EXCLUDED.signals,
completed_at = EXCLUDED.completed_at;
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("job_id", result.JobId);
command.Parameters.AddWithValue("provider", result.Provider);
command.Parameters.AddWithValue("subject", result.Subject);
command.Parameters.AddWithValue("score", result.Score);
command.Parameters.AddWithValue("success", result.Success);
command.Parameters.AddWithValue("error", (object?)result.Error ?? DBNull.Value);
command.Parameters.Add("signals", NpgsqlDbType.Jsonb).Value = JsonSerializer.Serialize(result.Signals, JsonOptions);
command.Parameters.AddWithValue("completed_at", result.CompletedAtUtc);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public bool TryGet(Guid jobId, out RiskScoreResult result)
{
EnsureTable();
const string sql = """
SELECT provider, subject, score, success, error, signals, completed_at
FROM riskengine.risk_score_results
WHERE job_id = @job_id;
""";
using var connection = _dataSource.OpenConnection();
using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("job_id", jobId);
using var reader = command.ExecuteReader();
if (!reader.Read())
{
result = default!;
return false;
}
var provider = reader.GetString(0);
var subject = reader.GetString(1);
var score = reader.GetDouble(2);
var success = reader.GetBoolean(3);
var error = reader.IsDBNull(4) ? null : reader.GetString(4);
var signalsJson = reader.GetString(5);
var completedAt = reader.GetFieldValue<DateTimeOffset>(6);
var signals = JsonSerializer.Deserialize<Dictionary<string, double>>(signalsJson, JsonOptions)
?? new Dictionary<string, double>(StringComparer.Ordinal);
result = new RiskScoreResult(
jobId,
provider,
subject,
score,
success,
error,
signals,
completedAt);
return true;
}
public ValueTask DisposeAsync()
{
return _dataSource.DisposeAsync();
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
lock (_initGate)
{
if (_tableInitialized)
{
return;
}
}
const string ddl = """
CREATE SCHEMA IF NOT EXISTS riskengine;
CREATE TABLE IF NOT EXISTS riskengine.risk_score_results (
job_id UUID PRIMARY KEY,
provider TEXT NOT NULL,
subject TEXT NOT NULL,
score DOUBLE PRECISION NOT NULL,
success BOOLEAN NOT NULL,
error TEXT NULL,
signals JSONB NOT NULL,
completed_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_risk_score_results_completed_at
ON riskengine.risk_score_results (completed_at DESC);
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
lock (_initGate)
{
_tableInitialized = true;
}
}
private void EnsureTable()
{
lock (_initGate)
{
if (_tableInitialized)
{
return;
}
}
const string ddl = """
CREATE SCHEMA IF NOT EXISTS riskengine;
CREATE TABLE IF NOT EXISTS riskengine.risk_score_results (
job_id UUID PRIMARY KEY,
provider TEXT NOT NULL,
subject TEXT NOT NULL,
score DOUBLE PRECISION NOT NULL,
success BOOLEAN NOT NULL,
error TEXT NULL,
signals JSONB NOT NULL,
completed_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_risk_score_results_completed_at
ON riskengine.risk_score_results (completed_at DESC);
""";
using var connection = _dataSource.OpenConnection();
using var command = new NpgsqlCommand(ddl, connection);
command.ExecuteNonQuery();
lock (_initGate)
{
_tableInitialized = true;
}
}
}

View File

@@ -0,0 +1,9 @@
# StellaOps.RiskEngine.Infrastructure 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.Infrastructure/StellaOps.RiskEngine.Infrastructure.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-005 | DONE | Added `PostgresRiskScoreResultStore` with schema/bootstrap and deterministic upsert/read behavior. |

View File

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

View File

@@ -0,0 +1,208 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace StellaOps.RiskEngine.Tests;
/// <summary>
/// API contract tests for exploit maturity endpoints.
/// </summary>
public sealed class ExploitMaturityApiTests : IClassFixture<RiskEngineApiWebApplicationFactory>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
public ExploitMaturityApiTests(RiskEngineApiWebApplicationFactory factory)
{
// Configure test services
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace with test sources
services.AddSingleton<IEpssSource>(new InMemoryEpssSource(new Dictionary<string, EpssData>
{
["CVE-2024-1234"] = new EpssData(0.85, 0.98),
["CVE-2024-5678"] = new EpssData(0.15, 0.55)
}));
});
});
}
#region GET /exploit-maturity/{cveId}
[Fact]
public async Task GetExploitMaturity_ValidCve_ReturnsResult()
{
// Arrange
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/exploit-maturity/CVE-2024-1234");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ExploitMaturityResult>(_jsonOptions);
result.Should().NotBeNull();
result!.CveId.Should().Be("CVE-2024-1234");
result.Level.Should().Be(ExploitMaturityLevel.Weaponized);
}
[Fact]
public async Task GetExploitMaturity_UnknownCve_ReturnsUnknownLevel()
{
// Arrange
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/exploit-maturity/CVE-2099-9999");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ExploitMaturityResult>(_jsonOptions);
result.Should().NotBeNull();
result!.CveId.Should().Be("CVE-2099-9999");
result.Level.Should().Be(ExploitMaturityLevel.Unknown);
}
[Fact]
public async Task GetExploitMaturity_EmptyCveId_ReturnsBadRequest()
{
// Arrange
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/exploit-maturity/%20");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
#region GET /exploit-maturity/{cveId}/level
[Fact]
public async Task GetExploitMaturityLevel_ValidCve_ReturnsLevel()
{
// Arrange
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/exploit-maturity/CVE-2024-5678/level");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("level");
}
#endregion
#region GET /exploit-maturity/{cveId}/history
[Fact]
public async Task GetExploitMaturityHistory_ReturnsEmptyList()
{
// Arrange (history not persisted in base implementation)
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/exploit-maturity/CVE-2024-1234/history");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("entries");
}
#endregion
#region POST /exploit-maturity/batch
[Fact]
public async Task BatchAssessMaturity_ValidRequest_ReturnsResults()
{
// Arrange
var client = CreateAuthenticatedClient();
var request = new { CveIds = new[] { "CVE-2024-1234", "CVE-2024-5678" } };
// Act
var response = await client.PostAsJsonAsync("/exploit-maturity/batch", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("results");
content.Should().Contain("CVE-2024-1234");
content.Should().Contain("CVE-2024-5678");
}
[Fact]
public async Task BatchAssessMaturity_EmptyList_ReturnsBadRequest()
{
// Arrange
var client = CreateAuthenticatedClient();
var request = new { CveIds = Array.Empty<string>() };
// Act
var response = await client.PostAsJsonAsync("/exploit-maturity/batch", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task BatchAssessMaturity_DeduplicatesCves()
{
// Arrange
var client = CreateAuthenticatedClient();
var request = new { CveIds = new[] { "CVE-2024-1234", "CVE-2024-1234", "CVE-2024-1234" } };
// Act
var response = await client.PostAsJsonAsync("/exploit-maturity/batch", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
// Count occurrences - should have single result
var occurrences = content.Split("CVE-2024-1234").Length - 1;
occurrences.Should().BeGreaterThanOrEqualTo(1);
}
#endregion
#region Response Contract Tests
[Fact]
public async Task GetExploitMaturity_ResponseIncludesAllFields()
{
// Arrange
var client = CreateAuthenticatedClient();
// Act
var response = await client.GetAsync("/exploit-maturity/CVE-2024-1234");
var content = await response.Content.ReadAsStringAsync();
// Assert: All required fields present
content.Should().Contain("cveId");
content.Should().Contain("level");
content.Should().Contain("confidence");
content.Should().Contain("signals");
content.Should().Contain("assessedAt");
}
#endregion
private HttpClient CreateAuthenticatedClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add(RiskEngineTestAuthHandler.HeaderName, RiskEngineTestAuthHandler.HeaderValue);
return client;
}
}

View File

@@ -0,0 +1,317 @@
using FluentAssertions;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers;
using Microsoft.Extensions.Time.Testing;
namespace StellaOps.RiskEngine.Tests;
/// <summary>
/// Unit tests for ExploitMaturityService.
/// Verifies EPSS/KEV/InTheWild signal aggregation to maturity levels.
/// </summary>
public sealed class ExploitMaturityServiceTests
{
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
#region Test Infrastructure
private sealed class TestEpssSource : IEpssSource
{
private readonly Dictionary<string, EpssData> _data = new(StringComparer.OrdinalIgnoreCase);
public void SetEpss(string cveId, double score, double percentile) =>
_data[cveId] = new EpssData(score, percentile);
public Task<EpssData?> GetEpssAsync(string cveId, CancellationToken cancellationToken)
{
_data.TryGetValue(cveId, out var result);
return Task.FromResult(result);
}
}
private sealed class TestKevSource : IKevSource
{
private readonly HashSet<string> _kevIds = new(StringComparer.OrdinalIgnoreCase);
public void MarkKev(string cveId) => _kevIds.Add(cveId);
public Task<bool?> IsKevAsync(string subject, CancellationToken cancellationToken) =>
Task.FromResult<bool?>(_kevIds.Contains(subject));
}
private sealed class TestInTheWildSource : IInTheWildSource
{
private readonly Dictionary<string, InTheWildResult> _data = new(StringComparer.OrdinalIgnoreCase);
public void SetExploited(string cveId, double confidence, string? evidence = null, DateTimeOffset? observedAt = null) =>
_data[cveId] = new InTheWildResult(true, confidence, evidence ?? "Observed in the wild", observedAt);
public Task<InTheWildResult?> IsExploitedInTheWildAsync(string cveId, CancellationToken cancellationToken)
{
_data.TryGetValue(cveId, out var result);
return Task.FromResult(result);
}
}
#endregion
#region Basic Signal Mapping Tests
[Fact]
public async Task NoSignals_ReturnsUnknown()
{
// Arrange
var epss = new TestEpssSource();
var kev = new TestKevSource();
var sut = new ExploitMaturityService(epss, kev, null, null, _timeProvider);
// Act
var result = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result.Level.Should().Be(ExploitMaturityLevel.Unknown);
result.Confidence.Should().Be(0.0);
result.Signals.Should().BeEmpty();
}
[Theory]
[InlineData(0.005, ExploitMaturityLevel.Unknown)] // Below threshold
[InlineData(0.01, ExploitMaturityLevel.Theoretical)] // Threshold boundary
[InlineData(0.05, ExploitMaturityLevel.Theoretical)] // Within band
[InlineData(0.10, ExploitMaturityLevel.ProofOfConcept)] // Threshold boundary
[InlineData(0.25, ExploitMaturityLevel.ProofOfConcept)] // Within band
[InlineData(0.40, ExploitMaturityLevel.Active)] // Threshold boundary
[InlineData(0.60, ExploitMaturityLevel.Active)] // Within band
[InlineData(0.80, ExploitMaturityLevel.Weaponized)] // Threshold boundary
[InlineData(0.95, ExploitMaturityLevel.Weaponized)] // High score
public async Task EpssOnly_MapsToCorrectLevel(double epssScore, ExploitMaturityLevel expectedLevel)
{
// Arrange
var epss = new TestEpssSource();
epss.SetEpss("CVE-2024-0001", epssScore, 0.50);
var kev = new TestKevSource();
var sut = new ExploitMaturityService(epss, kev, null, null, _timeProvider);
// Act
var result = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result.Level.Should().Be(expectedLevel);
if (expectedLevel != ExploitMaturityLevel.Unknown)
{
result.Signals.Should().ContainSingle()
.Which.Source.Should().Be(MaturityEvidenceSource.Epss);
}
}
[Fact]
public async Task KevOnly_ReturnsWeaponized()
{
// Arrange
var epss = new TestEpssSource();
var kev = new TestKevSource();
kev.MarkKev("CVE-2024-0001");
var sut = new ExploitMaturityService(epss, kev, null, null, _timeProvider);
// Act
var result = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result.Level.Should().Be(ExploitMaturityLevel.Weaponized);
result.Confidence.Should().Be(0.95);
result.Signals.Should().ContainSingle()
.Which.Source.Should().Be(MaturityEvidenceSource.Kev);
}
[Fact]
public async Task InTheWildOnly_ReturnsActive()
{
// Arrange
var epss = new TestEpssSource();
var kev = new TestKevSource();
var inTheWild = new TestInTheWildSource();
inTheWild.SetExploited("CVE-2024-0001", 0.85, "Observed by threat intel feeds");
var sut = new ExploitMaturityService(epss, kev, inTheWild, null, _timeProvider);
// Act
var result = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result.Level.Should().Be(ExploitMaturityLevel.Active);
result.Confidence.Should().Be(0.85);
result.Signals.Should().ContainSingle()
.Which.Source.Should().Be(MaturityEvidenceSource.InTheWild);
}
#endregion
#region Signal Aggregation Tests
[Fact]
public async Task KevAndEpss_TakesHigherLevel()
{
// Arrange: EPSS suggests ProofOfConcept, KEV suggests Weaponized
var epss = new TestEpssSource();
epss.SetEpss("CVE-2024-0001", 0.15, 0.75);
var kev = new TestKevSource();
kev.MarkKev("CVE-2024-0001");
var sut = new ExploitMaturityService(epss, kev, null, null, _timeProvider);
// Act
var result = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result.Level.Should().Be(ExploitMaturityLevel.Weaponized);
result.Signals.Should().HaveCount(2);
}
[Fact]
public async Task AllSignalsAgree_AveragesConfidence()
{
// Arrange: All signals indicate Weaponized
var epss = new TestEpssSource();
epss.SetEpss("CVE-2024-0001", 0.85, 0.99);
var kev = new TestKevSource();
kev.MarkKev("CVE-2024-0001");
var sut = new ExploitMaturityService(epss, kev, null, null, _timeProvider);
// Act
var result = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result.Level.Should().Be(ExploitMaturityLevel.Weaponized);
result.Signals.Should().HaveCount(2);
// Both KEV (0.95) and EPSS (high conf) contribute
result.Confidence.Should().BeGreaterThan(0.8);
}
[Fact]
public async Task MixedLevels_TakesMaxLevel()
{
// Arrange: InTheWild=Active, EPSS=Theoretical
var epss = new TestEpssSource();
epss.SetEpss("CVE-2024-0001", 0.02, 0.30);
var kev = new TestKevSource();
var inTheWild = new TestInTheWildSource();
inTheWild.SetExploited("CVE-2024-0001", 0.70);
var sut = new ExploitMaturityService(epss, kev, inTheWild, null, _timeProvider);
// Act
var result = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result.Level.Should().Be(ExploitMaturityLevel.Active);
result.Signals.Should().HaveCount(2);
}
#endregion
#region EPSS Confidence Tests
[Theory]
[InlineData(0.99, 0.9)] // High percentile = high confidence
[InlineData(0.90, 0.9)] // 90th percentile = high confidence
[InlineData(0.50, 0.6)] // 50th percentile = base
[InlineData(0.10, 0.42)] // Low percentile = lower confidence
public async Task EpssConfidence_ScalesWithPercentile(double percentile, double expectedMinConfidence)
{
// Arrange
var epss = new TestEpssSource();
epss.SetEpss("CVE-2024-0001", 0.50, percentile);
var kev = new TestKevSource();
var sut = new ExploitMaturityService(epss, kev, null, null, _timeProvider);
// Act
var result = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result.Signals.Single().Confidence.Should().BeGreaterThanOrEqualTo(expectedMinConfidence);
}
#endregion
#region Error Handling
[Fact]
public async Task NullCveId_ThrowsArgumentNullException()
{
// Arrange
var sut = new ExploitMaturityService(new TestEpssSource(), new TestKevSource(), null, null, _timeProvider);
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => sut.AssessMaturityAsync(null!));
}
[Fact]
public async Task EmptyCveId_ThrowsArgumentException()
{
// Arrange
var sut = new ExploitMaturityService(new TestEpssSource(), new TestKevSource(), null, null, _timeProvider);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => sut.AssessMaturityAsync(""));
}
#endregion
#region GetMaturityLevelAsync Tests
[Fact]
public async Task GetMaturityLevelAsync_ReturnsLevel()
{
// Arrange
var epss = new TestEpssSource();
epss.SetEpss("CVE-2024-0001", 0.50, 0.80);
var kev = new TestKevSource();
var sut = new ExploitMaturityService(epss, kev, null, null, _timeProvider);
// Act
var result = await sut.GetMaturityLevelAsync("CVE-2024-0001");
// Assert
result.Should().Be(ExploitMaturityLevel.Active);
}
#endregion
#region GetMaturityHistoryAsync Tests
[Fact]
public async Task GetMaturityHistoryAsync_ReturnsEmpty()
{
// Arrange (history not implemented yet)
var sut = new ExploitMaturityService(new TestEpssSource(), new TestKevSource(), null, null, _timeProvider);
// Act
var result = await sut.GetMaturityHistoryAsync("CVE-2024-0001");
// Assert
result.Should().BeEmpty();
}
#endregion
#region Determinism Tests
[Fact]
public async Task SameInputs_ProducesSameOutputs()
{
// Arrange
var epss = new TestEpssSource();
epss.SetEpss("CVE-2024-0001", 0.35, 0.70);
var kev = new TestKevSource();
kev.MarkKev("CVE-2024-0001");
var sut = new ExploitMaturityService(epss, kev, null, null, _timeProvider);
// Act
var result1 = await sut.AssessMaturityAsync("CVE-2024-0001");
var result2 = await sut.AssessMaturityAsync("CVE-2024-0001");
// Assert
result1.Level.Should().Be(result2.Level);
result1.Confidence.Should().Be(result2.Confidence);
result1.Signals.Count.Should().Be(result2.Signals.Count);
}
#endregion
}

View File

@@ -0,0 +1,343 @@
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_007_RISK
// Task: FCR-009 - Integration Tests
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers.FixChain;
using Xunit;
namespace StellaOps.RiskEngine.Tests;
[Trait("Category", "Integration")]
public sealed class FixChainRiskIntegrationTests
{
private readonly FixChainRiskOptions _options;
private readonly InMemoryFixChainAttestationClient _attestationClient;
private readonly FixChainRiskProvider _provider;
public FixChainRiskIntegrationTests()
{
_options = new FixChainRiskOptions
{
Enabled = true,
FixedReduction = 0.90,
PartialReduction = 0.50,
MinConfidenceThreshold = 0.60m
};
_attestationClient = new InMemoryFixChainAttestationClient();
_provider = new FixChainRiskProvider(
_options,
_attestationClient,
NullLogger<FixChainRiskProvider>.Instance);
}
[Fact]
public async Task FullWorkflow_FixedVerdict_ReducesRisk()
{
// Arrange
var cveId = "CVE-2024-12345";
var binarySha256 = new string('a', 64);
var attestation = new FixChainAttestationData
{
ContentDigest = "sha256:abc123",
CveId = cveId,
ComponentPurl = "pkg:deb/debian/openssl@3.0.11",
BinarySha256 = binarySha256,
Verdict = new FixChainVerdictData
{
Status = "fixed",
Confidence = 0.97m,
Rationale = ["3 vulnerable functions removed", "All paths eliminated"]
},
GoldenSetId = "gs-openssl-0727",
VerifiedAt = DateTimeOffset.UtcNow
};
_attestationClient.AddAttestation(cveId, binarySha256, attestation);
// Act
var status = await _provider.GetFixStatusAsync(cveId, binarySha256);
// Assert
status.Should().NotBeNull();
status!.Verdict.Should().Be("fixed");
status.Confidence.Should().Be(0.97m);
status.Rationale.Should().HaveCount(2);
status.GoldenSetId.Should().Be("gs-openssl-0727");
// Verify risk adjustment
var adjustment = _provider.ComputeRiskAdjustment(status);
adjustment.Should().BeLessThan(0.3); // Significant reduction
}
[Fact]
public async Task FullWorkflow_CreateRiskFactor_ProducesValidFactor()
{
// Arrange
var cveId = "CVE-2024-67890";
var binarySha256 = new string('b', 64);
var attestation = new FixChainAttestationData
{
ContentDigest = "sha256:def456",
CveId = cveId,
ComponentPurl = "pkg:npm/lodash@4.17.21",
BinarySha256 = binarySha256,
Verdict = new FixChainVerdictData
{
Status = "partial",
Confidence = 0.75m,
Rationale = ["2 paths eliminated", "1 path remaining"]
},
VerifiedAt = DateTimeOffset.UtcNow
};
_attestationClient.AddAttestation(cveId, binarySha256, attestation);
// Act
var status = await _provider.GetFixStatusAsync(cveId, binarySha256);
var factor = _provider.CreateRiskFactor(status!);
// Assert
factor.Verdict.Should().Be(FixChainVerdictStatus.Partial);
factor.Confidence.Should().Be(0.75m);
factor.RiskModifier.Should().BeLessThan(0);
factor.AttestationRef.Should().StartWith("fixchain://");
factor.Rationale.Should().HaveCount(2);
}
[Fact]
public async Task FullWorkflow_DisplayModel_HasCorrectValues()
{
// Arrange
var cveId = "CVE-2024-99999";
var binarySha256 = new string('c', 64);
var attestation = new FixChainAttestationData
{
ContentDigest = "sha256:ghi789",
CveId = cveId,
ComponentPurl = "pkg:maven/org.example/lib@1.0.0",
BinarySha256 = binarySha256,
Verdict = new FixChainVerdictData
{
Status = "fixed",
Confidence = 0.95m,
Rationale = ["Fix verified"]
},
GoldenSetId = "gs-example",
VerifiedAt = DateTimeOffset.UtcNow
};
_attestationClient.AddAttestation(cveId, binarySha256, attestation);
// Act
var status = await _provider.GetFixStatusAsync(cveId, binarySha256);
var factor = _provider.CreateRiskFactor(status!);
var display = factor.ToDisplay();
// Assert
display.Label.Should().Be("Fix Verification");
display.Value.Should().Contain("Fixed");
display.Value.Should().Contain("95");
display.ImpactDirection.Should().Be("decrease");
display.EvidenceRef.Should().Contain("fixchain://");
display.Details.Should().ContainKey("golden_set_id");
}
[Fact]
public async Task FullWorkflow_Badge_HasCorrectStyle()
{
// Arrange
var cveId = "CVE-2024-11111";
var binarySha256 = new string('d', 64);
var attestation = new FixChainAttestationData
{
ContentDigest = "sha256:jkl012",
CveId = cveId,
ComponentPurl = "pkg:pypi/requests@2.28.0",
BinarySha256 = binarySha256,
Verdict = new FixChainVerdictData
{
Status = "inconclusive",
Confidence = 0.45m,
Rationale = ["Could not determine"]
},
VerifiedAt = DateTimeOffset.UtcNow
};
_attestationClient.AddAttestation(cveId, binarySha256, attestation);
// Act
var status = await _provider.GetFixStatusAsync(cveId, binarySha256);
var factor = _provider.CreateRiskFactor(status!);
var badge = factor.ToBadge();
// Assert
badge.Status.Should().Be("Inconclusive");
badge.Color.Should().Be("gray");
}
[Fact]
public async Task FullWorkflow_MultipleAttestations_SameComponent()
{
// Arrange - add multiple CVE attestations for same component
var binarySha256 = new string('e', 64);
var cveIds = new[] { "CVE-2024-001", "CVE-2024-002", "CVE-2024-003" };
foreach (var cveId in cveIds)
{
_attestationClient.AddAttestation(cveId, binarySha256, new FixChainAttestationData
{
ContentDigest = $"sha256:{cveId}",
CveId = cveId,
ComponentPurl = "pkg:deb/debian/openssl@3.0.11",
BinarySha256 = binarySha256,
Verdict = new FixChainVerdictData
{
Status = "fixed",
Confidence = 0.95m,
Rationale = [$"Fix for {cveId}"]
},
VerifiedAt = DateTimeOffset.UtcNow
});
}
// Act & Assert - each CVE can be queried individually
foreach (var cveId in cveIds)
{
var status = await _provider.GetFixStatusAsync(cveId, binarySha256);
status.Should().NotBeNull();
status!.Verdict.Should().Be("fixed");
}
}
[Fact]
public async Task FullWorkflow_ScoreRequest_AppliesAdjustment()
{
// Arrange
var signals = new Dictionary<string, double>
{
[FixChainRiskProvider.SignalFixConfidence] = 0.90,
[FixChainRiskProvider.SignalFixStatus] = FixChainRiskProvider.EncodeStatus("fixed")
};
var request = new ScoreRequest("fixchain", "test-subject", signals);
// Act
var score = await _provider.ScoreAsync(request, CancellationToken.None);
// Assert
score.Should().BeLessThan(0.5); // Significant reduction applied
}
[Fact]
public async Task FullWorkflow_DisabledProvider_NoAdjustment()
{
// Arrange
var disabledOptions = new FixChainRiskOptions { Enabled = false };
var disabledProvider = new FixChainRiskProvider(disabledOptions);
var signals = new Dictionary<string, double>
{
[FixChainRiskProvider.SignalFixConfidence] = 1.0,
[FixChainRiskProvider.SignalFixStatus] = FixChainRiskProvider.EncodeStatus("fixed")
};
var request = new ScoreRequest("fixchain", "test-subject", signals);
// Act
var score = await disabledProvider.ScoreAsync(request, CancellationToken.None);
// Assert
score.Should().Be(1.0); // No adjustment when disabled
}
[Fact]
public async Task FullWorkflow_NoAttestation_ReturnsNull()
{
// Act
var status = await _provider.GetFixStatusAsync(
"CVE-NONEXISTENT",
new string('x', 64));
// Assert
status.Should().BeNull();
}
[Fact]
public async Task FullWorkflow_GetForComponent_ReturnsMultiple()
{
// Arrange
var componentPurl = "pkg:deb/debian/test@1.0.0";
var cves = new[] { "CVE-2024-A", "CVE-2024-B" };
foreach (var cveId in cves)
{
_attestationClient.AddAttestation(cveId, new string('f', 64), new FixChainAttestationData
{
ContentDigest = $"sha256:{cveId}",
CveId = cveId,
ComponentPurl = componentPurl,
BinarySha256 = new string('f', 64),
Verdict = new FixChainVerdictData
{
Status = "fixed",
Confidence = 0.90m,
Rationale = []
},
VerifiedAt = DateTimeOffset.UtcNow
});
}
// Act
var attestations = await _attestationClient.GetForComponentAsync(componentPurl);
// Assert
attestations.Should().HaveCount(2);
}
}
/// <summary>
/// In-memory attestation client for testing.
/// </summary>
internal sealed class InMemoryFixChainAttestationClient : IFixChainAttestationClient
{
private readonly Dictionary<string, FixChainAttestationData> _store = new();
private readonly Dictionary<string, List<FixChainAttestationData>> _byComponent = new();
public void AddAttestation(string cveId, string binarySha256, FixChainAttestationData attestation)
{
var key = $"{cveId}:{binarySha256}";
_store[key] = attestation;
if (!string.IsNullOrEmpty(attestation.ComponentPurl))
{
if (!_byComponent.TryGetValue(attestation.ComponentPurl, out var list))
{
list = [];
_byComponent[attestation.ComponentPurl] = list;
}
list.Add(attestation);
}
}
public Task<FixChainAttestationData?> GetFixChainAsync(
string cveId,
string binarySha256,
string? componentPurl = null,
CancellationToken ct = default)
{
var key = $"{cveId}:{binarySha256}";
return Task.FromResult(_store.GetValueOrDefault(key));
}
public Task<ImmutableArray<FixChainAttestationData>> GetForComponentAsync(
string componentPurl,
CancellationToken ct = default)
{
if (_byComponent.TryGetValue(componentPurl, out var list))
{
return Task.FromResult(list.ToImmutableArray());
}
return Task.FromResult(ImmutableArray<FixChainAttestationData>.Empty);
}
}

View File

@@ -0,0 +1,245 @@
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_007_RISK
// Task: FVS-005 - Unit Tests
using FluentAssertions;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers.FixChain;
using Xunit;
namespace StellaOps.RiskEngine.Tests;
[Trait("Category", "Unit")]
public sealed class FixChainRiskProviderTests
{
private readonly FixChainRiskProvider _provider;
private readonly FixChainRiskOptions _options;
public FixChainRiskProviderTests()
{
_options = new FixChainRiskOptions
{
FixedReduction = 0.90,
PartialReduction = 0.50,
MinConfidenceThreshold = 0.60m
};
_provider = new FixChainRiskProvider(_options);
}
[Fact]
public void Name_IsFixChain()
{
_provider.Name.Should().Be("fixchain");
}
[Fact]
public async Task ScoreAsync_NoSignals_ReturnsFullRisk()
{
// Arrange
var request = new ScoreRequest(
"fixchain",
"test-subject",
new Dictionary<string, double>());
// Act
var result = await _provider.ScoreAsync(request, CancellationToken.None);
// Assert
result.Should().Be(1.0);
}
[Fact]
public async Task ScoreAsync_FixedVerdict_HighConfidence_ReturnsLowRisk()
{
// Arrange
var signals = new Dictionary<string, double>
{
[FixChainRiskProvider.SignalFixConfidence] = 0.95,
[FixChainRiskProvider.SignalFixStatus] = FixChainRiskProvider.EncodeStatus("fixed")
};
var request = new ScoreRequest("fixchain", "test-subject", signals);
// Act
var result = await _provider.ScoreAsync(request, CancellationToken.None);
// Assert
// At 95% confidence with 60% threshold:
// confidenceScale = (0.95 - 0.60) / (1.0 - 0.60) = 0.35 / 0.40 = 0.875
// adjustment = 1.0 - (0.90 * 0.875) = 1.0 - 0.7875 = 0.2125
result.Should().BeApproximately(0.2125, 0.001);
}
[Fact]
public async Task ScoreAsync_FixedVerdict_100Confidence_ReturnsMinimumRisk()
{
// Arrange
var signals = new Dictionary<string, double>
{
[FixChainRiskProvider.SignalFixConfidence] = 1.0,
[FixChainRiskProvider.SignalFixStatus] = FixChainRiskProvider.EncodeStatus("fixed")
};
var request = new ScoreRequest("fixchain", "test-subject", signals);
// Act
var result = await _provider.ScoreAsync(request, CancellationToken.None);
// Assert
// At 100% confidence: 1.0 - 0.90 = 0.10
result.Should().BeApproximately(0.10, 0.001);
}
[Fact]
public async Task ScoreAsync_PartialVerdict_HighConfidence_ReturnsMediumRisk()
{
// Arrange
var signals = new Dictionary<string, double>
{
[FixChainRiskProvider.SignalFixConfidence] = 1.0,
[FixChainRiskProvider.SignalFixStatus] = FixChainRiskProvider.EncodeStatus("partial")
};
var request = new ScoreRequest("fixchain", "test-subject", signals);
// Act
var result = await _provider.ScoreAsync(request, CancellationToken.None);
// Assert
// At 100% confidence: 1.0 - 0.50 = 0.50
result.Should().BeApproximately(0.50, 0.001);
}
[Fact]
public async Task ScoreAsync_NotFixedVerdict_ReturnsFullRisk()
{
// Arrange
var signals = new Dictionary<string, double>
{
[FixChainRiskProvider.SignalFixConfidence] = 0.95,
[FixChainRiskProvider.SignalFixStatus] = FixChainRiskProvider.EncodeStatus("not_fixed")
};
var request = new ScoreRequest("fixchain", "test-subject", signals);
// Act
var result = await _provider.ScoreAsync(request, CancellationToken.None);
// Assert
result.Should().Be(1.0);
}
[Fact]
public async Task ScoreAsync_InconclusiveVerdict_ReturnsFullRisk()
{
// Arrange
var signals = new Dictionary<string, double>
{
[FixChainRiskProvider.SignalFixConfidence] = 0.80,
[FixChainRiskProvider.SignalFixStatus] = FixChainRiskProvider.EncodeStatus("inconclusive")
};
var request = new ScoreRequest("fixchain", "test-subject", signals);
// Act
var result = await _provider.ScoreAsync(request, CancellationToken.None);
// Assert
result.Should().Be(1.0);
}
[Fact]
public async Task ScoreAsync_BelowConfidenceThreshold_ReturnsFullRisk()
{
// Arrange
var signals = new Dictionary<string, double>
{
[FixChainRiskProvider.SignalFixConfidence] = 0.50, // Below 60% threshold
[FixChainRiskProvider.SignalFixStatus] = FixChainRiskProvider.EncodeStatus("fixed")
};
var request = new ScoreRequest("fixchain", "test-subject", signals);
// Act
var result = await _provider.ScoreAsync(request, CancellationToken.None);
// Assert
result.Should().Be(1.0);
}
[Fact]
public void ComputeRiskAdjustment_FixedStatus_ReturnsCorrectAdjustment()
{
// Arrange
var status = new FixVerificationStatus
{
Verdict = "fixed",
Confidence = 0.95m,
VerifiedAt = DateTimeOffset.UtcNow,
AttestationDigest = "sha256:test"
};
// Act
var result = _provider.ComputeRiskAdjustment(status);
// Assert
result.Should().BeLessThan(0.5);
}
[Fact]
public void ComputeRiskAdjustment_PartialStatus_ReturnsCorrectAdjustment()
{
// Arrange
var status = new FixVerificationStatus
{
Verdict = "partial",
Confidence = 0.95m,
VerifiedAt = DateTimeOffset.UtcNow,
AttestationDigest = "sha256:test"
};
// Act
var result = _provider.ComputeRiskAdjustment(status);
// Assert
result.Should().BeGreaterThan(0.2);
result.Should().BeLessThan(0.8);
}
[Theory]
[InlineData("fixed", 1.0)]
[InlineData("partial", 2.0)]
[InlineData("not_fixed", 3.0)]
[InlineData("inconclusive", 4.0)]
public void EncodeStatus_ReturnsCorrectCode(string verdict, double expectedCode)
{
var code = FixChainRiskProvider.EncodeStatus(verdict);
code.Should().Be(expectedCode);
}
[Fact]
public void EncodeStatus_UnknownVerdict_ReturnsZero()
{
var code = FixChainRiskProvider.EncodeStatus("unknown_verdict");
code.Should().Be(0.0);
}
[Fact]
public async Task GetFixStatusAsync_ReturnsNull_Placeholder()
{
// This is a placeholder test - actual implementation would query attestation store
var result = await _provider.GetFixStatusAsync("CVE-2024-1234", "sha256:test");
result.Should().BeNull();
}
[Fact]
public void DefaultOptions_HaveReasonableValues()
{
var defaultOptions = new FixChainRiskOptions();
defaultOptions.FixedReduction.Should().BeGreaterThan(0.5);
defaultOptions.PartialReduction.Should().BeGreaterThan(0.2);
defaultOptions.MinConfidenceThreshold.Should().BeGreaterThan(0.5m);
defaultOptions.CacheMaxAgeHours.Should().BeGreaterThan(0);
}
}

View File

@@ -0,0 +1,114 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Infrastructure.Stores;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.RiskEngine.Tests;
[Collection(RiskEnginePostgresCollection.Name)]
public sealed class PostgresRiskScoreResultStoreTests : IAsyncLifetime
{
private readonly RiskEnginePostgresFixture _fixture;
private readonly PostgresRiskScoreResultStore _store;
public PostgresRiskScoreResultStoreTests(RiskEnginePostgresFixture fixture)
{
_fixture = fixture;
_store = new PostgresRiskScoreResultStore(_fixture.ConnectionString);
}
public async ValueTask InitializeAsync()
{
await _fixture.ExecuteSqlAsync("DROP SCHEMA IF EXISTS riskengine CASCADE;");
}
public async ValueTask DisposeAsync()
{
await _store.DisposeAsync();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SaveAndTryGet_RoundTripsPersistedResult()
{
var result = new RiskScoreResult(
JobId: Guid.NewGuid(),
Provider: "default-transforms",
Subject: "pkg:npm/example@1.0.0",
Score: 42.5d,
Success: true,
Error: null,
Signals: new Dictionary<string, double>(StringComparer.Ordinal)
{
["epss"] = 0.7d,
["kev"] = 1d,
},
CompletedAtUtc: DateTimeOffset.UtcNow);
await _store.SaveAsync(result, CancellationToken.None);
var found = _store.TryGet(result.JobId, out var fetched);
found.Should().BeTrue();
fetched.JobId.Should().Be(result.JobId);
fetched.Provider.Should().Be(result.Provider);
fetched.Subject.Should().Be(result.Subject);
fetched.Score.Should().Be(result.Score);
fetched.Success.Should().BeTrue();
fetched.Signals.Should().ContainKey("epss").WhoseValue.Should().BeApproximately(0.7d, 0.0001d);
fetched.Signals.Should().ContainKey("kev").WhoseValue.Should().BeApproximately(1d, 0.0001d);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SaveAsync_WhenSameJobId_UpdatesExistingRow()
{
var jobId = Guid.NewGuid();
var baseline = new RiskScoreResult(
jobId,
"default-transforms",
"subject-a",
10d,
true,
null,
new Dictionary<string, double>(StringComparer.Ordinal) { ["signal"] = 0.1d },
DateTimeOffset.UtcNow);
var updated = new RiskScoreResult(
jobId,
"default-transforms",
"subject-b",
99d,
false,
"forced-error",
new Dictionary<string, double>(StringComparer.Ordinal) { ["signal"] = 0.9d },
DateTimeOffset.UtcNow.AddMinutes(1));
await _store.SaveAsync(baseline, CancellationToken.None);
await _store.SaveAsync(updated, CancellationToken.None);
var found = _store.TryGet(jobId, out var fetched);
found.Should().BeTrue();
fetched.Subject.Should().Be("subject-b");
fetched.Score.Should().Be(99d);
fetched.Success.Should().BeFalse();
fetched.Error.Should().Be("forced-error");
fetched.Signals.Should().ContainKey("signal").WhoseValue.Should().BeApproximately(0.9d, 0.0001d);
}
}
public sealed class RiskEnginePostgresFixture : PostgresIntegrationFixture, ICollectionFixture<RiskEnginePostgresFixture>
{
protected override System.Reflection.Assembly? GetMigrationAssembly() => null;
protected override string GetModuleName() => "RiskEngine";
}
[CollectionDefinition(Name)]
public sealed class RiskEnginePostgresCollection : ICollectionFixture<RiskEnginePostgresFixture>
{
public const string Name = "RiskEnginePostgres";
}

View File

@@ -0,0 +1,251 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using StellaOps.Auth.Abstractions;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.RiskEngine.Tests;
public class RiskEngineApiTests : IClassFixture<RiskEngineApiWebApplicationFactory>
{
private readonly RiskEngineApiWebApplicationFactory factory;
public RiskEngineApiTests(RiskEngineApiWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Providers_ListsDefaultTransforms()
{
using var client = CreateAuthenticatedClient();
var ct = CancellationToken.None;
var response = await client.GetAsync("/risk-scores/providers", ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<ProvidersResponse>(cancellationToken: ct);
Assert.NotNull(payload);
Assert.Contains(DefaultTransformsProvider.ProviderName, payload!.Providers);
Assert.Contains(CvssKevProvider.ProviderName, payload.Providers);
Assert.Contains(EpssProvider.ProviderName, payload.Providers);
Assert.Contains(CvssKevEpssProvider.ProviderName, payload.Providers);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Providers_WithoutAuth_ReturnsUnauthorizedOrForbidden()
{
using var client = factory.CreateClient();
var ct = CancellationToken.None;
var response = await client.GetAsync("/risk-scores/providers", ct);
Assert.True(
response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden,
$"Expected 401/403 for unauthenticated request, got {(int)response.StatusCode} ({response.StatusCode}).");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Jobs_WithReadOnlyScope_ReturnsForbidden()
{
using var client = CreateAuthenticatedClient(StellaOpsScopes.RiskEngineRead);
var ct = CancellationToken.None;
var request = new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-forbidden", new Dictionary<string, double>
{
["signal"] = 0.1
});
var response = await client.PostAsJsonAsync("/risk-scores/jobs", request, ct);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Job_SubmitAndRetrieve_PersistsResult()
{
using var client = CreateAuthenticatedClient();
var ct = CancellationToken.None;
var request = new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-1", new Dictionary<string, double>
{
["signal"] = 0.5
});
var submit = await client.PostAsJsonAsync("/risk-scores/jobs", request, ct);
Assert.Equal(HttpStatusCode.Accepted, submit.StatusCode);
var accepted = await submit.Content.ReadFromJsonAsync<JobAccepted>(cancellationToken: ct);
Assert.NotNull(accepted);
Assert.True(accepted!.Result.Success);
var fetched = await client.GetFromJsonAsync<RiskScoreResult>($"/risk-scores/jobs/{accepted.JobId}", ct);
Assert.NotNull(fetched);
Assert.Equal(accepted.JobId, fetched!.JobId);
Assert.True(fetched.Success);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Simulations_ReturnsBatch()
{
using var client = CreateAuthenticatedClient();
var ct = CancellationToken.None;
var requests = new[]
{
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-a", new Dictionary<string, double> { ["a"] = 0.2 }),
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-b", new Dictionary<string, double> { ["b"] = 0.8 })
};
var response = await client.PostAsJsonAsync("/risk-scores/simulations", requests, ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SimulationResponse>(cancellationToken: ct);
Assert.NotNull(payload);
Assert.Equal(2, payload!.Results.Count);
Assert.All(payload.Results, r => Assert.True(r.Success));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Simulations_Summary_ReturnsAggregatesAndTopMovers()
{
using var client = CreateAuthenticatedClient();
var ct = CancellationToken.None;
var requests = new[]
{
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-high", new Dictionary<string, double> { ["s1"] = 1.0 }),
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-mid", new Dictionary<string, double> { ["s1"] = 0.5 }),
new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset-low", new Dictionary<string, double> { ["s1"] = 0.2 })
};
var response = await client.PostAsJsonAsync("/risk-scores/simulations/summary", requests, ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SimulationSummaryResponse>(cancellationToken: ct);
Assert.NotNull(payload);
Assert.Equal(3, payload!.Results.Count);
Assert.All(payload.Results, r => Assert.True(r.Success));
Assert.Equal(0.566667, Math.Round(payload.Summary.AverageScore, 6));
Assert.Equal(0.2, payload.Summary.MinScore);
Assert.Equal(1.0, payload.Summary.MaxScore);
Assert.Equal(3, payload.Summary.TopMovers.Count);
Assert.Collection(payload.Summary.TopMovers,
first =>
{
Assert.Equal("asset-high", first.Subject);
Assert.Equal(1.0, first.Score);
},
second => Assert.Equal("asset-mid", second.Subject),
third => Assert.Equal("asset-low", third.Subject));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Simulations_CvssKev_UsesInlineSignals()
{
using var client = CreateAuthenticatedClient();
var ct = CancellationToken.None;
var requests = new[]
{
new ScoreRequest(CvssKevProvider.ProviderName, "CVE-LOCAL-1001", new Dictionary<string, double>
{
["Cvss"] = 7.5,
["Kev"] = 1
})
};
var response = await client.PostAsJsonAsync("/risk-scores/simulations", requests, ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SimulationResponse>(cancellationToken: ct);
Assert.NotNull(payload);
Assert.Single(payload!.Results);
Assert.True(payload.Results[0].Success);
Assert.Equal(0.95d, payload.Results[0].Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Simulations_Epss_UsesInlineSignals()
{
using var client = CreateAuthenticatedClient();
var ct = CancellationToken.None;
var requests = new[]
{
new ScoreRequest(EpssProvider.ProviderName, "CVE-LOCAL-1002", new Dictionary<string, double>
{
["EpssScore"] = 0.77,
["EpssPercentile"] = 0.93
})
};
var response = await client.PostAsJsonAsync("/risk-scores/simulations", requests, ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SimulationResponse>(cancellationToken: ct);
Assert.NotNull(payload);
Assert.Single(payload!.Results);
Assert.True(payload.Results[0].Success);
Assert.Equal(0.77d, payload.Results[0].Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Simulations_CvssKevEpss_UsesInlineSignals()
{
using var client = CreateAuthenticatedClient();
var ct = CancellationToken.None;
var requests = new[]
{
new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-LOCAL-1003", new Dictionary<string, double>
{
["Cvss"] = 5.0,
["Kev"] = 0,
["EpssScore"] = 0.35,
["EpssPercentile"] = 0.92
})
};
var response = await client.PostAsJsonAsync("/risk-scores/simulations", requests, ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SimulationResponse>(cancellationToken: ct);
Assert.NotNull(payload);
Assert.Single(payload!.Results);
Assert.True(payload.Results[0].Success);
Assert.Equal(0.55d, payload.Results[0].Score);
}
private HttpClient CreateAuthenticatedClient(string? scopes = null)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add(RiskEngineTestAuthHandler.HeaderName, RiskEngineTestAuthHandler.HeaderValue);
if (!string.IsNullOrWhiteSpace(scopes))
{
client.DefaultRequestHeaders.Add(RiskEngineTestAuthHandler.ScopesHeaderName, scopes);
}
return client;
}
private sealed record ProvidersResponse(IReadOnlyList<string> Providers);
private sealed record JobAccepted(Guid JobId, RiskScoreResult Result);
private sealed record SimulationResponse(IReadOnlyList<RiskScoreResult> Results);
private sealed record SimulationSummaryDto(double AverageScore, double MinScore, double MaxScore, IReadOnlyList<RiskScoreResult> TopMovers);
private sealed record SimulationSummaryResponse(SimulationSummaryDto Summary, IReadOnlyList<RiskScoreResult> Results);
}

View File

@@ -0,0 +1,98 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.RiskEngine.Tests;
public sealed class RiskEngineApiWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://authority.test.stella-ops.local",
["Authority:ResourceServer:RequireHttpsMetadata"] = "false"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = RiskEngineTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = RiskEngineTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, RiskEngineTestAuthHandler>(
RiskEngineTestAuthHandler.SchemeName,
_ => { })
.AddScheme<AuthenticationSchemeOptions, RiskEngineTestAuthHandler>(
StellaOpsAuthenticationDefaults.AuthenticationScheme,
_ => { });
});
}
}
internal sealed class RiskEngineTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
internal const string SchemeName = "RiskEngineTest";
internal const string HeaderName = "X-RiskEngine-Test-Auth";
internal const string HeaderValue = "true";
internal const string ScopesHeaderName = "X-RiskEngine-Test-Scopes";
internal const string TenantHeaderName = "X-RiskEngine-Test-Tenant";
public RiskEngineTestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(HeaderName, out var authHeader) ||
!string.Equals(authHeader.ToString(), HeaderValue, StringComparison.Ordinal))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var scopeValue = Request.Headers.TryGetValue(ScopesHeaderName, out var scopeHeader) &&
!string.IsNullOrWhiteSpace(scopeHeader.ToString())
? scopeHeader.ToString().Trim()
: $"{StellaOpsScopes.RiskEngineRead} {StellaOpsScopes.RiskEngineOperate}";
var tenantValue = Request.Headers.TryGetValue(TenantHeaderName, out var tenantHeader) &&
!string.IsNullOrWhiteSpace(tenantHeader.ToString())
? tenantHeader.ToString().Trim()
: "riskengine-test-tenant";
var claims = new List<Claim>
{
new(StellaOpsClaimTypes.Subject, "riskengine-test-user"),
new(StellaOpsClaimTypes.Tenant, tenantValue),
new(StellaOpsClaimTypes.Scope, scopeValue)
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
<Using Include="StellaOps.TestKit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.RiskEngine.Core/StellaOps.RiskEngine.Core.csproj"/>
<ProjectReference Include="../../__Libraries/StellaOps.RiskEngine.Infrastructure/StellaOps.RiskEngine.Infrastructure.csproj"/>
<ProjectReference Include="../../StellaOps.RiskEngine.WebService/StellaOps.RiskEngine.WebService.csproj"/>
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
# StellaOps.RiskEngine.Tests 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.Tests/StellaOps.RiskEngine.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-005 | DONE | Added `PostgresRiskScoreResultStoreTests` and validated via class-targeted xUnit run (2/2 pass). |

View File

@@ -0,0 +1,550 @@
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers;
using StellaOps.RiskEngine.Core.Services;
using StellaOps.RiskEngine.Infrastructure.Stores;
using StellaOps.TestKit;
namespace StellaOps.RiskEngine.Tests;
public class RiskScoreWorkerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessesJobsInFifoOrder()
{
var provider = new DeterministicProvider("default", 1.0);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var first = new ScoreRequest("default", "asset-1", new Dictionary<string, double> { ["a"] = 2 });
var second = new ScoreRequest("default", "asset-2", new Dictionary<string, double> { ["b"] = 3 });
await queue.EnqueueAsync(first, CancellationToken.None);
await queue.EnqueueAsync(second, CancellationToken.None);
var results = await worker.ProcessBatchAsync(2, CancellationToken.None);
Assert.Collection(
results,
r =>
{
Assert.Equal(first.Subject, r.Subject);
Assert.True(r.Success);
Assert.Equal(2, r.Score);
},
r =>
{
Assert.Equal(second.Subject, r.Subject);
Assert.True(r.Success);
Assert.Equal(3, r.Score);
});
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task MissingProviderYieldsFailure()
{
var registry = new RiskScoreProviderRegistry(Array.Empty<IRiskScoreProvider>());
var queue = new RiskScoreQueue();
var store = new InMemoryRiskScoreResultStore();
var worker = new RiskScoreWorker(queue, registry, store);
await queue.EnqueueAsync(
new ScoreRequest("absent", "asset", new Dictionary<string, double>()),
CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.False(result.Success);
Assert.Equal("absent", result.Provider);
Assert.NotNull(result.Error);
Assert.Equal(0d, result.Score);
Assert.True(store.TryGet(result.JobId, out var stored));
Assert.False(stored.Success);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeterministicProviderReturnsStableScore()
{
var provider = new DeterministicProvider("default", weight: 2.0);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest("default", "asset", new Dictionary<string, double> { ["x"] = 1.5, ["y"] = 0.5 });
await queue.EnqueueAsync(request, CancellationToken.None);
await queue.EnqueueAsync(request, CancellationToken.None);
var results = await worker.ProcessBatchAsync(2, CancellationToken.None);
var expected = await provider.ScoreAsync(request, CancellationToken.None);
Assert.All(results, r =>
{
Assert.True(r.Success);
Assert.Equal(expected, r.Score);
});
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DefaultProviderClampsAndAveragesSignals()
{
var provider = new DefaultTransformsProvider();
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(DefaultTransformsProvider.ProviderName, "asset", new Dictionary<string, double>
{
["low"] = -1,
["mid"] = 0.25,
["high"] = 2
});
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
var expected = Math.Round((0 + 0.25 + 1) / 3, 6, MidpointRounding.ToEven);
Assert.Equal(expected, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CvssKevProviderAddsKevBonus()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0001"] = 9.8
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>
{
["CVE-2025-0001"] = true
});
var provider = new CvssKevProvider(cvssSource, kevSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevProvider.ProviderName, "CVE-2025-0001", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(1.0d, result.Score); // 0.98 + 0.2 capped at 1.0
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CvssKevProvider_UsesInlineSignalsWhenProvided()
{
var provider = new CvssKevProvider(new FakeCvssSource(new Dictionary<string, double>()), new FakeKevSource(new Dictionary<string, bool>()));
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevProvider.ProviderName, "CVE-LOCAL-0001", new Dictionary<string, double>
{
["Cvss"] = 7.5,
["Kev"] = 1
});
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0.95d, result.Score); // (7.5/10) + 0.2
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CvssKevProviderHandlesMissingCvss()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>());
var kevSource = new FakeKevSource(new Dictionary<string, bool>());
var provider = new CvssKevProvider(cvssSource, kevSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevProvider.ProviderName, "unknown", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VexGateProviderShortCircuitsOnDenial()
{
var provider = new VexGateProvider();
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(VexGateProvider.ProviderName, "asset", new Dictionary<string, double>
{
["HasDenial"] = 1,
["Other"] = 0.9
});
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VexGateProviderUsesMaxSignalWhenNoDenial()
{
var provider = new VexGateProvider();
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(VexGateProvider.ProviderName, "asset", new Dictionary<string, double>
{
["HasDenial"] = 0,
["s1"] = 0.4,
["s2"] = 0.8
});
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0.8d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FixExposureProviderAppliesWeights()
{
var provider = new FixExposureProvider();
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(FixExposureProvider.ProviderName, "asset", new Dictionary<string, double>
{
["FixAvailability"] = 0.6,
["Criticality"] = 0.8,
["Exposure"] = 0.25
});
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
var expected = Math.Round((0.5 * 0.6) + (0.3 * 0.8) + (0.2 * 0.25), 6, MidpointRounding.ToEven);
Assert.Equal(expected, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FixExposureProviderDefaultsMissingSignalsToZero()
{
var provider = new FixExposureProvider();
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(FixExposureProvider.ProviderName, "asset", new Dictionary<string, double>
{
["FixAvailability"] = 1.0
});
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// Only fix=1; criticality/exposure default to 0 → 0.5 * 1.0
Assert.Equal(0.5d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ResultsPersistedToStore()
{
var provider = new DeterministicProvider("default", 1.0);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var store = new InMemoryRiskScoreResultStore();
var worker = new RiskScoreWorker(queue, registry, store);
var first = new ScoreRequest("default", "a1", new Dictionary<string, double> { ["s1"] = 1 });
var second = new ScoreRequest("default", "a2", new Dictionary<string, double> { ["s2"] = 2 });
await queue.EnqueueAsync(first, CancellationToken.None);
await queue.EnqueueAsync(second, CancellationToken.None);
var results = await worker.ProcessBatchAsync(2, CancellationToken.None);
var snapshot = store.Snapshot();
Assert.Equal(results.Select(r => r.JobId), snapshot.Select(r => r.JobId));
Assert.Equal(results.Select(r => r.Score), snapshot.Select(r => r.Score));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EpssProviderReturnsScoreDirectly()
{
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0002"] = new EpssData(0.75, 0.95)
});
var provider = new EpssProvider(epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(EpssProvider.ProviderName, "CVE-2025-0002", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0.75d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EpssProvider_UsesInlineSignalsWhenProvided()
{
var provider = new EpssProvider(new FakeEpssSource(new Dictionary<string, EpssData>()));
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(EpssProvider.ProviderName, "CVE-LOCAL-0002", new Dictionary<string, double>
{
["EpssScore"] = 0.77,
["EpssPercentile"] = 0.93
});
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0.77d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EpssProviderReturnsZeroForUnknown()
{
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>());
var provider = new EpssProvider(epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(EpssProvider.ProviderName, "CVE-9999-0000", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CvssKevEpssProviderCombinesAllSignals()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0003"] = 7.5
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>
{
["CVE-2025-0003"] = true
});
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0003"] = new EpssData(0.85, 0.99) // 99th percentile = +0.10
});
var provider = new CvssKevEpssProvider(cvssSource, kevSource, epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-2025-0003", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// 0.75 (cvss/10) + 0.2 (kev) + 0.10 (epss 99th) = 1.05 → clamped to 1.0
Assert.Equal(1.0d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CvssKevEpssProviderApplies90thPercentileBonus()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0004"] = 5.0
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>());
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0004"] = new EpssData(0.35, 0.92) // 90th percentile = +0.05
});
var provider = new CvssKevEpssProvider(cvssSource, kevSource, epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-2025-0004", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// 0.5 (cvss/10) + 0.0 (no kev) + 0.05 (epss 90th) = 0.55
Assert.Equal(0.55d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CvssKevEpssProvider_UsesInlineSignalsWhenProvided()
{
var provider = new CvssKevEpssProvider(
new FakeCvssSource(new Dictionary<string, double>()),
new FakeKevSource(new Dictionary<string, bool>()),
new FakeEpssSource(new Dictionary<string, EpssData>()));
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-LOCAL-0003", new Dictionary<string, double>
{
["Cvss"] = 5.0,
["Kev"] = 0,
["EpssScore"] = 0.35,
["EpssPercentile"] = 0.92
});
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0.55d, result.Score); // 0.5 + 0 + 0.05
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CvssKevEpssProviderApplies50thPercentileBonus()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0005"] = 4.0
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>());
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0005"] = new EpssData(0.15, 0.60) // 50th percentile = +0.02
});
var provider = new CvssKevEpssProvider(cvssSource, kevSource, epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-2025-0005", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// 0.4 (cvss/10) + 0.0 (no kev) + 0.02 (epss 50th) = 0.42
Assert.Equal(0.42d, result.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CvssKevEpssProviderNoBonusBelowThreshold()
{
var cvssSource = new FakeCvssSource(new Dictionary<string, double>
{
["CVE-2025-0006"] = 6.0
});
var kevSource = new FakeKevSource(new Dictionary<string, bool>());
var epssSource = new FakeEpssSource(new Dictionary<string, EpssData>
{
["CVE-2025-0006"] = new EpssData(0.05, 0.30) // Below 50th percentile = +0.0
});
var provider = new CvssKevEpssProvider(cvssSource, kevSource, epssSource);
var registry = new RiskScoreProviderRegistry(new[] { provider });
var queue = new RiskScoreQueue();
var worker = new RiskScoreWorker(queue, registry);
var request = new ScoreRequest(CvssKevEpssProvider.ProviderName, "CVE-2025-0006", new Dictionary<string, double>());
await queue.EnqueueAsync(request, CancellationToken.None);
var result = await worker.ProcessNextAsync(CancellationToken.None);
Assert.True(result.Success);
// 0.6 (cvss/10) + 0.0 (no kev) + 0.0 (epss below 50th) = 0.6
Assert.Equal(0.6d, result.Score);
}
private sealed class FakeCvssSource : ICvssSource
{
private readonly IReadOnlyDictionary<string, double> data;
public FakeCvssSource(IReadOnlyDictionary<string, double> data) => this.data = data;
public Task<double?> GetCvssAsync(string subject, CancellationToken cancellationToken) =>
Task.FromResult<double?>(data.TryGetValue(subject, out var value) ? value : null);
}
private sealed class FakeKevSource : IKevSource
{
private readonly IReadOnlyDictionary<string, bool> data;
public FakeKevSource(IReadOnlyDictionary<string, bool> data) => this.data = data;
public Task<bool?> IsKevAsync(string subject, CancellationToken cancellationToken) =>
Task.FromResult<bool?>(data.TryGetValue(subject, out var value) ? value : null);
}
private sealed class FakeEpssSource : IEpssSource
{
private readonly IReadOnlyDictionary<string, EpssData> data;
public FakeEpssSource(IReadOnlyDictionary<string, EpssData> data) => this.data = data;
public Task<EpssData?> GetEpssAsync(string cveId, CancellationToken cancellationToken) =>
Task.FromResult<EpssData?>(data.TryGetValue(cveId, out var value) ? value : null);
}
private sealed class DeterministicProvider : IRiskScoreProvider
{
public DeterministicProvider(string name, double weight)
{
Name = name;
this.weight = weight;
}
private readonly double weight;
public string Name { get; }
public Task<double> ScoreAsync(ScoreRequest request, CancellationToken cancellationToken)
{
var sum = request.Signals.Values.Sum();
return Task.FromResult(Math.Round(sum * weight, 6, MidpointRounding.ToEven));
}
}
}

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<!-- Test packages provided by Directory.Build.props -->
<ItemGroup>
<ProjectReference Include="../../StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.VulnExplorer.Api.Tests 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/__Tests/StellaOps.VulnExplorer.Api.Tests/StellaOps.VulnExplorer.Api.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using StellaOps.VulnExplorer.Api.Models;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.VulnExplorer.Api.Tests;
public class VulnApiTests : IClassFixture<VulnExplorerApiWebApplicationFactory>
{
private readonly VulnExplorerApiWebApplicationFactory factory;
public VulnApiTests(VulnExplorerApiWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task List_ReturnsDeterministicOrder()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns", cancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<VulnListResponse>(cancellationToken);
Assert.NotNull(payload);
Assert.Equal(new[] { "vuln-0001", "vuln-0002" }, payload!.Items.Select(v => v.Id));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task List_FiltersByCve()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns?cve=CVE-2024-2222", cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<VulnListResponse>(cancellationToken);
Assert.Single(payload!.Items);
Assert.Equal("vuln-0002", payload.Items[0].Id);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Detail_ReturnsNotFoundWhenMissing()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns/missing", cancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Detail_ReturnsRationaleAndPaths()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns/vuln-0001", cancellationToken);
response.EnsureSuccessStatusCode();
var detail = await response.Content.ReadFromJsonAsync<VulnDetail>(cancellationToken);
Assert.NotNull(detail);
Assert.Equal("rat-0001", detail!.Rationale.Id);
Assert.Contains("/src/app/Program.cs", detail.Paths);
Assert.NotEmpty(detail.Evidence);
}
private HttpClient CreateClient()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add(VulnExplorerTestAuthHandler.HeaderName, VulnExplorerTestAuthHandler.HeaderValue);
client.DefaultRequestHeaders.Add("x-stella-tenant", "tenant-a");
return client;
}
}

View File

@@ -0,0 +1,97 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.VulnExplorer.Api.Tests;
public sealed class VulnExplorerApiWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://authority.test.stella-ops.local",
["Authority:ResourceServer:RequireHttpsMetadata"] = "false"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = VulnExplorerTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = VulnExplorerTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, VulnExplorerTestAuthHandler>(
VulnExplorerTestAuthHandler.SchemeName,
_ => { })
.AddScheme<AuthenticationSchemeOptions, VulnExplorerTestAuthHandler>(
StellaOpsAuthenticationDefaults.AuthenticationScheme,
_ => { });
});
}
}
internal sealed class VulnExplorerTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
internal const string SchemeName = "VulnExplorerTest";
internal const string HeaderName = "X-VulnExplorer-Test-Auth";
internal const string HeaderValue = "true";
public VulnExplorerTestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(HeaderName, out var authHeader) ||
!string.Equals(authHeader.ToString(), HeaderValue, StringComparison.Ordinal))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var tenant = Request.Headers.TryGetValue(StellaOpsHttpHeaderNames.Tenant, out var tenantHeader) &&
!string.IsNullOrWhiteSpace(tenantHeader.ToString())
? tenantHeader.ToString().Trim()
: "tenant-a";
var claims = new List<Claim>
{
new(StellaOpsClaimTypes.Subject, "vulnexplorer-test-user"),
new(StellaOpsClaimTypes.Tenant, tenant),
new(StellaOpsClaimTypes.Scope, string.Join(' ', new[]
{
StellaOpsScopes.VulnView,
StellaOpsScopes.VulnInvestigate,
StellaOpsScopes.VulnOperate,
StellaOpsScopes.VulnAudit
}))
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,208 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.VulnExplorer.Api.Tests;
public sealed class VulnExplorerTriageApiE2ETests : IClassFixture<VulnExplorerApiWebApplicationFactory>
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly VulnExplorerApiWebApplicationFactory factory;
public VulnExplorerTriageApiE2ETests(VulnExplorerApiWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateAndGetVexDecision_WorksEndToEnd()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var createPayload = CreateDecisionPayload("CVE-2025-E2E-001", "notAffected", withAttestation: false);
var createResponse = await client.PostAsJsonAsync("/v1/vex-decisions", createPayload, JsonOptions, cancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
var decisionId = created?["id"]?.GetValue<string>();
Assert.False(string.IsNullOrWhiteSpace(decisionId));
var getResponse = await client.GetAsync($"/v1/vex-decisions/{decisionId}", cancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
Assert.Equal("CVE-2025-E2E-001", fetched?["vulnerabilityId"]?.GetValue<string>());
Assert.Equal("notAffected", fetched?["status"]?.GetValue<string>());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateWithAttestation_ReturnsSignedOverrideAndRekorReference()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var payload = CreateDecisionPayload("CVE-2025-E2E-002", "affectedMitigated", withAttestation: true);
var response = await client.PostAsJsonAsync("/v1/vex-decisions", payload, JsonOptions, cancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
var signedOverride = body?["signedOverride"]?.AsObject();
Assert.NotNull(signedOverride);
Assert.False(string.IsNullOrWhiteSpace(signedOverride?["envelopeDigest"]?.GetValue<string>()));
Assert.NotNull(signedOverride?["rekorLogIndex"]);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EvidenceSubgraphEndpoint_ReturnsReachabilityAndProofReferences()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/evidence-subgraph/CVE-2025-0001", cancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
Assert.NotNull(body?["root"]);
Assert.NotNull(body?["edges"]);
Assert.NotNull(body?["verdict"]);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task FixVerificationWorkflow_TracksStateTransitions()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var createResponse = await client.PostAsJsonAsync(
"/v1/fix-verifications",
new
{
cveId = "CVE-2025-E2E-003",
componentPurl = "pkg:maven/org.example/app@1.2.3",
artifactDigest = "sha256:abc123"
},
cancellationToken);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var patchResponse = await client.PatchAsync(
"/v1/fix-verifications/CVE-2025-E2E-003",
JsonContent.Create(new { verdict = "verified_by_scanner" }),
cancellationToken);
Assert.Equal(HttpStatusCode.OK, patchResponse.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateAuditBundle_ReturnsBundleForDecisionSet()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
var createPayload = CreateDecisionPayload("CVE-2025-E2E-004", "notAffected", withAttestation: false);
var decisionResponse = await client.PostAsJsonAsync("/v1/vex-decisions", createPayload, JsonOptions, cancellationToken);
Assert.Equal(HttpStatusCode.Created, decisionResponse.StatusCode);
var decision = await decisionResponse.Content.ReadFromJsonAsync<JsonObject>(cancellationToken);
var decisionId = decision?["id"]?.GetValue<string>();
Assert.False(string.IsNullOrWhiteSpace(decisionId));
var bundleResponse = await client.PostAsJsonAsync(
"/v1/audit-bundles",
new
{
tenant = "tenant-a",
decisionIds = new[] { decisionId }
},
cancellationToken);
Assert.Equal(HttpStatusCode.Created, bundleResponse.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task CreateDecision_WithInvalidStatus_ReturnsBadRequest()
{
var client = CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
const string invalidJson = """
{
"vulnerabilityId": "CVE-2025-E2E-005",
"subject": {
"type": "image",
"name": "registry.example/app:9.9.9",
"digest": {
"sha256": "zzz999"
}
},
"status": "invalidStatusLiteral",
"justificationType": "other"
}
""";
using var content = new StringContent(invalidJson, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/v1/vex-decisions", content, cancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private HttpClient CreateClient()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add(VulnExplorerTestAuthHandler.HeaderName, VulnExplorerTestAuthHandler.HeaderValue);
client.DefaultRequestHeaders.Add("x-stella-tenant", "tenant-a");
client.DefaultRequestHeaders.Add("x-stella-user-id", "e2e-analyst");
client.DefaultRequestHeaders.Add("x-stella-user-name", "E2E Analyst");
return client;
}
private static object CreateDecisionPayload(string vulnerabilityId, string status, bool withAttestation)
{
if (withAttestation)
{
return new
{
vulnerabilityId,
subject = new
{
type = "image",
name = "registry.example/app:2.0.0",
digest = new Dictionary<string, string> { ["sha256"] = "def456" }
},
status,
justificationType = "runtimeMitigationPresent",
justificationText = "Runtime guard active.",
attestationOptions = new
{
createAttestation = true,
anchorToRekor = true,
signingKeyId = "test-key"
}
};
}
return new
{
vulnerabilityId,
subject = new
{
type = "image",
name = "registry.example/app:1.2.3",
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
},
status,
justificationType = "codeNotReachable",
justificationText = "Guarded by deployment policy."
};
}
}