Complete batch 012 (golden set diff) and 013 (advisory chat), fix build errors

Sprints completed:
- SPRINT_20260110_012_* (golden set diff layer - 10 sprints)
- SPRINT_20260110_013_* (advisory chat - 4 sprints)

Build fixes applied:
- Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create
- Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite)
- Fix VexSchemaValidationTests FluentAssertions method name
- Fix FixChainGateIntegrationTests ambiguous type references
- Fix AdvisoryAI test files required properties and namespace aliases
- Add stub types for CveMappingController (ICveSymbolMappingService)
- Fix VerdictBuilderService static context issue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
master
2026-01-11 10:09:07 +02:00
parent a3b2f30a11
commit 7f7eb8b228
232 changed files with 58979 additions and 91 deletions

View File

@@ -0,0 +1,251 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_007_RISK
// Task: FCR-005 - FixChain Attestation Client Implementation
using System.Collections.Immutable;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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 AGPL-3.0-or-later. 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 AGPL-3.0-or-later. 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,348 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_007_RISK
// Task: FCR-001 through FCR-005 - FixChain Risk Provider
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.RiskEngine.Core.Contracts;
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 AGPL-3.0-or-later. 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

@@ -4,15 +4,19 @@
<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,343 @@
// Licensed under AGPL-3.0-or-later. 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 AGPL-3.0-or-later. 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);
}
}