// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-FileCopyrightText: 2025 StellaOps Contributors using FluentAssertions; using FsCheck; using FsCheck.Xunit; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Excititor.Core; using StellaOps.Excititor.Core.Lattice; namespace StellaOps.Policy.Engine.Tests.Properties; /// /// Property-based tests for VEX lattice merge semantics. /// Verifies that join/meet operations satisfy lattice algebraic properties. /// public sealed class VexLatticeMergePropertyTests { private readonly IVexLatticeProvider _lattice; public VexLatticeMergePropertyTests() { // Use the default K4 lattice provider _lattice = new K4VexLatticeProvider(NullLogger.Instance); } #region Join Properties (Least Upper Bound) /// /// Property: Join is commutative - Join(a, b) = Join(b, a). /// [Property(MaxTest = 100)] public Property Join_IsCommutative() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), VexLatticeArbs.AnyVexClaim(), (a, b) => { var joinAB = _lattice.Join(a, b); var joinBA = _lattice.Join(b, a); return (joinAB.ResultStatus == joinBA.ResultStatus) .Label($"Join({a.Status}, {b.Status}) = {joinAB.ResultStatus}, Join({b.Status}, {a.Status}) = {joinBA.ResultStatus}"); }); } /// /// Property: Join is idempotent - Join(a, a) = a. /// [Property(MaxTest = 100)] public Property Join_IsIdempotent() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), a => { var result = _lattice.Join(a, a); return (result.ResultStatus == a.Status) .Label($"Join({a.Status}, {a.Status}) = {result.ResultStatus}, expected {a.Status}"); }); } /// /// Property: Join with bottom (unknown) yields the other element - Join(a, unknown) = a. /// [Property(MaxTest = 100)] public Property Join_WithBottom_YieldsOther() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), a => { var bottom = VexLatticeArbs.CreateClaim(VexClaimStatus.Unknown); var result = _lattice.Join(a, bottom); // Join with bottom should yield the non-bottom element (or bottom if both are bottom) var expected = a.Status == VexClaimStatus.Unknown ? VexClaimStatus.Unknown : a.Status; return (result.ResultStatus == expected) .Label($"Join({a.Status}, Unknown) = {result.ResultStatus}, expected {expected}"); }); } /// /// Property: Join with top (affected) yields top - Join(a, affected) = affected. /// [Property(MaxTest = 100)] public Property Join_WithTop_YieldsTop() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), a => { var top = VexLatticeArbs.CreateClaim(VexClaimStatus.Affected); var result = _lattice.Join(a, top); return (result.ResultStatus == VexClaimStatus.Affected) .Label($"Join({a.Status}, Affected) = {result.ResultStatus}, expected Affected"); }); } #endregion #region Meet Properties (Greatest Lower Bound) /// /// Property: Meet is commutative - Meet(a, b) = Meet(b, a). /// [Property(MaxTest = 100)] public Property Meet_IsCommutative() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), VexLatticeArbs.AnyVexClaim(), (a, b) => { var meetAB = _lattice.Meet(a, b); var meetBA = _lattice.Meet(b, a); return (meetAB.ResultStatus == meetBA.ResultStatus) .Label($"Meet({a.Status}, {b.Status}) = {meetAB.ResultStatus}, Meet({b.Status}, {a.Status}) = {meetBA.ResultStatus}"); }); } /// /// Property: Meet is idempotent - Meet(a, a) = a. /// [Property(MaxTest = 100)] public Property Meet_IsIdempotent() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), a => { var result = _lattice.Meet(a, a); return (result.ResultStatus == a.Status) .Label($"Meet({a.Status}, {a.Status}) = {result.ResultStatus}, expected {a.Status}"); }); } /// /// Property: Meet with bottom (unknown) yields bottom - Meet(a, unknown) = unknown. /// [Property(MaxTest = 100)] public Property Meet_WithBottom_YieldsBottom() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), a => { var bottom = VexLatticeArbs.CreateClaim(VexClaimStatus.Unknown); var result = _lattice.Meet(a, bottom); return (result.ResultStatus == VexClaimStatus.Unknown) .Label($"Meet({a.Status}, Unknown) = {result.ResultStatus}, expected Unknown"); }); } /// /// Property: Meet with top (affected) yields the other element - Meet(a, affected) = a. /// [Property(MaxTest = 100)] public Property Meet_WithTop_YieldsOther() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), a => { var top = VexLatticeArbs.CreateClaim(VexClaimStatus.Affected); var result = _lattice.Meet(a, top); var expected = a.Status == VexClaimStatus.Affected ? VexClaimStatus.Affected : a.Status; return (result.ResultStatus == expected) .Label($"Meet({a.Status}, Affected) = {result.ResultStatus}, expected {expected}"); }); } #endregion #region Absorption Laws /// /// Property: Absorption law 1 - Join(a, Meet(a, b)) = a. /// [Property(MaxTest = 100)] public Property Absorption_JoinMeet() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), VexLatticeArbs.AnyVexClaim(), (a, b) => { var meet = _lattice.Meet(a, b); var meetClaim = VexLatticeArbs.CreateClaim(meet.ResultStatus); var result = _lattice.Join(a, meetClaim); return (result.ResultStatus == a.Status) .Label($"Join({a.Status}, Meet({a.Status}, {b.Status})) = {result.ResultStatus}, expected {a.Status}"); }); } /// /// Property: Absorption law 2 - Meet(a, Join(a, b)) = a. /// [Property(MaxTest = 100)] public Property Absorption_MeetJoin() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), VexLatticeArbs.AnyVexClaim(), (a, b) => { var join = _lattice.Join(a, b); var joinClaim = VexLatticeArbs.CreateClaim(join.ResultStatus); var result = _lattice.Meet(a, joinClaim); return (result.ResultStatus == a.Status) .Label($"Meet({a.Status}, Join({a.Status}, {b.Status})) = {result.ResultStatus}, expected {a.Status}"); }); } #endregion #region IsHigher / Ordering Properties /// /// Property: IsHigher is antisymmetric - if a > b and b > a then a = b. /// [Property(MaxTest = 100)] public Property IsHigher_IsAntisymmetric() { return Prop.ForAll( VexLatticeArbs.AnyVexClaimStatus(), VexLatticeArbs.AnyVexClaimStatus(), (a, b) => { var aHigherB = _lattice.IsHigher(a, b); var bHigherA = _lattice.IsHigher(b, a); // If both are true, they must be equal return (!(aHigherB && bHigherA) || a == b) .Label($"IsHigher({a}, {b}) = {aHigherB}, IsHigher({b}, {a}) = {bHigherA}"); }); } /// /// Property: IsHigher is reflexive for equality - IsHigher(a, a) is well-defined. /// [Property(MaxTest = 100)] public Property IsHigher_IsReflexive() { return Prop.ForAll( VexLatticeArbs.AnyVexClaimStatus(), a => { var result = _lattice.IsHigher(a, a); // Same status should not be "higher" than itself return (!result) .Label($"IsHigher({a}, {a}) = {result}, expected false"); }); } /// /// Property: Top element (Affected) is higher than all non-top elements. /// [Property(MaxTest = 100)] public Property Top_IsHigherThanAllNonTop() { return Prop.ForAll( VexLatticeArbs.AnyVexClaimStatus(), a => { if (a == VexClaimStatus.Affected) return true.Label("Skip: comparing top with itself"); var result = _lattice.IsHigher(VexClaimStatus.Affected, a); return result .Label($"IsHigher(Affected, {a}) = {result}, expected true"); }); } /// /// Property: Bottom element (Unknown) is not higher than any element. /// [Property(MaxTest = 100)] public Property Bottom_IsNotHigherThanAnything() { return Prop.ForAll( VexLatticeArbs.AnyVexClaimStatus(), a => { if (a == VexClaimStatus.Unknown) return true.Label("Skip: comparing bottom with itself"); var result = _lattice.IsHigher(VexClaimStatus.Unknown, a); return (!result) .Label($"IsHigher(Unknown, {a}) = {result}, expected false"); }); } #endregion #region Conflict Resolution Properties /// /// Property: Conflict resolution always produces a valid winner. /// [Property(MaxTest = 100)] public Property ConflictResolution_ProducesValidWinner() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), VexLatticeArbs.AnyVexClaim(), (a, b) => { var resolution = _lattice.ResolveConflict(a, b); // Winner must be one of the inputs var winnerIsValid = resolution.Winner.Status == a.Status || resolution.Winner.Status == b.Status; return winnerIsValid .Label($"Winner status {resolution.Winner.Status} must be {a.Status} or {b.Status}"); }); } /// /// Property: Conflict resolution is deterministic. /// [Property(MaxTest = 100)] public Property ConflictResolution_IsDeterministic() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), VexLatticeArbs.AnyVexClaim(), (a, b) => { var resolution1 = _lattice.ResolveConflict(a, b); var resolution2 = _lattice.ResolveConflict(a, b); return (resolution1.Winner.Status == resolution2.Winner.Status && resolution1.Reason == resolution2.Reason) .Label($"Determinism: {resolution1.Winner.Status}/{resolution1.Reason} vs {resolution2.Winner.Status}/{resolution2.Reason}"); }); } /// /// Property: Higher trust weight wins in conflict. /// [Property(MaxTest = 100)] public Property ConflictResolution_HigherTrustWins() { return Prop.ForAll( VexLatticeArbs.AnyVexClaim(), VexLatticeArbs.AnyVexClaim(), (a, b) => { var trustA = _lattice.GetTrustWeight(a); var trustB = _lattice.GetTrustWeight(b); var resolution = _lattice.ResolveConflict(a, b); // If trust weights differ significantly, higher should win if (Math.Abs(trustA - trustB) > 0.01m) { var expectedWinner = trustA > trustB ? a : b; return (resolution.Winner.Status == expectedWinner.Status || resolution.Reason != ConflictResolutionReason.TrustWeight) .Label($"Trust: A={trustA}, B={trustB}, Winner={resolution.Winner.Status}"); } // Otherwise, any result is acceptable return true.Label("Trust weights equal, any result acceptable"); }); } #endregion } /// /// Custom FsCheck arbitraries for VEX lattice types. /// internal static class VexLatticeArbs { private static readonly VexClaimStatus[] AllStatuses = [ VexClaimStatus.Unknown, VexClaimStatus.NotAffected, VexClaimStatus.Fixed, VexClaimStatus.UnderInvestigation, VexClaimStatus.Affected ]; public static Arbitrary AnyVexClaimStatus() => Arb.From(Gen.Elements(AllStatuses)); public static Arbitrary AnyVexClaim() => Arb.From( from status in Gen.Elements(AllStatuses) from providerId in Gen.Elements("vendor", "maintainer", "third-party", "scanner") from dayOffset in Gen.Choose(0, 365) select CreateClaim(status, providerId, DateTime.UtcNow.AddDays(-dayOffset))); public static VexClaim CreateClaim( VexClaimStatus status, string providerId = "test-provider", DateTime? lastSeen = null) { var now = lastSeen ?? DateTime.UtcNow; return new VexClaim { VulnerabilityId = "CVE-2024-0001", Status = status, ProviderId = providerId, Product = new VexProduct { Key = "test-product", Name = "Test Product", Version = "1.0.0" }, Document = new VexDocumentSource { SourceUri = new Uri($"https://example.com/vex/{Guid.NewGuid()}"), Digest = $"sha256:{Guid.NewGuid():N}", Format = VexFormat.OpenVex }, FirstSeen = now.AddDays(-30), LastSeen = now }; } } /// /// Default K4 lattice provider for testing. /// The K4 lattice: Unknown < {NotAffected, Fixed, UnderInvestigation} < Affected /// internal sealed class K4VexLatticeProvider : IVexLatticeProvider { private readonly ILogger _logger; // K4 lattice ordering (higher value = higher in lattice) private static readonly Dictionary LatticeOrder = new() { [VexClaimStatus.Unknown] = 0, [VexClaimStatus.NotAffected] = 1, [VexClaimStatus.Fixed] = 1, [VexClaimStatus.UnderInvestigation] = 1, [VexClaimStatus.Affected] = 2 }; // Trust weights by provider type private static readonly Dictionary TrustWeights = new(StringComparer.OrdinalIgnoreCase) { ["vendor"] = 1.0m, ["maintainer"] = 0.9m, ["third-party"] = 0.7m, ["scanner"] = 0.5m }; public K4VexLatticeProvider(ILogger logger) { _logger = logger; } public VexLatticeResult Join(VexClaim left, VexClaim right) { var leftOrder = LatticeOrder.GetValueOrDefault(left.Status, 0); var rightOrder = LatticeOrder.GetValueOrDefault(right.Status, 0); if (leftOrder >= rightOrder) { return new VexLatticeResult(left.Status, left, "Left is higher or equal in lattice", null); } return new VexLatticeResult(right.Status, right, "Right is higher in lattice", null); } public VexLatticeResult Meet(VexClaim left, VexClaim right) { var leftOrder = LatticeOrder.GetValueOrDefault(left.Status, 0); var rightOrder = LatticeOrder.GetValueOrDefault(right.Status, 0); if (leftOrder <= rightOrder) { return new VexLatticeResult(left.Status, left, "Left is lower or equal in lattice", null); } return new VexLatticeResult(right.Status, right, "Right is lower in lattice", null); } public bool IsHigher(VexClaimStatus a, VexClaimStatus b) { var aOrder = LatticeOrder.GetValueOrDefault(a, 0); var bOrder = LatticeOrder.GetValueOrDefault(b, 0); return aOrder > bOrder; } public decimal GetTrustWeight(VexClaim statement) { return TrustWeights.GetValueOrDefault(statement.ProviderId, 0.5m); } public VexConflictResolution ResolveConflict(VexClaim left, VexClaim right) { var leftTrust = GetTrustWeight(left); var rightTrust = GetTrustWeight(right); ConflictResolutionReason reason; VexClaim winner; VexClaim loser; if (Math.Abs(leftTrust - rightTrust) > 0.01m) { winner = leftTrust > rightTrust ? left : right; loser = leftTrust > rightTrust ? right : left; reason = ConflictResolutionReason.TrustWeight; } else if (left.LastSeen != right.LastSeen) { winner = left.LastSeen > right.LastSeen ? left : right; loser = left.LastSeen > right.LastSeen ? right : left; reason = ConflictResolutionReason.Freshness; } else { var leftOrder = LatticeOrder.GetValueOrDefault(left.Status, 0); var rightOrder = LatticeOrder.GetValueOrDefault(right.Status, 0); winner = leftOrder >= rightOrder ? left : right; loser = leftOrder >= rightOrder ? right : left; reason = leftOrder != rightOrder ? ConflictResolutionReason.LatticePosition : ConflictResolutionReason.Tie; } return new VexConflictResolution(winner, loser, reason, new MergeTrace { LeftSource = left.ProviderId, RightSource = right.ProviderId, LeftStatus = left.Status, RightStatus = right.Status, LeftTrust = leftTrust, RightTrust = rightTrust, ResultStatus = winner.Status, Explanation = $"Resolved by {reason}: {winner.Status} from {winner.ProviderId}", EvaluatedAt = DateTimeOffset.UtcNow }); } }