sprints work

This commit is contained in:
StellaOps Bot
2025-12-24 21:46:08 +02:00
parent 43e2af88f6
commit b9f71fc7e9
161 changed files with 29566 additions and 527 deletions

View File

@@ -0,0 +1,130 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Evidence tier for backport detection.
/// </summary>
public enum BackportEvidenceTier
{
/// <summary>No backport evidence.</summary>
None = 0,
/// <summary>Heuristic detection (changelog mention, commit patterns).</summary>
Heuristic = 1,
/// <summary>Patch-graph signature match.</summary>
PatchSignature = 2,
/// <summary>Binary-level diff confirmation.</summary>
BinaryDiff = 3,
/// <summary>Vendor-issued VEX statement.</summary>
VendorVex = 4,
/// <summary>Cryptographically signed proof (DSSE attestation).</summary>
SignedProof = 5
}
/// <summary>
/// Backport detection status.
/// </summary>
public enum BackportStatus
{
/// <summary>Vulnerability status unknown.</summary>
Unknown = 0,
/// <summary>Confirmed affected.</summary>
Affected = 1,
/// <summary>Confirmed not affected (e.g., backported, never included).</summary>
NotAffected = 2,
/// <summary>Fixed in this version.</summary>
Fixed = 3,
/// <summary>Under investigation.</summary>
UnderInvestigation = 4
}
/// <summary>
/// Detailed backport input for explanation generation.
/// </summary>
public sealed record BackportInput
{
/// <summary>Evidence tier for the backport detection.</summary>
public required BackportEvidenceTier EvidenceTier { get; init; }
/// <summary>Unique proof identifier for verification.</summary>
public string? ProofId { get; init; }
/// <summary>Backport detection status.</summary>
public required BackportStatus Status { get; init; }
/// <summary>Confidence in the backport detection [0, 1].</summary>
public required double Confidence { get; init; }
/// <summary>Source of backport evidence (e.g., "distro-changelog", "vendor-vex", "binary-diff").</summary>
public string? EvidenceSource { get; init; }
/// <summary>Evidence timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EvidenceTimestamp { get; init; }
/// <summary>Upstream fix commit (if known).</summary>
public string? UpstreamFixCommit { get; init; }
/// <summary>Backport commit in distribution (if known).</summary>
public string? BackportCommit { get; init; }
/// <summary>Distribution/vendor that issued the backport.</summary>
public string? Distributor { get; init; }
/// <summary>
/// Validates the backport input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (Confidence < 0.0 || Confidence > 1.0)
errors.Add($"Confidence must be in range [0, 1], got {Confidence}");
return errors;
}
/// <summary>
/// Generates a human-readable explanation of the backport evidence.
/// </summary>
public string GetExplanation()
{
if (EvidenceTier == BackportEvidenceTier.None)
return "No backport evidence";
var statusDesc = Status switch
{
BackportStatus.Unknown => "status unknown",
BackportStatus.Affected => "confirmed affected",
BackportStatus.NotAffected => "confirmed not affected",
BackportStatus.Fixed => "fixed",
BackportStatus.UnderInvestigation => "under investigation",
_ => $"unknown status ({Status})"
};
var tierDesc = EvidenceTier switch
{
BackportEvidenceTier.Heuristic => "heuristic",
BackportEvidenceTier.PatchSignature => "patch-signature",
BackportEvidenceTier.BinaryDiff => "binary-diff",
BackportEvidenceTier.VendorVex => "vendor VEX",
BackportEvidenceTier.SignedProof => "signed proof",
_ => $"unknown tier ({EvidenceTier})"
};
var distributorInfo = !string.IsNullOrEmpty(Distributor)
? $" from {Distributor}"
: "";
return $"{statusDesc} ({tierDesc}{distributorInfo}, {Confidence:P0} confidence)";
}
}

View File

@@ -0,0 +1,325 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Evidence weights for score calculation.
/// All weights except MIT should sum to approximately 1.0 (normalizable).
/// MIT is subtractive and applied separately.
/// </summary>
public sealed record EvidenceWeights
{
/// <summary>Weight for reachability dimension [0, 1].</summary>
public required double Rch { get; init; }
/// <summary>Weight for runtime dimension [0, 1].</summary>
public required double Rts { get; init; }
/// <summary>Weight for backport dimension [0, 1].</summary>
public required double Bkp { get; init; }
/// <summary>Weight for exploit dimension [0, 1].</summary>
public required double Xpl { get; init; }
/// <summary>Weight for source trust dimension [0, 1].</summary>
public required double Src { get; init; }
/// <summary>Weight for mitigation dimension (subtractive) [0, 1].</summary>
public required double Mit { get; init; }
/// <summary>
/// Default weights as specified in the scoring model.
/// </summary>
public static EvidenceWeights Default => new()
{
Rch = 0.30,
Rts = 0.25,
Bkp = 0.15,
Xpl = 0.15,
Src = 0.10,
Mit = 0.10
};
/// <summary>
/// Validates all weight values.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
ValidateWeight(nameof(Rch), Rch, errors);
ValidateWeight(nameof(Rts), Rts, errors);
ValidateWeight(nameof(Bkp), Bkp, errors);
ValidateWeight(nameof(Xpl), Xpl, errors);
ValidateWeight(nameof(Src), Src, errors);
ValidateWeight(nameof(Mit), Mit, errors);
return errors;
}
/// <summary>
/// Gets the sum of additive weights (excludes MIT).
/// </summary>
public double AdditiveSum => Rch + Rts + Bkp + Xpl + Src;
/// <summary>
/// Returns normalized weights where additive weights sum to 1.0.
/// MIT is preserved as-is (subtractive).
/// </summary>
public EvidenceWeights Normalize()
{
var sum = AdditiveSum;
if (sum <= 0)
return Default;
return new EvidenceWeights
{
Rch = Rch / sum,
Rts = Rts / sum,
Bkp = Bkp / sum,
Xpl = Xpl / sum,
Src = Src / sum,
Mit = Mit // MIT is not normalized
};
}
private static void ValidateWeight(string name, double value, List<string> errors)
{
if (double.IsNaN(value) || double.IsInfinity(value))
errors.Add($"{name} must be a valid number, got {value}");
else if (value < 0.0 || value > 1.0)
errors.Add($"{name} must be in range [0, 1], got {value}");
}
}
/// <summary>
/// Guardrail configuration for score caps and floors.
/// </summary>
public sealed record GuardrailConfig
{
/// <summary>Not-affected cap configuration.</summary>
public NotAffectedCapConfig NotAffectedCap { get; init; } = NotAffectedCapConfig.Default;
/// <summary>Runtime floor configuration.</summary>
public RuntimeFloorConfig RuntimeFloor { get; init; } = RuntimeFloorConfig.Default;
/// <summary>Speculative cap configuration.</summary>
public SpeculativeCapConfig SpeculativeCap { get; init; } = SpeculativeCapConfig.Default;
/// <summary>Default guardrail configuration.</summary>
public static GuardrailConfig Default => new();
}
/// <summary>Configuration for not-affected cap guardrail.</summary>
public sealed record NotAffectedCapConfig
{
/// <summary>Whether this guardrail is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Maximum score when guardrail is triggered.</summary>
public int MaxScore { get; init; } = 15;
/// <summary>Minimum BKP value required to trigger.</summary>
public double RequiresBkpMin { get; init; } = 1.0;
/// <summary>Maximum RTS value allowed to trigger.</summary>
public double RequiresRtsMax { get; init; } = 0.6;
public static NotAffectedCapConfig Default => new();
}
/// <summary>Configuration for runtime floor guardrail.</summary>
public sealed record RuntimeFloorConfig
{
/// <summary>Whether this guardrail is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Minimum score when guardrail is triggered.</summary>
public int MinScore { get; init; } = 60;
/// <summary>Minimum RTS value required to trigger.</summary>
public double RequiresRtsMin { get; init; } = 0.8;
public static RuntimeFloorConfig Default => new();
}
/// <summary>Configuration for speculative cap guardrail.</summary>
public sealed record SpeculativeCapConfig
{
/// <summary>Whether this guardrail is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Maximum score when guardrail is triggered.</summary>
public int MaxScore { get; init; } = 45;
/// <summary>Maximum RCH value allowed to trigger (must be at or below).</summary>
public double RequiresRchMax { get; init; } = 0.0;
/// <summary>Maximum RTS value allowed to trigger (must be at or below).</summary>
public double RequiresRtsMax { get; init; } = 0.0;
public static SpeculativeCapConfig Default => new();
}
/// <summary>
/// Score bucket threshold configuration.
/// </summary>
public sealed record BucketThresholds
{
/// <summary>Minimum score for ActNow bucket.</summary>
public int ActNowMin { get; init; } = 90;
/// <summary>Minimum score for ScheduleNext bucket.</summary>
public int ScheduleNextMin { get; init; } = 70;
/// <summary>Minimum score for Investigate bucket.</summary>
public int InvestigateMin { get; init; } = 40;
/// <summary>Below InvestigateMin is Watchlist.</summary>
public static BucketThresholds Default => new();
}
/// <summary>
/// Complete evidence weight policy with version tracking.
/// </summary>
public sealed record EvidenceWeightPolicy
{
/// <summary>Policy schema version (e.g., "ews.v1").</summary>
public required string Version { get; init; }
/// <summary>Policy profile name (e.g., "production", "development").</summary>
public required string Profile { get; init; }
/// <summary>Dimension weights.</summary>
public required EvidenceWeights Weights { get; init; }
/// <summary>Guardrail configuration.</summary>
public GuardrailConfig Guardrails { get; init; } = GuardrailConfig.Default;
/// <summary>Bucket thresholds.</summary>
public BucketThresholds Buckets { get; init; } = BucketThresholds.Default;
/// <summary>Optional tenant ID for multi-tenant scenarios.</summary>
public string? TenantId { get; init; }
/// <summary>Policy creation timestamp (UTC ISO-8601).</summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Default production policy.
/// </summary>
public static EvidenceWeightPolicy DefaultProduction => new()
{
Version = "ews.v1",
Profile = "production",
Weights = EvidenceWeights.Default
};
private string? _cachedDigest;
/// <summary>
/// Computes a deterministic digest of this policy for versioning.
/// Uses canonical JSON serialization → SHA256.
/// </summary>
public string ComputeDigest()
{
if (_cachedDigest is not null)
return _cachedDigest;
var canonical = GetCanonicalJson();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
_cachedDigest = Convert.ToHexStringLower(hash);
return _cachedDigest;
}
/// <summary>
/// Gets the canonical JSON representation for hashing.
/// Uses deterministic property ordering and formatting.
/// </summary>
public string GetCanonicalJson()
{
// Use a deterministic structure for hashing
var canonical = new
{
version = Version,
profile = Profile,
weights = new
{
rch = Weights.Rch,
rts = Weights.Rts,
bkp = Weights.Bkp,
xpl = Weights.Xpl,
src = Weights.Src,
mit = Weights.Mit
},
guardrails = new
{
not_affected_cap = new
{
enabled = Guardrails.NotAffectedCap.Enabled,
max_score = Guardrails.NotAffectedCap.MaxScore,
requires_bkp_min = Guardrails.NotAffectedCap.RequiresBkpMin,
requires_rts_max = Guardrails.NotAffectedCap.RequiresRtsMax
},
runtime_floor = new
{
enabled = Guardrails.RuntimeFloor.Enabled,
min_score = Guardrails.RuntimeFloor.MinScore,
requires_rts_min = Guardrails.RuntimeFloor.RequiresRtsMin
},
speculative_cap = new
{
enabled = Guardrails.SpeculativeCap.Enabled,
max_score = Guardrails.SpeculativeCap.MaxScore,
requires_rch_max = Guardrails.SpeculativeCap.RequiresRchMax,
requires_rts_max = Guardrails.SpeculativeCap.RequiresRtsMax
}
},
buckets = new
{
act_now_min = Buckets.ActNowMin,
schedule_next_min = Buckets.ScheduleNextMin,
investigate_min = Buckets.InvestigateMin
}
};
return JsonSerializer.Serialize(canonical, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
}
/// <summary>
/// Validates the policy configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(Version))
errors.Add("Version is required");
if (string.IsNullOrWhiteSpace(Profile))
errors.Add("Profile is required");
errors.AddRange(Weights.Validate());
// Validate bucket ordering
if (Buckets.ActNowMin <= Buckets.ScheduleNextMin)
errors.Add("ActNowMin must be greater than ScheduleNextMin");
if (Buckets.ScheduleNextMin <= Buckets.InvestigateMin)
errors.Add("ScheduleNextMin must be greater than InvestigateMin");
if (Buckets.InvestigateMin < 0 || Buckets.ActNowMin > 100)
errors.Add("Bucket thresholds must be in range [0, 100]");
return errors;
}
}

View File

@@ -0,0 +1,242 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Configuration options for evidence-weighted scoring.
/// </summary>
public sealed class EvidenceWeightPolicyOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "EvidenceWeightedScore";
/// <summary>
/// Default environment profile (e.g., "production", "development").
/// </summary>
public string DefaultEnvironment { get; set; } = "production";
/// <summary>
/// Path to the weight policy YAML file (optional, for file-based provider).
/// </summary>
public string? PolicyFilePath { get; set; }
/// <summary>
/// Whether to enable hot-reload for policy file changes.
/// </summary>
public bool EnableHotReload { get; set; } = true;
/// <summary>
/// Hot-reload polling interval in seconds.
/// </summary>
public int HotReloadIntervalSeconds { get; set; } = 30;
/// <summary>
/// Default weights for production environment.
/// </summary>
public WeightConfiguration ProductionWeights { get; set; } = new()
{
Rch = 0.35,
Rts = 0.30,
Bkp = 0.10,
Xpl = 0.15,
Src = 0.05,
Mit = 0.05
};
/// <summary>
/// Default weights for development environment.
/// </summary>
public WeightConfiguration DevelopmentWeights { get; set; } = new()
{
Rch = 0.20,
Rts = 0.15,
Bkp = 0.20,
Xpl = 0.20,
Src = 0.15,
Mit = 0.10
};
/// <summary>
/// Guardrail configuration.
/// </summary>
public GuardrailConfiguration Guardrails { get; set; } = new();
/// <summary>
/// Bucket threshold configuration.
/// </summary>
public BucketConfiguration Buckets { get; set; } = new();
}
/// <summary>
/// Weight configuration for an environment.
/// </summary>
public sealed class WeightConfiguration
{
public double Rch { get; set; } = 0.30;
public double Rts { get; set; } = 0.25;
public double Bkp { get; set; } = 0.15;
public double Xpl { get; set; } = 0.15;
public double Src { get; set; } = 0.10;
public double Mit { get; set; } = 0.10;
/// <summary>
/// Converts to EvidenceWeights record.
/// </summary>
public EvidenceWeights ToEvidenceWeights() => new()
{
Rch = Rch,
Rts = Rts,
Bkp = Bkp,
Xpl = Xpl,
Src = Src,
Mit = Mit
};
}
/// <summary>
/// Guardrail configuration options.
/// </summary>
public sealed class GuardrailConfiguration
{
public NotAffectedCapConfiguration NotAffectedCap { get; set; } = new();
public RuntimeFloorConfiguration RuntimeFloor { get; set; } = new();
public SpeculativeCapConfiguration SpeculativeCap { get; set; } = new();
/// <summary>
/// Converts to GuardrailConfig record.
/// </summary>
public GuardrailConfig ToGuardrailConfig() => new()
{
NotAffectedCap = NotAffectedCap.ToConfig(),
RuntimeFloor = RuntimeFloor.ToConfig(),
SpeculativeCap = SpeculativeCap.ToConfig()
};
}
public sealed class NotAffectedCapConfiguration
{
public bool Enabled { get; set; } = true;
public int MaxScore { get; set; } = 15;
public double RequiresBkpMin { get; set; } = 1.0;
public double RequiresRtsMax { get; set; } = 0.6;
public NotAffectedCapConfig ToConfig() => new()
{
Enabled = Enabled,
MaxScore = MaxScore,
RequiresBkpMin = RequiresBkpMin,
RequiresRtsMax = RequiresRtsMax
};
}
public sealed class RuntimeFloorConfiguration
{
public bool Enabled { get; set; } = true;
public int MinScore { get; set; } = 60;
public double RequiresRtsMin { get; set; } = 0.8;
public RuntimeFloorConfig ToConfig() => new()
{
Enabled = Enabled,
MinScore = MinScore,
RequiresRtsMin = RequiresRtsMin
};
}
public sealed class SpeculativeCapConfiguration
{
public bool Enabled { get; set; } = true;
public int MaxScore { get; set; } = 45;
public double RequiresRchMax { get; set; } = 0.0;
public double RequiresRtsMax { get; set; } = 0.0;
public SpeculativeCapConfig ToConfig() => new()
{
Enabled = Enabled,
MaxScore = MaxScore,
RequiresRchMax = RequiresRchMax,
RequiresRtsMax = RequiresRtsMax
};
}
/// <summary>
/// Bucket threshold configuration options.
/// </summary>
public sealed class BucketConfiguration
{
public int ActNowMin { get; set; } = 90;
public int ScheduleNextMin { get; set; } = 70;
public int InvestigateMin { get; set; } = 40;
/// <summary>
/// Converts to BucketThresholds record.
/// </summary>
public BucketThresholds ToBucketThresholds() => new()
{
ActNowMin = ActNowMin,
ScheduleNextMin = ScheduleNextMin,
InvestigateMin = InvestigateMin
};
}
/// <summary>
/// Policy provider backed by IOptions configuration.
/// </summary>
public sealed class OptionsEvidenceWeightPolicyProvider : IEvidenceWeightPolicyProvider
{
private readonly IOptionsMonitor<EvidenceWeightPolicyOptions> _options;
public OptionsEvidenceWeightPolicyProvider(IOptionsMonitor<EvidenceWeightPolicyOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public Task<EvidenceWeightPolicy> GetPolicyAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
// Options provider doesn't support per-tenant policies
// Fall back to environment-based defaults
return GetDefaultPolicyAsync(environment, cancellationToken);
}
public Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(
string environment,
CancellationToken cancellationToken = default)
{
var options = _options.CurrentValue;
var weights = environment.Equals("production", StringComparison.OrdinalIgnoreCase)
? options.ProductionWeights.ToEvidenceWeights()
: environment.Equals("development", StringComparison.OrdinalIgnoreCase)
? options.DevelopmentWeights.ToEvidenceWeights()
: EvidenceWeights.Default;
var policy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = environment,
Weights = weights,
Guardrails = options.Guardrails.ToGuardrailConfig(),
Buckets = options.Buckets.ToBucketThresholds()
};
return Task.FromResult(policy);
}
public Task<bool> PolicyExistsAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
// Options-based provider always has a policy for any environment
return Task.FromResult(true);
}
}

View File

@@ -0,0 +1,437 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Score bucket for quick triage categorization.
/// </summary>
public enum ScoreBucket
{
/// <summary>90-100: Act now - strong evidence of exploitable risk; immediate action required.</summary>
ActNow = 0,
/// <summary>70-89: Likely real; schedule for next sprint.</summary>
ScheduleNext = 1,
/// <summary>40-69: Moderate evidence; investigate when touching component.</summary>
Investigate = 2,
/// <summary>0-39: Low/insufficient evidence; watchlist.</summary>
Watchlist = 3
}
/// <summary>
/// Record of applied guardrails during score calculation.
/// </summary>
public sealed record AppliedGuardrails
{
/// <summary>Whether the speculative cap was applied.</summary>
public bool SpeculativeCap { get; init; }
/// <summary>Whether the not-affected cap was applied.</summary>
public bool NotAffectedCap { get; init; }
/// <summary>Whether the runtime floor was applied.</summary>
public bool RuntimeFloor { get; init; }
/// <summary>Original score before guardrails.</summary>
public int OriginalScore { get; init; }
/// <summary>Score after guardrails.</summary>
public int AdjustedScore { get; init; }
/// <summary>No guardrails applied.</summary>
public static AppliedGuardrails None(int score) => new()
{
SpeculativeCap = false,
NotAffectedCap = false,
RuntimeFloor = false,
OriginalScore = score,
AdjustedScore = score
};
/// <summary>Check if any guardrail was applied.</summary>
public bool AnyApplied => SpeculativeCap || NotAffectedCap || RuntimeFloor;
}
/// <summary>
/// Per-dimension contribution to the final score.
/// </summary>
public sealed record DimensionContribution
{
/// <summary>Dimension name (e.g., "Reachability", "Runtime").</summary>
public required string Dimension { get; init; }
/// <summary>Symbol (RCH, RTS, BKP, XPL, SRC, MIT).</summary>
public required string Symbol { get; init; }
/// <summary>Normalized input value [0, 1].</summary>
public required double InputValue { get; init; }
/// <summary>Weight applied.</summary>
public required double Weight { get; init; }
/// <summary>Contribution to raw score (weight * input, or negative for MIT).</summary>
public required double Contribution { get; init; }
/// <summary>Whether this is a subtractive dimension (like MIT).</summary>
public bool IsSubtractive { get; init; }
}
/// <summary>
/// Normalized input values echoed in result.
/// </summary>
public sealed record EvidenceInputValues(
double Rch, double Rts, double Bkp,
double Xpl, double Src, double Mit);
/// <summary>
/// Result of evidence-weighted score calculation.
/// </summary>
public sealed record EvidenceWeightedScoreResult
{
/// <summary>Finding identifier.</summary>
public required string FindingId { get; init; }
/// <summary>Final score [0, 100]. Higher = more evidence of real risk.</summary>
public required int Score { get; init; }
/// <summary>Score bucket for quick triage.</summary>
public required ScoreBucket Bucket { get; init; }
/// <summary>Normalized input values used.</summary>
public required EvidenceInputValues Inputs { get; init; }
/// <summary>Weight values used.</summary>
public required EvidenceWeights Weights { get; init; }
/// <summary>Per-dimension score contributions (breakdown).</summary>
public required IReadOnlyList<DimensionContribution> Breakdown { get; init; }
/// <summary>Active flags for badges (e.g., "live-signal", "proven-path", "vendor-na", "speculative").</summary>
public required IReadOnlyList<string> Flags { get; init; }
/// <summary>Human-readable explanations of top contributing factors.</summary>
public required IReadOnlyList<string> Explanations { get; init; }
/// <summary>Applied guardrails (caps/floors).</summary>
public required AppliedGuardrails Caps { get; init; }
/// <summary>Policy digest for determinism verification.</summary>
public required string PolicyDigest { get; init; }
/// <summary>Calculation timestamp (UTC ISO-8601).</summary>
public required DateTimeOffset CalculatedAt { get; init; }
}
/// <summary>
/// Interface for evidence-weighted score calculation.
/// </summary>
public interface IEvidenceWeightedScoreCalculator
{
/// <summary>
/// Calculates the evidence-weighted score for a finding.
/// </summary>
/// <param name="input">Normalized input values.</param>
/// <param name="policy">Weight policy to apply.</param>
/// <returns>Calculation result with score, breakdown, and explanations.</returns>
EvidenceWeightedScoreResult Calculate(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy);
}
/// <summary>
/// Evidence-weighted score calculator implementation.
/// Formula: Score = clamp01(W_rch*RCH + W_rts*RTS + W_bkp*BKP + W_xpl*XPL + W_src*SRC - W_mit*MIT) * 100
/// </summary>
public sealed class EvidenceWeightedScoreCalculator : IEvidenceWeightedScoreCalculator
{
private readonly TimeProvider _timeProvider;
public EvidenceWeightedScoreCalculator() : this(TimeProvider.System)
{
}
public EvidenceWeightedScoreCalculator(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public EvidenceWeightedScoreResult Calculate(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(policy);
// Clamp input values to ensure they're in valid range
var clampedInput = input.Clamp();
var weights = policy.Weights;
// Calculate raw score using formula
var rawScore =
weights.Rch * clampedInput.Rch +
weights.Rts * clampedInput.Rts +
weights.Bkp * clampedInput.Bkp +
weights.Xpl * clampedInput.Xpl +
weights.Src * clampedInput.Src -
weights.Mit * clampedInput.Mit; // MIT is subtractive
// Clamp to [0, 1] and scale to [0, 100]
var clampedScore = Math.Clamp(rawScore, 0.0, 1.0);
var scaledScore = (int)Math.Round(clampedScore * 100);
// Apply guardrails
var (finalScore, guardrails) = ApplyGuardrails(
scaledScore,
clampedInput,
policy.Guardrails);
// Calculate breakdown
var breakdown = CalculateBreakdown(clampedInput, weights);
// Generate flags
var flags = GenerateFlags(clampedInput, guardrails);
// Generate explanations
var explanations = GenerateExplanations(clampedInput, breakdown, guardrails);
// Determine bucket
var bucket = GetBucket(finalScore, policy.Buckets);
return new EvidenceWeightedScoreResult
{
FindingId = input.FindingId,
Score = finalScore,
Bucket = bucket,
Inputs = new EvidenceInputValues(
clampedInput.Rch, clampedInput.Rts, clampedInput.Bkp,
clampedInput.Xpl, clampedInput.Src, clampedInput.Mit),
Weights = weights,
Breakdown = breakdown,
Flags = flags,
Explanations = explanations,
Caps = guardrails,
PolicyDigest = policy.ComputeDigest(),
CalculatedAt = _timeProvider.GetUtcNow()
};
}
private static (int finalScore, AppliedGuardrails guardrails) ApplyGuardrails(
int score,
EvidenceWeightedScoreInput input,
GuardrailConfig config)
{
var originalScore = score;
var speculativeCap = false;
var notAffectedCap = false;
var runtimeFloor = false;
// Order matters: caps before floors
// 1. Speculative cap: if RCH=0 + RTS=0 → cap at configured max (default 45)
if (config.SpeculativeCap.Enabled &&
input.Rch <= config.SpeculativeCap.RequiresRchMax &&
input.Rts <= config.SpeculativeCap.RequiresRtsMax)
{
if (score > config.SpeculativeCap.MaxScore)
{
score = config.SpeculativeCap.MaxScore;
speculativeCap = true;
}
}
// 2. Not-affected cap: if BKP>=1 + not_affected + RTS<0.6 → cap at configured max (default 15)
if (config.NotAffectedCap.Enabled &&
input.Bkp >= config.NotAffectedCap.RequiresBkpMin &&
input.Rts < config.NotAffectedCap.RequiresRtsMax &&
string.Equals(input.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase))
{
if (score > config.NotAffectedCap.MaxScore)
{
score = config.NotAffectedCap.MaxScore;
notAffectedCap = true;
}
}
// 3. Runtime floor: if RTS >= 0.8 → floor at configured min (default 60)
if (config.RuntimeFloor.Enabled &&
input.Rts >= config.RuntimeFloor.RequiresRtsMin)
{
if (score < config.RuntimeFloor.MinScore)
{
score = config.RuntimeFloor.MinScore;
runtimeFloor = true;
}
}
return (score, new AppliedGuardrails
{
SpeculativeCap = speculativeCap,
NotAffectedCap = notAffectedCap,
RuntimeFloor = runtimeFloor,
OriginalScore = originalScore,
AdjustedScore = score
});
}
private static IReadOnlyList<DimensionContribution> CalculateBreakdown(
EvidenceWeightedScoreInput input,
EvidenceWeights weights)
{
return
[
new DimensionContribution
{
Dimension = "Reachability",
Symbol = "RCH",
InputValue = input.Rch,
Weight = weights.Rch,
Contribution = weights.Rch * input.Rch
},
new DimensionContribution
{
Dimension = "Runtime",
Symbol = "RTS",
InputValue = input.Rts,
Weight = weights.Rts,
Contribution = weights.Rts * input.Rts
},
new DimensionContribution
{
Dimension = "Backport",
Symbol = "BKP",
InputValue = input.Bkp,
Weight = weights.Bkp,
Contribution = weights.Bkp * input.Bkp
},
new DimensionContribution
{
Dimension = "Exploit",
Symbol = "XPL",
InputValue = input.Xpl,
Weight = weights.Xpl,
Contribution = weights.Xpl * input.Xpl
},
new DimensionContribution
{
Dimension = "Source Trust",
Symbol = "SRC",
InputValue = input.Src,
Weight = weights.Src,
Contribution = weights.Src * input.Src
},
new DimensionContribution
{
Dimension = "Mitigations",
Symbol = "MIT",
InputValue = input.Mit,
Weight = weights.Mit,
Contribution = -weights.Mit * input.Mit, // Negative because subtractive
IsSubtractive = true
}
];
}
private static IReadOnlyList<string> GenerateFlags(
EvidenceWeightedScoreInput input,
AppliedGuardrails guardrails)
{
var flags = new List<string>();
// Live signal flag
if (input.Rts >= 0.6)
flags.Add("live-signal");
// Proven path flag
if (input.Rch >= 0.7 && input.Rts >= 0.5)
flags.Add("proven-path");
// Vendor not-affected flag
if (guardrails.NotAffectedCap ||
string.Equals(input.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase))
flags.Add("vendor-na");
// Speculative flag
if (guardrails.SpeculativeCap || (input.Rch == 0 && input.Rts == 0))
flags.Add("speculative");
// High exploit probability
if (input.Xpl >= 0.5)
flags.Add("high-epss");
// Strong mitigations
if (input.Mit >= 0.7)
flags.Add("well-mitigated");
return flags;
}
private static IReadOnlyList<string> GenerateExplanations(
EvidenceWeightedScoreInput input,
IReadOnlyList<DimensionContribution> breakdown,
AppliedGuardrails guardrails)
{
var explanations = new List<string>();
// Sort by contribution magnitude (excluding MIT which is negative)
var topContributors = breakdown
.Where(d => d.Contribution > 0)
.OrderByDescending(d => d.Contribution)
.Take(2)
.ToList();
foreach (var contributor in topContributors)
{
var level = contributor.InputValue switch
{
>= 0.8 => "very high",
>= 0.6 => "high",
>= 0.4 => "moderate",
>= 0.2 => "low",
_ => "minimal"
};
explanations.Add($"{contributor.Dimension}: {level} ({contributor.InputValue:P0})");
}
// Add guardrail explanations
if (guardrails.SpeculativeCap)
explanations.Add($"Speculative cap applied: no reachability or runtime evidence (capped at {guardrails.AdjustedScore})");
if (guardrails.NotAffectedCap)
explanations.Add($"Not-affected cap applied: vendor confirms not affected (capped at {guardrails.AdjustedScore})");
if (guardrails.RuntimeFloor)
explanations.Add($"Runtime floor applied: strong live signal (floor at {guardrails.AdjustedScore})");
// Add mitigation note if significant
if (input.Mit >= 0.5)
{
explanations.Add($"Mitigations reduce effective risk ({input.Mit:P0} effectiveness)");
}
// Add detailed explanations from input if available
if (input.ReachabilityDetails is not null)
explanations.Add($"Reachability: {input.ReachabilityDetails.GetExplanation()}");
if (input.RuntimeDetails is not null)
explanations.Add($"Runtime: {input.RuntimeDetails.GetExplanation()}");
if (input.BackportDetails is not null)
explanations.Add($"Backport: {input.BackportDetails.GetExplanation()}");
if (input.ExploitDetails is not null)
explanations.Add($"Exploit: {input.ExploitDetails.GetExplanation()}");
return explanations;
}
/// <summary>
/// Determines the score bucket based on thresholds.
/// </summary>
public static ScoreBucket GetBucket(int score, BucketThresholds thresholds)
{
return score >= thresholds.ActNowMin ? ScoreBucket.ActNow
: score >= thresholds.ScheduleNextMin ? ScoreBucket.ScheduleNext
: score >= thresholds.InvestigateMin ? ScoreBucket.Investigate
: ScoreBucket.Watchlist;
}
}

View File

@@ -0,0 +1,108 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Normalized inputs for evidence-weighted score calculation.
/// All primary dimension values are [0, 1] where higher = stronger evidence.
/// </summary>
public sealed record EvidenceWeightedScoreInput
{
/// <summary>Finding identifier (CVE@PURL format or similar).</summary>
public required string FindingId { get; init; }
/// <summary>Reachability confidence [0, 1]. Higher = more reachable.</summary>
public required double Rch { get; init; }
/// <summary>Runtime signal strength [0, 1]. Higher = stronger live signal.</summary>
public required double Rts { get; init; }
/// <summary>Backport evidence [0, 1]. Higher = stronger patch proof.</summary>
public required double Bkp { get; init; }
/// <summary>Exploit likelihood [0, 1]. Higher = more likely to be exploited.</summary>
public required double Xpl { get; init; }
/// <summary>Source trust [0, 1]. Higher = more trustworthy source.</summary>
public required double Src { get; init; }
/// <summary>Mitigation effectiveness [0, 1]. Higher = stronger mitigations.</summary>
public required double Mit { get; init; }
/// <summary>VEX status for backport guardrail evaluation (e.g., "not_affected", "affected", "fixed").</summary>
public string? VexStatus { get; init; }
/// <summary>Detailed inputs for explanation generation (reachability).</summary>
public ReachabilityInput? ReachabilityDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (runtime).</summary>
public RuntimeInput? RuntimeDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (backport).</summary>
public BackportInput? BackportDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (exploit).</summary>
public ExploitInput? ExploitDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (source trust).</summary>
public SourceTrustInput? SourceTrustDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (mitigations).</summary>
public MitigationInput? MitigationDetails { get; init; }
/// <summary>
/// Validates all dimension values are within [0, 1] range.
/// </summary>
/// <returns>List of validation errors, empty if valid.</returns>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(FindingId))
errors.Add("FindingId is required");
ValidateDimension(nameof(Rch), Rch, errors);
ValidateDimension(nameof(Rts), Rts, errors);
ValidateDimension(nameof(Bkp), Bkp, errors);
ValidateDimension(nameof(Xpl), Xpl, errors);
ValidateDimension(nameof(Src), Src, errors);
ValidateDimension(nameof(Mit), Mit, errors);
return errors;
}
/// <summary>
/// Creates a clamped version of this input with all values in [0, 1].
/// </summary>
/// <returns>New input with clamped values.</returns>
public EvidenceWeightedScoreInput Clamp()
{
return this with
{
Rch = ClampValue(Rch),
Rts = ClampValue(Rts),
Bkp = ClampValue(Bkp),
Xpl = ClampValue(Xpl),
Src = ClampValue(Src),
Mit = ClampValue(Mit)
};
}
private static void ValidateDimension(string name, double value, List<string> errors)
{
if (double.IsNaN(value) || double.IsInfinity(value))
errors.Add($"{name} must be a valid number, got {value}");
else if (value < 0.0 || value > 1.0)
errors.Add($"{name} must be in range [0, 1], got {value}");
}
private static double ClampValue(double value)
{
if (double.IsNaN(value) || double.IsNegativeInfinity(value))
return 0.0;
if (double.IsPositiveInfinity(value))
return 1.0;
return Math.Clamp(value, 0.0, 1.0);
}
}

View File

@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Known Exploited Vulnerabilities (KEV) status.
/// </summary>
public enum KevStatus
{
/// <summary>Not in KEV catalog.</summary>
NotInKev = 0,
/// <summary>In KEV catalog, actively exploited.</summary>
InKev = 1,
/// <summary>Removed from KEV (remediated widely or false positive).</summary>
RemovedFromKev = 2
}
/// <summary>
/// Detailed exploit likelihood input for explanation generation.
/// </summary>
public sealed record ExploitInput
{
/// <summary>EPSS score [0, 1]. Probability of exploitation in the next 30 days.</summary>
public required double EpssScore { get; init; }
/// <summary>EPSS percentile [0, 100]. Relative rank among all CVEs.</summary>
public required double EpssPercentile { get; init; }
/// <summary>Known Exploited Vulnerabilities (KEV) catalog status.</summary>
public required KevStatus KevStatus { get; init; }
/// <summary>Date added to KEV (if applicable).</summary>
public DateTimeOffset? KevAddedDate { get; init; }
/// <summary>KEV due date for remediation (if applicable).</summary>
public DateTimeOffset? KevDueDate { get; init; }
/// <summary>Whether public exploit code is available.</summary>
public bool PublicExploitAvailable { get; init; }
/// <summary>Exploit maturity (e.g., "poc", "functional", "weaponized").</summary>
public string? ExploitMaturity { get; init; }
/// <summary>Source of EPSS data (e.g., "first.org", "stellaops-cache").</summary>
public string? EpssSource { get; init; }
/// <summary>EPSS model version.</summary>
public string? EpssModelVersion { get; init; }
/// <summary>EPSS score timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EpssTimestamp { get; init; }
/// <summary>
/// Validates the exploit input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (EpssScore < 0.0 || EpssScore > 1.0)
errors.Add($"EpssScore must be in range [0, 1], got {EpssScore}");
if (EpssPercentile < 0.0 || EpssPercentile > 100.0)
errors.Add($"EpssPercentile must be in range [0, 100], got {EpssPercentile}");
return errors;
}
/// <summary>
/// Generates a human-readable explanation of the exploit evidence.
/// </summary>
public string GetExplanation()
{
var parts = new List<string>();
// EPSS info
var epssDesc = EpssScore switch
{
>= 0.7 => $"Very high EPSS ({EpssScore:P1}, top {100 - EpssPercentile:F0}%)",
>= 0.4 => $"High EPSS ({EpssScore:P1}, top {100 - EpssPercentile:F0}%)",
>= 0.1 => $"Moderate EPSS ({EpssScore:P1})",
_ => $"Low EPSS ({EpssScore:P1})"
};
parts.Add(epssDesc);
// KEV info
if (KevStatus == KevStatus.InKev)
{
var kevInfo = "in KEV catalog";
if (KevAddedDate.HasValue)
kevInfo += $" (added {KevAddedDate.Value:yyyy-MM-dd})";
parts.Add(kevInfo);
}
// Public exploit
if (PublicExploitAvailable)
{
var maturityInfo = !string.IsNullOrEmpty(ExploitMaturity)
? $"public exploit ({ExploitMaturity})"
: "public exploit available";
parts.Add(maturityInfo);
}
return string.Join("; ", parts);
}
}

View File

@@ -0,0 +1,166 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Provider for evidence weight policies.
/// Supports multi-tenant and multi-environment scenarios.
/// </summary>
public interface IEvidenceWeightPolicyProvider
{
/// <summary>
/// Gets the weight policy for the specified tenant and environment.
/// </summary>
/// <param name="tenantId">Optional tenant identifier. Null for default/global policy.</param>
/// <param name="environment">Environment name (e.g., "production", "development").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The applicable weight policy.</returns>
Task<EvidenceWeightPolicy> GetPolicyAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the default policy for the specified environment.
/// </summary>
Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(
string environment,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a specific policy exists.
/// </summary>
Task<bool> PolicyExistsAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory policy provider for testing and development.
/// </summary>
public sealed class InMemoryEvidenceWeightPolicyProvider : IEvidenceWeightPolicyProvider
{
private readonly Dictionary<string, EvidenceWeightPolicy> _policies = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
/// <summary>
/// Adds or updates a policy.
/// </summary>
public void SetPolicy(EvidenceWeightPolicy policy)
{
var key = GetPolicyKey(policy.TenantId, policy.Profile);
lock (_lock)
{
_policies[key] = policy;
}
}
/// <summary>
/// Removes a policy.
/// </summary>
public bool RemovePolicy(string? tenantId, string environment)
{
var key = GetPolicyKey(tenantId, environment);
lock (_lock)
{
return _policies.Remove(key);
}
}
/// <summary>
/// Clears all policies.
/// </summary>
public void Clear()
{
lock (_lock)
{
_policies.Clear();
}
}
public Task<EvidenceWeightPolicy> GetPolicyAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
// Try tenant-specific first, then fall back to global
var tenantKey = GetPolicyKey(tenantId, environment);
var globalKey = GetPolicyKey(null, environment);
lock (_lock)
{
if (_policies.TryGetValue(tenantKey, out var tenantPolicy))
return Task.FromResult(tenantPolicy);
if (_policies.TryGetValue(globalKey, out var globalPolicy))
return Task.FromResult(globalPolicy);
}
// Return default if nothing found
return Task.FromResult(CreateDefaultPolicy(environment));
}
public Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(
string environment,
CancellationToken cancellationToken = default)
{
return GetPolicyAsync(null, environment, cancellationToken);
}
public Task<bool> PolicyExistsAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var key = GetPolicyKey(tenantId, environment);
lock (_lock)
{
return Task.FromResult(_policies.ContainsKey(key));
}
}
private static string GetPolicyKey(string? tenantId, string environment)
{
return string.IsNullOrEmpty(tenantId)
? $"__global__:{environment}"
: $"{tenantId}:{environment}";
}
private static EvidenceWeightPolicy CreateDefaultPolicy(string environment)
{
var weights = environment.Equals("production", StringComparison.OrdinalIgnoreCase)
? new EvidenceWeights
{
Rch = 0.35,
Rts = 0.30,
Bkp = 0.10,
Xpl = 0.15,
Src = 0.05,
Mit = 0.05
}
: environment.Equals("development", StringComparison.OrdinalIgnoreCase)
? new EvidenceWeights
{
Rch = 0.20,
Rts = 0.15,
Bkp = 0.20,
Xpl = 0.20,
Src = 0.15,
Mit = 0.10
}
: EvidenceWeights.Default;
return new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = environment,
Weights = weights
};
}
}

View File

@@ -0,0 +1,182 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Type of mitigation control.
/// </summary>
public enum MitigationType
{
/// <summary>Unknown mitigation type.</summary>
Unknown = 0,
/// <summary>Network-level control (WAF, firewall rules).</summary>
NetworkControl = 1,
/// <summary>Runtime feature flag (code disabled).</summary>
FeatureFlag = 2,
/// <summary>Seccomp/AppArmor/SELinux policy.</summary>
SecurityPolicy = 3,
/// <summary>Sandbox/container isolation.</summary>
Isolation = 4,
/// <summary>Rate limiting or input validation.</summary>
InputValidation = 5,
/// <summary>Authentication/authorization requirement.</summary>
AuthRequired = 6,
/// <summary>Virtual patching (IDS/IPS rule).</summary>
VirtualPatch = 7,
/// <summary>Complete removal of vulnerable component.</summary>
ComponentRemoval = 8
}
/// <summary>
/// Active mitigation control.
/// </summary>
public sealed record ActiveMitigation
{
/// <summary>Mitigation type.</summary>
public required MitigationType Type { get; init; }
/// <summary>Mitigation identifier or name.</summary>
public string? Name { get; init; }
/// <summary>Effectiveness of this mitigation [0, 1].</summary>
public required double Effectiveness { get; init; }
/// <summary>Whether the mitigation has been verified active.</summary>
public bool Verified { get; init; }
/// <summary>Source of mitigation evidence.</summary>
public string? EvidenceSource { get; init; }
/// <summary>
/// Validates the mitigation.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (Effectiveness < 0.0 || Effectiveness > 1.0)
errors.Add($"Effectiveness must be in range [0, 1], got {Effectiveness}");
return errors;
}
}
/// <summary>
/// Detailed mitigation input for explanation generation.
/// </summary>
public sealed record MitigationInput
{
/// <summary>List of active mitigations.</summary>
public required IReadOnlyList<ActiveMitigation> ActiveMitigations { get; init; }
/// <summary>Combined effectiveness score [0, 1] (pre-computed or from formula).</summary>
public required double CombinedEffectiveness { get; init; }
/// <summary>Whether mitigations have been verified in runtime.</summary>
public bool RuntimeVerified { get; init; }
/// <summary>Evidence timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EvidenceTimestamp { get; init; }
/// <summary>Source of mitigation assessment.</summary>
public string? AssessmentSource { get; init; }
/// <summary>
/// Validates the mitigation input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (CombinedEffectiveness < 0.0 || CombinedEffectiveness > 1.0)
errors.Add($"CombinedEffectiveness must be in range [0, 1], got {CombinedEffectiveness}");
foreach (var mitigation in ActiveMitigations)
{
var mitigationErrors = mitigation.Validate();
errors.AddRange(mitigationErrors);
}
return errors;
}
/// <summary>
/// Calculates combined effectiveness using diminishing returns formula.
/// Each additional mitigation has decreasing marginal effectiveness.
/// </summary>
/// <returns>Combined effectiveness [0, 1].</returns>
public static double CalculateCombinedEffectiveness(IReadOnlyList<ActiveMitigation> mitigations)
{
if (mitigations.Count == 0)
return 0.0;
// Sort by effectiveness descending for stable ordering
var sorted = mitigations
.OrderByDescending(m => m.Effectiveness)
.ThenBy(m => m.Name ?? "", StringComparer.Ordinal)
.ToList();
// Diminishing returns: combined = 1 - Π(1 - e_i)
// Each mitigation reduces remaining risk multiplicatively
var remainingRisk = 1.0;
foreach (var mitigation in sorted)
{
remainingRisk *= (1.0 - mitigation.Effectiveness);
}
return Math.Clamp(1.0 - remainingRisk, 0.0, 1.0);
}
/// <summary>
/// Generates a human-readable explanation of the mitigations.
/// </summary>
public string GetExplanation()
{
if (ActiveMitigations.Count == 0)
return "No active mitigations";
var verifiedCount = ActiveMitigations.Count(m => m.Verified);
var totalCount = ActiveMitigations.Count;
var typeGroups = ActiveMitigations
.GroupBy(m => m.Type)
.Select(g => GetMitigationTypeDescription(g.Key))
.Distinct()
.Take(3);
var typeSummary = string.Join(", ", typeGroups);
var verificationInfo = RuntimeVerified
? " (runtime verified)"
: verifiedCount > 0
? $" ({verifiedCount}/{totalCount} verified)"
: "";
return $"{totalCount} active mitigation(s): {typeSummary}, {CombinedEffectiveness:P0} combined effectiveness{verificationInfo}";
}
private static string GetMitigationTypeDescription(MitigationType type)
{
return type switch
{
MitigationType.NetworkControl => "network control",
MitigationType.FeatureFlag => "feature flag",
MitigationType.SecurityPolicy => "security policy",
MitigationType.Isolation => "isolation",
MitigationType.InputValidation => "input validation",
MitigationType.AuthRequired => "auth required",
MitigationType.VirtualPatch => "virtual patch",
MitigationType.ComponentRemoval => "component removed",
_ => "unknown"
};
}
}

View File

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Reachability state from static/dynamic analysis.
/// </summary>
public enum ReachabilityState
{
/// <summary>No reachability data available.</summary>
Unknown = 0,
/// <summary>Definitely not reachable.</summary>
NotReachable = 1,
/// <summary>Potentially reachable (conservative analysis).</summary>
PotentiallyReachable = 2,
/// <summary>Confirmed reachable via static analysis.</summary>
StaticReachable = 3,
/// <summary>Confirmed reachable via dynamic analysis.</summary>
DynamicReachable = 4,
/// <summary>Live exploit path observed.</summary>
LiveExploitPath = 5
}
/// <summary>
/// Detailed reachability input for explanation generation.
/// </summary>
public sealed record ReachabilityInput
{
/// <summary>Current reachability state.</summary>
public required ReachabilityState State { get; init; }
/// <summary>Confidence score [0, 1] from the analysis.</summary>
public required double Confidence { get; init; }
/// <summary>Number of hops from entry point to vulnerable sink (0 = direct).</summary>
public int HopCount { get; init; }
/// <summary>Whether analysis includes inter-procedural flow.</summary>
public bool HasInterproceduralFlow { get; init; }
/// <summary>Whether analysis includes taint tracking.</summary>
public bool HasTaintTracking { get; init; }
/// <summary>Whether analysis includes data-flow sensitivity.</summary>
public bool HasDataFlowSensitivity { get; init; }
/// <summary>Analysis method used (e.g., "call-graph", "taint-tracking", "symbolic-execution").</summary>
public string? AnalysisMethod { get; init; }
/// <summary>Source of reachability evidence (e.g., "codeql", "semgrep", "stellaops-native").</summary>
public string? EvidenceSource { get; init; }
/// <summary>Evidence timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EvidenceTimestamp { get; init; }
/// <summary>
/// Validates the reachability input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (Confidence < 0.0 || Confidence > 1.0)
errors.Add($"Confidence must be in range [0, 1], got {Confidence}");
if (HopCount < 0)
errors.Add($"HopCount must be non-negative, got {HopCount}");
return errors;
}
/// <summary>
/// Generates a human-readable explanation of the reachability evidence.
/// </summary>
public string GetExplanation()
{
var stateDesc = State switch
{
ReachabilityState.Unknown => "No reachability data available",
ReachabilityState.NotReachable => "Confirmed not reachable",
ReachabilityState.PotentiallyReachable => "Potentially reachable",
ReachabilityState.StaticReachable => "Statically reachable",
ReachabilityState.DynamicReachable => "Dynamically confirmed reachable",
ReachabilityState.LiveExploitPath => "Live exploit path observed",
_ => $"Unknown state ({State})"
};
var hopInfo = HopCount switch
{
0 => "direct path",
1 => "1 hop away",
_ => $"{HopCount} hops away"
};
var analysisFlags = new List<string>();
if (HasInterproceduralFlow) analysisFlags.Add("interprocedural");
if (HasTaintTracking) analysisFlags.Add("taint-tracked");
if (HasDataFlowSensitivity) analysisFlags.Add("data-flow");
var analysis = analysisFlags.Count > 0
? $" ({string.Join(", ", analysisFlags)})"
: "";
return $"{stateDesc}, {hopInfo}, {Confidence:P0} confidence{analysis}";
}
}

View File

@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Runtime observation posture.
/// </summary>
public enum RuntimePosture
{
/// <summary>No runtime observation.</summary>
None = 0,
/// <summary>Passive monitoring (logs, metrics).</summary>
Passive = 1,
/// <summary>Active tracing (syscalls, ETW, dtrace).</summary>
ActiveTracing = 2,
/// <summary>eBPF-based deep observation.</summary>
EbpfDeep = 3,
/// <summary>Full coverage instrumentation.</summary>
FullInstrumentation = 4
}
/// <summary>
/// Detailed runtime signal input for explanation generation.
/// </summary>
public sealed record RuntimeInput
{
/// <summary>Current observation posture.</summary>
public required RuntimePosture Posture { get; init; }
/// <summary>Number of code path observations.</summary>
public required int ObservationCount { get; init; }
/// <summary>Most recent observation timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? LastObservation { get; init; }
/// <summary>Observation recency factor [0, 1]. 1 = within last 24h, decays over time.</summary>
public required double RecencyFactor { get; init; }
/// <summary>Observed session digests (for cross-session correlation).</summary>
public IReadOnlyList<string>? SessionDigests { get; init; }
/// <summary>Whether the vulnerable code path was directly observed.</summary>
public bool DirectPathObserved { get; init; }
/// <summary>Whether the observation was in production traffic.</summary>
public bool IsProductionTraffic { get; init; }
/// <summary>Source of runtime evidence (e.g., "ebpf-sensor", "dyld-trace", "etw-provider").</summary>
public string? EvidenceSource { get; init; }
/// <summary>Correlation ID linking to runtime evidence.</summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Validates the runtime input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (ObservationCount < 0)
errors.Add($"ObservationCount must be non-negative, got {ObservationCount}");
if (RecencyFactor < 0.0 || RecencyFactor > 1.0)
errors.Add($"RecencyFactor must be in range [0, 1], got {RecencyFactor}");
return errors;
}
/// <summary>
/// Generates a human-readable explanation of the runtime evidence.
/// </summary>
public string GetExplanation()
{
if (Posture == RuntimePosture.None || ObservationCount == 0)
return "No runtime observations";
var postureDesc = Posture switch
{
RuntimePosture.Passive => "passive monitoring",
RuntimePosture.ActiveTracing => "active tracing",
RuntimePosture.EbpfDeep => "eBPF deep observation",
RuntimePosture.FullInstrumentation => "full instrumentation",
_ => $"unknown posture ({Posture})"
};
var pathInfo = DirectPathObserved
? "vulnerable path directly observed"
: "related code executed";
var trafficInfo = IsProductionTraffic
? " in production"
: "";
var recencyInfo = RecencyFactor switch
{
>= 0.9 => " (recent)",
>= 0.5 => " (moderate age)",
_ => " (old)"
};
return $"{ObservationCount} observations via {postureDesc}, {pathInfo}{trafficInfo}{recencyInfo}";
}
}

View File

@@ -0,0 +1,148 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// VEX/advisory issuer type.
/// </summary>
public enum IssuerType
{
/// <summary>Unknown or unverified source.</summary>
Unknown = 0,
/// <summary>Community/crowd-sourced advisory.</summary>
Community = 1,
/// <summary>Security researcher or organization.</summary>
SecurityResearcher = 2,
/// <summary>Linux distribution (Debian, RedHat, Ubuntu, etc.).</summary>
Distribution = 3,
/// <summary>Upstream project maintainer.</summary>
Upstream = 4,
/// <summary>Commercial software vendor.</summary>
Vendor = 5,
/// <summary>CVE Numbering Authority (CNA).</summary>
Cna = 6,
/// <summary>CISA or government agency.</summary>
GovernmentAgency = 7
}
/// <summary>
/// Detailed source trust input for explanation generation.
/// </summary>
public sealed record SourceTrustInput
{
/// <summary>Issuer type for the VEX/advisory.</summary>
public required IssuerType IssuerType { get; init; }
/// <summary>Issuer identifier (e.g., "debian-security", "redhat-psirt").</summary>
public string? IssuerId { get; init; }
/// <summary>Provenance trust factor [0, 1]. Higher = better attestation chain.</summary>
public required double ProvenanceTrust { get; init; }
/// <summary>Coverage completeness [0, 1]. Higher = more complete analysis.</summary>
public required double CoverageCompleteness { get; init; }
/// <summary>Replayability factor [0, 1]. Higher = more reproducible.</summary>
public required double Replayability { get; init; }
/// <summary>Whether the source is cryptographically attested (DSSE/in-toto).</summary>
public bool IsCryptographicallyAttested { get; init; }
/// <summary>Whether the source has been independently verified.</summary>
public bool IndependentlyVerified { get; init; }
/// <summary>Historical accuracy of this source [0, 1] (if known).</summary>
public double? HistoricalAccuracy { get; init; }
/// <summary>Number of corroborating sources.</summary>
public int CorroboratingSourceCount { get; init; }
/// <summary>
/// Validates the source trust input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (ProvenanceTrust < 0.0 || ProvenanceTrust > 1.0)
errors.Add($"ProvenanceTrust must be in range [0, 1], got {ProvenanceTrust}");
if (CoverageCompleteness < 0.0 || CoverageCompleteness > 1.0)
errors.Add($"CoverageCompleteness must be in range [0, 1], got {CoverageCompleteness}");
if (Replayability < 0.0 || Replayability > 1.0)
errors.Add($"Replayability must be in range [0, 1], got {Replayability}");
if (HistoricalAccuracy.HasValue && (HistoricalAccuracy < 0.0 || HistoricalAccuracy > 1.0))
errors.Add($"HistoricalAccuracy must be in range [0, 1], got {HistoricalAccuracy}");
if (CorroboratingSourceCount < 0)
errors.Add($"CorroboratingSourceCount must be non-negative, got {CorroboratingSourceCount}");
return errors;
}
/// <summary>
/// Calculates the combined trust vector score [0, 1].
/// </summary>
public double GetCombinedTrustScore()
{
// Weighted combination: provenance most important, then coverage, then replayability
const double wProvenance = 0.5;
const double wCoverage = 0.3;
const double wReplay = 0.2;
return wProvenance * ProvenanceTrust +
wCoverage * CoverageCompleteness +
wReplay * Replayability;
}
/// <summary>
/// Generates a human-readable explanation of the source trust.
/// </summary>
public string GetExplanation()
{
var issuerDesc = IssuerType switch
{
IssuerType.Unknown => "unknown source",
IssuerType.Community => "community source",
IssuerType.SecurityResearcher => "security researcher",
IssuerType.Distribution => "distribution maintainer",
IssuerType.Upstream => "upstream project",
IssuerType.Vendor => "software vendor",
IssuerType.Cna => "CVE Numbering Authority",
IssuerType.GovernmentAgency => "government agency",
_ => $"unknown type ({IssuerType})"
};
var parts = new List<string> { issuerDesc };
if (IsCryptographicallyAttested)
parts.Add("cryptographically attested");
if (IndependentlyVerified)
parts.Add("independently verified");
if (CorroboratingSourceCount > 0)
parts.Add($"{CorroboratingSourceCount} corroborating source(s)");
var trustScore = GetCombinedTrustScore();
var trustLevel = trustScore switch
{
>= 0.8 => "high trust",
>= 0.5 => "moderate trust",
_ => "low trust"
};
parts.Add(trustLevel);
return string.Join(", ", parts);
}
}