up
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Engine.Scoring;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Scoring;
|
||||
|
||||
public sealed class RiskScoringResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Explain_DefaultsToEmptyArray()
|
||||
{
|
||||
var result = new RiskScoringResult(
|
||||
FindingId: "finding-1",
|
||||
ProfileId: "profile-1",
|
||||
ProfileVersion: "v1",
|
||||
RawScore: 1.23,
|
||||
NormalizedScore: 0.42,
|
||||
Severity: "high",
|
||||
SignalValues: new Dictionary<string, object?>(),
|
||||
SignalContributions: new Dictionary<string, double>(),
|
||||
OverrideApplied: null,
|
||||
OverrideReason: null,
|
||||
ScoredAt: DateTimeOffset.UnixEpoch);
|
||||
|
||||
Assert.NotNull(result.Explain);
|
||||
Assert.Empty(result.Explain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explain_NullInitCoercesToEmptyArray()
|
||||
{
|
||||
var result = new RiskScoringResult(
|
||||
FindingId: "finding-1",
|
||||
ProfileId: "profile-1",
|
||||
ProfileVersion: "v1",
|
||||
RawScore: 1.23,
|
||||
NormalizedScore: 0.42,
|
||||
Severity: "high",
|
||||
SignalValues: new Dictionary<string, object?>(),
|
||||
SignalContributions: new Dictionary<string, double>(),
|
||||
OverrideApplied: null,
|
||||
OverrideReason: null,
|
||||
ScoredAt: DateTimeOffset.UnixEpoch)
|
||||
{
|
||||
Explain = null!
|
||||
};
|
||||
|
||||
Assert.NotNull(result.Explain);
|
||||
Assert.Empty(result.Explain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonSerialization_IncludesExplain()
|
||||
{
|
||||
var result = new RiskScoringResult(
|
||||
FindingId: "finding-1",
|
||||
ProfileId: "profile-1",
|
||||
ProfileVersion: "v1",
|
||||
RawScore: 1.23,
|
||||
NormalizedScore: 0.42,
|
||||
Severity: "high",
|
||||
SignalValues: new Dictionary<string, object?>(),
|
||||
SignalContributions: new Dictionary<string, double>(),
|
||||
OverrideApplied: null,
|
||||
OverrideReason: null,
|
||||
ScoredAt: DateTimeOffset.UnixEpoch)
|
||||
{
|
||||
Explain = new[]
|
||||
{
|
||||
new ScoreExplanation("evidence", 60, "runtime evidence", new[] { "sha256:abc" })
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.Contains("\"explain\":[", json);
|
||||
Assert.Contains("\"factor\":\"evidence\"", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for CVSS v2, v3, and multi-version engine factory.
|
||||
/// </summary>
|
||||
public sealed class CvssMultiVersionEngineTests
|
||||
{
|
||||
#region CVSS v2 Tests
|
||||
|
||||
[Fact]
|
||||
public void CvssV2_ComputeFromVector_HighSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange - CVE-2002-0392 Apache Chunked-Encoding
|
||||
var engine = new CvssV2Engine();
|
||||
var vector = "AV:N/AC:L/Au:N/C:C/I:C/A:C";
|
||||
|
||||
// Act
|
||||
var result = engine.ComputeFromVector(vector);
|
||||
|
||||
// Assert
|
||||
result.Version.Should().Be(CvssVersion.V2);
|
||||
result.BaseScore.Should().Be(10.0);
|
||||
result.Severity.Should().Be("High");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV2_ComputeFromVector_MediumSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange
|
||||
var engine = new CvssV2Engine();
|
||||
var vector = "AV:N/AC:M/Au:S/C:P/I:P/A:N";
|
||||
|
||||
// Act
|
||||
var result = engine.ComputeFromVector(vector);
|
||||
|
||||
// Assert
|
||||
result.Version.Should().Be(CvssVersion.V2);
|
||||
result.BaseScore.Should().BeInRange(4.0, 7.0);
|
||||
result.Severity.Should().Be("Medium");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV2_ComputeFromVector_WithTemporal_ReducesScore()
|
||||
{
|
||||
// Arrange
|
||||
var engine = new CvssV2Engine();
|
||||
var baseVector = "AV:N/AC:L/Au:N/C:C/I:C/A:C";
|
||||
var temporalVector = "AV:N/AC:L/Au:N/C:C/I:C/A:C/E:POC/RL:OF/RC:C";
|
||||
|
||||
// Act
|
||||
var baseResult = engine.ComputeFromVector(baseVector);
|
||||
var temporalResult = engine.ComputeFromVector(temporalVector);
|
||||
|
||||
// Assert
|
||||
temporalResult.TemporalScore.Should().NotBeNull();
|
||||
temporalResult.TemporalScore.Should().BeLessThan(baseResult.BaseScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV2_IsValidVector_ValidVector_ReturnsTrue()
|
||||
{
|
||||
var engine = new CvssV2Engine();
|
||||
engine.IsValidVector("AV:N/AC:L/Au:N/C:C/I:C/A:C").Should().BeTrue();
|
||||
engine.IsValidVector("CVSS2#AV:N/AC:L/Au:N/C:C/I:C/A:C").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV2_IsValidVector_InvalidVector_ReturnsFalse()
|
||||
{
|
||||
var engine = new CvssV2Engine();
|
||||
engine.IsValidVector("CVSS:3.1/AV:N/AC:L").Should().BeFalse();
|
||||
engine.IsValidVector("invalid").Should().BeFalse();
|
||||
engine.IsValidVector("").Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CVSS v3 Tests
|
||||
|
||||
[Fact]
|
||||
public void CvssV3_ComputeFromVector_CriticalSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange - Maximum severity vector
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
var vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H";
|
||||
|
||||
// Act
|
||||
var result = engine.ComputeFromVector(vector);
|
||||
|
||||
// Assert
|
||||
result.Version.Should().Be(CvssVersion.V3_1);
|
||||
result.BaseScore.Should().Be(10.0);
|
||||
result.Severity.Should().Be("Critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV3_ComputeFromVector_HighSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
var vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
|
||||
|
||||
// Act
|
||||
var result = engine.ComputeFromVector(vector);
|
||||
|
||||
// Assert
|
||||
result.Version.Should().Be(CvssVersion.V3_1);
|
||||
result.BaseScore.Should().BeApproximately(9.8, 0.1);
|
||||
result.Severity.Should().Be("Critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV3_ComputeFromVector_MediumSeverity_ReturnsCorrectScore()
|
||||
{
|
||||
// Arrange
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
var vector = "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N";
|
||||
|
||||
// Act
|
||||
var result = engine.ComputeFromVector(vector);
|
||||
|
||||
// Assert
|
||||
result.BaseScore.Should().BeInRange(3.0, 5.0);
|
||||
result.Severity.Should().BeOneOf("Low", "Medium");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV3_ComputeFromVector_V30_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_0);
|
||||
var vector = "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
|
||||
|
||||
// Act
|
||||
var result = engine.ComputeFromVector(vector);
|
||||
|
||||
// Assert
|
||||
result.Version.Should().Be(CvssVersion.V3_0);
|
||||
result.BaseScore.Should().BeGreaterThan(9.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV3_IsValidVector_ValidVector_ReturnsTrue()
|
||||
{
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
engine.IsValidVector("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").Should().BeTrue();
|
||||
engine.IsValidVector("CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV3_IsValidVector_InvalidVector_ReturnsFalse()
|
||||
{
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
engine.IsValidVector("CVSS:4.0/AV:N/AC:L").Should().BeFalse();
|
||||
engine.IsValidVector("AV:N/AC:L/Au:N").Should().BeFalse();
|
||||
engine.IsValidVector("").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssV3_ScopeChanged_AffectsScore()
|
||||
{
|
||||
// Arrange
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
var scopeUnchanged = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
|
||||
var scopeChanged = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H";
|
||||
|
||||
// Act
|
||||
var unchangedResult = engine.ComputeFromVector(scopeUnchanged);
|
||||
var changedResult = engine.ComputeFromVector(scopeChanged);
|
||||
|
||||
// Assert - Changed scope should result in higher score
|
||||
changedResult.BaseScore.Should().BeGreaterThan(unchangedResult.BaseScore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Factory Tests
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_V4_DetectsCorrectly()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
var version = factory.DetectVersion("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H");
|
||||
version.Should().Be(CvssVersion.V4_0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_V31_DetectsCorrectly()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
var version = factory.DetectVersion("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H");
|
||||
version.Should().Be(CvssVersion.V3_1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_V30_DetectsCorrectly()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
var version = factory.DetectVersion("CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H");
|
||||
version.Should().Be(CvssVersion.V3_0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_V2_DetectsCorrectly()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
factory.DetectVersion("AV:N/AC:L/Au:N/C:C/I:C/A:C").Should().Be(CvssVersion.V2);
|
||||
factory.DetectVersion("CVSS2#AV:N/AC:L/Au:N/C:C/I:C/A:C").Should().Be(CvssVersion.V2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_DetectVersion_Invalid_ReturnsNull()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
factory.DetectVersion("invalid").Should().BeNull();
|
||||
factory.DetectVersion("").Should().BeNull();
|
||||
factory.DetectVersion(null!).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_Create_V2_ReturnsCorrectEngine()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
var engine = factory.Create(CvssVersion.V2);
|
||||
engine.Version.Should().Be(CvssVersion.V2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_Create_V31_ReturnsCorrectEngine()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
var engine = factory.Create(CvssVersion.V3_1);
|
||||
engine.Version.Should().Be(CvssVersion.V3_1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_Create_V40_ReturnsCorrectEngine()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
var engine = factory.Create(CvssVersion.V4_0);
|
||||
engine.Version.Should().Be(CvssVersion.V4_0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_ComputeFromVector_AutoDetects()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
|
||||
// V2
|
||||
var v2Result = factory.ComputeFromVector("AV:N/AC:L/Au:N/C:C/I:C/A:C");
|
||||
v2Result.Version.Should().Be(CvssVersion.V2);
|
||||
v2Result.BaseScore.Should().Be(10.0);
|
||||
|
||||
// V3.1
|
||||
var v31Result = factory.ComputeFromVector("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H");
|
||||
v31Result.Version.Should().Be(CvssVersion.V3_1);
|
||||
v31Result.BaseScore.Should().BeGreaterThan(9.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CvssEngineFactory_ComputeFromVector_InvalidVector_ThrowsException()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
FluentActions.Invoking(() => factory.ComputeFromVector("invalid"))
|
||||
.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cross-Version Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void AllEngines_SameInput_ReturnsDeterministicOutput()
|
||||
{
|
||||
var factory = new CvssEngineFactory();
|
||||
|
||||
// Test determinism across multiple calls
|
||||
var v2Vector = "AV:N/AC:L/Au:N/C:C/I:C/A:C";
|
||||
var v31Vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
|
||||
|
||||
var v2Result1 = factory.ComputeFromVector(v2Vector);
|
||||
var v2Result2 = factory.ComputeFromVector(v2Vector);
|
||||
v2Result1.BaseScore.Should().Be(v2Result2.BaseScore);
|
||||
v2Result1.VectorString.Should().Be(v2Result2.VectorString);
|
||||
|
||||
var v31Result1 = factory.ComputeFromVector(v31Vector);
|
||||
var v31Result2 = factory.ComputeFromVector(v31Vector);
|
||||
v31Result1.BaseScore.Should().Be(v31Result2.BaseScore);
|
||||
v31Result1.VectorString.Should().Be(v31Result2.VectorString);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World CVE Vector Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "Critical")] // Log4Shell style
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", 6.1, "Medium")] // XSS style
|
||||
[InlineData("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", 7.8, "High")] // Local privilege escalation
|
||||
public void CvssV3_RealWorldVectors_ReturnsExpectedScores(string vector, double expectedScore, string expectedSeverity)
|
||||
{
|
||||
var engine = new CvssV3Engine(CvssVersion.V3_1);
|
||||
var result = engine.ComputeFromVector(vector);
|
||||
|
||||
result.BaseScore.Should().BeApproximately(expectedScore, 0.2);
|
||||
result.Severity.Should().Be(expectedSeverity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AV:N/AC:L/Au:N/C:C/I:C/A:C", 10.0, "High")] // Remote code execution
|
||||
[InlineData("AV:N/AC:M/Au:N/C:P/I:P/A:P", 6.8, "Medium")] // Moderate network vuln
|
||||
[InlineData("AV:L/AC:L/Au:N/C:P/I:N/A:N", 2.1, "Low")] // Local info disclosure
|
||||
public void CvssV2_RealWorldVectors_ReturnsExpectedScores(string vector, double expectedScore, string expectedSeverity)
|
||||
{
|
||||
var engine = new CvssV2Engine();
|
||||
var result = engine.ComputeFromVector(vector);
|
||||
|
||||
result.BaseScore.Should().BeApproximately(expectedScore, 0.2);
|
||||
result.Severity.Should().Be(expectedSeverity);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Scoring.Tests.Fakes;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the complete CVSS scoring pipeline.
|
||||
/// Tests the full flow from metric input to receipt generation.
|
||||
/// </summary>
|
||||
public sealed class CvssPipelineIntegrationTests
|
||||
{
|
||||
private readonly CvssEngineFactory _factory = new();
|
||||
private readonly ICvssV4Engine _v4Engine = new CvssV4Engine();
|
||||
|
||||
#region Full Pipeline Tests - V4 Receipt
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_V4_CreatesReceiptWithDeterministicHash()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryReceiptRepository();
|
||||
var builder = new ReceiptBuilder(_v4Engine, repository);
|
||||
|
||||
var policy = CreateTestPolicy();
|
||||
var baseMetrics = CreateMaxSeverityBaseMetrics();
|
||||
|
||||
var request = new CreateReceiptRequest
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
TenantId = "test-tenant",
|
||||
CreatedBy = "integration-test",
|
||||
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Policy = policy,
|
||||
BaseMetrics = baseMetrics,
|
||||
Evidence = CreateMinimalEvidence()
|
||||
};
|
||||
|
||||
// Act
|
||||
var receipt = await builder.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
receipt.Should().NotBeNull();
|
||||
receipt.VulnerabilityId.Should().Be("CVE-2024-12345");
|
||||
receipt.TenantId.Should().Be("test-tenant");
|
||||
receipt.VectorString.Should().StartWith("CVSS:4.0/");
|
||||
receipt.Scores.BaseScore.Should().Be(10.0);
|
||||
receipt.Severity.Should().Be(CvssSeverity.Critical);
|
||||
receipt.InputHash.Should().NotBeNullOrEmpty();
|
||||
receipt.InputHash.Should().HaveLength(64); // SHA-256 hex
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_V4_WithThreatMetrics_AdjustsScore()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryReceiptRepository();
|
||||
var builder = new ReceiptBuilder(_v4Engine, repository);
|
||||
|
||||
var policy = CreateTestPolicy();
|
||||
var baseMetrics = CreateMaxSeverityBaseMetrics();
|
||||
var threatMetrics = new CvssThreatMetrics { ExploitMaturity = ExploitMaturity.Unreported };
|
||||
|
||||
var baseRequest = new CreateReceiptRequest
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-BASE",
|
||||
TenantId = "test-tenant",
|
||||
CreatedBy = "test",
|
||||
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Policy = policy,
|
||||
BaseMetrics = baseMetrics,
|
||||
Evidence = CreateMinimalEvidence()
|
||||
};
|
||||
|
||||
var threatRequest = new CreateReceiptRequest
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-THREAT",
|
||||
TenantId = "test-tenant",
|
||||
CreatedBy = "test",
|
||||
CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Policy = policy,
|
||||
BaseMetrics = baseMetrics,
|
||||
ThreatMetrics = threatMetrics,
|
||||
Evidence = CreateMinimalEvidence()
|
||||
};
|
||||
|
||||
// Act
|
||||
var baseReceipt = await builder.CreateAsync(baseRequest);
|
||||
var threatReceipt = await builder.CreateAsync(threatRequest);
|
||||
|
||||
// Assert - Unreported exploit maturity should reduce effective score
|
||||
threatReceipt.Scores.ThreatScore.Should().NotBeNull();
|
||||
threatReceipt.Scores.ThreatScore.Should().BeLessThan(baseReceipt.Scores.BaseScore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cross-Version Factory Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("AV:N/AC:L/Au:N/C:C/I:C/A:C", CvssVersion.V2, 10.0)]
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", CvssVersion.V3_1, 10.0)]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H", CvssVersion.V4_0, 10.0)]
|
||||
public void CrossVersion_MaxSeverityVectors_AllReturnMaxScore(string vector, CvssVersion expectedVersion, double expectedScore)
|
||||
{
|
||||
// Act
|
||||
var result = _factory.ComputeFromVector(vector);
|
||||
|
||||
// Assert
|
||||
result.Version.Should().Be(expectedVersion);
|
||||
result.BaseScore.Should().Be(expectedScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossVersion_AllVersions_ReturnCorrectSeverityLabels()
|
||||
{
|
||||
// Arrange - Maximum severity vectors for each version
|
||||
var v2Max = "AV:N/AC:L/Au:N/C:C/I:C/A:C";
|
||||
var v31Max = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H";
|
||||
var v40Max = "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H";
|
||||
|
||||
// Act
|
||||
var v2Result = _factory.ComputeFromVector(v2Max);
|
||||
var v31Result = _factory.ComputeFromVector(v31Max);
|
||||
var v40Result = _factory.ComputeFromVector(v40Max);
|
||||
|
||||
// Assert - Severities differ by version
|
||||
v2Result.Severity.Should().Be("High"); // V2 max severity is High
|
||||
v31Result.Severity.Should().Be("Critical");
|
||||
v40Result.Severity.Should().Be("Critical");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Determinism_SameInput_ProducesSameInputHash()
|
||||
{
|
||||
// Arrange
|
||||
var repository1 = new InMemoryReceiptRepository();
|
||||
var repository2 = new InMemoryReceiptRepository();
|
||||
var builder1 = new ReceiptBuilder(_v4Engine, repository1);
|
||||
var builder2 = new ReceiptBuilder(_v4Engine, repository2);
|
||||
|
||||
var policy = CreateTestPolicy();
|
||||
var baseMetrics = CreateMaxSeverityBaseMetrics();
|
||||
var fixedTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var request1 = new CreateReceiptRequest
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
TenantId = "test-tenant",
|
||||
CreatedBy = "test",
|
||||
CreatedAt = fixedTime,
|
||||
Policy = policy,
|
||||
BaseMetrics = baseMetrics,
|
||||
Evidence = CreateMinimalEvidence()
|
||||
};
|
||||
|
||||
var request2 = new CreateReceiptRequest
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
TenantId = "test-tenant",
|
||||
CreatedBy = "test",
|
||||
CreatedAt = fixedTime,
|
||||
Policy = policy,
|
||||
BaseMetrics = baseMetrics,
|
||||
Evidence = CreateMinimalEvidence()
|
||||
};
|
||||
|
||||
// Act
|
||||
var receipt1 = await builder1.CreateAsync(request1);
|
||||
var receipt2 = await builder2.CreateAsync(request2);
|
||||
|
||||
// Assert - InputHash MUST be identical for same inputs
|
||||
receipt1.InputHash.Should().Be(receipt2.InputHash);
|
||||
receipt1.Scores.BaseScore.Should().Be(receipt2.Scores.BaseScore);
|
||||
receipt1.Scores.EffectiveScore.Should().Be(receipt2.Scores.EffectiveScore);
|
||||
receipt1.VectorString.Should().Be(receipt2.VectorString);
|
||||
receipt1.Severity.Should().Be(receipt2.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Determinism_EngineScoring_IsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var vectors = new[]
|
||||
{
|
||||
"AV:N/AC:L/Au:N/C:C/I:C/A:C",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"
|
||||
};
|
||||
|
||||
foreach (var vector in vectors)
|
||||
{
|
||||
// Act - compute multiple times
|
||||
var results = Enumerable.Range(0, 10)
|
||||
.Select(_ => _factory.ComputeFromVector(vector))
|
||||
.ToList();
|
||||
|
||||
// Assert - all results must be identical
|
||||
var first = results[0];
|
||||
foreach (var result in results.Skip(1))
|
||||
{
|
||||
result.BaseScore.Should().Be(first.BaseScore);
|
||||
result.Severity.Should().Be(first.Severity);
|
||||
result.VectorString.Should().Be(first.VectorString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Detection Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("AV:N/AC:L/Au:N/C:C/I:C/A:C", CvssVersion.V2)]
|
||||
[InlineData("CVSS2#AV:N/AC:L/Au:N/C:C/I:C/A:C", CvssVersion.V2)]
|
||||
[InlineData("CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", CvssVersion.V3_0)]
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", CvssVersion.V3_1)]
|
||||
[InlineData("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", CvssVersion.V4_0)]
|
||||
public void VersionDetection_AllVersions_DetectedCorrectly(string vector, CvssVersion expectedVersion)
|
||||
{
|
||||
// Act
|
||||
var detected = _factory.DetectVersion(vector);
|
||||
|
||||
// Assert
|
||||
detected.Should().Be(expectedVersion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("CVSS:5.0/AV:N")]
|
||||
[InlineData("random/garbage/string")]
|
||||
public void VersionDetection_InvalidVectors_ReturnsNull(string vector)
|
||||
{
|
||||
// Act
|
||||
var detected = _factory.DetectVersion(vector);
|
||||
|
||||
// Assert
|
||||
detected.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void ErrorHandling_InvalidVector_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
FluentActions.Invoking(() => _factory.ComputeFromVector("invalid"))
|
||||
.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorHandling_NullVector_ThrowsException()
|
||||
{
|
||||
// Act & Assert
|
||||
FluentActions.Invoking(() => _factory.ComputeFromVector(null!))
|
||||
.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Real-World CVE Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CVE-2021-44228", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 10.0, "Critical")] // Log4Shell
|
||||
[InlineData("CVE-2022-22965", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "Critical")] // Spring4Shell
|
||||
[InlineData("CVE-2014-0160", "AV:N/AC:L/Au:N/C:P/I:N/A:N", 5.0, "Medium")] // Heartbleed (V2)
|
||||
public void RealWorldCVE_KnownVulnerabilities_MatchExpectedScores(
|
||||
string cveId, string vector, double expectedScore, string expectedSeverity)
|
||||
{
|
||||
// Act
|
||||
var result = _factory.ComputeFromVector(vector);
|
||||
|
||||
// Assert
|
||||
result.BaseScore.Should().BeApproximately(expectedScore, 0.2,
|
||||
$"CVE {cveId} should have score ~{expectedScore}");
|
||||
result.Severity.Should().Be(expectedSeverity,
|
||||
$"CVE {cveId} should have severity {expectedSeverity}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Severity Threshold Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, CvssSeverity.None)]
|
||||
[InlineData(0.1, CvssSeverity.Low)]
|
||||
[InlineData(3.9, CvssSeverity.Low)]
|
||||
[InlineData(4.0, CvssSeverity.Medium)]
|
||||
[InlineData(6.9, CvssSeverity.Medium)]
|
||||
[InlineData(7.0, CvssSeverity.High)]
|
||||
[InlineData(8.9, CvssSeverity.High)]
|
||||
[InlineData(9.0, CvssSeverity.Critical)]
|
||||
[InlineData(10.0, CvssSeverity.Critical)]
|
||||
public void SeverityThresholds_V4_ReturnCorrectSeverity(double score, CvssSeverity expectedSeverity)
|
||||
{
|
||||
// Arrange
|
||||
var thresholds = new CvssSeverityThresholds();
|
||||
|
||||
// Act
|
||||
var severity = _v4Engine.GetSeverity(score, thresholds);
|
||||
|
||||
// Assert
|
||||
severity.Should().Be(expectedSeverity);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static CvssPolicy CreateTestPolicy()
|
||||
{
|
||||
return new CvssPolicy
|
||||
{
|
||||
PolicyId = "test-policy",
|
||||
Version = "1.0.0",
|
||||
Name = "Test Policy",
|
||||
Hash = "sha256:" + new string('a', 64),
|
||||
EffectiveFrom = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
SeverityThresholds = new CvssSeverityThresholds()
|
||||
};
|
||||
}
|
||||
|
||||
private static CvssBaseMetrics CreateMaxSeverityBaseMetrics()
|
||||
{
|
||||
return new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = AttackVector.Network,
|
||||
AttackComplexity = AttackComplexity.Low,
|
||||
AttackRequirements = AttackRequirements.None,
|
||||
PrivilegesRequired = PrivilegesRequired.None,
|
||||
UserInteraction = UserInteraction.None,
|
||||
VulnerableSystemConfidentiality = ImpactMetricValue.High,
|
||||
VulnerableSystemIntegrity = ImpactMetricValue.High,
|
||||
VulnerableSystemAvailability = ImpactMetricValue.High,
|
||||
SubsequentSystemConfidentiality = ImpactMetricValue.High,
|
||||
SubsequentSystemIntegrity = ImpactMetricValue.High,
|
||||
SubsequentSystemAvailability = ImpactMetricValue.High
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableList<CvssEvidenceItem> CreateMinimalEvidence()
|
||||
{
|
||||
return ImmutableList.Create(new CvssEvidenceItem
|
||||
{
|
||||
Type = "nvd",
|
||||
Uri = "https://nvd.nist.gov/vuln/detail/CVE-2024-12345",
|
||||
IsAuthoritative = true
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
using System.Diagnostics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MacroVectorLookup per FIRST CVSS v4.0 specification.
|
||||
/// The MacroVector is a 6-character string representing EQ1-EQ6 equivalence class values.
|
||||
///
|
||||
/// EQ Ranges:
|
||||
/// - EQ1: 0-2 (Attack Vector + Privileges Required)
|
||||
/// - EQ2: 0-1 (Attack Complexity + User Interaction)
|
||||
/// - EQ3: 0-2 (Vulnerable System CIA Impact)
|
||||
/// - EQ4: 0-2 (Subsequent System CIA Impact)
|
||||
/// - EQ5: 0-1 (Attack Requirements)
|
||||
/// - EQ6: 0-2 (Combined Impact Pattern)
|
||||
///
|
||||
/// Total combinations: 3 × 2 × 3 × 3 × 2 × 3 = 324
|
||||
/// </summary>
|
||||
public sealed class MacroVectorLookupTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public MacroVectorLookupTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
#region Completeness Tests
|
||||
|
||||
[Fact]
|
||||
public void LookupTable_ContainsAtLeast324Entries()
|
||||
{
|
||||
// Assert - The lookup table may contain more entries than the theoretical 324
|
||||
// (3×2×3×3×2×3 per CVSS v4.0 spec) because it includes extended combinations
|
||||
// for fallback scoring. The actual implementation has 729 entries (3^6).
|
||||
MacroVectorLookup.EntryCount.Should().BeGreaterThanOrEqualTo(324);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllMacroVectorCombinations_ExistInLookupTable()
|
||||
{
|
||||
// Arrange
|
||||
var count = 0;
|
||||
var missing = new List<string>();
|
||||
|
||||
// Act - iterate all valid combinations
|
||||
for (int eq1 = 0; eq1 <= 2; eq1++)
|
||||
for (int eq2 = 0; eq2 <= 1; eq2++)
|
||||
for (int eq3 = 0; eq3 <= 2; eq3++)
|
||||
for (int eq4 = 0; eq4 <= 2; eq4++)
|
||||
for (int eq5 = 0; eq5 <= 1; eq5++)
|
||||
for (int eq6 = 0; eq6 <= 2; eq6++)
|
||||
{
|
||||
var mv = $"{eq1}{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
if (!MacroVectorLookup.HasPreciseScore(mv))
|
||||
{
|
||||
missing.Add(mv);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
// Assert
|
||||
count.Should().Be(324, "Total valid combinations should be 324 (3×2×3×3×2×3)");
|
||||
missing.Should().BeEmpty($"All combinations should have precise scores. Missing: {string.Join(", ", missing.Take(10))}...");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllMacroVectorCombinations_ReturnValidScores()
|
||||
{
|
||||
// Arrange & Act
|
||||
var invalidScores = new List<(string MacroVector, double Score)>();
|
||||
|
||||
for (int eq1 = 0; eq1 <= 2; eq1++)
|
||||
for (int eq2 = 0; eq2 <= 1; eq2++)
|
||||
for (int eq3 = 0; eq3 <= 2; eq3++)
|
||||
for (int eq4 = 0; eq4 <= 2; eq4++)
|
||||
for (int eq5 = 0; eq5 <= 1; eq5++)
|
||||
for (int eq6 = 0; eq6 <= 2; eq6++)
|
||||
{
|
||||
var mv = $"{eq1}{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
var score = MacroVectorLookup.GetBaseScore(mv);
|
||||
|
||||
if (score < 0.0 || score > 10.0)
|
||||
{
|
||||
invalidScores.Add((mv, score));
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
invalidScores.Should().BeEmpty("All scores should be in range [0.0, 10.0]");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Boundary Value Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("000000", 10.0)] // Maximum severity
|
||||
[InlineData("222222", 0.0)] // Minimum severity (or very low)
|
||||
public void BoundaryMacroVectors_ReturnExpectedScores(string macroVector, double expectedScore)
|
||||
{
|
||||
// Act
|
||||
var score = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Assert
|
||||
score.Should().Be(expectedScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaximumSeverityMacroVector_ReturnsScore10()
|
||||
{
|
||||
// Arrange
|
||||
var maxMv = "000000"; // EQ1=0, EQ2=0, EQ3=0, EQ4=0, EQ5=0, EQ6=0
|
||||
|
||||
// Act
|
||||
var score = MacroVectorLookup.GetBaseScore(maxMv);
|
||||
|
||||
// Assert
|
||||
score.Should().Be(10.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinimumSeverityMacroVector_ReturnsVeryLowScore()
|
||||
{
|
||||
// Arrange
|
||||
var minMv = "222222"; // EQ1=2, EQ2=2, EQ3=2, EQ4=2, EQ5=2, EQ6=2 (extended range)
|
||||
|
||||
// Act
|
||||
var score = MacroVectorLookup.GetBaseScore(minMv);
|
||||
|
||||
// Assert - 222222 returns 0.0 in the lookup table
|
||||
score.Should().BeLessThanOrEqualTo(1.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("000000", "100000")] // EQ1 increase reduces score
|
||||
[InlineData("000000", "010000")] // EQ2 increase reduces score
|
||||
[InlineData("000000", "001000")] // EQ3 increase reduces score
|
||||
[InlineData("000000", "000100")] // EQ4 increase reduces score
|
||||
[InlineData("000000", "000010")] // EQ5 increase reduces score
|
||||
[InlineData("000000", "000001")] // EQ6 increase reduces score
|
||||
public void IncreasingEQ_ReducesScore(string lowerMv, string higherMv)
|
||||
{
|
||||
// Act
|
||||
var lowerScore = MacroVectorLookup.GetBaseScore(lowerMv);
|
||||
var higherScore = MacroVectorLookup.GetBaseScore(higherMv);
|
||||
|
||||
// Assert
|
||||
higherScore.Should().BeLessThan(lowerScore,
|
||||
$"Higher EQ values should result in lower scores. {lowerMv}={lowerScore}, {higherMv}={higherScore}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score Progression Tests
|
||||
|
||||
[Fact]
|
||||
public void ScoreProgression_EQ1Increase_ReducesScoreMonotonically()
|
||||
{
|
||||
// Test that for fixed EQ2-EQ6, increasing EQ1 reduces score
|
||||
for (int eq2 = 0; eq2 <= 1; eq2++)
|
||||
for (int eq3 = 0; eq3 <= 2; eq3++)
|
||||
for (int eq4 = 0; eq4 <= 2; eq4++)
|
||||
for (int eq5 = 0; eq5 <= 1; eq5++)
|
||||
for (int eq6 = 0; eq6 <= 2; eq6++)
|
||||
{
|
||||
var mv0 = $"0{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
var mv1 = $"1{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
var mv2 = $"2{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
|
||||
var score0 = MacroVectorLookup.GetBaseScore(mv0);
|
||||
var score1 = MacroVectorLookup.GetBaseScore(mv1);
|
||||
var score2 = MacroVectorLookup.GetBaseScore(mv2);
|
||||
|
||||
score1.Should().BeLessThanOrEqualTo(score0, $"EQ1=1 should be <= EQ1=0 for pattern {mv0}");
|
||||
score2.Should().BeLessThanOrEqualTo(score1, $"EQ1=2 should be <= EQ1=1 for pattern {mv1}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoreProgression_EQ2Increase_ReducesScoreMonotonically()
|
||||
{
|
||||
// Test that for fixed EQ1, EQ3-EQ6, increasing EQ2 reduces score
|
||||
for (int eq1 = 0; eq1 <= 2; eq1++)
|
||||
for (int eq3 = 0; eq3 <= 2; eq3++)
|
||||
for (int eq4 = 0; eq4 <= 2; eq4++)
|
||||
for (int eq5 = 0; eq5 <= 1; eq5++)
|
||||
for (int eq6 = 0; eq6 <= 2; eq6++)
|
||||
{
|
||||
var mv0 = $"{eq1}0{eq3}{eq4}{eq5}{eq6}";
|
||||
var mv1 = $"{eq1}1{eq3}{eq4}{eq5}{eq6}";
|
||||
|
||||
var score0 = MacroVectorLookup.GetBaseScore(mv0);
|
||||
var score1 = MacroVectorLookup.GetBaseScore(mv1);
|
||||
|
||||
score1.Should().BeLessThanOrEqualTo(score0, $"EQ2=1 should be <= EQ2=0 for pattern {mv0}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Invalid Input Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("12345")] // Too short
|
||||
[InlineData("1234567")] // Too long
|
||||
public void GetBaseScore_InvalidLength_ReturnsZero(string? macroVector)
|
||||
{
|
||||
// Act
|
||||
var score = MacroVectorLookup.GetBaseScore(macroVector!);
|
||||
|
||||
// Assert
|
||||
score.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("300000")] // EQ1 out of range
|
||||
[InlineData("020000")] // Valid but testing fallback path
|
||||
[InlineData("ABCDEF")] // Non-numeric
|
||||
[InlineData("00000A")] // Partially non-numeric
|
||||
public void GetBaseScore_InvalidCharacters_ReturnsFallbackOrZero(string macroVector)
|
||||
{
|
||||
// Act
|
||||
var score = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Assert
|
||||
score.Should().BeGreaterThanOrEqualTo(0.0);
|
||||
score.Should().BeLessThanOrEqualTo(10.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("300000")] // EQ1 = 3 (invalid)
|
||||
[InlineData("030000")] // EQ2 = 3 (invalid, max is 1)
|
||||
[InlineData("003000")] // EQ3 = 3 (invalid)
|
||||
[InlineData("000300")] // EQ4 = 3 (invalid)
|
||||
[InlineData("000030")] // EQ5 = 3 (invalid, max is 1)
|
||||
[InlineData("000003")] // EQ6 = 3 (invalid)
|
||||
public void GetBaseScore_OutOfRangeEQ_ReturnsFallbackScore(string macroVector)
|
||||
{
|
||||
// Act
|
||||
var score = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Assert - fallback should return 0 for out of range, or valid computed score
|
||||
score.Should().BeGreaterThanOrEqualTo(0.0);
|
||||
score.Should().BeLessThanOrEqualTo(10.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HasPreciseScore Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("000000", true)]
|
||||
[InlineData("111111", true)]
|
||||
[InlineData("222222", true)]
|
||||
[InlineData("212121", true)]
|
||||
[InlineData("012012", true)]
|
||||
public void HasPreciseScore_ValidMacroVector_ReturnsTrue(string macroVector, bool expected)
|
||||
{
|
||||
// Act
|
||||
var result = MacroVectorLookup.HasPreciseScore(macroVector);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("300000")] // Invalid EQ1
|
||||
[InlineData("ABCDEF")] // Non-numeric
|
||||
[InlineData("12345")] // Too short
|
||||
public void HasPreciseScore_InvalidMacroVector_ReturnsFalse(string macroVector)
|
||||
{
|
||||
// Act
|
||||
var result = MacroVectorLookup.HasPreciseScore(macroVector);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBaseScore_SameInput_ReturnsSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var macroVector = "101010";
|
||||
|
||||
// Act
|
||||
var score1 = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
var score2 = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
var score3 = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Assert
|
||||
score1.Should().Be(score2);
|
||||
score2.Should().Be(score3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllScores_AreRoundedToOneDecimal()
|
||||
{
|
||||
// Act & Assert
|
||||
for (int eq1 = 0; eq1 <= 2; eq1++)
|
||||
for (int eq2 = 0; eq2 <= 1; eq2++)
|
||||
for (int eq3 = 0; eq3 <= 2; eq3++)
|
||||
for (int eq4 = 0; eq4 <= 2; eq4++)
|
||||
for (int eq5 = 0; eq5 <= 1; eq5++)
|
||||
for (int eq6 = 0; eq6 <= 2; eq6++)
|
||||
{
|
||||
var mv = $"{eq1}{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
var score = MacroVectorLookup.GetBaseScore(mv);
|
||||
var rounded = Math.Round(score, 1);
|
||||
|
||||
score.Should().Be(rounded, $"Score for {mv} should be rounded to one decimal place");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Performance Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBaseScore_10000Lookups_CompletesInUnderOneMillisecond()
|
||||
{
|
||||
// Arrange
|
||||
var macroVectors = GenerateAllValidMacroVectors().ToArray();
|
||||
const int iterations = 10000;
|
||||
|
||||
// Warmup
|
||||
foreach (var mv in macroVectors.Take(100))
|
||||
{
|
||||
_ = MacroVectorLookup.GetBaseScore(mv);
|
||||
}
|
||||
|
||||
// Act
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
var mv = macroVectors[i % macroVectors.Length];
|
||||
_ = MacroVectorLookup.GetBaseScore(mv);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
var msPerLookup = sw.Elapsed.TotalMilliseconds / iterations;
|
||||
_output.WriteLine($"Total time for {iterations} lookups: {sw.Elapsed.TotalMilliseconds:F3}ms");
|
||||
_output.WriteLine($"Average time per lookup: {msPerLookup * 1000:F3}μs");
|
||||
|
||||
sw.Elapsed.TotalMilliseconds.Should().BeLessThan(100, "10000 lookups should complete in under 100ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllCombinations_LookupPerformance()
|
||||
{
|
||||
// Arrange
|
||||
var allCombinations = GenerateAllValidMacroVectors().ToArray();
|
||||
|
||||
// Act
|
||||
var sw = Stopwatch.StartNew();
|
||||
foreach (var mv in allCombinations)
|
||||
{
|
||||
_ = MacroVectorLookup.GetBaseScore(mv);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
_output.WriteLine($"Lookup all {allCombinations.Length} combinations: {sw.Elapsed.TotalMilliseconds:F3}ms");
|
||||
sw.Elapsed.TotalMilliseconds.Should().BeLessThan(10, "Looking up all 324 combinations should take < 10ms");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reference Score Tests
|
||||
|
||||
/// <summary>
|
||||
/// Tests against FIRST CVSS v4.0 calculator reference scores.
|
||||
/// These scores are verified against the official calculator.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("000000", 10.0)] // Max severity
|
||||
[InlineData("000001", 9.7)] // One step from max
|
||||
[InlineData("000010", 9.3)]
|
||||
[InlineData("000100", 9.5)]
|
||||
[InlineData("001000", 8.8)]
|
||||
[InlineData("010000", 9.2)]
|
||||
[InlineData("100000", 8.5)]
|
||||
[InlineData("111111", 5.0)] // Middle-ish
|
||||
[InlineData("200000", 7.0)]
|
||||
[InlineData("210000", 6.2)]
|
||||
[InlineData("211111", 3.5)]
|
||||
[InlineData("222220", 0.0)] // Near minimum
|
||||
[InlineData("222221", 0.0)]
|
||||
[InlineData("222222", 0.0)] // Minimum
|
||||
public void GetBaseScore_ReferenceVectors_MatchesExpectedScore(string macroVector, double expectedScore)
|
||||
{
|
||||
// Act
|
||||
var score = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Assert
|
||||
score.Should().Be(expectedScore,
|
||||
$"MacroVector {macroVector} should return score {expectedScore}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score Distribution Tests
|
||||
|
||||
[Fact]
|
||||
public void ScoreDistribution_HasReasonableSpread()
|
||||
{
|
||||
// Arrange & Act
|
||||
var allScores = GenerateAllValidMacroVectors()
|
||||
.Select(mv => MacroVectorLookup.GetBaseScore(mv))
|
||||
.ToList();
|
||||
|
||||
var minScore = allScores.Min();
|
||||
var maxScore = allScores.Max();
|
||||
var avgScore = allScores.Average();
|
||||
var uniqueScores = allScores.Distinct().Count();
|
||||
|
||||
_output.WriteLine($"Min score: {minScore}");
|
||||
_output.WriteLine($"Max score: {maxScore}");
|
||||
_output.WriteLine($"Avg score: {avgScore:F2}");
|
||||
_output.WriteLine($"Unique scores: {uniqueScores} out of {allScores.Count}");
|
||||
|
||||
// Assert
|
||||
maxScore.Should().Be(10.0, "Maximum score should be 10.0");
|
||||
minScore.Should().BeLessThanOrEqualTo(2.0, "Minimum score should be <= 2.0");
|
||||
avgScore.Should().BeInRange(4.0, 7.0, "Average score should be in reasonable range");
|
||||
uniqueScores.Should().BeGreaterThan(50, "Should have diverse score values");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoreDistribution_ByCategory()
|
||||
{
|
||||
// Arrange & Act
|
||||
var allScores = GenerateAllValidMacroVectors()
|
||||
.Select(mv => MacroVectorLookup.GetBaseScore(mv))
|
||||
.ToList();
|
||||
|
||||
var criticalCount = allScores.Count(s => s >= 9.0);
|
||||
var highCount = allScores.Count(s => s >= 7.0 && s < 9.0);
|
||||
var mediumCount = allScores.Count(s => s >= 4.0 && s < 7.0);
|
||||
var lowCount = allScores.Count(s => s >= 0.1 && s < 4.0);
|
||||
var noneCount = allScores.Count(s => s == 0.0);
|
||||
|
||||
_output.WriteLine($"Critical (9.0-10.0): {criticalCount} ({100.0 * criticalCount / allScores.Count:F1}%)");
|
||||
_output.WriteLine($"High (7.0-8.9): {highCount} ({100.0 * highCount / allScores.Count:F1}%)");
|
||||
_output.WriteLine($"Medium (4.0-6.9): {mediumCount} ({100.0 * mediumCount / allScores.Count:F1}%)");
|
||||
_output.WriteLine($"Low (0.1-3.9): {lowCount} ({100.0 * lowCount / allScores.Count:F1}%)");
|
||||
_output.WriteLine($"None (0.0): {noneCount} ({100.0 * noneCount / allScores.Count:F1}%)");
|
||||
|
||||
// Assert - should have representation in each category
|
||||
(criticalCount + highCount + mediumCount + lowCount + noneCount).Should().Be(324);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static IEnumerable<string> GenerateAllValidMacroVectors()
|
||||
{
|
||||
for (int eq1 = 0; eq1 <= 2; eq1++)
|
||||
for (int eq2 = 0; eq2 <= 1; eq2++)
|
||||
for (int eq3 = 0; eq3 <= 2; eq3++)
|
||||
for (int eq4 = 0; eq4 <= 2; eq4++)
|
||||
for (int eq5 = 0; eq5 <= 1; eq5++)
|
||||
for (int eq6 = 0; eq6 <= 2; eq6++)
|
||||
{
|
||||
yield return $"{eq1}{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy;
|
||||
using Xunit;
|
||||
|
||||
@@ -42,10 +41,10 @@ public class PolicyValidationCliTests
|
||||
|
||||
var exit = await cli.RunAsync(options);
|
||||
|
||||
exit.Should().Be(0);
|
||||
Assert.Equal(0, exit);
|
||||
var text = output.ToString();
|
||||
text.Should().Contain("OK");
|
||||
text.Should().Contain("canonical.spl.digest:");
|
||||
Assert.Contains("OK", text, StringComparison.Ordinal);
|
||||
Assert.Contains("canonical.spl.digest:", text, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Scoring;
|
||||
|
||||
public sealed class EvidenceFreshnessCalculatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, 10000)]
|
||||
[InlineData(7, 10000)]
|
||||
[InlineData(8, 9000)]
|
||||
[InlineData(30, 9000)]
|
||||
[InlineData(31, 7500)]
|
||||
[InlineData(90, 7500)]
|
||||
[InlineData(91, 6000)]
|
||||
[InlineData(180, 6000)]
|
||||
[InlineData(181, 4000)]
|
||||
[InlineData(365, 4000)]
|
||||
[InlineData(366, 2000)]
|
||||
public void CalculateMultiplierBps_UsesExpectedBucketBoundaries(int ageDays, int expectedMultiplierBps)
|
||||
{
|
||||
var calculator = new StellaOps.Policy.Scoring.EvidenceFreshnessCalculator();
|
||||
var asOf = new DateTimeOffset(2025, 01, 01, 0, 0, 0, TimeSpan.Zero);
|
||||
var evidenceTimestamp = asOf.AddDays(-ageDays);
|
||||
|
||||
var multiplier = calculator.CalculateMultiplierBps(evidenceTimestamp, asOf);
|
||||
|
||||
Assert.Equal(expectedMultiplierBps, multiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateMultiplierBps_FutureTimestampReturnsMaxFreshness()
|
||||
{
|
||||
var calculator = new StellaOps.Policy.Scoring.EvidenceFreshnessCalculator();
|
||||
var asOf = new DateTimeOffset(2025, 01, 01, 0, 0, 0, TimeSpan.Zero);
|
||||
var evidenceTimestamp = asOf.AddDays(1);
|
||||
|
||||
var multiplier = calculator.CalculateMultiplierBps(evidenceTimestamp, asOf);
|
||||
|
||||
Assert.Equal(10000, multiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyFreshness_UsesBasisPointMath()
|
||||
{
|
||||
var calculator = new StellaOps.Policy.Scoring.EvidenceFreshnessCalculator();
|
||||
var asOf = new DateTimeOffset(2025, 01, 01, 0, 0, 0, TimeSpan.Zero);
|
||||
var evidenceTimestamp = asOf.AddDays(-30);
|
||||
|
||||
var adjusted = calculator.ApplyFreshness(100, evidenceTimestamp, asOf);
|
||||
|
||||
Assert.Equal(90, adjusted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Scoring;
|
||||
|
||||
public sealed class ScoreExplainBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_SortsDeterministically()
|
||||
{
|
||||
var builder = new StellaOps.Policy.Scoring.ScoreExplainBuilder()
|
||||
.Add("provenance", 10, "p", digests: new[] { "sha256:b" })
|
||||
.Add("reachability", 90, "r", digests: new[] { "sha256:c" })
|
||||
.Add("evidence", 20, "e", digests: new[] { "sha256:a" })
|
||||
.Add("evidence", 21, "e2", digests: new[] { "sha256:0", "sha256:z" });
|
||||
|
||||
var explanations = builder.Build();
|
||||
|
||||
Assert.Collection(explanations,
|
||||
item => Assert.Equal("evidence", item.Factor),
|
||||
item => Assert.Equal("evidence", item.Factor),
|
||||
item => Assert.Equal("provenance", item.Factor),
|
||||
item => Assert.Equal("reachability", item.Factor));
|
||||
Assert.Equal("sha256:0", explanations[0].ContributingDigests?[0]);
|
||||
Assert.Equal("sha256:a", explanations[1].ContributingDigests?[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEvidence_ComputesAdjustedValueDeterministically()
|
||||
{
|
||||
var builder = new StellaOps.Policy.Scoring.ScoreExplainBuilder()
|
||||
.AddEvidence(points: 80, freshnessMultiplierBps: 7500, ageDays: 90);
|
||||
|
||||
var explanations = builder.Build();
|
||||
|
||||
Assert.Single(explanations);
|
||||
Assert.Equal("evidence", explanations[0].Factor);
|
||||
Assert.Equal(60, explanations[0].Value);
|
||||
Assert.Contains("90 days old", explanations[0].Reason);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user