more features checks. setup improvements
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Counterfactuals;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Counterfactuals;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral tests for CounterfactualEngine.
|
||||
/// Verifies the 5 counterfactual path types and their conditional logic.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Feature", "counterfactual-engine")]
|
||||
public sealed class CounterfactualEngineTests
|
||||
{
|
||||
private readonly CounterfactualEngine _engine;
|
||||
|
||||
public CounterfactualEngineTests()
|
||||
{
|
||||
_engine = new CounterfactualEngine(NullLogger<CounterfactualEngine>.Instance);
|
||||
}
|
||||
|
||||
// ── Already passing ────────────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "ComputeAsync returns AlreadyPassing when verdict is Pass")]
|
||||
public async Task ComputeAsync_AlreadyPassing_ReturnsNoPaths()
|
||||
{
|
||||
var finding = CreateBlockedFinding();
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Pass);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config);
|
||||
|
||||
result.FindingId.Should().Be(finding.FindingId);
|
||||
result.CurrentVerdict.Should().Be("Ship");
|
||||
result.TargetVerdict.Should().Be("Ship");
|
||||
result.Paths.Should().BeEmpty();
|
||||
result.HasPaths.Should().BeFalse();
|
||||
result.RecommendedPath.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Exception path ─────────────────────────────────────
|
||||
|
||||
[Theory(DisplayName = "Exception path effort varies by severity")]
|
||||
[InlineData(PolicySeverity.Critical, 5)]
|
||||
[InlineData(PolicySeverity.High, 4)]
|
||||
[InlineData(PolicySeverity.Medium, 3)]
|
||||
[InlineData(PolicySeverity.Low, 2)]
|
||||
public async Task ExceptionPath_EffortVariesBySeverity(PolicySeverity severity, int expectedEffort)
|
||||
{
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-1",
|
||||
severity,
|
||||
cve: "CVE-2025-1234",
|
||||
purl: "pkg:npm/lodash@4.17.0",
|
||||
tags: ImmutableArray.Create("vex:not_affected", "reachability:no"));
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var options = new CounterfactualOptions
|
||||
{
|
||||
IncludeVexPaths = false,
|
||||
IncludeReachabilityPaths = false,
|
||||
IncludeVersionUpgradePaths = false,
|
||||
IncludeCompensatingControlPaths = false,
|
||||
IncludeExceptionPaths = true,
|
||||
PolicyAllowsExceptions = true
|
||||
};
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
|
||||
|
||||
result.Paths.Should().ContainSingle();
|
||||
var exceptionPath = result.Paths[0];
|
||||
exceptionPath.Type.Should().Be(CounterfactualType.Exception);
|
||||
exceptionPath.EstimatedEffort.Should().Be(expectedEffort);
|
||||
exceptionPath.Description.Should().Contain("CVE-2025-1234");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Exception path excluded when PolicyAllowsExceptions is false")]
|
||||
public async Task ExceptionPath_ExcludedWhenPolicyDisallows()
|
||||
{
|
||||
var finding = CreateBlockedFinding();
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var options = new CounterfactualOptions
|
||||
{
|
||||
IncludeVexPaths = false,
|
||||
IncludeReachabilityPaths = false,
|
||||
IncludeVersionUpgradePaths = false,
|
||||
IncludeCompensatingControlPaths = false,
|
||||
IncludeExceptionPaths = true,
|
||||
PolicyAllowsExceptions = false
|
||||
};
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
|
||||
|
||||
result.Paths.Should().NotContain(p => p.Type == CounterfactualType.Exception);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Exception path excluded when IncludeExceptionPaths is false")]
|
||||
public async Task ExceptionPath_ExcludedWhenOptionDisabled()
|
||||
{
|
||||
var finding = CreateBlockedFinding();
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var options = new CounterfactualOptions
|
||||
{
|
||||
IncludeVexPaths = false,
|
||||
IncludeReachabilityPaths = false,
|
||||
IncludeVersionUpgradePaths = false,
|
||||
IncludeCompensatingControlPaths = false,
|
||||
IncludeExceptionPaths = false
|
||||
};
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
|
||||
|
||||
result.Paths.Should().NotContain(p => p.Type == CounterfactualType.Exception);
|
||||
}
|
||||
|
||||
// ── Version upgrade path ───────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "Version upgrade path uses FixedVersionLookup delegate")]
|
||||
public async Task VersionUpgradePath_UsesFixedVersionLookup()
|
||||
{
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-1",
|
||||
PolicySeverity.High,
|
||||
cve: "CVE-2025-5678",
|
||||
purl: "pkg:npm/lodash@4.17.0",
|
||||
tags: ImmutableArray.Create("vex:not_affected", "reachability:no"));
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var options = new CounterfactualOptions
|
||||
{
|
||||
IncludeVexPaths = false,
|
||||
IncludeExceptionPaths = false,
|
||||
IncludeReachabilityPaths = false,
|
||||
IncludeCompensatingControlPaths = false,
|
||||
IncludeVersionUpgradePaths = true,
|
||||
FixedVersionLookup = (cve, purl, ct) => Task.FromResult<string?>("4.17.21")
|
||||
};
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
|
||||
|
||||
result.Paths.Should().ContainSingle();
|
||||
var versionPath = result.Paths[0];
|
||||
versionPath.Type.Should().Be(CounterfactualType.VersionUpgrade);
|
||||
versionPath.Description.Should().Contain("4.17.21");
|
||||
versionPath.Conditions[0].CurrentValue.Should().Be("4.17.0");
|
||||
versionPath.Conditions[0].RequiredValue.Should().Be("4.17.21");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Version upgrade path not produced when FixedVersionLookup returns null")]
|
||||
public async Task VersionUpgradePath_NotProducedWhenNoFixAvailable()
|
||||
{
|
||||
var finding = CreateBlockedFinding();
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var options = new CounterfactualOptions
|
||||
{
|
||||
IncludeVexPaths = false,
|
||||
IncludeExceptionPaths = false,
|
||||
IncludeReachabilityPaths = false,
|
||||
IncludeCompensatingControlPaths = false,
|
||||
IncludeVersionUpgradePaths = true,
|
||||
FixedVersionLookup = (cve, purl, ct) => Task.FromResult<string?>(null)
|
||||
};
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
|
||||
|
||||
result.Paths.Should().NotContain(p => p.Type == CounterfactualType.VersionUpgrade);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Version upgrade path not produced when FixedVersionLookup is not set")]
|
||||
public async Task VersionUpgradePath_NotProducedWhenDelegateNotSet()
|
||||
{
|
||||
var finding = CreateBlockedFinding();
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var options = new CounterfactualOptions
|
||||
{
|
||||
IncludeVexPaths = false,
|
||||
IncludeExceptionPaths = false,
|
||||
IncludeReachabilityPaths = false,
|
||||
IncludeCompensatingControlPaths = false,
|
||||
IncludeVersionUpgradePaths = true,
|
||||
FixedVersionLookup = null
|
||||
};
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
|
||||
|
||||
result.Paths.Should().NotContain(p => p.Type == CounterfactualType.VersionUpgrade);
|
||||
}
|
||||
|
||||
// ── Compensating control path ──────────────────────────
|
||||
|
||||
[Fact(DisplayName = "Compensating control path has effort 4")]
|
||||
public async Task CompensatingControlPath_HasEffort4()
|
||||
{
|
||||
var finding = CreateBlockedFinding();
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var options = new CounterfactualOptions
|
||||
{
|
||||
IncludeVexPaths = false,
|
||||
IncludeExceptionPaths = false,
|
||||
IncludeReachabilityPaths = false,
|
||||
IncludeVersionUpgradePaths = false,
|
||||
IncludeCompensatingControlPaths = true,
|
||||
PolicyAllowsCompensatingControls = true
|
||||
};
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
|
||||
|
||||
result.Paths.Should().ContainSingle();
|
||||
var controlPath = result.Paths[0];
|
||||
controlPath.Type.Should().Be(CounterfactualType.CompensatingControl);
|
||||
controlPath.EstimatedEffort.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Compensating control excluded when PolicyAllowsCompensatingControls is false")]
|
||||
public async Task CompensatingControlPath_ExcludedWhenPolicyDisallows()
|
||||
{
|
||||
var finding = CreateBlockedFinding();
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var options = new CounterfactualOptions
|
||||
{
|
||||
IncludeVexPaths = false,
|
||||
IncludeExceptionPaths = false,
|
||||
IncludeReachabilityPaths = false,
|
||||
IncludeVersionUpgradePaths = false,
|
||||
IncludeCompensatingControlPaths = true,
|
||||
PolicyAllowsCompensatingControls = false
|
||||
};
|
||||
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
|
||||
|
||||
result.Paths.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── Null validation ────────────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "ComputeAsync throws on null finding")]
|
||||
public async Task ComputeAsync_ThrowsOnNullFinding()
|
||||
{
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var act = () => _engine.ComputeAsync(null!, verdict, document, config);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ComputeAsync throws on null verdict")]
|
||||
public async Task ComputeAsync_ThrowsOnNullVerdict()
|
||||
{
|
||||
var finding = CreateBlockedFinding();
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
var act = () => _engine.ComputeAsync(finding, null!, document, config);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── Default options produce all applicable paths ──────
|
||||
|
||||
[Fact(DisplayName = "Default options include exception and compensating control paths for blocked finding")]
|
||||
public async Task DefaultOptions_IncludeExceptionAndCompensatingControl()
|
||||
{
|
||||
var finding = PolicyFinding.Create(
|
||||
"finding-1",
|
||||
PolicySeverity.High,
|
||||
cve: "CVE-2025-9999",
|
||||
purl: "pkg:npm/express@4.18.0",
|
||||
tags: ImmutableArray.Create("vex:not_affected", "reachability:no"));
|
||||
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
|
||||
var document = CreateDocument();
|
||||
var config = PolicyScoringConfig.Default;
|
||||
|
||||
// Use default options (all enabled, but no FixedVersionLookup)
|
||||
var result = await _engine.ComputeAsync(finding, verdict, document, config);
|
||||
|
||||
// Exception + compensating control should always be present for a blocked finding with CVE
|
||||
result.Paths.Should().Contain(p => p.Type == CounterfactualType.Exception);
|
||||
result.Paths.Should().Contain(p => p.Type == CounterfactualType.CompensatingControl);
|
||||
}
|
||||
|
||||
// ── Result model ───────────────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "CounterfactualResult.Blocked sorts paths by effort")]
|
||||
public void CounterfactualResult_Blocked_SortsByEffort()
|
||||
{
|
||||
var paths = new[]
|
||||
{
|
||||
CounterfactualPath.CompensatingControl("f1", effort: 4),
|
||||
CounterfactualPath.Vex("affected", "CVE-1", effort: 2),
|
||||
CounterfactualPath.Exception("CVE-1", effort: 5)
|
||||
};
|
||||
|
||||
var result = CounterfactualResult.Blocked("f1", paths);
|
||||
|
||||
result.Paths[0].EstimatedEffort.Should().Be(2);
|
||||
result.Paths[1].EstimatedEffort.Should().Be(4);
|
||||
result.Paths[2].EstimatedEffort.Should().Be(5);
|
||||
result.RecommendedPath!.Type.Should().Be(CounterfactualType.VexStatus);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CounterfactualResult.AlreadyPassing has Ship verdict and no paths")]
|
||||
public void CounterfactualResult_AlreadyPassing_Properties()
|
||||
{
|
||||
var result = CounterfactualResult.AlreadyPassing("f1");
|
||||
|
||||
result.FindingId.Should().Be("f1");
|
||||
result.CurrentVerdict.Should().Be("Ship");
|
||||
result.TargetVerdict.Should().Be("Ship");
|
||||
result.HasPaths.Should().BeFalse();
|
||||
result.RecommendedPath.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── CounterfactualPath factory methods ─────────────────
|
||||
|
||||
[Fact(DisplayName = "CounterfactualPath.Vex creates correct path structure")]
|
||||
public void CounterfactualPath_Vex_CorrectStructure()
|
||||
{
|
||||
var path = CounterfactualPath.Vex("affected", "CVE-2025-001", effort: 2);
|
||||
|
||||
path.Type.Should().Be(CounterfactualType.VexStatus);
|
||||
path.EstimatedEffort.Should().Be(2);
|
||||
path.Actor.Should().Contain("Vendor");
|
||||
path.Conditions.Should().ContainSingle();
|
||||
path.Conditions[0].CurrentValue.Should().Be("affected");
|
||||
path.Conditions[0].RequiredValue.Should().Be("NotAffected");
|
||||
path.Conditions[0].IsMet.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CounterfactualPath.Exception creates correct path structure")]
|
||||
public void CounterfactualPath_Exception_CorrectStructure()
|
||||
{
|
||||
var path = CounterfactualPath.Exception("CVE-2025-002", effort: 4);
|
||||
|
||||
path.Type.Should().Be(CounterfactualType.Exception);
|
||||
path.EstimatedEffort.Should().Be(4);
|
||||
path.Actor.Should().Contain("Security");
|
||||
path.Description.Should().Contain("CVE-2025-002");
|
||||
path.ActionUri.Should().Contain("CVE-2025-002");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CounterfactualPath.Reachability creates correct path structure")]
|
||||
public void CounterfactualPath_Reachability_CorrectStructure()
|
||||
{
|
||||
var path = CounterfactualPath.Reachability("yes", "finding-1", effort: 4);
|
||||
|
||||
path.Type.Should().Be(CounterfactualType.Reachability);
|
||||
path.EstimatedEffort.Should().Be(4);
|
||||
path.Actor.Should().Contain("Development");
|
||||
path.Conditions[0].CurrentValue.Should().Be("yes");
|
||||
path.Conditions[0].RequiredValue.Should().Contain("not reachable");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CounterfactualPath.VersionUpgrade creates correct path structure")]
|
||||
public void CounterfactualPath_VersionUpgrade_CorrectStructure()
|
||||
{
|
||||
var path = CounterfactualPath.VersionUpgrade("4.17.0", "4.17.21", "pkg:npm/lodash@4.17.0", effort: 2);
|
||||
|
||||
path.Type.Should().Be(CounterfactualType.VersionUpgrade);
|
||||
path.EstimatedEffort.Should().Be(2);
|
||||
path.Description.Should().Contain("4.17.21");
|
||||
path.Conditions[0].CurrentValue.Should().Be("4.17.0");
|
||||
path.Conditions[0].RequiredValue.Should().Be("4.17.21");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CounterfactualPath.CompensatingControl creates correct path structure")]
|
||||
public void CounterfactualPath_CompensatingControl_CorrectStructure()
|
||||
{
|
||||
var path = CounterfactualPath.CompensatingControl("finding-1", effort: 4);
|
||||
|
||||
path.Type.Should().Be(CounterfactualType.CompensatingControl);
|
||||
path.EstimatedEffort.Should().Be(4);
|
||||
path.Actor.Should().Contain("Security");
|
||||
path.ActionUri.Should().Contain("finding-1");
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
private static PolicyFinding CreateBlockedFinding()
|
||||
{
|
||||
return PolicyFinding.Create(
|
||||
"finding-blocked-1",
|
||||
PolicySeverity.High,
|
||||
cve: "CVE-2025-1234",
|
||||
purl: "pkg:npm/lodash@4.17.0",
|
||||
tags: ImmutableArray.Create("vex:affected", "reachability:yes"));
|
||||
}
|
||||
|
||||
private static PolicyVerdict CreateVerdict(PolicyVerdictStatus status)
|
||||
{
|
||||
return new PolicyVerdict(
|
||||
FindingId: "finding-blocked-1",
|
||||
Status: status,
|
||||
RuleName: "BlockHigh",
|
||||
Score: 75.0);
|
||||
}
|
||||
|
||||
private static PolicyDocument CreateDocument()
|
||||
{
|
||||
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
|
||||
var rule = PolicyRule.Create(
|
||||
"BlockHigh",
|
||||
action,
|
||||
ImmutableArray.Create(PolicySeverity.High, PolicySeverity.Critical),
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
PolicyRuleMatchCriteria.Empty,
|
||||
expires: null,
|
||||
justification: null);
|
||||
|
||||
return new PolicyDocument(
|
||||
PolicySchema.CurrentVersion,
|
||||
ImmutableArray.Create(rule),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
PolicyExceptionConfiguration.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using Xunit;
|
||||
|
||||
using ScorePolicy = StellaOps.Policy.Scoring.ScorePolicy;
|
||||
using WeightsBps = StellaOps.Policy.Scoring.WeightsBps;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral tests for the Evidence-Weighted Score (EWS) model components:
|
||||
/// SignalWeights, ScoringWeights, GradeThresholds, SeverityMultipliers,
|
||||
/// FreshnessDecayConfig, TrustSourceWeightService, and ScorePolicyLoader.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Feature", "evidence-weighted-score-model")]
|
||||
public sealed class EvidenceWeightedScoreModelTests
|
||||
{
|
||||
// ─── SignalWeights (6-dimension) ───────────────────────────
|
||||
|
||||
[Fact(DisplayName = "SignalWeights.Default has 6 dimensions summing to 1.0")]
|
||||
public void SignalWeights_Default_SixDimensionsSumToOne()
|
||||
{
|
||||
var w = SignalWeights.Default;
|
||||
|
||||
w.VexWeight.Should().Be(0.25);
|
||||
w.EpssWeight.Should().Be(0.15);
|
||||
w.ReachabilityWeight.Should().Be(0.25);
|
||||
w.RuntimeWeight.Should().Be(0.15);
|
||||
w.BackportWeight.Should().Be(0.10);
|
||||
w.SbomLineageWeight.Should().Be(0.10);
|
||||
w.TotalWeight.Should().BeApproximately(1.0, 0.001);
|
||||
w.IsNormalized().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SignalWeights.IsNormalized returns false for non-normalized weights")]
|
||||
public void SignalWeights_IsNormalized_FalseForNonNormalized()
|
||||
{
|
||||
var w = new SignalWeights
|
||||
{
|
||||
VexWeight = 0.50,
|
||||
EpssWeight = 0.50,
|
||||
ReachabilityWeight = 0.50,
|
||||
RuntimeWeight = 0.0,
|
||||
BackportWeight = 0.0,
|
||||
SbomLineageWeight = 0.0
|
||||
};
|
||||
|
||||
w.TotalWeight.Should().Be(1.5);
|
||||
w.IsNormalized().Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SignalWeights.IsNormalized respects tolerance parameter")]
|
||||
public void SignalWeights_IsNormalized_RespectsToleranceParameter()
|
||||
{
|
||||
var w = new SignalWeights
|
||||
{
|
||||
VexWeight = 0.25,
|
||||
EpssWeight = 0.15,
|
||||
ReachabilityWeight = 0.25,
|
||||
RuntimeWeight = 0.15,
|
||||
BackportWeight = 0.10,
|
||||
SbomLineageWeight = 0.11 // 1.01 total
|
||||
};
|
||||
|
||||
w.IsNormalized(tolerance: 0.001).Should().BeFalse();
|
||||
w.IsNormalized(tolerance: 0.02).Should().BeTrue();
|
||||
}
|
||||
|
||||
// ─── ScoringWeights (6-category) ──────────────────────────
|
||||
|
||||
[Fact(DisplayName = "ScoringWeights.Default validates to true (sums to 1.0)")]
|
||||
public void ScoringWeights_Default_ValidatesToTrue()
|
||||
{
|
||||
var w = new ScoringWeights();
|
||||
|
||||
w.Vulnerability.Should().Be(0.25);
|
||||
w.Exploitability.Should().Be(0.20);
|
||||
w.Reachability.Should().Be(0.20);
|
||||
w.Compliance.Should().Be(0.15);
|
||||
w.SupplyChain.Should().Be(0.10);
|
||||
w.Mitigation.Should().Be(0.10);
|
||||
w.Validate().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ScoringWeights.Validate returns false when sum is not 1.0")]
|
||||
public void ScoringWeights_Validate_FalseWhenNotNormalized()
|
||||
{
|
||||
var w = new ScoringWeights
|
||||
{
|
||||
Vulnerability = 0.50,
|
||||
Exploitability = 0.50,
|
||||
Reachability = 0.20
|
||||
};
|
||||
|
||||
w.Validate().Should().BeFalse();
|
||||
}
|
||||
|
||||
// ─── GradeThresholds ──────────────────────────────────────
|
||||
|
||||
[Theory(DisplayName = "GradeThresholds.GetGrade maps scores to correct letter grades")]
|
||||
[InlineData(100, "A")]
|
||||
[InlineData(95, "A")]
|
||||
[InlineData(90, "A")]
|
||||
[InlineData(89, "B")]
|
||||
[InlineData(80, "B")]
|
||||
[InlineData(79, "C")]
|
||||
[InlineData(70, "C")]
|
||||
[InlineData(69, "D")]
|
||||
[InlineData(60, "D")]
|
||||
[InlineData(59, "F")]
|
||||
[InlineData(0, "F")]
|
||||
[InlineData(-1, "F")]
|
||||
public void GradeThresholds_GetGrade_MapsCorrectly(int score, string expectedGrade)
|
||||
{
|
||||
var thresholds = new GradeThresholds();
|
||||
|
||||
thresholds.GetGrade(score).Should().Be(expectedGrade);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GradeThresholds with custom values apply correctly")]
|
||||
public void GradeThresholds_CustomValues_ApplyCorrectly()
|
||||
{
|
||||
var thresholds = new GradeThresholds
|
||||
{
|
||||
A = 95,
|
||||
B = 85,
|
||||
C = 75,
|
||||
D = 65
|
||||
};
|
||||
|
||||
thresholds.GetGrade(96).Should().Be("A");
|
||||
thresholds.GetGrade(94).Should().Be("B");
|
||||
thresholds.GetGrade(84).Should().Be("C");
|
||||
thresholds.GetGrade(74).Should().Be("D");
|
||||
thresholds.GetGrade(64).Should().Be("F");
|
||||
}
|
||||
|
||||
// ─── SeverityMultipliers ──────────────────────────────────
|
||||
|
||||
[Theory(DisplayName = "SeverityMultipliers.GetMultiplier returns correct value for each severity")]
|
||||
[InlineData("CRITICAL", 1.5)]
|
||||
[InlineData("HIGH", 1.2)]
|
||||
[InlineData("MEDIUM", 1.0)]
|
||||
[InlineData("LOW", 0.8)]
|
||||
[InlineData("INFORMATIONAL", 0.5)]
|
||||
[InlineData("INFO", 0.5)]
|
||||
[InlineData("critical", 1.5)]
|
||||
[InlineData("high", 1.2)]
|
||||
public void SeverityMultipliers_GetMultiplier_ReturnsCorrectValue(string severity, double expected)
|
||||
{
|
||||
var m = new SeverityMultipliers();
|
||||
|
||||
m.GetMultiplier(severity).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SeverityMultipliers.GetMultiplier defaults to Medium for unknown severity")]
|
||||
public void SeverityMultipliers_GetMultiplier_DefaultsToMedium()
|
||||
{
|
||||
var m = new SeverityMultipliers();
|
||||
|
||||
m.GetMultiplier("UNKNOWN").Should().Be(1.0);
|
||||
m.GetMultiplier("").Should().Be(1.0);
|
||||
}
|
||||
|
||||
// ─── FreshnessDecayConfig ─────────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "FreshnessDecayConfig defaults match specification")]
|
||||
public void FreshnessDecayConfig_Defaults_MatchSpec()
|
||||
{
|
||||
var config = new FreshnessDecayConfig();
|
||||
|
||||
config.SbomDecayStartHours.Should().Be(168); // 7 days
|
||||
config.FeedDecayStartHours.Should().Be(24);
|
||||
config.DecayRatePerHour.Should().Be(0.001);
|
||||
config.MinimumFreshness.Should().Be(0.5);
|
||||
}
|
||||
|
||||
// ─── WeightsBps (4-factor basis points) ───────────────────
|
||||
|
||||
[Fact(DisplayName = "WeightsBps.Default sums to 10000")]
|
||||
public void WeightsBps_Default_SumsTo10000()
|
||||
{
|
||||
var w = WeightsBps.Default;
|
||||
|
||||
(w.BaseSeverity + w.Reachability + w.Evidence + w.Provenance).Should().Be(10000);
|
||||
w.BaseSeverity.Should().Be(1000);
|
||||
w.Reachability.Should().Be(4500);
|
||||
w.Evidence.Should().Be(3000);
|
||||
w.Provenance.Should().Be(1500);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ScorePolicy.Default validates weights correctly")]
|
||||
public void ScorePolicy_Default_ValidatesWeights()
|
||||
{
|
||||
var policy = ScorePolicy.Default;
|
||||
|
||||
policy.ValidateWeights().Should().BeTrue();
|
||||
policy.PolicyVersion.Should().Be("score.v1");
|
||||
policy.ScoringProfile.Should().Be("advanced");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ScorePolicy.ValidateWeights rejects invalid sums")]
|
||||
public void ScorePolicy_ValidateWeights_RejectsInvalidSum()
|
||||
{
|
||||
var policy = new ScorePolicy
|
||||
{
|
||||
PolicyVersion = "score.v1",
|
||||
WeightsBps = new WeightsBps
|
||||
{
|
||||
BaseSeverity = 1000,
|
||||
Reachability = 1000,
|
||||
Evidence = 1000,
|
||||
Provenance = 1000
|
||||
}
|
||||
};
|
||||
|
||||
policy.ValidateWeights().Should().BeFalse();
|
||||
}
|
||||
|
||||
// ─── ReachabilityPolicyConfig ─────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "ReachabilityPolicyConfig.Default has 6 hop buckets with decreasing scores")]
|
||||
public void ReachabilityPolicyConfig_Default_HasDecreasingBuckets()
|
||||
{
|
||||
var config = ReachabilityPolicyConfig.Default;
|
||||
|
||||
config.HopBuckets.Should().NotBeNull();
|
||||
config.HopBuckets!.Should().HaveCount(6);
|
||||
config.HopBuckets![0].Score.Should().Be(100); // Direct call
|
||||
config.HopBuckets![1].Score.Should().Be(90); // 1 hop
|
||||
config.HopBuckets![2].Score.Should().Be(70); // 2-3 hops
|
||||
config.HopBuckets![3].Score.Should().Be(50); // 4-5 hops
|
||||
config.HopBuckets![4].Score.Should().Be(30); // 6-10 hops
|
||||
config.HopBuckets![5].Score.Should().Be(10); // > 10 hops
|
||||
config.UnreachableScore.Should().Be(0);
|
||||
}
|
||||
|
||||
// ─── EvidencePolicyConfig ─────────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "EvidencePolicyConfig.Default has 6 freshness buckets with decreasing freshness")]
|
||||
public void EvidencePolicyConfig_Default_HasDecreasingFreshnessBuckets()
|
||||
{
|
||||
var config = EvidencePolicyConfig.Default;
|
||||
|
||||
config.FreshnessBuckets.Should().NotBeNull();
|
||||
config.FreshnessBuckets!.Should().HaveCount(6);
|
||||
config.FreshnessBuckets![0].Should().Be(new FreshnessBucket(7, 10000)); // 100%
|
||||
config.FreshnessBuckets![1].Should().Be(new FreshnessBucket(30, 9000)); // 90%
|
||||
config.FreshnessBuckets![2].Should().Be(new FreshnessBucket(90, 7000)); // 70%
|
||||
config.FreshnessBuckets![3].Should().Be(new FreshnessBucket(180, 5000)); // 50%
|
||||
config.FreshnessBuckets![4].Should().Be(new FreshnessBucket(365, 3000)); // 30%
|
||||
config.FreshnessBuckets![5].Should().Be(new FreshnessBucket(int.MaxValue, 1000)); // 10%
|
||||
}
|
||||
|
||||
// ─── ProvenanceLevels ─────────────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "ProvenanceLevels.Default increases from Unsigned to Reproducible")]
|
||||
public void ProvenanceLevels_Default_IncreasingScale()
|
||||
{
|
||||
var levels = ProvenanceLevels.Default;
|
||||
|
||||
levels.Unsigned.Should().Be(0);
|
||||
levels.Signed.Should().Be(30);
|
||||
levels.SignedWithSbom.Should().Be(60);
|
||||
levels.SignedWithSbomAndAttestations.Should().Be(80);
|
||||
levels.Reproducible.Should().Be(100);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for TrustSourceWeightService - the weighted source merging engine.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Feature", "evidence-weighted-score-model")]
|
||||
public sealed class TrustSourceWeightServiceTests
|
||||
{
|
||||
private readonly TrustSourceWeightService _service;
|
||||
|
||||
public TrustSourceWeightServiceTests()
|
||||
{
|
||||
_service = new TrustSourceWeightService();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetSourceWeight returns explicit weight for known sources")]
|
||||
public void GetSourceWeight_ReturnsExplicitWeight_ForKnownSources()
|
||||
{
|
||||
var nvdSource = CreateSource(KnownSources.NvdNist, SourceCategory.Government);
|
||||
var cisaSource = CreateSource(KnownSources.CisaKev, SourceCategory.Government);
|
||||
var osvSource = CreateSource(KnownSources.Osv, SourceCategory.Community);
|
||||
|
||||
_service.GetSourceWeight(nvdSource).Should().Be(0.90);
|
||||
_service.GetSourceWeight(cisaSource).Should().Be(0.98);
|
||||
_service.GetSourceWeight(osvSource).Should().Be(0.75);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetSourceWeight falls back to category weight for unknown sources")]
|
||||
public void GetSourceWeight_FallsBackToCategoryWeight()
|
||||
{
|
||||
var unknownGov = CreateSource("unknown-gov-source", SourceCategory.Government);
|
||||
|
||||
_service.GetSourceWeight(unknownGov).Should().Be(0.95);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetSourceWeight falls back to default for completely unknown source")]
|
||||
public void GetSourceWeight_FallsBackToDefault()
|
||||
{
|
||||
// Create a source with a category that isn't in defaults - use Internal since it's defined
|
||||
var config = new TrustSourceWeightConfig
|
||||
{
|
||||
Weights = System.Collections.Immutable.ImmutableDictionary<string, double>.Empty,
|
||||
CategoryWeights = System.Collections.Immutable.ImmutableDictionary<SourceCategory, double>.Empty,
|
||||
DefaultWeight = 0.42
|
||||
};
|
||||
var service = new TrustSourceWeightService(config);
|
||||
|
||||
var source = CreateSource("totally-unknown", SourceCategory.Government);
|
||||
|
||||
service.GetSourceWeight(source).Should().Be(0.42);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetSourceWeight boosts signed data by 1.05x")]
|
||||
public void GetSourceWeight_BoostsSignedData()
|
||||
{
|
||||
var unsigned = CreateSource(KnownSources.NvdNist, SourceCategory.Government, isSigned: false);
|
||||
var signed = CreateSource(KnownSources.NvdNist, SourceCategory.Government, isSigned: true);
|
||||
|
||||
var unsignedWeight = _service.GetSourceWeight(unsigned);
|
||||
var signedWeight = _service.GetSourceWeight(signed);
|
||||
|
||||
signedWeight.Should().BeGreaterThan(unsignedWeight);
|
||||
signedWeight.Should().BeApproximately(0.90 * 1.05, 0.001);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetSourceWeight penalizes stale data >7 days old")]
|
||||
public void GetSourceWeight_PenalizesStaleData()
|
||||
{
|
||||
var fresh = CreateSource(KnownSources.NvdNist, SourceCategory.Government,
|
||||
fetchedAt: DateTimeOffset.UtcNow.AddDays(-1));
|
||||
var stale = CreateSource(KnownSources.NvdNist, SourceCategory.Government,
|
||||
fetchedAt: DateTimeOffset.UtcNow.AddDays(-10));
|
||||
|
||||
var freshWeight = _service.GetSourceWeight(fresh);
|
||||
var staleWeight = _service.GetSourceWeight(stale);
|
||||
|
||||
staleWeight.Should().BeLessThan(freshWeight);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetSourceWeight applies double penalty for >30 days stale data")]
|
||||
public void GetSourceWeight_AppliesDoublePenaltyForVeryStaleData()
|
||||
{
|
||||
var moderatelyStale = CreateSource(KnownSources.NvdNist, SourceCategory.Government,
|
||||
fetchedAt: DateTimeOffset.UtcNow.AddDays(-10));
|
||||
var veryStale = CreateSource(KnownSources.NvdNist, SourceCategory.Government,
|
||||
fetchedAt: DateTimeOffset.UtcNow.AddDays(-35));
|
||||
|
||||
var moderatelyStaleWeight = _service.GetSourceWeight(moderatelyStale);
|
||||
var veryStaleWeight = _service.GetSourceWeight(veryStale);
|
||||
|
||||
// >30 days gets both the >7d (0.95x) and >30d (0.90x) penalties
|
||||
veryStaleWeight.Should().BeLessThan(moderatelyStaleWeight);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GetSourceWeight clamps to [0.0, 1.0] range")]
|
||||
public void GetSourceWeight_ClampsToValidRange()
|
||||
{
|
||||
// Even with boost, CISA-KEV (0.98 * 1.05 = 1.029) should clamp to 1.0
|
||||
var signedCisa = CreateSource(KnownSources.CisaKev, SourceCategory.Government, isSigned: true);
|
||||
|
||||
_service.GetSourceWeight(signedCisa).Should().BeLessThanOrEqualTo(1.0);
|
||||
_service.GetSourceWeight(signedCisa).Should().BeGreaterThanOrEqualTo(0.0);
|
||||
}
|
||||
|
||||
// ─── MergeFindings ────────────────────────────────────────
|
||||
|
||||
[Fact(DisplayName = "MergeFindings with empty list returns zero confidence")]
|
||||
public void MergeFindings_EmptyList_ReturnsZeroConfidence()
|
||||
{
|
||||
var result = _service.MergeFindings([]);
|
||||
|
||||
result.Confidence.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "MergeFindings uses highest-weight source for severity")]
|
||||
public void MergeFindings_UsesHighestWeightSourceForSeverity()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding(KnownSources.CisaKev, SourceCategory.Government, severity: "CRITICAL", cvss: 9.8),
|
||||
CreateFinding(KnownSources.Osv, SourceCategory.Community, severity: "HIGH", cvss: 7.5)
|
||||
};
|
||||
|
||||
var result = _service.MergeFindings(findings);
|
||||
|
||||
// CISA-KEV has highest weight (0.98), so its severity should be used
|
||||
result.Severity.Should().Be("CRITICAL");
|
||||
result.ContributingSources.Should().HaveCount(2);
|
||||
result.ContributingSources[0].Should().Be(KnownSources.CisaKev);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "MergeFindings computes weighted CVSS average")]
|
||||
public void MergeFindings_ComputesWeightedCvssAverage()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, severity: "CRITICAL", cvss: 9.0),
|
||||
CreateFinding(KnownSources.Osv, SourceCategory.Community, severity: "CRITICAL", cvss: 7.0)
|
||||
};
|
||||
|
||||
var result = _service.MergeFindings(findings);
|
||||
|
||||
result.CvssScore.Should().NotBeNull();
|
||||
// Weighted average: (9.0 * 0.90 + 7.0 * 0.75) / (0.90 + 0.75) = 13.35 / 1.65 = 8.09
|
||||
result.CvssScore!.Value.Should().BeApproximately(8.09, 0.1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "MergeFindings applies corroboration boost when sources agree on severity")]
|
||||
public void MergeFindings_AppliesCorroborationBoost()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, severity: "HIGH", cvss: 8.0),
|
||||
CreateFinding(KnownSources.CisaKev, SourceCategory.Government, severity: "HIGH", cvss: 8.5),
|
||||
CreateFinding(KnownSources.Osv, SourceCategory.Community, severity: "HIGH", cvss: 7.5)
|
||||
};
|
||||
|
||||
var result = _service.MergeFindings(findings);
|
||||
|
||||
result.Corroborated.Should().BeTrue();
|
||||
result.CorroborationBoost.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "MergeFindings does not corroborate when severities disagree")]
|
||||
public void MergeFindings_NoCorroborationWhenSeveritiesDisagree()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, severity: "HIGH", cvss: 8.0),
|
||||
CreateFinding(KnownSources.Osv, SourceCategory.Community, severity: "CRITICAL", cvss: 9.5)
|
||||
};
|
||||
|
||||
var result = _service.MergeFindings(findings);
|
||||
|
||||
result.Corroborated.Should().BeFalse();
|
||||
result.CorroborationBoost.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "MergeFindings selects earliest fix version")]
|
||||
public void MergeFindings_SelectsEarliestFixVersion()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, fixVersion: "2.0.1"),
|
||||
CreateFinding(KnownSources.VendorAdvisory, SourceCategory.Vendor, fixVersion: "1.9.5")
|
||||
};
|
||||
|
||||
var result = _service.MergeFindings(findings);
|
||||
|
||||
// Should pick earliest (alphabetical sort: "1.9.5" < "2.0.1")
|
||||
result.FixVersion.Should().Be("1.9.5");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "MergeFindings confidence is clamped to 1.0")]
|
||||
public void MergeFindings_ConfidenceClamped()
|
||||
{
|
||||
// High weight source + corroboration boost could exceed 1.0
|
||||
var findings = new[]
|
||||
{
|
||||
CreateFinding(KnownSources.CisaKev, SourceCategory.Government, severity: "CRITICAL"),
|
||||
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, severity: "CRITICAL"),
|
||||
CreateFinding(KnownSources.VendorAdvisory, SourceCategory.Vendor, severity: "CRITICAL")
|
||||
};
|
||||
|
||||
var result = _service.MergeFindings(findings);
|
||||
|
||||
result.Confidence.Should().BeLessThanOrEqualTo(1.0);
|
||||
}
|
||||
|
||||
// ─── ScoringRulesSnapshotBuilder ──────────────────────────
|
||||
|
||||
[Fact(DisplayName = "ScoringRulesSnapshotBuilder.Build computes content-addressed digest")]
|
||||
public void SnapshotBuilder_Build_ComputesDigest()
|
||||
{
|
||||
var snapshot = ScoringRulesSnapshotBuilder
|
||||
.Create("snap-1", 1, DateTimeOffset.UtcNow)
|
||||
.WithDescription("test snapshot")
|
||||
.Build();
|
||||
|
||||
snapshot.Digest.Should().NotBeNullOrEmpty();
|
||||
snapshot.Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ScoringRulesSnapshotBuilder.Build is deterministic")]
|
||||
public void SnapshotBuilder_Build_IsDeterministic()
|
||||
{
|
||||
var ts = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var snap1 = ScoringRulesSnapshotBuilder
|
||||
.Create("snap-1", 1, ts)
|
||||
.WithDescription("test")
|
||||
.Build();
|
||||
|
||||
var snap2 = ScoringRulesSnapshotBuilder
|
||||
.Create("snap-1", 1, ts)
|
||||
.WithDescription("test")
|
||||
.Build();
|
||||
|
||||
snap1.Digest.Should().Be(snap2.Digest);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ScoringRulesSnapshotBuilder.Build throws when weights are invalid")]
|
||||
public void SnapshotBuilder_Build_ThrowsOnInvalidWeights()
|
||||
{
|
||||
var builder = ScoringRulesSnapshotBuilder
|
||||
.Create("snap-bad", 1, DateTimeOffset.UtcNow)
|
||||
.WithWeights(new ScoringWeights
|
||||
{
|
||||
Vulnerability = 0.50,
|
||||
Exploitability = 0.50,
|
||||
Reachability = 0.50
|
||||
});
|
||||
|
||||
var act = () => builder.Build();
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*weights*sum*");
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
private static SourceMetadata CreateSource(
|
||||
string id,
|
||||
SourceCategory category,
|
||||
bool isSigned = false,
|
||||
DateTimeOffset? fetchedAt = null)
|
||||
{
|
||||
return new SourceMetadata
|
||||
{
|
||||
Id = id,
|
||||
Category = category,
|
||||
IsSigned = isSigned,
|
||||
FetchedAt = fetchedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceFinding CreateFinding(
|
||||
string sourceId,
|
||||
SourceCategory category,
|
||||
string? severity = null,
|
||||
double? cvss = null,
|
||||
string? fixVersion = null)
|
||||
{
|
||||
return new SourceFinding
|
||||
{
|
||||
Source = new SourceMetadata
|
||||
{
|
||||
Id = sourceId,
|
||||
Category = category
|
||||
},
|
||||
Severity = severity,
|
||||
CvssScore = cvss,
|
||||
FixVersion = fixVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user