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:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user