sprints work
This commit is contained in:
@@ -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)";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user