Add comprehensive security tests for OWASP A02, A05, A07, and A08 categories
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled

- Implemented tests for Cryptographic Failures (A02) to ensure proper handling of sensitive data, secure algorithms, and key management.
- Added tests for Security Misconfiguration (A05) to validate production configurations, security headers, CORS settings, and feature management.
- Developed tests for Authentication Failures (A07) to enforce strong password policies, rate limiting, session management, and MFA support.
- Created tests for Software and Data Integrity Failures (A08) to verify artifact signatures, SBOM integrity, attestation chains, and feed updates.
This commit is contained in:
master
2025-12-16 16:40:19 +02:00
parent 415eff1207
commit 2170a58734
206 changed files with 30547 additions and 534 deletions

View File

@@ -0,0 +1,460 @@
// -----------------------------------------------------------------------------
// AdvancedScoringEngine.cs
// Sprint: SPRINT_3407_0001_0001_configurable_scoring
// Task: PROF-3407-004
// Description: Advanced entropy-based + CVSS hybrid scoring engine
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Scoring;
namespace StellaOps.Policy.Engine.Scoring.Engines;
/// <summary>
/// Advanced entropy-based + CVSS hybrid scoring engine.
/// Uses uncertainty tiers, entropy penalties, and CVSS v4.0 receipts.
/// This is the default scoring engine.
/// </summary>
public sealed class AdvancedScoringEngine : IScoringEngine
{
private readonly EvidenceFreshnessCalculator _freshnessCalculator;
private readonly ILogger<AdvancedScoringEngine> _logger;
public ScoringProfile Profile => ScoringProfile.Advanced;
public AdvancedScoringEngine(
EvidenceFreshnessCalculator freshnessCalculator,
ILogger<AdvancedScoringEngine> logger)
{
_freshnessCalculator = freshnessCalculator ?? throw new ArgumentNullException(nameof(freshnessCalculator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<ScoringEngineResult> ScoreAsync(
ScoringInput input,
ScorePolicy policy,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(policy);
var explain = new ScoreExplainBuilder();
var weights = policy.WeightsBps;
// 1. Base Severity with CVSS entropy consideration
var baseSeverity = CalculateAdvancedBaseSeverity(input, explain);
// 2. Reachability with semantic analysis
var reachability = CalculateAdvancedReachability(input.Reachability, policy, explain);
// 3. Evidence with uncertainty tiers
var evidence = CalculateAdvancedEvidence(input.Evidence, input.AsOf, policy, explain);
// 4. Provenance with attestation weighting
var provenance = CalculateAdvancedProvenance(input.Provenance, policy, explain);
// Apply KEV boost if applicable
var kevBoost = 0;
if (input.IsKnownExploited)
{
kevBoost = 20; // Boost for known exploited vulnerabilities
explain.Add("kev_boost", kevBoost, "Known exploited vulnerability (KEV) boost");
}
// Final score calculation with entropy penalty
var rawScoreLong =
((long)weights.BaseSeverity * baseSeverity) +
((long)weights.Reachability * reachability) +
((long)weights.Evidence * evidence) +
((long)weights.Provenance * provenance);
var rawScore = (int)(rawScoreLong / 10000) + kevBoost;
rawScore = Math.Clamp(rawScore, 0, 100);
// Apply uncertainty penalty
var uncertaintyPenalty = CalculateUncertaintyPenalty(input, explain);
var penalizedScore = Math.Max(0, rawScore - uncertaintyPenalty);
// Apply overrides
var (finalScore, appliedOverride) = ApplyOverrides(
penalizedScore, reachability, evidence, input.IsKnownExploited, policy);
var signalValues = new Dictionary<string, int>
{
["baseSeverity"] = baseSeverity,
["reachability"] = reachability,
["evidence"] = evidence,
["provenance"] = provenance,
["kevBoost"] = kevBoost,
["uncertaintyPenalty"] = uncertaintyPenalty
};
var signalContributions = new Dictionary<string, double>
{
["baseSeverity"] = (weights.BaseSeverity * baseSeverity) / 10000.0,
["reachability"] = (weights.Reachability * reachability) / 10000.0,
["evidence"] = (weights.Evidence * evidence) / 10000.0,
["provenance"] = (weights.Provenance * provenance) / 10000.0,
["kevBoost"] = kevBoost,
["uncertaintyPenalty"] = -uncertaintyPenalty
};
var result = new ScoringEngineResult
{
FindingId = input.FindingId,
ProfileId = input.ProfileId,
ProfileVersion = "advanced-v1",
RawScore = rawScore,
FinalScore = finalScore,
Severity = MapToSeverity(finalScore),
SignalValues = signalValues,
SignalContributions = signalContributions,
OverrideApplied = appliedOverride,
OverrideReason = appliedOverride is not null ? $"Override applied: {appliedOverride}" : null,
ScoringProfile = ScoringProfile.Advanced,
ScoredAt = input.AsOf,
Explain = explain.Build()
};
_logger.LogDebug(
"Advanced score for {FindingId}: B={B}, R={R}, E={E}, P={P}, KEV={KEV}, Penalty={Penalty} -> Raw={RawScore}, Final={FinalScore}",
input.FindingId, baseSeverity, reachability, evidence, provenance, kevBoost, uncertaintyPenalty, rawScore, finalScore);
return Task.FromResult(result);
}
private int CalculateAdvancedBaseSeverity(
ScoringInput input,
ScoreExplainBuilder explain)
{
// Base severity from CVSS
var baseSeverity = (int)Math.Round(input.CvssBase * 10);
// Apply version-specific adjustments
var versionMultiplier = input.CvssVersion switch
{
"4.0" => 10000, // No adjustment for CVSS 4.0
"3.1" => 9500, // Slight reduction for older versions
"3.0" => 9000,
"2.0" => 8500,
_ => 9000 // Default for unknown versions
};
var adjustedSeverity = (baseSeverity * versionMultiplier) / 10000;
adjustedSeverity = Math.Clamp(adjustedSeverity, 0, 100);
var versionInfo = input.CvssVersion ?? "unknown";
explain.Add("baseSeverity", adjustedSeverity,
$"CVSS {input.CvssBase:F1} (v{versionInfo}) with version adjustment");
return adjustedSeverity;
}
private int CalculateAdvancedReachability(
ReachabilityInput input,
ScorePolicy policy,
ScoreExplainBuilder explain)
{
// Use advanced score if available
if (input.AdvancedScore.HasValue)
{
var advScore = (int)Math.Round(input.AdvancedScore.Value * 100);
advScore = Math.Clamp(advScore, 0, 100);
var category = input.Category ?? "computed";
explain.Add("reachability", advScore, $"Advanced reachability: {category}");
return advScore;
}
var config = policy.Reachability ?? ReachabilityPolicyConfig.Default;
// Fall back to hop-based scoring
int bucketScore;
if (input.HopCount is null)
{
bucketScore = config.UnreachableScore;
explain.AddReachability(-1, bucketScore, "unreachable");
}
else
{
var hops = input.HopCount.Value;
// Apply semantic category boost/penalty
var categoryMultiplier = input.Category?.ToLowerInvariant() switch
{
"direct_entrypoint" => 12000, // 120% - Direct entry points are high risk
"api_endpoint" => 11000, // 110% - API endpoints are high risk
"internal_service" => 9000, // 90% - Internal services lower risk
"dead_code" => 2000, // 20% - Dead code very low risk
_ => 10000 // 100% - Default
};
bucketScore = GetBucketScore(hops, config.HopBuckets);
bucketScore = (bucketScore * categoryMultiplier) / 10000;
bucketScore = Math.Clamp(bucketScore, 0, 100);
explain.AddReachability(hops, bucketScore, input.Category ?? "call graph");
}
// Apply gate multiplier if gates present
if (input.Gates is { Count: > 0 })
{
var gateMultiplier = CalculateGateMultiplierBps(input.Gates, config.GateMultipliersBps);
bucketScore = (bucketScore * gateMultiplier) / 10000;
var primaryGate = input.Gates.OrderByDescending(g => g.Confidence).First();
explain.Add("gate", gateMultiplier / 100,
$"Gate: {primaryGate.Type}" + (primaryGate.Detail is not null ? $" ({primaryGate.Detail})" : ""));
}
return bucketScore;
}
private int CalculateAdvancedEvidence(
EvidenceInput input,
DateTimeOffset asOf,
ScorePolicy policy,
ScoreExplainBuilder explain)
{
// Use advanced score if available
if (input.AdvancedScore.HasValue)
{
var advScore = (int)Math.Round(input.AdvancedScore.Value * 100);
advScore = Math.Clamp(advScore, 0, 100);
explain.Add("evidence", advScore, "Advanced evidence score");
return advScore;
}
var config = policy.Evidence ?? EvidencePolicyConfig.Default;
var points = config.Points ?? EvidencePoints.Default;
// Sum evidence points with overlap bonus
var totalPoints = 0;
var typeCount = 0;
foreach (var type in input.Types)
{
totalPoints += type switch
{
EvidenceType.Runtime => points.Runtime,
EvidenceType.Dast => points.Dast,
EvidenceType.Sast => points.Sast,
EvidenceType.Sca => points.Sca,
_ => 0
};
typeCount++;
}
// Multi-evidence overlap bonus (10% per additional type beyond first)
if (typeCount > 1)
{
var overlapBonus = (totalPoints * (typeCount - 1) * 1000) / 10000;
totalPoints += overlapBonus;
}
totalPoints = Math.Min(100, totalPoints);
// Apply freshness multiplier
var freshnessMultiplier = 10000;
var ageDays = 0;
if (input.NewestEvidenceAt.HasValue)
{
ageDays = Math.Max(0, (int)(asOf - input.NewestEvidenceAt.Value).TotalDays);
freshnessMultiplier = _freshnessCalculator.CalculateMultiplierBps(
input.NewestEvidenceAt.Value, asOf);
}
var finalEvidence = (totalPoints * freshnessMultiplier) / 10000;
explain.AddEvidence(totalPoints, freshnessMultiplier, ageDays);
return finalEvidence;
}
private int CalculateAdvancedProvenance(
ProvenanceInput input,
ScorePolicy policy,
ScoreExplainBuilder explain)
{
// Use advanced score if available
if (input.AdvancedScore.HasValue)
{
var advScore = (int)Math.Round(input.AdvancedScore.Value * 100);
advScore = Math.Clamp(advScore, 0, 100);
explain.Add("provenance", advScore, "Advanced provenance score");
return advScore;
}
var config = policy.Provenance ?? ProvenancePolicyConfig.Default;
var levels = config.Levels ?? ProvenanceLevels.Default;
var score = input.Level switch
{
ProvenanceLevel.Unsigned => levels.Unsigned,
ProvenanceLevel.Signed => levels.Signed,
ProvenanceLevel.SignedWithSbom => levels.SignedWithSbom,
ProvenanceLevel.SignedWithSbomAndAttestations => levels.SignedWithSbomAndAttestations,
ProvenanceLevel.Reproducible => levels.Reproducible,
_ => levels.Unsigned
};
explain.AddProvenance(input.Level.ToString(), score);
return score;
}
private int CalculateUncertaintyPenalty(
ScoringInput input,
ScoreExplainBuilder explain)
{
var penalty = 0;
// Penalty for missing reachability data
if (input.Reachability.HopCount is null &&
input.Reachability.AdvancedScore is null)
{
penalty += 5;
}
// Penalty for no evidence
if (input.Evidence.Types.Count == 0 &&
input.Evidence.AdvancedScore is null)
{
penalty += 10;
}
// Penalty for unsigned provenance
if (input.Provenance.Level == ProvenanceLevel.Unsigned &&
input.Provenance.AdvancedScore is null)
{
penalty += 5;
}
// Penalty for missing CVSS version
if (string.IsNullOrEmpty(input.CvssVersion))
{
penalty += 3;
}
if (penalty > 0)
{
explain.Add("uncertainty_penalty", -penalty, $"Uncertainty penalty for missing data");
}
return penalty;
}
private static int GetBucketScore(int hops, IReadOnlyList<HopBucket>? buckets)
{
if (buckets is null or { Count: 0 })
{
return hops switch
{
0 => 100,
1 => 90,
<= 3 => 70,
<= 5 => 50,
<= 10 => 30,
_ => 10
};
}
foreach (var bucket in buckets)
{
if (hops <= bucket.MaxHops)
{
return bucket.Score;
}
}
return buckets[^1].Score;
}
private static int CalculateGateMultiplierBps(
IReadOnlyList<DetectedGate> gates,
GateMultipliersBps? config)
{
config ??= GateMultipliersBps.Default;
var lowestMultiplier = 10000;
foreach (var gate in gates)
{
var multiplier = gate.Type.ToLowerInvariant() switch
{
"feature_flag" or "featureflag" => config.FeatureFlag,
"auth_required" or "authrequired" => config.AuthRequired,
"admin_only" or "adminonly" => config.AdminOnly,
"non_default_config" or "nondefaultconfig" => config.NonDefaultConfig,
_ => 10000
};
var weightedMultiplier = (int)(multiplier + ((10000 - multiplier) * (1.0 - gate.Confidence)));
lowestMultiplier = Math.Min(lowestMultiplier, weightedMultiplier);
}
return lowestMultiplier;
}
private static (int Score, string? Override) ApplyOverrides(
int score,
int reachability,
int evidence,
bool isKnownExploited,
ScorePolicy policy)
{
if (policy.Overrides is null or { Count: 0 })
return (score, null);
foreach (var rule in policy.Overrides)
{
if (!MatchesCondition(rule.When, reachability, evidence, isKnownExploited))
continue;
if (rule.SetScore.HasValue)
return (rule.SetScore.Value, rule.Name);
if (rule.ClampMaxScore.HasValue && score > rule.ClampMaxScore.Value)
return (rule.ClampMaxScore.Value, $"{rule.Name} (clamped)");
if (rule.ClampMinScore.HasValue && score < rule.ClampMinScore.Value)
return (rule.ClampMinScore.Value, $"{rule.Name} (clamped)");
}
return (score, null);
}
private static bool MatchesCondition(
ScoreOverrideCondition condition,
int reachability,
int evidence,
bool isKnownExploited)
{
if (condition.Flags?.TryGetValue("knownExploited", out var kevRequired) == true)
{
if (kevRequired != isKnownExploited)
return false;
}
if (condition.MinReachability.HasValue && reachability < condition.MinReachability.Value)
return false;
if (condition.MaxReachability.HasValue && reachability > condition.MaxReachability.Value)
return false;
if (condition.MinEvidence.HasValue && evidence < condition.MinEvidence.Value)
return false;
if (condition.MaxEvidence.HasValue && evidence > condition.MaxEvidence.Value)
return false;
return true;
}
private static string MapToSeverity(int score) => score switch
{
>= 90 => "critical",
>= 70 => "high",
>= 40 => "medium",
>= 20 => "low",
_ => "info"
};
}

View File

@@ -0,0 +1,326 @@
// -----------------------------------------------------------------------------
// SimpleScoringEngine.cs
// Sprint: SPRINT_3407_0001_0001_configurable_scoring
// Task: PROF-3407-003
// Description: Simple 4-factor basis-points scoring engine
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Scoring;
namespace StellaOps.Policy.Engine.Scoring.Engines;
/// <summary>
/// Simple 4-factor basis-points scoring engine.
/// Formula: riskScore = (wB*B + wR*R + wE*E + wP*P) / 10000
/// </summary>
public sealed class SimpleScoringEngine : IScoringEngine
{
private readonly EvidenceFreshnessCalculator _freshnessCalculator;
private readonly ILogger<SimpleScoringEngine> _logger;
public ScoringProfile Profile => ScoringProfile.Simple;
public SimpleScoringEngine(
EvidenceFreshnessCalculator freshnessCalculator,
ILogger<SimpleScoringEngine> logger)
{
_freshnessCalculator = freshnessCalculator ?? throw new ArgumentNullException(nameof(freshnessCalculator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<ScoringEngineResult> ScoreAsync(
ScoringInput input,
ScorePolicy policy,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(policy);
var explain = new ScoreExplainBuilder();
var weights = policy.WeightsBps;
// 1. Base Severity: B = round(CVSS * 10)
var baseSeverity = (int)Math.Round(input.CvssBase * 10);
baseSeverity = Math.Clamp(baseSeverity, 0, 100);
explain.AddBaseSeverity(input.CvssBase, baseSeverity);
// 2. Reachability: R = bucketScore * gateMultiplier / 10000
var reachability = CalculateReachability(input.Reachability, policy, explain);
// 3. Evidence: E = min(100, sum(points)) * freshness / 10000
var evidence = CalculateEvidence(input.Evidence, input.AsOf, policy, explain);
// 4. Provenance: P = level score
var provenance = CalculateProvenance(input.Provenance, policy, explain);
// Final score: (wB*B + wR*R + wE*E + wP*P) / 10000
var rawScoreLong =
((long)weights.BaseSeverity * baseSeverity) +
((long)weights.Reachability * reachability) +
((long)weights.Evidence * evidence) +
((long)weights.Provenance * provenance);
var rawScore = (int)(rawScoreLong / 10000);
rawScore = Math.Clamp(rawScore, 0, 100);
// Apply overrides
var (finalScore, appliedOverride) = ApplyOverrides(
rawScore, reachability, evidence, input.IsKnownExploited, policy);
var signalValues = new Dictionary<string, int>
{
["baseSeverity"] = baseSeverity,
["reachability"] = reachability,
["evidence"] = evidence,
["provenance"] = provenance
};
var signalContributions = new Dictionary<string, double>
{
["baseSeverity"] = (weights.BaseSeverity * baseSeverity) / 10000.0,
["reachability"] = (weights.Reachability * reachability) / 10000.0,
["evidence"] = (weights.Evidence * evidence) / 10000.0,
["provenance"] = (weights.Provenance * provenance) / 10000.0
};
var result = new ScoringEngineResult
{
FindingId = input.FindingId,
ProfileId = input.ProfileId,
ProfileVersion = "simple-v1",
RawScore = rawScore,
FinalScore = finalScore,
Severity = MapToSeverity(finalScore),
SignalValues = signalValues,
SignalContributions = signalContributions,
OverrideApplied = appliedOverride,
OverrideReason = appliedOverride is not null ? $"Override applied: {appliedOverride}" : null,
ScoringProfile = ScoringProfile.Simple,
ScoredAt = input.AsOf,
Explain = explain.Build()
};
_logger.LogDebug(
"Simple score for {FindingId}: B={B}, R={R}, E={E}, P={P} -> Raw={RawScore}, Final={FinalScore} (override: {Override})",
input.FindingId, baseSeverity, reachability, evidence, provenance, rawScore, finalScore, appliedOverride);
return Task.FromResult(result);
}
private int CalculateReachability(
ReachabilityInput input,
ScorePolicy policy,
ScoreExplainBuilder explain)
{
var config = policy.Reachability ?? ReachabilityPolicyConfig.Default;
// Get bucket score
int bucketScore;
if (input.HopCount is null)
{
bucketScore = config.UnreachableScore;
explain.AddReachability(-1, bucketScore, "unreachable");
}
else
{
var hops = input.HopCount.Value;
bucketScore = GetBucketScore(hops, config.HopBuckets);
explain.AddReachability(hops, bucketScore, hops == 0 ? "direct call" : "call graph");
}
// Apply gate multiplier if gates are present
if (input.Gates is { Count: > 0 })
{
var gateMultiplier = CalculateGateMultiplierBps(input.Gates, config.GateMultipliersBps);
bucketScore = (bucketScore * gateMultiplier) / 10000;
var primaryGate = input.Gates.OrderByDescending(g => g.Confidence).First();
explain.Add("gate", gateMultiplier / 100,
$"Gate: {primaryGate.Type}" + (primaryGate.Detail is not null ? $" ({primaryGate.Detail})" : ""));
}
return bucketScore;
}
private static int GetBucketScore(int hops, IReadOnlyList<HopBucket>? buckets)
{
if (buckets is null or { Count: 0 })
{
// Default buckets
return hops switch
{
0 => 100,
1 => 90,
<= 3 => 70,
<= 5 => 50,
<= 10 => 30,
_ => 10
};
}
foreach (var bucket in buckets)
{
if (hops <= bucket.MaxHops)
{
return bucket.Score;
}
}
return buckets[^1].Score;
}
private static int CalculateGateMultiplierBps(
IReadOnlyList<DetectedGate> gates,
GateMultipliersBps? config)
{
config ??= GateMultipliersBps.Default;
// Find the most restrictive gate (lowest multiplier = highest mitigation)
var lowestMultiplier = 10000; // 100% = no mitigation
foreach (var gate in gates)
{
var multiplier = gate.Type.ToLowerInvariant() switch
{
"feature_flag" or "featureflag" => config.FeatureFlag,
"auth_required" or "authrequired" => config.AuthRequired,
"admin_only" or "adminonly" => config.AdminOnly,
"non_default_config" or "nondefaultconfig" => config.NonDefaultConfig,
_ => 10000
};
// Weight by confidence
var weightedMultiplier = (int)(multiplier + ((10000 - multiplier) * (1.0 - gate.Confidence)));
lowestMultiplier = Math.Min(lowestMultiplier, weightedMultiplier);
}
return lowestMultiplier;
}
private int CalculateEvidence(
EvidenceInput input,
DateTimeOffset asOf,
ScorePolicy policy,
ScoreExplainBuilder explain)
{
var config = policy.Evidence ?? EvidencePolicyConfig.Default;
var points = config.Points ?? EvidencePoints.Default;
// Sum evidence points
var totalPoints = 0;
foreach (var type in input.Types)
{
totalPoints += type switch
{
EvidenceType.Runtime => points.Runtime,
EvidenceType.Dast => points.Dast,
EvidenceType.Sast => points.Sast,
EvidenceType.Sca => points.Sca,
_ => 0
};
}
totalPoints = Math.Min(100, totalPoints);
// Apply freshness multiplier
var freshnessMultiplier = 10000;
var ageDays = 0;
if (input.NewestEvidenceAt.HasValue)
{
ageDays = Math.Max(0, (int)(asOf - input.NewestEvidenceAt.Value).TotalDays);
freshnessMultiplier = _freshnessCalculator.CalculateMultiplierBps(
input.NewestEvidenceAt.Value, asOf);
}
var finalEvidence = (totalPoints * freshnessMultiplier) / 10000;
explain.AddEvidence(totalPoints, freshnessMultiplier, ageDays);
return finalEvidence;
}
private static int CalculateProvenance(
ProvenanceInput input,
ScorePolicy policy,
ScoreExplainBuilder explain)
{
var config = policy.Provenance ?? ProvenancePolicyConfig.Default;
var levels = config.Levels ?? ProvenanceLevels.Default;
var score = input.Level switch
{
ProvenanceLevel.Unsigned => levels.Unsigned,
ProvenanceLevel.Signed => levels.Signed,
ProvenanceLevel.SignedWithSbom => levels.SignedWithSbom,
ProvenanceLevel.SignedWithSbomAndAttestations => levels.SignedWithSbomAndAttestations,
ProvenanceLevel.Reproducible => levels.Reproducible,
_ => levels.Unsigned
};
explain.AddProvenance(input.Level.ToString(), score);
return score;
}
private static (int Score, string? Override) ApplyOverrides(
int score,
int reachability,
int evidence,
bool isKnownExploited,
ScorePolicy policy)
{
if (policy.Overrides is null or { Count: 0 })
return (score, null);
foreach (var rule in policy.Overrides)
{
if (!MatchesCondition(rule.When, reachability, evidence, isKnownExploited))
continue;
if (rule.SetScore.HasValue)
return (rule.SetScore.Value, rule.Name);
if (rule.ClampMaxScore.HasValue && score > rule.ClampMaxScore.Value)
return (rule.ClampMaxScore.Value, $"{rule.Name} (clamped)");
if (rule.ClampMinScore.HasValue && score < rule.ClampMinScore.Value)
return (rule.ClampMinScore.Value, $"{rule.Name} (clamped)");
}
return (score, null);
}
private static bool MatchesCondition(
ScoreOverrideCondition condition,
int reachability,
int evidence,
bool isKnownExploited)
{
if (condition.Flags?.TryGetValue("knownExploited", out var kevRequired) == true)
{
if (kevRequired != isKnownExploited)
return false;
}
if (condition.MinReachability.HasValue && reachability < condition.MinReachability.Value)
return false;
if (condition.MaxReachability.HasValue && reachability > condition.MaxReachability.Value)
return false;
if (condition.MinEvidence.HasValue && evidence < condition.MinEvidence.Value)
return false;
if (condition.MaxEvidence.HasValue && evidence > condition.MaxEvidence.Value)
return false;
return true;
}
private static string MapToSeverity(int score) => score switch
{
>= 90 => "critical",
>= 70 => "high",
>= 40 => "medium",
>= 20 => "low",
_ => "info"
};
}

View File

@@ -0,0 +1,291 @@
// -----------------------------------------------------------------------------
// IScoringEngine.cs
// Sprint: SPRINT_3407_0001_0001_configurable_scoring
// Task: PROF-3407-002
// Description: Interface for pluggable scoring engines
// -----------------------------------------------------------------------------
using StellaOps.Policy.Scoring;
namespace StellaOps.Policy.Engine.Scoring;
/// <summary>
/// Interface for pluggable scoring engines.
/// </summary>
public interface IScoringEngine
{
/// <summary>
/// Scoring profile this engine implements.
/// </summary>
ScoringProfile Profile { get; }
/// <summary>
/// Computes risk score for a finding.
/// </summary>
/// <param name="input">Scoring input with all factors.</param>
/// <param name="policy">Score policy configuration.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Scoring result with explanation.</returns>
Task<ScoringEngineResult> ScoreAsync(
ScoringInput input,
ScorePolicy policy,
CancellationToken ct = default);
}
/// <summary>
/// Input for scoring calculation.
/// </summary>
public sealed record ScoringInput
{
/// <summary>
/// Finding identifier.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Profile identifier.
/// </summary>
public required string ProfileId { get; init; }
/// <summary>
/// Explicit reference time for determinism.
/// </summary>
public required DateTimeOffset AsOf { get; init; }
/// <summary>
/// CVSS base score (0.0-10.0).
/// </summary>
public required decimal CvssBase { get; init; }
/// <summary>
/// CVSS version used.
/// </summary>
public string? CvssVersion { get; init; }
/// <summary>
/// Reachability analysis result.
/// </summary>
public required ReachabilityInput Reachability { get; init; }
/// <summary>
/// Evidence analysis result.
/// </summary>
public required EvidenceInput Evidence { get; init; }
/// <summary>
/// Provenance verification result.
/// </summary>
public required ProvenanceInput Provenance { get; init; }
/// <summary>
/// Known Exploited Vulnerability flag.
/// </summary>
public bool IsKnownExploited { get; init; }
/// <summary>
/// Input digests for determinism tracking.
/// </summary>
public IReadOnlyDictionary<string, string>? InputDigests { get; init; }
}
/// <summary>
/// Reachability analysis input.
/// </summary>
public sealed record ReachabilityInput
{
/// <summary>
/// Hop count to vulnerable code (null = unreachable).
/// </summary>
public int? HopCount { get; init; }
/// <summary>
/// Detected gates on the path.
/// </summary>
public IReadOnlyList<DetectedGate>? Gates { get; init; }
/// <summary>
/// Semantic reachability category (current advanced model).
/// </summary>
public string? Category { get; init; }
/// <summary>
/// Raw reachability score from advanced engine.
/// </summary>
public double? AdvancedScore { get; init; }
}
/// <summary>
/// A detected gate that may mitigate reachability.
/// </summary>
/// <param name="Type">Gate type (e.g., "feature_flag", "auth_required").</param>
/// <param name="Detail">Additional detail about the gate.</param>
/// <param name="Confidence">Confidence level (0.0-1.0).</param>
public sealed record DetectedGate(string Type, string? Detail, double Confidence);
/// <summary>
/// Evidence analysis input.
/// </summary>
public sealed record EvidenceInput
{
/// <summary>
/// Evidence types present.
/// </summary>
public required IReadOnlySet<EvidenceType> Types { get; init; }
/// <summary>
/// Newest evidence timestamp.
/// </summary>
public DateTimeOffset? NewestEvidenceAt { get; init; }
/// <summary>
/// Raw evidence score from advanced engine.
/// </summary>
public double? AdvancedScore { get; init; }
/// <summary>
/// Creates an empty evidence input.
/// </summary>
public static EvidenceInput Empty => new()
{
Types = new HashSet<EvidenceType>()
};
}
/// <summary>
/// Evidence types that can contribute to scoring.
/// </summary>
public enum EvidenceType
{
/// <summary>Runtime execution evidence (highest value).</summary>
Runtime,
/// <summary>Dynamic analysis security testing.</summary>
Dast,
/// <summary>Static analysis security testing.</summary>
Sast,
/// <summary>Software composition analysis.</summary>
Sca
}
/// <summary>
/// Provenance verification input.
/// </summary>
public sealed record ProvenanceInput
{
/// <summary>
/// Provenance level.
/// </summary>
public required ProvenanceLevel Level { get; init; }
/// <summary>
/// Raw provenance score from advanced engine.
/// </summary>
public double? AdvancedScore { get; init; }
/// <summary>
/// Creates default provenance input (unsigned).
/// </summary>
public static ProvenanceInput Default => new()
{
Level = ProvenanceLevel.Unsigned
};
}
/// <summary>
/// Provenance verification levels.
/// </summary>
public enum ProvenanceLevel
{
/// <summary>No signature or provenance.</summary>
Unsigned,
/// <summary>Basic signature present.</summary>
Signed,
/// <summary>Signed with SBOM.</summary>
SignedWithSbom,
/// <summary>Signed with SBOM and attestations.</summary>
SignedWithSbomAndAttestations,
/// <summary>Fully reproducible build.</summary>
Reproducible
}
/// <summary>
/// Result from a scoring engine.
/// </summary>
public sealed record ScoringEngineResult
{
/// <summary>
/// Finding identifier.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Profile identifier.
/// </summary>
public required string ProfileId { get; init; }
/// <summary>
/// Profile version/digest.
/// </summary>
public required string ProfileVersion { get; init; }
/// <summary>
/// Raw score before overrides (0-100).
/// </summary>
public required int RawScore { get; init; }
/// <summary>
/// Final score after overrides (0-100).
/// </summary>
public required int FinalScore { get; init; }
/// <summary>
/// Severity classification.
/// </summary>
public required string Severity { get; init; }
/// <summary>
/// Individual signal values used in scoring.
/// </summary>
public required IReadOnlyDictionary<string, int> SignalValues { get; init; }
/// <summary>
/// Contribution of each signal to the final score.
/// </summary>
public required IReadOnlyDictionary<string, double> SignalContributions { get; init; }
/// <summary>
/// Override rule that was applied, if any.
/// </summary>
public string? OverrideApplied { get; init; }
/// <summary>
/// Reason for override, if any.
/// </summary>
public string? OverrideReason { get; init; }
/// <summary>
/// Scoring profile used.
/// </summary>
public required ScoringProfile ScoringProfile { get; init; }
/// <summary>
/// Timestamp when scoring was performed.
/// </summary>
public required DateTimeOffset ScoredAt { get; init; }
/// <summary>
/// Structured explanation of score contributions.
/// </summary>
public required IReadOnlyList<ScoreExplanation> Explain { get; init; }
}

View File

@@ -0,0 +1,153 @@
// -----------------------------------------------------------------------------
// ProfileAwareScoringService.cs
// Sprint: SPRINT_3407_0001_0001_configurable_scoring
// Task: PROF-3407-008
// Description: Integrates profile switching into the scoring pipeline
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Scoring;
namespace StellaOps.Policy.Engine.Scoring;
/// <summary>
/// Profile-aware scoring service that routes to the appropriate scoring engine.
/// </summary>
public interface IProfileAwareScoringService
{
/// <summary>
/// Scores a finding using the tenant's configured profile.
/// </summary>
Task<ScoringEngineResult> ScoreAsync(
ScoringInput input,
CancellationToken ct = default);
/// <summary>
/// Scores a finding using a specific profile (for comparison/testing).
/// </summary>
Task<ScoringEngineResult> ScoreWithProfileAsync(
ScoringInput input,
ScoringProfile profile,
CancellationToken ct = default);
/// <summary>
/// Compares scores across different profiles for the same input.
/// </summary>
Task<ProfileComparisonResult> CompareProfilesAsync(
ScoringInput input,
CancellationToken ct = default);
}
/// <summary>
/// Result of comparing scores across different profiles.
/// </summary>
public sealed record ProfileComparisonResult
{
/// <summary>
/// Finding identifier.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Results from each profile.
/// </summary>
public required IReadOnlyDictionary<ScoringProfile, ScoringEngineResult> Results { get; init; }
/// <summary>
/// Score variance across profiles.
/// </summary>
public required int ScoreVariance { get; init; }
/// <summary>
/// Whether severity differs across profiles.
/// </summary>
public required bool SeverityDiffers { get; init; }
}
/// <summary>
/// Implementation of profile-aware scoring service.
/// </summary>
public sealed class ProfileAwareScoringService : IProfileAwareScoringService
{
private readonly IScoringEngineFactory _engineFactory;
private readonly IScorePolicyService _policyService;
private readonly ILogger<ProfileAwareScoringService> _logger;
public ProfileAwareScoringService(
IScoringEngineFactory engineFactory,
IScorePolicyService policyService,
ILogger<ProfileAwareScoringService> logger)
{
_engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
_policyService = policyService ?? throw new ArgumentNullException(nameof(policyService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ScoringEngineResult> ScoreAsync(
ScoringInput input,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(input);
var engine = _engineFactory.GetEngineForTenant(input.TenantId);
var policy = _policyService.GetPolicy(input.TenantId);
_logger.LogDebug(
"Scoring finding {FindingId} with {Profile} profile for tenant {TenantId}",
input.FindingId, engine.Profile, input.TenantId);
return await engine.ScoreAsync(input, policy, ct).ConfigureAwait(false);
}
public async Task<ScoringEngineResult> ScoreWithProfileAsync(
ScoringInput input,
ScoringProfile profile,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(input);
var engine = _engineFactory.GetEngine(profile);
var policy = _policyService.GetPolicy(input.TenantId);
_logger.LogDebug(
"Scoring finding {FindingId} with explicit {Profile} profile",
input.FindingId, profile);
return await engine.ScoreAsync(input, policy, ct).ConfigureAwait(false);
}
public async Task<ProfileComparisonResult> CompareProfilesAsync(
ScoringInput input,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(input);
var profiles = _engineFactory.GetAvailableProfiles();
var policy = _policyService.GetPolicy(input.TenantId);
var results = new Dictionary<ScoringProfile, ScoringEngineResult>();
foreach (var profile in profiles)
{
var engine = _engineFactory.GetEngine(profile);
var result = await engine.ScoreAsync(input, policy, ct).ConfigureAwait(false);
results[profile] = result;
}
var scores = results.Values.Select(r => r.FinalScore).ToList();
var severities = results.Values.Select(r => r.Severity).Distinct().ToList();
var comparison = new ProfileComparisonResult
{
FindingId = input.FindingId,
Results = results,
ScoreVariance = scores.Count > 0 ? scores.Max() - scores.Min() : 0,
SeverityDiffers = severities.Count > 1
};
_logger.LogInformation(
"Profile comparison for {FindingId}: variance={Variance}, severity_differs={SeverityDiffers}",
input.FindingId, comparison.ScoreVariance, comparison.SeverityDiffers);
return comparison;
}
}

View File

@@ -132,6 +132,7 @@ public enum RiskScoringJobStatus
/// <param name="OverrideApplied">Override rule that was applied, if any.</param>
/// <param name="OverrideReason">Reason for the override, if any.</param>
/// <param name="ScoredAt">Timestamp when scoring was performed.</param>
/// <param name="ScoringProfile">Scoring profile used (Simple, Advanced, Custom).</param>
public sealed record RiskScoringResult(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("profile_id")] string ProfileId,
@@ -143,7 +144,8 @@ public sealed record RiskScoringResult(
[property: JsonPropertyName("signal_contributions")] IReadOnlyDictionary<string, double> SignalContributions,
[property: JsonPropertyName("override_applied")] string? OverrideApplied,
[property: JsonPropertyName("override_reason")] string? OverrideReason,
[property: JsonPropertyName("scored_at")] DateTimeOffset ScoredAt)
[property: JsonPropertyName("scored_at")] DateTimeOffset ScoredAt,
[property: JsonPropertyName("scoring_profile")] string? ScoringProfile = null)
{
private IReadOnlyList<ScoreExplanation> _explain = Array.Empty<ScoreExplanation>();

View File

@@ -0,0 +1,102 @@
// -----------------------------------------------------------------------------
// ScoringEngineFactory.cs
// Sprint: SPRINT_3407_0001_0001_configurable_scoring
// Task: PROF-3407-005
// Description: Factory for creating scoring engines based on profile
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Scoring.Engines;
using StellaOps.Policy.Scoring;
namespace StellaOps.Policy.Engine.Scoring;
/// <summary>
/// Factory for creating scoring engines based on profile.
/// </summary>
public interface IScoringEngineFactory
{
/// <summary>
/// Gets a scoring engine for the specified profile.
/// </summary>
IScoringEngine GetEngine(ScoringProfile profile);
/// <summary>
/// Gets a scoring engine for a tenant's configured profile.
/// </summary>
IScoringEngine GetEngineForTenant(string tenantId);
/// <summary>
/// Gets all available profiles.
/// </summary>
IReadOnlyList<ScoringProfile> GetAvailableProfiles();
}
/// <summary>
/// Default implementation of scoring engine factory.
/// </summary>
public sealed class ScoringEngineFactory : IScoringEngineFactory
{
private readonly IServiceProvider _services;
private readonly IScoringProfileService _profileService;
private readonly ILogger<ScoringEngineFactory> _logger;
public ScoringEngineFactory(
IServiceProvider services,
IScoringProfileService profileService,
ILogger<ScoringEngineFactory> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Gets a scoring engine for the specified profile.
/// </summary>
public IScoringEngine GetEngine(ScoringProfile profile)
{
var engine = profile switch
{
ScoringProfile.Simple => _services.GetRequiredService<SimpleScoringEngine>(),
ScoringProfile.Advanced => _services.GetRequiredService<AdvancedScoringEngine>(),
ScoringProfile.Custom => throw new NotSupportedException(
"Custom scoring profile requires Rego policy configuration. Use GetCustomEngine instead."),
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, "Unknown scoring profile")
};
_logger.LogDebug("Created scoring engine for profile {Profile}", profile);
return engine;
}
/// <summary>
/// Gets a scoring engine for a tenant's configured profile.
/// </summary>
public IScoringEngine GetEngineForTenant(string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var profileConfig = _profileService.GetProfileForTenant(tenantId);
var profile = profileConfig?.Profile ?? ScoringProfile.Advanced;
_logger.LogDebug(
"Resolved scoring profile {Profile} for tenant {TenantId}",
profile, tenantId);
return GetEngine(profile);
}
/// <summary>
/// Gets all available profiles.
/// </summary>
public IReadOnlyList<ScoringProfile> GetAvailableProfiles()
{
return
[
ScoringProfile.Simple,
ScoringProfile.Advanced
// Custom is not listed as generally available
];
}
}

View File

@@ -0,0 +1,156 @@
// -----------------------------------------------------------------------------
// ScoringProfileService.cs
// Sprint: SPRINT_3407_0001_0001_configurable_scoring
// Task: PROF-3407-006
// Description: Service for managing tenant scoring profile configurations
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Scoring;
namespace StellaOps.Policy.Engine.Scoring;
/// <summary>
/// Service for managing tenant scoring profile configurations.
/// </summary>
public interface IScoringProfileService
{
/// <summary>
/// Gets the scoring profile configuration for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <returns>Profile configuration, or null for default.</returns>
ScoringProfileConfig? GetProfileForTenant(string tenantId);
/// <summary>
/// Sets the scoring profile for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="config">Profile configuration.</param>
void SetProfileForTenant(string tenantId, ScoringProfileConfig config);
/// <summary>
/// Removes custom profile for a tenant (reverts to default).
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <returns>True if a profile was removed.</returns>
bool RemoveProfileForTenant(string tenantId);
/// <summary>
/// Gets all tenants with custom profile configurations.
/// </summary>
IReadOnlyDictionary<string, ScoringProfileConfig> GetAllProfiles();
/// <summary>
/// Gets the default profile for new tenants.
/// </summary>
ScoringProfileConfig DefaultProfile { get; }
}
/// <summary>
/// In-memory implementation of scoring profile service.
/// For production, this should be backed by persistent storage.
/// </summary>
public sealed class ScoringProfileService : IScoringProfileService
{
private readonly ConcurrentDictionary<string, ScoringProfileConfig> _profiles = new();
private readonly IScorePolicyService _policyService;
private readonly ILogger<ScoringProfileService> _logger;
public ScoringProfileConfig DefaultProfile { get; } = ScoringProfileConfig.DefaultAdvanced;
public ScoringProfileService(
IScorePolicyService policyService,
ILogger<ScoringProfileService> logger)
{
_policyService = policyService ?? throw new ArgumentNullException(nameof(policyService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ScoringProfileConfig? GetProfileForTenant(string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
// First, check for explicit tenant profile
if (_profiles.TryGetValue(tenantId, out var profile))
{
return profile;
}
// Then, check the score policy for profile setting
try
{
var policy = _policyService.GetPolicy(tenantId);
var policyProfile = ParseProfileFromPolicy(policy);
if (policyProfile.HasValue)
{
return new ScoringProfileConfig
{
Profile = policyProfile.Value
};
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to get score policy for tenant {TenantId}, using default profile",
tenantId);
}
// Default: return null (caller uses default)
return null;
}
public void SetProfileForTenant(string tenantId, ScoringProfileConfig config)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(config);
_profiles[tenantId] = config;
_logger.LogInformation(
"Set scoring profile {Profile} for tenant {TenantId}",
config.Profile, tenantId);
}
public bool RemoveProfileForTenant(string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var removed = _profiles.TryRemove(tenantId, out _);
if (removed)
{
_logger.LogInformation(
"Removed custom scoring profile for tenant {TenantId}, reverted to default",
tenantId);
}
return removed;
}
public IReadOnlyDictionary<string, ScoringProfileConfig> GetAllProfiles()
{
return _profiles.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
private static ScoringProfile? ParseProfileFromPolicy(ScorePolicy policy)
{
// Check if policy has a scoring profile setting
// This would be read from the YAML scoringProfile field
var profileStr = policy.ScoringProfile;
if (string.IsNullOrWhiteSpace(profileStr))
{
return null;
}
return profileStr.ToLowerInvariant() switch
{
"simple" => ScoringProfile.Simple,
"advanced" => ScoringProfile.Advanced,
"custom" => ScoringProfile.Custom,
_ => null
};
}
}