feat: Add VEX Status Chip component and integration tests for reachability drift detection

- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips.
- Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling.
- Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation.
- Updated project references to include the new Reachability Drift library.
This commit is contained in:
StellaOps Bot
2025-12-20 01:26:42 +02:00
parent edc91ea96f
commit 5fc469ad98
159 changed files with 41116 additions and 2305 deletions

View File

@@ -6,7 +6,19 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,321 @@
/**
* K4 Lattice Unit Tests
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
* Task: TRUST-017
*
* Tests for Belnap four-valued logic operations:
* - Join (knowledge union)
* - Meet (knowledge intersection)
* - Order (knowledge ordering)
* - Negation
*/
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Tests.TrustLattice;
public class K4LatticeTests
{
#region Join Tests
[Fact]
public void Join_UnknownWithUnknown_ReturnsUnknown()
{
Assert.Equal(K4Value.Unknown, K4Lattice.Join(K4Value.Unknown, K4Value.Unknown));
}
[Theory]
[InlineData(K4Value.True)]
[InlineData(K4Value.False)]
[InlineData(K4Value.Conflict)]
public void Join_UnknownWithAny_ReturnsOther(K4Value other)
{
Assert.Equal(other, K4Lattice.Join(K4Value.Unknown, other));
Assert.Equal(other, K4Lattice.Join(other, K4Value.Unknown));
}
[Fact]
public void Join_TrueWithTrue_ReturnsTrue()
{
Assert.Equal(K4Value.True, K4Lattice.Join(K4Value.True, K4Value.True));
}
[Fact]
public void Join_FalseWithFalse_ReturnsFalse()
{
Assert.Equal(K4Value.False, K4Lattice.Join(K4Value.False, K4Value.False));
}
[Fact]
public void Join_TrueWithFalse_ReturnsConflict()
{
Assert.Equal(K4Value.Conflict, K4Lattice.Join(K4Value.True, K4Value.False));
Assert.Equal(K4Value.Conflict, K4Lattice.Join(K4Value.False, K4Value.True));
}
[Theory]
[InlineData(K4Value.Unknown)]
[InlineData(K4Value.True)]
[InlineData(K4Value.False)]
[InlineData(K4Value.Conflict)]
public void Join_ConflictWithAny_ReturnsConflict(K4Value other)
{
Assert.Equal(K4Value.Conflict, K4Lattice.Join(K4Value.Conflict, other));
Assert.Equal(K4Value.Conflict, K4Lattice.Join(other, K4Value.Conflict));
}
[Fact]
public void Join_IsCommutative()
{
var values = new[] { K4Value.Unknown, K4Value.True, K4Value.False, K4Value.Conflict };
foreach (var a in values)
foreach (var b in values)
{
Assert.Equal(K4Lattice.Join(a, b), K4Lattice.Join(b, a));
}
}
[Fact]
public void Join_IsAssociative()
{
var values = new[] { K4Value.Unknown, K4Value.True, K4Value.False, K4Value.Conflict };
foreach (var a in values)
foreach (var b in values)
foreach (var c in values)
{
Assert.Equal(
K4Lattice.Join(K4Lattice.Join(a, b), c),
K4Lattice.Join(a, K4Lattice.Join(b, c)));
}
}
[Fact]
public void JoinAll_EmptySequence_ReturnsUnknown()
{
Assert.Equal(K4Value.Unknown, K4Lattice.JoinAll([]));
}
[Fact]
public void JoinAll_SingleValue_ReturnsSame()
{
Assert.Equal(K4Value.True, K4Lattice.JoinAll([K4Value.True]));
}
[Fact]
public void JoinAll_MultipleValues_ReturnsJoin()
{
Assert.Equal(K4Value.Conflict, K4Lattice.JoinAll([K4Value.Unknown, K4Value.True, K4Value.False]));
}
#endregion
#region Meet Tests
[Fact]
public void Meet_ConflictWithConflict_ReturnsConflict()
{
Assert.Equal(K4Value.Conflict, K4Lattice.Meet(K4Value.Conflict, K4Value.Conflict));
}
[Theory]
[InlineData(K4Value.Unknown)]
[InlineData(K4Value.True)]
[InlineData(K4Value.False)]
public void Meet_ConflictWithAny_ReturnsOther(K4Value other)
{
Assert.Equal(other, K4Lattice.Meet(K4Value.Conflict, other));
Assert.Equal(other, K4Lattice.Meet(other, K4Value.Conflict));
}
[Fact]
public void Meet_TrueWithFalse_ReturnsUnknown()
{
Assert.Equal(K4Value.Unknown, K4Lattice.Meet(K4Value.True, K4Value.False));
Assert.Equal(K4Value.Unknown, K4Lattice.Meet(K4Value.False, K4Value.True));
}
[Theory]
[InlineData(K4Value.Unknown)]
[InlineData(K4Value.True)]
[InlineData(K4Value.False)]
[InlineData(K4Value.Conflict)]
public void Meet_UnknownWithAny_ReturnsUnknown(K4Value other)
{
Assert.Equal(K4Value.Unknown, K4Lattice.Meet(K4Value.Unknown, other));
Assert.Equal(K4Value.Unknown, K4Lattice.Meet(other, K4Value.Unknown));
}
[Fact]
public void Meet_IsCommutative()
{
var values = new[] { K4Value.Unknown, K4Value.True, K4Value.False, K4Value.Conflict };
foreach (var a in values)
foreach (var b in values)
{
Assert.Equal(K4Lattice.Meet(a, b), K4Lattice.Meet(b, a));
}
}
#endregion
#region Order Tests
[Fact]
public void LessOrEqual_UnknownLessOrEqualToAll()
{
Assert.True(K4Lattice.LessOrEqual(K4Value.Unknown, K4Value.Unknown));
Assert.True(K4Lattice.LessOrEqual(K4Value.Unknown, K4Value.True));
Assert.True(K4Lattice.LessOrEqual(K4Value.Unknown, K4Value.False));
Assert.True(K4Lattice.LessOrEqual(K4Value.Unknown, K4Value.Conflict));
}
[Fact]
public void LessOrEqual_ConflictGreaterOrEqualToAll()
{
Assert.True(K4Lattice.LessOrEqual(K4Value.Unknown, K4Value.Conflict));
Assert.True(K4Lattice.LessOrEqual(K4Value.True, K4Value.Conflict));
Assert.True(K4Lattice.LessOrEqual(K4Value.False, K4Value.Conflict));
Assert.True(K4Lattice.LessOrEqual(K4Value.Conflict, K4Value.Conflict));
}
[Fact]
public void LessOrEqual_TrueAndFalseIncomparable()
{
Assert.False(K4Lattice.LessOrEqual(K4Value.True, K4Value.False));
Assert.False(K4Lattice.LessOrEqual(K4Value.False, K4Value.True));
}
[Fact]
public void LessOrEqual_IsReflexive()
{
var values = new[] { K4Value.Unknown, K4Value.True, K4Value.False, K4Value.Conflict };
foreach (var v in values)
{
Assert.True(K4Lattice.LessOrEqual(v, v));
}
}
[Fact]
public void LessOrEqual_IsTransitive()
{
// ⊥ ≤ T ≤ and ⊥ ≤ F ≤
Assert.True(K4Lattice.LessOrEqual(K4Value.Unknown, K4Value.True));
Assert.True(K4Lattice.LessOrEqual(K4Value.True, K4Value.Conflict));
Assert.True(K4Lattice.LessOrEqual(K4Value.Unknown, K4Value.Conflict));
Assert.True(K4Lattice.LessOrEqual(K4Value.Unknown, K4Value.False));
Assert.True(K4Lattice.LessOrEqual(K4Value.False, K4Value.Conflict));
}
#endregion
#region FromSupport Tests
[Fact]
public void FromSupport_NoSupport_ReturnsUnknown()
{
Assert.Equal(K4Value.Unknown, K4Lattice.FromSupport(false, false));
}
[Fact]
public void FromSupport_TrueSupportOnly_ReturnsTrue()
{
Assert.Equal(K4Value.True, K4Lattice.FromSupport(true, false));
}
[Fact]
public void FromSupport_FalseSupportOnly_ReturnsFalse()
{
Assert.Equal(K4Value.False, K4Lattice.FromSupport(false, true));
}
[Fact]
public void FromSupport_BothSupports_ReturnsConflict()
{
Assert.Equal(K4Value.Conflict, K4Lattice.FromSupport(true, true));
}
#endregion
#region Negation Tests
[Fact]
public void Negate_True_ReturnsFalse()
{
Assert.Equal(K4Value.False, K4Lattice.Negate(K4Value.True));
}
[Fact]
public void Negate_False_ReturnsTrue()
{
Assert.Equal(K4Value.True, K4Lattice.Negate(K4Value.False));
}
[Fact]
public void Negate_Unknown_ReturnsUnknown()
{
Assert.Equal(K4Value.Unknown, K4Lattice.Negate(K4Value.Unknown));
}
[Fact]
public void Negate_Conflict_ReturnsConflict()
{
Assert.Equal(K4Value.Conflict, K4Lattice.Negate(K4Value.Conflict));
}
[Fact]
public void Negate_IsInvolutive()
{
var values = new[] { K4Value.Unknown, K4Value.True, K4Value.False, K4Value.Conflict };
foreach (var v in values)
{
Assert.Equal(v, K4Lattice.Negate(K4Lattice.Negate(v)));
}
}
#endregion
#region Support Predicates Tests
[Theory]
[InlineData(K4Value.True, true)]
[InlineData(K4Value.False, false)]
[InlineData(K4Value.Unknown, false)]
[InlineData(K4Value.Conflict, true)]
public void HasTrueSupport_ReturnsCorrectValue(K4Value value, bool expected)
{
Assert.Equal(expected, K4Lattice.HasTrueSupport(value));
}
[Theory]
[InlineData(K4Value.True, false)]
[InlineData(K4Value.False, true)]
[InlineData(K4Value.Unknown, false)]
[InlineData(K4Value.Conflict, true)]
public void HasFalseSupport_ReturnsCorrectValue(K4Value value, bool expected)
{
Assert.Equal(expected, K4Lattice.HasFalseSupport(value));
}
[Theory]
[InlineData(K4Value.True, true)]
[InlineData(K4Value.False, true)]
[InlineData(K4Value.Unknown, false)]
[InlineData(K4Value.Conflict, false)]
public void IsDefinite_ReturnsCorrectValue(K4Value value, bool expected)
{
Assert.Equal(expected, K4Lattice.IsDefinite(value));
}
[Theory]
[InlineData(K4Value.True, false)]
[InlineData(K4Value.False, false)]
[InlineData(K4Value.Unknown, true)]
[InlineData(K4Value.Conflict, true)]
public void IsIndeterminate_ReturnsCorrectValue(K4Value value, bool expected)
{
Assert.Equal(expected, K4Lattice.IsIndeterminate(value));
}
#endregion
}

View File

@@ -0,0 +1,402 @@
/**
* LatticeStore Aggregation Unit Tests
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
* Task: TRUST-019
*
* Tests for claim aggregation and K4 value computation:
* - Support set tracking
* - K4 value computation from support sets
* - Conflict detection
* - Trust label tracking
*/
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Tests.TrustLattice;
public class LatticeStoreTests
{
private static Subject CreateTestSubject(string vulnId = "CVE-2024-1234")
{
return new Subject
{
Artifact = new ArtifactRef
{
Digest = "sha256:abc123",
Name = "test-image:latest",
Type = "oci",
},
Component = new ComponentRef
{
Purl = "pkg:npm/lodash@4.17.21",
},
Vulnerability = new VulnerabilityRef
{
Id = vulnId,
Source = "NVD",
},
};
}
private static Principal CreateTestPrincipal(string id = "vendor")
{
return new Principal
{
Id = id,
Roles = PrincipalRole.Vendor,
};
}
private static Claim CreateTestClaim(Subject subject, Principal issuer, params AtomAssertion[] assertions)
{
return new Claim
{
Subject = subject,
Issuer = issuer,
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
Assertions = assertions,
};
}
#region Basic Store Operations
[Fact]
public void NewStore_IsEmpty()
{
var store = new LatticeStore();
var stats = store.GetStats();
Assert.Equal(0, stats.SubjectCount);
Assert.Equal(0, stats.ClaimCount);
Assert.Equal(0, stats.EvidenceCount);
}
[Fact]
public void IngestClaim_AddsToStore()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
var claim = new Claim
{
Subject = subject,
Principal = CreateTestPrincipal(),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
};
var ingested = store.IngestClaim(claim);
Assert.NotNull(ingested.Id);
Assert.Equal(1, store.GetStats().SubjectCount);
Assert.Equal(1, store.GetStats().ClaimCount);
}
[Fact]
public void IngestClaim_ComputesContentAddressableId()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
var claim = new Claim
{
Subject = subject,
Principal = CreateTestPrincipal(),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
};
var ingested = store.IngestClaim(claim);
Assert.StartsWith("sha256:", ingested.Id);
}
[Fact]
public void GetClaim_ReturnsIngestedClaim()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
var claim = new Claim
{
Subject = subject,
Principal = CreateTestPrincipal(),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
};
var ingested = store.IngestClaim(claim);
var retrieved = store.GetClaim(ingested.Id!);
Assert.NotNull(retrieved);
Assert.Equal(ingested.Id, retrieved.Id);
}
[Fact]
public void Clear_RemovesAllData()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal(),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
store.Clear();
Assert.Equal(0, store.GetStats().SubjectCount);
Assert.Equal(0, store.GetStats().ClaimCount);
}
#endregion
#region K4 Value Computation
[Fact]
public void NoAssertions_ReturnsUnknown()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
var state = store.GetOrCreateSubject(subject);
Assert.Equal(K4Value.Unknown, state.GetValue(SecurityAtom.Present));
Assert.Equal(K4Value.Unknown, state.GetValue(SecurityAtom.Applies));
Assert.Equal(K4Value.Unknown, state.GetValue(SecurityAtom.Reachable));
}
[Fact]
public void TrueAssertion_ReturnsTrue()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal(),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
Assert.Equal(K4Value.True, store.GetValue(subject, SecurityAtom.Present));
}
[Fact]
public void FalseAssertion_ReturnsFalse()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal(),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = false }],
});
Assert.Equal(K4Value.False, store.GetValue(subject, SecurityAtom.Present));
}
[Fact]
public void MultipleTrueAssertions_ReturnsTrue()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal("vendor1"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal("vendor2"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
Assert.Equal(K4Value.True, store.GetValue(subject, SecurityAtom.Present));
}
[Fact]
public void ConflictingAssertions_ReturnsConflict()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal("vendor"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal("scanner"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = false }],
});
Assert.Equal(K4Value.Conflict, store.GetValue(subject, SecurityAtom.Present));
}
#endregion
#region Conflict Detection
[Fact]
public void GetConflictingSubjects_ReturnsConflicts()
{
var store = new LatticeStore();
// Subject with conflict
var conflictSubject = CreateTestSubject("CVE-2024-0001");
store.IngestClaim(new Claim
{
Subject = conflictSubject,
Principal = CreateTestPrincipal("vendor"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
store.IngestClaim(new Claim
{
Subject = conflictSubject,
Principal = CreateTestPrincipal("scanner"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = false }],
});
// Subject without conflict
var okSubject = CreateTestSubject("CVE-2024-0002");
store.IngestClaim(new Claim
{
Subject = okSubject,
Principal = CreateTestPrincipal(),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
var conflicting = store.GetConflictingSubjects().ToList();
Assert.Single(conflicting);
Assert.Equal(conflictSubject.ComputeDigest(), conflicting[0].SubjectDigest);
}
[Fact]
public void GetIncompleteSubjects_ReturnsUnknowns()
{
var store = new LatticeStore();
// Subject with all required atoms known
var completeSubject = CreateTestSubject("CVE-2024-0001");
store.IngestClaim(new Claim
{
Subject = completeSubject,
Principal = CreateTestPrincipal(),
Assertions =
[
new AtomAssertion { Atom = SecurityAtom.Present, Value = true },
new AtomAssertion { Atom = SecurityAtom.Applies, Value = true },
new AtomAssertion { Atom = SecurityAtom.Reachable, Value = true },
],
});
// Subject with missing required atoms
var incompleteSubject = CreateTestSubject("CVE-2024-0002");
store.IngestClaim(new Claim
{
Subject = incompleteSubject,
Principal = CreateTestPrincipal(),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
var incomplete = store.GetIncompleteSubjects().ToList();
Assert.Single(incomplete);
Assert.Equal(incompleteSubject.ComputeDigest(), incomplete[0].SubjectDigest);
}
#endregion
#region Support Set Tracking
[Fact]
public void AtomValue_TracksSupportSets()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
var claim1 = store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal("vendor"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
var claim2 = store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal("scanner"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = false }],
});
var state = store.GetSubjectState(subject.ComputeDigest());
var atomValue = state!.GetAtomValue(SecurityAtom.Present);
Assert.Single(atomValue.SupportTrue);
Assert.Single(atomValue.SupportFalse);
Assert.Contains(claim1.Id!, atomValue.SupportTrue);
Assert.Contains(claim2.Id!, atomValue.SupportFalse);
}
[Fact]
public void SubjectState_TracksAllClaimIds()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
var claim1 = store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal("vendor"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Present, Value = true }],
});
var claim2 = store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal("scanner"),
Assertions = [new AtomAssertion { Atom = SecurityAtom.Reachable, Value = false }],
});
var state = store.GetSubjectState(subject.ComputeDigest());
Assert.Equal(2, state!.ClaimIds.Count);
Assert.Contains(claim1.Id!, state.ClaimIds);
Assert.Contains(claim2.Id!, state.ClaimIds);
}
#endregion
#region Snapshot Tests
[Fact]
public void SubjectState_ToSnapshot_CapturesAllAtoms()
{
var store = new LatticeStore();
var subject = CreateTestSubject();
store.IngestClaim(new Claim
{
Subject = subject,
Principal = CreateTestPrincipal(),
Assertions =
[
new AtomAssertion { Atom = SecurityAtom.Present, Value = true },
new AtomAssertion { Atom = SecurityAtom.Applies, Value = true },
],
});
var state = store.GetSubjectState(subject.ComputeDigest());
var snapshot = state!.ToSnapshot();
Assert.Equal(6, snapshot.Count); // All 6 atoms
Assert.Equal(K4Value.True, snapshot[SecurityAtom.Present].Value);
Assert.Equal(K4Value.True, snapshot[SecurityAtom.Applies].Value);
Assert.Equal(K4Value.Unknown, snapshot[SecurityAtom.Reachable].Value);
}
#endregion
}

View File

@@ -0,0 +1,408 @@
/**
* Trust Lattice Engine Integration Tests
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
* Task: TRUST-020
*
* Integration tests for the complete trust evaluation pipeline:
* - Vendor vs scanner conflict scenario
* - Multi-source claim aggregation
* - Disposition selection with conflicts
* - Proof bundle generation
*/
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Tests.TrustLattice;
public class TrustLatticeEngineIntegrationTests
{
private static Subject CreateTestSubject(
string vulnId = "CVE-2024-1234",
string component = "pkg:npm/lodash@4.17.21")
{
return new Subject
{
Artifact = new ArtifactRef
{
Digest = "sha256:abc123def456",
Name = "myapp:v1.0",
Type = "oci",
},
Component = new ComponentRef
{
Purl = component,
},
Vulnerability = new VulnerabilityRef
{
Id = vulnId,
Source = "NVD",
},
};
}
#region Vendor vs Scanner Conflict Scenario
[Fact]
public void VendorVsScannerConflict_DetectsConflict()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
var vendor = new Principal
{
Id = "npm-lodash-maintainer",
DisplayName = "Lodash Maintainers",
Roles = PrincipalRole.Vendor,
};
var scanner = new Principal
{
Id = "stellaops-scanner",
DisplayName = "StellaOps Scanner",
Roles = PrincipalRole.Scanner,
};
// Vendor claims: not affected - code not reachable
engine.CreateClaim()
.ForSubject(subject)
.FromPrincipal(vendor)
.Applies(false, "not_affected - test function only")
.Reachable(false, "vulnerable code not in main execution path")
.Build();
// Scanner claims: affected - found via static analysis
engine.CreateClaim()
.ForSubject(subject)
.FromPrincipal(scanner)
.Present(true, "component detected in SBOM")
.Applies(true, "version matches CVE range")
.Build();
// Evaluate
var result = engine.GetDisposition(subject);
// APPLIES has conflict (vendor says false, scanner says true)
Assert.Contains(SecurityAtom.Applies, result.Conflicts);
Assert.Equal(Disposition.InTriage, result.Disposition);
Assert.Contains("conflict", result.Explanation.ToLowerInvariant());
}
[Fact]
public void VendorVsScannerConflict_ProofBundleCapturesEvidence()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
var vendor = new Principal { Id = "vendor", Roles = PrincipalRole.Vendor };
var scanner = new Principal { Id = "scanner", Roles = PrincipalRole.Scanner };
engine.CreateClaim()
.ForSubject(subject)
.FromPrincipal(vendor)
.Reachable(false, "not in execution path")
.Build();
engine.CreateClaim()
.ForSubject(subject)
.FromPrincipal(scanner)
.Reachable(true, "static analysis shows call path")
.Build();
var evalResult = engine.Evaluate();
Assert.True(evalResult.Success);
Assert.NotNull(evalResult.ProofBundle);
var proof = evalResult.ProofBundle!;
Assert.Equal(2, proof.Claims.Count);
Assert.Single(proof.AtomTables);
Assert.Single(proof.Decisions);
// Verify conflict is captured in atom table
var atomTable = proof.AtomTables[0];
Assert.Equal(K4Value.Conflict, atomTable.Atoms[SecurityAtom.Reachable].Value);
}
#endregion
#region Resolution Scenarios
[Fact]
public void AllSourcesAgree_Exploitable_Disposition()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
engine.CreateClaim()
.ForSubject(subject)
.Present(true)
.Applies(true)
.Reachable(true)
.Build();
var result = engine.GetDisposition(subject);
Assert.Equal(Disposition.Exploitable, result.Disposition);
Assert.Empty(result.Conflicts);
Assert.Empty(result.Unknowns);
}
[Fact]
public void Fixed_Overrides_Exploitability()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
// Initially exploitable
engine.CreateClaim()
.ForSubject(subject)
.Present(true)
.Applies(true)
.Reachable(true)
.Build();
// Then fixed
engine.CreateClaim()
.ForSubject(subject)
.Fixed(true, "patched in v4.17.22")
.Build();
var result = engine.GetDisposition(subject);
Assert.Equal(Disposition.ResolvedWithPedigree, result.Disposition);
}
[Fact]
public void Misattributed_Produces_FalsePositive()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
engine.CreateClaim()
.ForSubject(subject)
.Present(true)
.Applies(true)
.Misattributed(true, "CVE assigned to wrong package version")
.Build();
var result = engine.GetDisposition(subject);
Assert.Equal(Disposition.FalsePositive, result.Disposition);
}
[Fact]
public void NotReachable_Produces_NotAffected()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
engine.CreateClaim()
.ForSubject(subject)
.Present(true)
.Applies(true)
.Reachable(false, "dead code branch")
.Build();
var result = engine.GetDisposition(subject);
Assert.Equal(Disposition.NotAffected, result.Disposition);
}
[Fact]
public void Mitigated_Produces_NotAffected()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
engine.CreateClaim()
.ForSubject(subject)
.Present(true)
.Applies(true)
.Reachable(true)
.Mitigated(true, "WAF blocks exploit")
.Build();
var result = engine.GetDisposition(subject);
Assert.Equal(Disposition.NotAffected, result.Disposition);
}
[Fact]
public void InsufficientData_Produces_InTriage()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
// No claims at all
var state = engine.Store.GetOrCreateSubject(subject);
var result = engine.GetDisposition(subject);
Assert.Equal(Disposition.InTriage, result.Disposition);
Assert.Contains(SecurityAtom.Present, result.Unknowns);
Assert.Contains(SecurityAtom.Applies, result.Unknowns);
}
#endregion
#region Decision Trace Tests
[Fact]
public void DecisionTrace_ContainsAllEvaluatedRules()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
engine.CreateClaim()
.ForSubject(subject)
.Present(true)
.Applies(true)
.Reachable(true)
.Build();
var result = engine.GetDisposition(subject);
Assert.NotEmpty(result.Trace);
Assert.All(result.Trace, step => Assert.NotNull(step.RuleName));
Assert.Contains(result.Trace, step => step.Matched);
}
[Fact]
public void DecisionTrace_FirstMatchWins()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
// Fixed should match before exploitable
engine.CreateClaim()
.ForSubject(subject)
.Present(true)
.Applies(true)
.Reachable(true)
.Fixed(true)
.Build();
var result = engine.GetDisposition(subject);
// Verify the fixed rule matched first
Assert.Equal("fixed_resolved", result.MatchedRule);
}
#endregion
#region Multi-Subject Evaluation
[Fact]
public void MultipleSubjects_EvaluatesAll()
{
var engine = new TrustLatticeEngine();
var subject1 = CreateTestSubject("CVE-2024-0001", "pkg:npm/a@1.0.0");
var subject2 = CreateTestSubject("CVE-2024-0002", "pkg:npm/b@1.0.0");
var subject3 = CreateTestSubject("CVE-2024-0003", "pkg:npm/c@1.0.0");
// Subject 1: exploitable
engine.CreateClaim()
.ForSubject(subject1)
.Present(true).Applies(true).Reachable(true)
.Build();
// Subject 2: fixed
engine.CreateClaim()
.ForSubject(subject2)
.Fixed(true)
.Build();
// Subject 3: not present
engine.CreateClaim()
.ForSubject(subject3)
.Present(false)
.Build();
var evalResult = engine.Evaluate();
Assert.True(evalResult.Success);
Assert.Equal(3, evalResult.Dispositions.Count);
Assert.Equal(Disposition.Exploitable, evalResult.Dispositions[subject1.ComputeDigest()].Disposition);
Assert.Equal(Disposition.ResolvedWithPedigree, evalResult.Dispositions[subject2.ComputeDigest()].Disposition);
Assert.Equal(Disposition.FalsePositive, evalResult.Dispositions[subject3.ComputeDigest()].Disposition);
}
[Fact]
public void ProofBundle_ContentAddressable()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
engine.CreateClaim()
.ForSubject(subject)
.Present(true).Applies(true).Reachable(true)
.Build();
var result1 = engine.Evaluate();
var result2 = engine.Evaluate();
// Same inputs should produce same proof bundle ID
Assert.Equal(result1.ProofBundle!.Id, result2.ProofBundle!.Id);
}
#endregion
#region Statistics Tests
[Fact]
public void Stats_ReflectStoreState()
{
var engine = new TrustLatticeEngine();
// Add a conflicting subject
var conflictSubject = CreateTestSubject("CVE-2024-0001");
engine.CreateClaim()
.ForSubject(conflictSubject)
.Present(true)
.Build();
engine.CreateClaim()
.ForSubject(conflictSubject)
.Present(false)
.Build();
// Add an incomplete subject
var incompleteSubject = CreateTestSubject("CVE-2024-0002");
engine.CreateClaim()
.ForSubject(incompleteSubject)
.Mitigated(true) // Only mitigated, no PRESENT/APPLIES/REACHABLE
.Build();
var stats = engine.GetStats();
Assert.Equal(2, stats.SubjectCount);
Assert.Equal(3, stats.ClaimCount);
Assert.Equal(1, stats.ConflictCount);
Assert.Equal(1, stats.IncompleteCount);
}
#endregion
#region Engine Clear Tests
[Fact]
public void Clear_ResetsEngine()
{
var engine = new TrustLatticeEngine();
var subject = CreateTestSubject();
engine.CreateClaim()
.ForSubject(subject)
.Present(true)
.Build();
Assert.Equal(1, engine.GetStats().SubjectCount);
engine.Clear();
Assert.Equal(0, engine.GetStats().SubjectCount);
Assert.Equal(0, engine.GetStats().ClaimCount);
}
#endregion
}

View File

@@ -0,0 +1,376 @@
/**
* VEX Normalizer Unit Tests
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
* Task: TRUST-018
*
* Tests for VEX format normalization to canonical atoms:
* - CycloneDX/ECMA-424 status and justification mappings
* - OpenVEX status and justification mappings
* - CSAF product status and flag mappings
*/
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Tests.TrustLattice;
public class VexNormalizerTests
{
private static Subject CreateTestSubject(string vulnId = "CVE-2024-1234")
{
return new Subject
{
Artifact = new ArtifactRef
{
Digest = "sha256:abc123",
Name = "test-image:latest",
Type = "oci",
},
Component = new ComponentRef
{
Purl = "pkg:npm/lodash@4.17.21",
},
Vulnerability = new VulnerabilityRef
{
Id = vulnId,
Source = "NVD",
},
};
}
#region CycloneDX/ECMA-424 Tests
[Fact]
public void CycloneDx_Affected_SetsPresent_And_Applies_True()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CycloneDxVexStatus.Affected);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Present && a.Value == true);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Applies && a.Value == true);
}
[Fact]
public void CycloneDx_NotAffected_SetsApplies_False()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CycloneDxVexStatus.NotAffected);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Applies && a.Value == false);
}
[Fact]
public void CycloneDx_Fixed_SetsFixed_True()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CycloneDxVexStatus.Fixed);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Fixed && a.Value == true);
}
[Fact]
public void CycloneDx_FixAvailable_SetsFixed_False()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CycloneDxVexStatus.FixAvailable);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Fixed && a.Value == false);
}
[Fact]
public void CycloneDx_InTriage_ProducesNoAssertions()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CycloneDxVexStatus.InTriage);
Assert.Empty(claim.Assertions);
}
[Fact]
public void CycloneDx_CodeNotPresent_SetsPresent_False()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(
subject,
CycloneDxVexStatus.NotAffected,
CycloneDxJustification.CodeNotPresent);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Present && a.Value == false);
}
[Fact]
public void CycloneDx_CodeNotReachable_SetsReachable_False()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(
subject,
CycloneDxVexStatus.NotAffected,
CycloneDxJustification.CodeNotReachable);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Reachable && a.Value == false);
}
[Fact]
public void CycloneDx_ProtectedByMitigatingControl_SetsMitigated_True()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(
subject,
CycloneDxVexStatus.NotAffected,
CycloneDxJustification.ProtectedByMitigatingControl);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Mitigated && a.Value == true);
}
[Fact]
public void CycloneDx_WithDetail_IncludesDetailInJustification()
{
var normalizer = new CycloneDxVexNormalizer();
var subject = CreateTestSubject();
const string detail = "WAF blocks this attack vector";
var claim = normalizer.NormalizeStatement(
subject,
CycloneDxVexStatus.NotAffected,
CycloneDxJustification.ProtectedAtPerimeter,
detail);
Assert.Contains(claim.Assertions, a =>
a.Justification != null && a.Justification.Contains(detail));
}
#endregion
#region OpenVEX Tests
[Fact]
public void OpenVex_Affected_SetsPresent_And_Applies_True()
{
var normalizer = new OpenVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, OpenVexStatus.Affected);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Present && a.Value == true);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Applies && a.Value == true);
}
[Fact]
public void OpenVex_NotAffected_SetsApplies_False()
{
var normalizer = new OpenVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, OpenVexStatus.NotAffected);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Applies && a.Value == false);
}
[Fact]
public void OpenVex_Fixed_SetsFixed_True()
{
var normalizer = new OpenVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, OpenVexStatus.Fixed);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Fixed && a.Value == true);
}
[Fact]
public void OpenVex_UnderInvestigation_ProducesNoAssertions()
{
var normalizer = new OpenVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, OpenVexStatus.UnderInvestigation);
Assert.Empty(claim.Assertions);
}
[Fact]
public void OpenVex_VulnerableCodeNotInExecutePath_SetsReachable_False()
{
var normalizer = new OpenVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(
subject,
OpenVexStatus.NotAffected,
OpenVexJustification.VulnerableCodeNotInExecutePath);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Reachable && a.Value == false);
}
[Fact]
public void OpenVex_ComponentNotPresent_SetsPresent_False()
{
var normalizer = new OpenVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(
subject,
OpenVexStatus.NotAffected,
OpenVexJustification.ComponentNotPresent);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Present && a.Value == false);
}
[Fact]
public void OpenVex_WithActionAndImpact_IncludesInJustification()
{
var normalizer = new OpenVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(
subject,
OpenVexStatus.Affected,
OpenVexJustification.None,
actionStatement: "Apply patch CVE-2024-1234-fix",
impactStatement: "Remote code execution");
Assert.Contains(claim.Assertions, a =>
a.Justification != null && a.Justification.Contains("action:"));
Assert.Contains(claim.Assertions, a =>
a.Justification != null && a.Justification.Contains("impact:"));
}
#endregion
#region CSAF Tests
[Fact]
public void Csaf_KnownAffected_SetsPresent_And_Applies_True()
{
var normalizer = new CsafVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CsafProductStatus.KnownAffected);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Present && a.Value == true);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Applies && a.Value == true);
}
[Fact]
public void Csaf_KnownNotAffected_SetsApplies_False()
{
var normalizer = new CsafVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CsafProductStatus.KnownNotAffected);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Applies && a.Value == false);
}
[Fact]
public void Csaf_Fixed_SetsFixed_True()
{
var normalizer = new CsafVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CsafProductStatus.Fixed);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Fixed && a.Value == true);
}
[Fact]
public void Csaf_UnderInvestigation_ProducesNoAssertions()
{
var normalizer = new CsafVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(subject, CsafProductStatus.UnderInvestigation);
Assert.Empty(claim.Assertions);
}
[Fact]
public void Csaf_VulnerableCodeNotInExecutePath_SetsReachable_False()
{
var normalizer = new CsafVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(
subject,
CsafProductStatus.KnownNotAffected,
CsafFlagLabel.VulnerableCodeNotInExecutePath);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Reachable && a.Value == false);
}
[Fact]
public void Csaf_ComponentNotPresent_SetsPresent_False()
{
var normalizer = new CsafVexNormalizer();
var subject = CreateTestSubject();
var claim = normalizer.NormalizeStatement(
subject,
CsafProductStatus.KnownNotAffected,
CsafFlagLabel.ComponentNotPresent);
Assert.Contains(claim.Assertions, a =>
a.Atom == SecurityAtom.Present && a.Value == false);
}
#endregion
#region Format Property Tests
[Fact]
public void CycloneDxNormalizer_Format_IsCorrect()
{
var normalizer = new CycloneDxVexNormalizer();
Assert.Equal("CycloneDX/ECMA-424", normalizer.Format);
}
[Fact]
public void OpenVexNormalizer_Format_IsCorrect()
{
var normalizer = new OpenVexNormalizer();
Assert.Equal("OpenVEX", normalizer.Format);
}
[Fact]
public void CsafNormalizer_Format_IsCorrect()
{
var normalizer = new CsafVexNormalizer();
Assert.Equal("CSAF", normalizer.Format);
}
#endregion
}