Implement VEX document verification system with issuer management and signature verification

- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation.
- Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments.
- Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats.
- Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats.
- Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction.
- Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
This commit is contained in:
StellaOps Bot
2025-12-06 13:41:22 +02:00
parent 2141196496
commit 5e514532df
112 changed files with 24861 additions and 211 deletions

View File

@@ -0,0 +1,662 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Engine.Simulation;
using StellaOps.Policy.RiskProfile.Models;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Simulation;
/// <summary>
/// Tests for RiskSimulationBreakdownService.
/// Per POLICY-RISK-67-003.
/// </summary>
public sealed class RiskSimulationBreakdownServiceTests
{
private readonly RiskSimulationBreakdownService _service;
public RiskSimulationBreakdownServiceTests()
{
_service = new RiskSimulationBreakdownService(
NullLogger<RiskSimulationBreakdownService>.Instance);
}
[Fact]
public void GenerateBreakdown_WithValidInput_ReturnsBreakdown()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(5);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.Should().NotBeNull();
breakdown.SimulationId.Should().Be(result.SimulationId);
breakdown.ProfileRef.Should().NotBeNull();
breakdown.ProfileRef.Id.Should().Be(profile.Id);
breakdown.SignalAnalysis.Should().NotBeNull();
breakdown.OverrideAnalysis.Should().NotBeNull();
breakdown.ScoreDistribution.Should().NotBeNull();
breakdown.SeverityBreakdown.Should().NotBeNull();
breakdown.ActionBreakdown.Should().NotBeNull();
breakdown.DeterminismHash.Should().StartWith("sha256:");
}
[Fact]
public void GenerateBreakdown_SignalAnalysis_ComputesCorrectCoverage()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(10);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.SignalAnalysis.TotalSignals.Should().Be(profile.Signals.Count);
breakdown.SignalAnalysis.SignalsUsed.Should().BeGreaterThan(0);
breakdown.SignalAnalysis.SignalCoverage.Should().BeGreaterThan(0);
breakdown.SignalAnalysis.SignalStats.Should().NotBeEmpty();
}
[Fact]
public void GenerateBreakdown_SignalAnalysis_IdentifiesTopContributors()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(20);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.SignalAnalysis.TopContributors.Should().NotBeEmpty();
breakdown.SignalAnalysis.TopContributors.Length.Should().BeLessOrEqualTo(10);
// Top contributors should be ordered by contribution
for (var i = 1; i < breakdown.SignalAnalysis.TopContributors.Length; i++)
{
breakdown.SignalAnalysis.TopContributors[i - 1].TotalContribution
.Should().BeGreaterOrEqualTo(breakdown.SignalAnalysis.TopContributors[i].TotalContribution);
}
}
[Fact]
public void GenerateBreakdown_OverrideAnalysis_TracksApplications()
{
// Arrange
var profile = CreateTestProfileWithOverrides();
var findings = CreateTestFindingsWithKev(5);
var result = CreateTestResultWithOverrides(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.OverrideAnalysis.Should().NotBeNull();
breakdown.OverrideAnalysis.TotalOverridesEvaluated.Should().BeGreaterThan(0);
}
[Fact]
public void GenerateBreakdown_ScoreDistribution_ComputesStatistics()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(50);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ScoreDistribution.Should().NotBeNull();
breakdown.ScoreDistribution.RawScoreStats.Should().NotBeNull();
breakdown.ScoreDistribution.NormalizedScoreStats.Should().NotBeNull();
breakdown.ScoreDistribution.ScoreBuckets.Should().HaveCount(10);
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p50");
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p90");
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p99");
}
[Fact]
public void GenerateBreakdown_ScoreDistribution_ComputesSkewnessAndKurtosis()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(100);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
var stats = breakdown.ScoreDistribution.NormalizedScoreStats;
stats.Skewness.Should().NotBe(0); // With random data, unlikely to be exactly 0
// Kurtosis can be any value, just verify it's computed
}
[Fact]
public void GenerateBreakdown_ScoreDistribution_IdentifiesOutliers()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(50);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ScoreDistribution.Outliers.Should().NotBeNull();
breakdown.ScoreDistribution.Outliers.OutlierThreshold.Should().BeGreaterThan(0);
}
[Fact]
public void GenerateBreakdown_SeverityBreakdown_GroupsCorrectly()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(30);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.SeverityBreakdown.Should().NotBeNull();
breakdown.SeverityBreakdown.BySeverity.Should().NotBeEmpty();
// Total count should match findings
var totalCount = breakdown.SeverityBreakdown.BySeverity.Values.Sum(b => b.Count);
totalCount.Should().Be(findings.Count);
}
[Fact]
public void GenerateBreakdown_SeverityBreakdown_ComputesConcentration()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(20);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
// HHI ranges from 1/n to 1
breakdown.SeverityBreakdown.SeverityConcentration.Should().BeGreaterOrEqualTo(0);
breakdown.SeverityBreakdown.SeverityConcentration.Should().BeLessOrEqualTo(1);
}
[Fact]
public void GenerateBreakdown_ActionBreakdown_GroupsCorrectly()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(25);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ActionBreakdown.Should().NotBeNull();
breakdown.ActionBreakdown.ByAction.Should().NotBeEmpty();
// Total count should match findings
var totalCount = breakdown.ActionBreakdown.ByAction.Values.Sum(b => b.Count);
totalCount.Should().Be(findings.Count);
}
[Fact]
public void GenerateBreakdown_ActionBreakdown_ComputesStability()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(20);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
// Stability ranges from 0 to 1
breakdown.ActionBreakdown.DecisionStability.Should().BeGreaterOrEqualTo(0);
breakdown.ActionBreakdown.DecisionStability.Should().BeLessOrEqualTo(1);
}
[Fact]
public void GenerateBreakdown_ComponentBreakdown_IncludedByDefault()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(15);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ComponentBreakdown.Should().NotBeNull();
breakdown.ComponentBreakdown!.TotalComponents.Should().BeGreaterThan(0);
breakdown.ComponentBreakdown.TopRiskComponents.Should().NotBeEmpty();
}
[Fact]
public void GenerateBreakdown_ComponentBreakdown_ExtractsEcosystems()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateMixedEcosystemFindings();
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ComponentBreakdown.Should().NotBeNull();
breakdown.ComponentBreakdown!.EcosystemBreakdown.Should().NotBeEmpty();
breakdown.ComponentBreakdown.EcosystemBreakdown.Should().ContainKey("npm");
breakdown.ComponentBreakdown.EcosystemBreakdown.Should().ContainKey("maven");
}
[Fact]
public void GenerateBreakdown_WithQuickOptions_ExcludesComponentBreakdown()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(10);
var result = CreateTestResult(findings, profile);
var options = RiskSimulationBreakdownOptions.Quick;
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings, options);
// Assert
breakdown.ComponentBreakdown.Should().BeNull();
}
[Fact]
public void GenerateBreakdown_DeterminismHash_IsConsistent()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(10);
var result = CreateTestResult(findings, profile);
// Act
var breakdown1 = _service.GenerateBreakdown(result, profile, findings);
var breakdown2 = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown1.DeterminismHash.Should().Be(breakdown2.DeterminismHash);
}
[Fact]
public void GenerateComparisonBreakdown_IncludesRiskTrends()
{
// Arrange
var baseProfile = CreateTestProfile();
var compareProfile = CreateTestProfileVariant();
var findings = CreateTestFindings(20);
var baseResult = CreateTestResult(findings, baseProfile);
var compareResult = CreateTestResult(findings, compareProfile);
// Act
var breakdown = _service.GenerateComparisonBreakdown(
baseResult, compareResult,
baseProfile, compareProfile,
findings);
// Assert
breakdown.RiskTrends.Should().NotBeNull();
breakdown.RiskTrends!.ComparisonType.Should().Be("profile_comparison");
breakdown.RiskTrends.ScoreTrend.Should().NotBeNull();
breakdown.RiskTrends.SeverityTrend.Should().NotBeNull();
breakdown.RiskTrends.ActionTrend.Should().NotBeNull();
}
[Fact]
public void GenerateComparisonBreakdown_TracksImprovementsAndRegressions()
{
// Arrange
var baseProfile = CreateTestProfile();
var compareProfile = CreateTestProfile(); // Same profile = no changes
var findings = CreateTestFindings(15);
var baseResult = CreateTestResult(findings, baseProfile);
var compareResult = CreateTestResult(findings, compareProfile);
// Act
var breakdown = _service.GenerateComparisonBreakdown(
baseResult, compareResult,
baseProfile, compareProfile,
findings);
// Assert
var trends = breakdown.RiskTrends!;
var total = trends.FindingsImproved + trends.FindingsWorsened + trends.FindingsUnchanged;
total.Should().Be(findings.Count);
}
[Fact]
public void GenerateBreakdown_EmptyFindings_ReturnsValidBreakdown()
{
// Arrange
var profile = CreateTestProfile();
var findings = Array.Empty<SimulationFinding>();
var result = CreateEmptyResult(profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.Should().NotBeNull();
breakdown.ScoreDistribution.RawScoreStats.Count.Should().Be(0);
breakdown.SeverityBreakdown.BySeverity.Should().BeEmpty();
}
[Fact]
public void GenerateBreakdown_MissingSignals_ReportsImpact()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateFindingsWithMissingSignals();
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.SignalAnalysis.MissingSignalImpact.Should().NotBeNull();
// Some findings have missing signals
breakdown.SignalAnalysis.SignalsMissing.Should().BeGreaterOrEqualTo(0);
}
#region Test Helpers
private static RiskProfileModel CreateTestProfile()
{
return new RiskProfileModel
{
Id = "test-profile",
Version = "1.0.0",
Description = "Test profile for unit tests",
Signals = new List<RiskSignal>
{
new() { Name = "cvss", Source = "nvd", Type = RiskSignalType.Numeric },
new() { Name = "kev", Source = "cisa", Type = RiskSignalType.Boolean },
new() { Name = "reachability", Source = "scanner", Type = RiskSignalType.Numeric },
new() { Name = "exploit_maturity", Source = "epss", Type = RiskSignalType.Categorical }
},
Weights = new Dictionary<string, double>
{
["cvss"] = 0.4,
["kev"] = 0.3,
["reachability"] = 0.2,
["exploit_maturity"] = 0.1
},
Overrides = new RiskOverrides()
};
}
private static RiskProfileModel CreateTestProfileWithOverrides()
{
var profile = CreateTestProfile();
profile.Overrides = new RiskOverrides
{
Severity = new List<SeverityOverride>
{
new()
{
When = new Dictionary<string, object> { ["kev"] = true },
Set = RiskSeverity.Critical
}
},
Decisions = new List<DecisionOverride>
{
new()
{
When = new Dictionary<string, object> { ["kev"] = true },
Action = RiskAction.Deny,
Reason = "KEV findings must be denied"
}
}
};
return profile;
}
private static RiskProfileModel CreateTestProfileVariant()
{
var profile = CreateTestProfile();
profile.Id = "test-profile-variant";
profile.Weights = new Dictionary<string, double>
{
["cvss"] = 0.5, // Higher weight for CVSS
["kev"] = 0.2,
["reachability"] = 0.2,
["exploit_maturity"] = 0.1
};
return profile;
}
private static IReadOnlyList<SimulationFinding> CreateTestFindings(int count)
{
var random = new Random(42); // Deterministic seed
return Enumerable.Range(1, count)
.Select(i => new SimulationFinding(
$"finding-{i}",
$"pkg:npm/package-{i}@{i}.0.0",
$"CVE-2024-{i:D4}",
new Dictionary<string, object?>
{
["cvss"] = Math.Round(random.NextDouble() * 10, 1),
["kev"] = random.Next(10) < 2, // 20% chance of KEV
["reachability"] = Math.Round(random.NextDouble(), 2),
["exploit_maturity"] = random.Next(4) switch
{
0 => "none",
1 => "low",
2 => "medium",
_ => "high"
}
}))
.ToList();
}
private static IReadOnlyList<SimulationFinding> CreateTestFindingsWithKev(int count)
{
return Enumerable.Range(1, count)
.Select(i => new SimulationFinding(
$"finding-{i}",
$"pkg:npm/package-{i}@{i}.0.0",
$"CVE-2024-{i:D4}",
new Dictionary<string, object?>
{
["cvss"] = 8.0 + (i % 3),
["kev"] = true, // All have KEV
["reachability"] = 0.9,
["exploit_maturity"] = "high"
}))
.ToList();
}
private static IReadOnlyList<SimulationFinding> CreateMixedEcosystemFindings()
{
return new List<SimulationFinding>
{
new("f1", "pkg:npm/lodash@4.17.0", "CVE-2024-0001", CreateSignals(7.5)),
new("f2", "pkg:npm/express@4.0.0", "CVE-2024-0002", CreateSignals(6.0)),
new("f3", "pkg:maven/org.apache.log4j/log4j-core@2.0.0", "CVE-2024-0003", CreateSignals(9.8)),
new("f4", "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.9.0", "CVE-2024-0004", CreateSignals(7.2)),
new("f5", "pkg:pypi/requests@2.25.0", "CVE-2024-0005", CreateSignals(5.5)),
};
}
private static IReadOnlyList<SimulationFinding> CreateFindingsWithMissingSignals()
{
return new List<SimulationFinding>
{
new("f1", "pkg:npm/a@1.0.0", "CVE-2024-0001",
new Dictionary<string, object?> { ["cvss"] = 7.0 }), // Missing kev, reachability
new("f2", "pkg:npm/b@1.0.0", "CVE-2024-0002",
new Dictionary<string, object?> { ["cvss"] = 6.0, ["kev"] = false }), // Missing reachability
new("f3", "pkg:npm/c@1.0.0", "CVE-2024-0003",
new Dictionary<string, object?> { ["cvss"] = 8.0, ["kev"] = true, ["reachability"] = 0.5 }), // All present
};
}
private static Dictionary<string, object?> CreateSignals(double cvss)
{
return new Dictionary<string, object?>
{
["cvss"] = cvss,
["kev"] = cvss >= 9.0,
["reachability"] = 0.7,
["exploit_maturity"] = cvss >= 8.0 ? "high" : "medium"
};
}
private static RiskSimulationResult CreateTestResult(
IReadOnlyList<SimulationFinding> findings,
RiskProfileModel profile)
{
var findingScores = findings.Select(f =>
{
var cvss = f.Signals.GetValueOrDefault("cvss") switch
{
double d => d,
_ => 5.0
};
var kev = f.Signals.GetValueOrDefault("kev") switch
{
bool b => b,
_ => false
};
var reachability = f.Signals.GetValueOrDefault("reachability") switch
{
double d => d,
_ => 0.5
};
var rawScore = cvss * 0.4 + (kev ? 1.0 : 0.0) * 0.3 + reachability * 0.2;
var normalizedScore = Math.Clamp(rawScore * 10, 0, 100);
var severity = normalizedScore switch
{
>= 90 => RiskSeverity.Critical,
>= 70 => RiskSeverity.High,
>= 40 => RiskSeverity.Medium,
>= 10 => RiskSeverity.Low,
_ => RiskSeverity.Informational
};
var action = severity switch
{
RiskSeverity.Critical or RiskSeverity.High => RiskAction.Deny,
RiskSeverity.Medium => RiskAction.Review,
_ => RiskAction.Allow
};
var contributions = new List<SignalContribution>
{
new("cvss", cvss, 0.4, cvss * 0.4, rawScore > 0 ? cvss * 0.4 / rawScore * 100 : 0),
new("kev", kev, 0.3, (kev ? 1.0 : 0.0) * 0.3, rawScore > 0 ? (kev ? 0.3 : 0.0) / rawScore * 100 : 0),
new("reachability", reachability, 0.2, reachability * 0.2, rawScore > 0 ? reachability * 0.2 / rawScore * 100 : 0)
};
return new FindingScore(
f.FindingId,
rawScore,
normalizedScore,
severity,
action,
contributions,
null);
}).ToList();
var aggregateMetrics = new AggregateRiskMetrics(
findings.Count,
findingScores.Count > 0 ? findingScores.Average(s => s.NormalizedScore) : 0,
findingScores.Count > 0 ? findingScores.OrderBy(s => s.NormalizedScore).ElementAt(findingScores.Count / 2).NormalizedScore : 0,
0, // std dev
findingScores.Count > 0 ? findingScores.Max(s => s.NormalizedScore) : 0,
findingScores.Count > 0 ? findingScores.Min(s => s.NormalizedScore) : 0,
findingScores.Count(s => s.Severity == RiskSeverity.Critical),
findingScores.Count(s => s.Severity == RiskSeverity.High),
findingScores.Count(s => s.Severity == RiskSeverity.Medium),
findingScores.Count(s => s.Severity == RiskSeverity.Low),
findingScores.Count(s => s.Severity == RiskSeverity.Informational));
return new RiskSimulationResult(
SimulationId: $"rsim-test-{Guid.NewGuid():N}",
ProfileId: profile.Id,
ProfileVersion: profile.Version,
ProfileHash: $"sha256:test{profile.Id.GetHashCode():x8}",
Timestamp: DateTimeOffset.UtcNow,
FindingScores: findingScores,
Distribution: null,
TopMovers: null,
AggregateMetrics: aggregateMetrics,
ExecutionTimeMs: 10.5);
}
private static RiskSimulationResult CreateTestResultWithOverrides(
IReadOnlyList<SimulationFinding> findings,
RiskProfileModel profile)
{
var result = CreateTestResult(findings, profile);
// Add overrides to findings with KEV
var findingScoresWithOverrides = result.FindingScores.Select(fs =>
{
var finding = findings.FirstOrDefault(f => f.FindingId == fs.FindingId);
var kev = finding?.Signals.GetValueOrDefault("kev") switch { bool b => b, _ => false };
if (kev)
{
return fs with
{
Severity = RiskSeverity.Critical,
RecommendedAction = RiskAction.Deny,
OverridesApplied = new List<AppliedOverride>
{
new("severity",
new Dictionary<string, object> { ["kev"] = true },
fs.Severity.ToString(),
RiskSeverity.Critical.ToString(),
null),
new("decision",
new Dictionary<string, object> { ["kev"] = true },
fs.RecommendedAction.ToString(),
RiskAction.Deny.ToString(),
"KEV findings must be denied")
}
};
}
return fs;
}).ToList();
return result with { FindingScores = findingScoresWithOverrides };
}
private static RiskSimulationResult CreateEmptyResult(RiskProfileModel profile)
{
return new RiskSimulationResult(
SimulationId: "rsim-empty",
ProfileId: profile.Id,
ProfileVersion: profile.Version,
ProfileHash: "sha256:empty",
Timestamp: DateTimeOffset.UtcNow,
FindingScores: Array.Empty<FindingScore>(),
Distribution: null,
TopMovers: null,
AggregateMetrics: new AggregateRiskMetrics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
ExecutionTimeMs: 1.0);
}
#endregion
}