save progress
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": [
|
||||
{
|
||||
"name": "file://dist/app.tar.gz",
|
||||
"digest": {
|
||||
"sha256": "b2c3d4e5f6789012345678901234567890123456789012345678901234abcdef"
|
||||
}
|
||||
}
|
||||
],
|
||||
"predicateType": "https://in-toto.io/Link/v1",
|
||||
"predicate": {
|
||||
"name": "build",
|
||||
"command": [
|
||||
"make",
|
||||
"release",
|
||||
"VERSION=1.0.0"
|
||||
],
|
||||
"materials": [
|
||||
{
|
||||
"uri": "git://github.com/example/repo@abc123def456",
|
||||
"digest": {
|
||||
"sha256": "abc123def4567890123456789012345678901234567890123456789012345678"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "file://Cargo.lock",
|
||||
"digest": {
|
||||
"sha256": "def456789012345678901234567890123456789012345678901234567890abcd"
|
||||
}
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"uri": "file://dist/app.tar.gz",
|
||||
"digest": {
|
||||
"sha256": "b2c3d4e5f6789012345678901234567890123456789012345678901234abcdef"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "file://dist/app.tar.gz.sha256",
|
||||
"digest": {
|
||||
"sha256": "c3d4e5f6789012345678901234567890123456789012345678901234abcdef12"
|
||||
}
|
||||
}
|
||||
],
|
||||
"byproducts": {
|
||||
"return-value": 0,
|
||||
"stdout": "Building release v1.0.0...\nBuild complete.",
|
||||
"stderr": ""
|
||||
},
|
||||
"environment": {
|
||||
"GITHUB_SHA": "abc123def456",
|
||||
"GITHUB_RUN_ID": "12345",
|
||||
"RUST_VERSION": "1.75.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"name": "build",
|
||||
"expectedMaterials": ["git://*"],
|
||||
"expectedProducts": ["file://dist/*"],
|
||||
"threshold": 1
|
||||
},
|
||||
{
|
||||
"name": "scan",
|
||||
"expectedMaterials": ["oci://*", "file://dist/*"],
|
||||
"expectedProducts": ["file://sbom.*", "file://vulns.*"],
|
||||
"threshold": 1
|
||||
},
|
||||
{
|
||||
"name": "sign",
|
||||
"expectedMaterials": ["file://dist/*"],
|
||||
"expectedProducts": ["file://dist/*.sig"],
|
||||
"threshold": 2
|
||||
}
|
||||
],
|
||||
"keys": {
|
||||
"builder-key-001": {
|
||||
"keyType": "ecdsa-p256",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----",
|
||||
"allowedSteps": ["build"]
|
||||
},
|
||||
"scanner-key-001": {
|
||||
"keyType": "ecdsa-p256",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----",
|
||||
"allowedSteps": ["scan"]
|
||||
},
|
||||
"signer-key-001": {
|
||||
"keyType": "ecdsa-p256",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----",
|
||||
"allowedSteps": ["sign"]
|
||||
},
|
||||
"signer-key-002": {
|
||||
"keyType": "ecdsa-p256",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----",
|
||||
"allowedSteps": ["sign"]
|
||||
}
|
||||
},
|
||||
"rootLayoutId": "layout-v1-20260102",
|
||||
"expires": "2027-01-01T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": [
|
||||
{
|
||||
"name": "file://sbom.cdx.json",
|
||||
"digest": {
|
||||
"sha256": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"
|
||||
}
|
||||
}
|
||||
],
|
||||
"predicateType": "https://in-toto.io/Link/v1",
|
||||
"predicate": {
|
||||
"name": "scan",
|
||||
"command": [
|
||||
"stella",
|
||||
"scan",
|
||||
"--image",
|
||||
"nginx:1.25"
|
||||
],
|
||||
"materials": [
|
||||
{
|
||||
"uri": "oci://docker.io/library/nginx@sha256:abc123456789",
|
||||
"digest": {
|
||||
"sha256": "abc123456789012345678901234567890123456789012345678901234567890a"
|
||||
}
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"uri": "file://sbom.cdx.json",
|
||||
"digest": {
|
||||
"sha256": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"
|
||||
}
|
||||
}
|
||||
],
|
||||
"byproducts": {
|
||||
"return-value": 0
|
||||
},
|
||||
"environment": {
|
||||
"STELLAOPS_VERSION": "2026.01",
|
||||
"CI": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Core.InToto;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.InToto;
|
||||
|
||||
/// <summary>
|
||||
/// Golden tests that verify in-toto link parsing and serialization against reference fixtures.
|
||||
/// These tests ensure compatibility with the in-toto specification.
|
||||
/// </summary>
|
||||
public class InTotoGoldenTests
|
||||
{
|
||||
private static readonly string FixturesPath = Path.Combine(
|
||||
AppContext.BaseDirectory, "Fixtures", "InToto");
|
||||
|
||||
/// <summary>
|
||||
/// Test that we can parse the golden scan link fixture.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseGoldenScanLink_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var json = File.ReadAllText(Path.Combine(FixturesPath, "golden_scan_link.json"));
|
||||
|
||||
// Act
|
||||
var link = InTotoLink.FromJson(json);
|
||||
|
||||
// Assert
|
||||
link.Should().NotBeNull();
|
||||
link.Subjects.Should().HaveCount(1);
|
||||
link.Subjects[0].Name.Should().Be("file://sbom.cdx.json");
|
||||
link.Subjects[0].Digest.Sha256.Should().Be("a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd");
|
||||
|
||||
link.Predicate.Name.Should().Be("scan");
|
||||
link.Predicate.Command.Should().BeEquivalentTo(new[] { "stella", "scan", "--image", "nginx:1.25" });
|
||||
link.Predicate.Materials.Should().HaveCount(1);
|
||||
link.Predicate.Materials[0].Uri.Should().Be("oci://docker.io/library/nginx@sha256:abc123456789");
|
||||
link.Predicate.Products.Should().HaveCount(1);
|
||||
link.Predicate.Products[0].Uri.Should().Be("file://sbom.cdx.json");
|
||||
link.Predicate.ByProducts.ReturnValue.Should().Be(0);
|
||||
link.Predicate.Environment.Should().ContainKey("STELLAOPS_VERSION");
|
||||
link.Predicate.Environment["STELLAOPS_VERSION"].Should().Be("2026.01");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that we can parse the golden build link fixture.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseGoldenBuildLink_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var json = File.ReadAllText(Path.Combine(FixturesPath, "golden_build_link.json"));
|
||||
|
||||
// Act
|
||||
var link = InTotoLink.FromJson(json);
|
||||
|
||||
// Assert
|
||||
link.Should().NotBeNull();
|
||||
link.Predicate.Name.Should().Be("build");
|
||||
link.Predicate.Command.Should().Contain("make");
|
||||
link.Predicate.Materials.Should().HaveCount(2);
|
||||
link.Predicate.Products.Should().HaveCount(2);
|
||||
link.Predicate.ByProducts.ReturnValue.Should().Be(0);
|
||||
link.Predicate.ByProducts.Stdout.Should().Contain("Build complete");
|
||||
link.Predicate.Environment.Should().ContainKey("GITHUB_SHA");
|
||||
link.Predicate.Environment.Should().ContainKey("RUST_VERSION");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test round-trip serialization of the golden scan link.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RoundTripGoldenScanLink_ShouldPreserveContent()
|
||||
{
|
||||
// Arrange
|
||||
var originalJson = File.ReadAllText(Path.Combine(FixturesPath, "golden_scan_link.json"));
|
||||
var link = InTotoLink.FromJson(originalJson);
|
||||
|
||||
// Act
|
||||
var serializedJson = link.ToJson(indented: true);
|
||||
var reparsedLink = InTotoLink.FromJson(serializedJson);
|
||||
|
||||
// Assert
|
||||
reparsedLink.Predicate.Name.Should().Be(link.Predicate.Name);
|
||||
reparsedLink.Predicate.Command.Should().BeEquivalentTo(link.Predicate.Command);
|
||||
reparsedLink.Predicate.Materials.Should().HaveCount(link.Predicate.Materials.Length);
|
||||
reparsedLink.Predicate.Products.Should().HaveCount(link.Predicate.Products.Length);
|
||||
reparsedLink.Subjects.Should().HaveCount(link.Subjects.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test round-trip serialization of the golden build link.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RoundTripGoldenBuildLink_ShouldPreserveContent()
|
||||
{
|
||||
// Arrange
|
||||
var originalJson = File.ReadAllText(Path.Combine(FixturesPath, "golden_build_link.json"));
|
||||
var link = InTotoLink.FromJson(originalJson);
|
||||
|
||||
// Act
|
||||
var serializedJson = link.ToJson(indented: true);
|
||||
var reparsedLink = InTotoLink.FromJson(serializedJson);
|
||||
|
||||
// Assert
|
||||
reparsedLink.Predicate.Name.Should().Be(link.Predicate.Name);
|
||||
reparsedLink.Predicate.Environment.Should().BeEquivalentTo(link.Predicate.Environment);
|
||||
reparsedLink.Predicate.ByProducts.Stdout.Should().Be(link.Predicate.ByProducts.Stdout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that golden links have the correct in-toto statement type.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("golden_scan_link.json")]
|
||||
[InlineData("golden_build_link.json")]
|
||||
public void GoldenLinks_ShouldHaveCorrectStatementType(string filename)
|
||||
{
|
||||
// Arrange
|
||||
var json = File.ReadAllText(Path.Combine(FixturesPath, filename));
|
||||
|
||||
// Act
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Assert
|
||||
root.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v1");
|
||||
root.GetProperty("predicateType").GetString().Should().Be("https://in-toto.io/Link/v1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that golden links have required predicate fields per in-toto spec.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("golden_scan_link.json")]
|
||||
[InlineData("golden_build_link.json")]
|
||||
public void GoldenLinks_ShouldHaveRequiredPredicateFields(string filename)
|
||||
{
|
||||
// Arrange
|
||||
var json = File.ReadAllText(Path.Combine(FixturesPath, filename));
|
||||
|
||||
// Act
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var predicate = doc.RootElement.GetProperty("predicate");
|
||||
|
||||
// Assert - Required fields per in-toto Link predicate spec
|
||||
predicate.TryGetProperty("name", out _).Should().BeTrue("name is required");
|
||||
predicate.TryGetProperty("command", out _).Should().BeTrue("command is required");
|
||||
predicate.TryGetProperty("materials", out _).Should().BeTrue("materials is required");
|
||||
predicate.TryGetProperty("products", out _).Should().BeTrue("products is required");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that subjects match products (per in-toto link semantics).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("golden_scan_link.json")]
|
||||
[InlineData("golden_build_link.json")]
|
||||
public void GoldenLinks_SubjectsShouldMatchProducts(string filename)
|
||||
{
|
||||
// Arrange
|
||||
var json = File.ReadAllText(Path.Combine(FixturesPath, filename));
|
||||
var link = InTotoLink.FromJson(json);
|
||||
|
||||
// Act & Assert
|
||||
// In in-toto, subjects are the products - they should have matching digests
|
||||
foreach (var subject in link.Subjects)
|
||||
{
|
||||
var matchingProduct = link.Predicate.Products
|
||||
.FirstOrDefault(p => p.Uri == subject.Name);
|
||||
|
||||
matchingProduct.Should().NotBeNull(
|
||||
$"Subject '{subject.Name}' should have a matching product");
|
||||
|
||||
if (matchingProduct is not null)
|
||||
{
|
||||
matchingProduct.Digest.Sha256.Should().Be(subject.Digest.Sha256,
|
||||
"Subject and product digests should match");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that all artifacts have valid digests.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("golden_scan_link.json")]
|
||||
[InlineData("golden_build_link.json")]
|
||||
public void GoldenLinks_AllArtifactsShouldHaveValidDigests(string filename)
|
||||
{
|
||||
// Arrange
|
||||
var json = File.ReadAllText(Path.Combine(FixturesPath, filename));
|
||||
var link = InTotoLink.FromJson(json);
|
||||
|
||||
// Act & Assert
|
||||
foreach (var material in link.Predicate.Materials)
|
||||
{
|
||||
material.Digest.HasDigest.Should().BeTrue(
|
||||
$"Material '{material.Uri}' should have a digest");
|
||||
material.Digest.Sha256.Should().MatchRegex("^[a-f0-9]{64}$",
|
||||
"SHA-256 digest should be 64 hex characters");
|
||||
}
|
||||
|
||||
foreach (var product in link.Predicate.Products)
|
||||
{
|
||||
product.Digest.HasDigest.Should().BeTrue(
|
||||
$"Product '{product.Uri}' should have a digest");
|
||||
product.Digest.Sha256.Should().MatchRegex("^[a-f0-9]{64}$",
|
||||
"SHA-256 digest should be 64 hex characters");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that byproducts have a return value.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("golden_scan_link.json", 0)]
|
||||
[InlineData("golden_build_link.json", 0)]
|
||||
public void GoldenLinks_ByProductsShouldHaveReturnValue(string filename, int expectedReturnValue)
|
||||
{
|
||||
// Arrange
|
||||
var json = File.ReadAllText(Path.Combine(FixturesPath, filename));
|
||||
var link = InTotoLink.FromJson(json);
|
||||
|
||||
// Act & Assert
|
||||
link.Predicate.ByProducts.ReturnValue.Should().Be(expectedReturnValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test golden layout fixture parsing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseGoldenLayout_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var json = File.ReadAllText(Path.Combine(FixturesPath, "golden_layout.json"));
|
||||
|
||||
// Act
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Assert
|
||||
root.GetProperty("steps").GetArrayLength().Should().Be(3);
|
||||
root.GetProperty("keys").EnumerateObject().Count().Should().Be(4);
|
||||
|
||||
var steps = root.GetProperty("steps").EnumerateArray().ToList();
|
||||
steps[0].GetProperty("name").GetString().Should().Be("build");
|
||||
steps[1].GetProperty("name").GetString().Should().Be("scan");
|
||||
steps[2].GetProperty("name").GetString().Should().Be("sign");
|
||||
|
||||
// Sign step should require threshold of 2
|
||||
steps[2].GetProperty("threshold").GetInt32().Should().Be(2);
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,11 @@
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy fixture files to output directory -->
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -39,7 +39,7 @@ internal static class SourceRetryPolicy
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var delay = ComputeDelay(baseDelay, attempt, jitterSource: jitterSource);
|
||||
var delay = ComputeDelay(baseDelay, attempt, retryAfter: null, jitterSource: jitterSource);
|
||||
onRetry?.Invoke(new SourceRetryAttemptContext(attempt, null, ex, delay));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
|
||||
10
src/Policy/StellaOps.Policy.Registry/TASKS.md
Normal file
10
src/Policy/StellaOps.Policy.Registry/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Policy.Registry Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0450-M | DONE | Maintainability audit for StellaOps.Policy.Registry. |
|
||||
| AUDIT-0450-T | DONE | Test coverage audit for StellaOps.Policy.Registry. |
|
||||
| AUDIT-0450-A | TODO | APPLY pending approval for StellaOps.Policy.Registry. |
|
||||
10
src/Policy/StellaOps.Policy.RiskProfile/TASKS.md
Normal file
10
src/Policy/StellaOps.Policy.RiskProfile/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Policy.RiskProfile Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0451-M | DONE | Maintainability audit for StellaOps.Policy.RiskProfile. |
|
||||
| AUDIT-0451-T | DONE | Test coverage audit for StellaOps.Policy.RiskProfile. |
|
||||
| AUDIT-0451-A | TODO | APPLY pending approval for StellaOps.Policy.RiskProfile. |
|
||||
10
src/Policy/StellaOps.Policy.Scoring/TASKS.md
Normal file
10
src/Policy/StellaOps.Policy.Scoring/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Policy.Scoring Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0453-M | DONE | Maintainability audit for StellaOps.Policy.Scoring. |
|
||||
| AUDIT-0453-T | DONE | Test coverage audit for StellaOps.Policy.Scoring. |
|
||||
| AUDIT-0453-A | TODO | Awaiting approval to apply changes. |
|
||||
@@ -0,0 +1,12 @@
|
||||
# StellaOps.Policy.RiskProfile.Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Maintain unit tests for risk profile canonicalization, validation, lifecycle, overrides, export, and scope services.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/policy/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep tests deterministic and offline-friendly.
|
||||
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Policy.RiskProfile.Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0452-M | DONE | Maintainability audit for StellaOps.Policy.RiskProfile.Tests. |
|
||||
| AUDIT-0452-T | DONE | Test coverage audit for StellaOps.Policy.RiskProfile.Tests. |
|
||||
| AUDIT-0452-A | DONE | Waived (test project). |
|
||||
13
src/Policy/__Tests/StellaOps.Policy.Scoring.Tests/AGENTS.md
Normal file
13
src/Policy/__Tests/StellaOps.Policy.Scoring.Tests/AGENTS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# StellaOps.Policy.Scoring.Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Maintain unit/integration tests for CVSS scoring, receipt generation, and policy loading.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/policy/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- FIRST CVSS v4.0 Specification: https://www.first.org/cvss/v4-0/specification-document
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Prefer deterministic test data (fixed IDs/timestamps, FakeTimeProvider).
|
||||
10
src/Policy/__Tests/StellaOps.Policy.Scoring.Tests/TASKS.md
Normal file
10
src/Policy/__Tests/StellaOps.Policy.Scoring.Tests/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# StellaOps.Policy.Scoring.Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0454-M | DONE | Maintainability audit for StellaOps.Policy.Scoring.Tests. |
|
||||
| AUDIT-0454-T | DONE | Test coverage audit for StellaOps.Policy.Scoring.Tests. |
|
||||
| AUDIT-0454-A | DONE | Waived (test project). |
|
||||
@@ -34,6 +34,7 @@ using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.Triage;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.WebService.Diagnostics;
|
||||
using StellaOps.Scanner.WebService.Determinism;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
@@ -155,7 +156,16 @@ builder.Services.AddSingleton<IBaselineService, BaselineService>();
|
||||
builder.Services.AddSingleton<IActionablesService, ActionablesService>();
|
||||
builder.Services.AddSingleton<ICounterfactualApiService, CounterfactualApiService>();
|
||||
builder.Services.AddDbContext<TriageDbContext>(options =>
|
||||
options.UseNpgsql(bootstrapOptions.Storage.Dsn));
|
||||
options.UseNpgsql(bootstrapOptions.Storage.Dsn, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MapEnum<TriageLane>();
|
||||
npgsqlOptions.MapEnum<TriageVerdict>();
|
||||
npgsqlOptions.MapEnum<TriageReachability>();
|
||||
npgsqlOptions.MapEnum<TriageVexStatus>();
|
||||
npgsqlOptions.MapEnum<TriageDecisionKind>();
|
||||
npgsqlOptions.MapEnum<TriageSnapshotTrigger>();
|
||||
npgsqlOptions.MapEnum<TriageEvidenceType>();
|
||||
}));
|
||||
builder.Services.AddScoped<ITriageQueryService, TriageQueryService>();
|
||||
builder.Services.AddScoped<ITriageStatusService, TriageStatusService>();
|
||||
|
||||
@@ -503,6 +513,10 @@ app.UseExceptionHandler(errorApp =>
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
var feature = context.Features.Get<IExceptionHandlerFeature>();
|
||||
var error = feature?.Error;
|
||||
if (error is not null)
|
||||
{
|
||||
app.Logger.LogError(error, "Unhandled exception.");
|
||||
}
|
||||
|
||||
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
|
||||
@@ -49,7 +49,8 @@ CREATE TABLE IF NOT EXISTS links (
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_links_from_artifact ON links (from_type, from_digest, artifact_id);
|
||||
|
||||
CREATE TYPE job_state AS ENUM ('Pending','Running','Succeeded','Failed','Cancelled');
|
||||
DO $$ BEGIN CREATE TYPE job_state AS ENUM ('Pending','Running','Succeeded','Failed','Cancelled');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -6,21 +8,27 @@ namespace StellaOps.Scanner.Triage.Entities;
|
||||
public enum TriageLane
|
||||
{
|
||||
/// <summary>Finding is actively being evaluated.</summary>
|
||||
[PgName("ACTIVE")]
|
||||
Active,
|
||||
|
||||
/// <summary>Finding is blocking shipment.</summary>
|
||||
[PgName("BLOCKED")]
|
||||
Blocked,
|
||||
|
||||
/// <summary>Finding requires a security exception to proceed.</summary>
|
||||
[PgName("NEEDS_EXCEPTION")]
|
||||
NeedsException,
|
||||
|
||||
/// <summary>Finding is muted due to reachability analysis (not reachable).</summary>
|
||||
[PgName("MUTED_REACH")]
|
||||
MutedReach,
|
||||
|
||||
/// <summary>Finding is muted due to VEX status (not affected).</summary>
|
||||
[PgName("MUTED_VEX")]
|
||||
MutedVex,
|
||||
|
||||
/// <summary>Finding is mitigated by compensating controls.</summary>
|
||||
[PgName("COMPENSATED")]
|
||||
Compensated
|
||||
}
|
||||
|
||||
@@ -30,12 +38,15 @@ public enum TriageLane
|
||||
public enum TriageVerdict
|
||||
{
|
||||
/// <summary>Can ship - no blocking issues.</summary>
|
||||
[PgName("SHIP")]
|
||||
Ship,
|
||||
|
||||
/// <summary>Cannot ship - blocking issues present.</summary>
|
||||
[PgName("BLOCK")]
|
||||
Block,
|
||||
|
||||
/// <summary>Exception granted - can ship with documented exception.</summary>
|
||||
[PgName("EXCEPTION")]
|
||||
Exception
|
||||
}
|
||||
|
||||
@@ -45,12 +56,15 @@ public enum TriageVerdict
|
||||
public enum TriageReachability
|
||||
{
|
||||
/// <summary>Vulnerable code is reachable.</summary>
|
||||
[PgName("YES")]
|
||||
Yes,
|
||||
|
||||
/// <summary>Vulnerable code is not reachable.</summary>
|
||||
[PgName("NO")]
|
||||
No,
|
||||
|
||||
/// <summary>Reachability cannot be determined.</summary>
|
||||
[PgName("UNKNOWN")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
@@ -60,15 +74,19 @@ public enum TriageReachability
|
||||
public enum TriageVexStatus
|
||||
{
|
||||
/// <summary>Product is affected by the vulnerability.</summary>
|
||||
[PgName("affected")]
|
||||
Affected,
|
||||
|
||||
/// <summary>Product is not affected by the vulnerability.</summary>
|
||||
[PgName("not_affected")]
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Investigation is ongoing.</summary>
|
||||
[PgName("under_investigation")]
|
||||
UnderInvestigation,
|
||||
|
||||
/// <summary>Status is unknown.</summary>
|
||||
[PgName("unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
@@ -78,15 +96,19 @@ public enum TriageVexStatus
|
||||
public enum TriageDecisionKind
|
||||
{
|
||||
/// <summary>Mute based on reachability analysis.</summary>
|
||||
[PgName("MUTE_REACH")]
|
||||
MuteReach,
|
||||
|
||||
/// <summary>Mute based on VEX status.</summary>
|
||||
[PgName("MUTE_VEX")]
|
||||
MuteVex,
|
||||
|
||||
/// <summary>Acknowledge the finding without action.</summary>
|
||||
[PgName("ACK")]
|
||||
Ack,
|
||||
|
||||
/// <summary>Grant a security exception.</summary>
|
||||
[PgName("EXCEPTION")]
|
||||
Exception
|
||||
}
|
||||
|
||||
@@ -96,24 +118,31 @@ public enum TriageDecisionKind
|
||||
public enum TriageSnapshotTrigger
|
||||
{
|
||||
/// <summary>Vulnerability feed was updated.</summary>
|
||||
[PgName("FEED_UPDATE")]
|
||||
FeedUpdate,
|
||||
|
||||
/// <summary>VEX document was updated.</summary>
|
||||
[PgName("VEX_UPDATE")]
|
||||
VexUpdate,
|
||||
|
||||
/// <summary>SBOM was updated.</summary>
|
||||
[PgName("SBOM_UPDATE")]
|
||||
SbomUpdate,
|
||||
|
||||
/// <summary>Runtime trace was received.</summary>
|
||||
[PgName("RUNTIME_TRACE")]
|
||||
RuntimeTrace,
|
||||
|
||||
/// <summary>Policy was updated.</summary>
|
||||
[PgName("POLICY_UPDATE")]
|
||||
PolicyUpdate,
|
||||
|
||||
/// <summary>A triage decision was made.</summary>
|
||||
[PgName("DECISION")]
|
||||
Decision,
|
||||
|
||||
/// <summary>Manual rescan was triggered.</summary>
|
||||
[PgName("RESCAN")]
|
||||
Rescan
|
||||
}
|
||||
|
||||
@@ -123,29 +152,38 @@ public enum TriageSnapshotTrigger
|
||||
public enum TriageEvidenceType
|
||||
{
|
||||
/// <summary>Slice of the SBOM relevant to the finding.</summary>
|
||||
[PgName("SBOM_SLICE")]
|
||||
SbomSlice,
|
||||
|
||||
/// <summary>VEX document.</summary>
|
||||
[PgName("VEX_DOC")]
|
||||
VexDoc,
|
||||
|
||||
/// <summary>Build provenance attestation.</summary>
|
||||
[PgName("PROVENANCE")]
|
||||
Provenance,
|
||||
|
||||
/// <summary>Callstack or callgraph slice.</summary>
|
||||
[PgName("CALLSTACK_SLICE")]
|
||||
CallstackSlice,
|
||||
|
||||
/// <summary>Reachability proof document.</summary>
|
||||
[PgName("REACHABILITY_PROOF")]
|
||||
ReachabilityProof,
|
||||
|
||||
/// <summary>Replay manifest for deterministic reproduction.</summary>
|
||||
[PgName("REPLAY_MANIFEST")]
|
||||
ReplayManifest,
|
||||
|
||||
/// <summary>Policy document that was applied.</summary>
|
||||
[PgName("POLICY")]
|
||||
Policy,
|
||||
|
||||
/// <summary>Scan log output.</summary>
|
||||
[PgName("SCAN_LOG")]
|
||||
ScanLog,
|
||||
|
||||
/// <summary>Other evidence type.</summary>
|
||||
[PgName("OTHER")]
|
||||
Other
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
-- Generated from docs/db/triage_schema.sql
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
@@ -64,6 +62,27 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Scan metadata
|
||||
CREATE TABLE IF NOT EXISTS triage_scan (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
image_reference text NOT NULL,
|
||||
image_digest text NULL,
|
||||
target_digest text NULL,
|
||||
target_reference text NULL,
|
||||
knowledge_snapshot_id text NULL,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz NULL,
|
||||
status text NOT NULL,
|
||||
policy_hash text NULL,
|
||||
feed_snapshot_hash text NULL,
|
||||
snapshot_created_at timestamptz NULL,
|
||||
feed_versions jsonb NULL,
|
||||
snapshot_content_hash text NULL,
|
||||
final_digest text NULL,
|
||||
feed_snapshot_at timestamptz NULL,
|
||||
offline_bundle_id text NULL
|
||||
);
|
||||
|
||||
-- Core: finding (caseId == findingId)
|
||||
CREATE TABLE IF NOT EXISTS triage_finding (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -73,8 +92,18 @@ CREATE TABLE IF NOT EXISTS triage_finding (
|
||||
purl text NOT NULL,
|
||||
cve_id text NULL,
|
||||
rule_id text NULL,
|
||||
artifact_digest text NULL,
|
||||
scan_id uuid NULL,
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
status text NULL,
|
||||
is_muted boolean NOT NULL DEFAULT false,
|
||||
is_backport_fixed boolean NOT NULL DEFAULT false,
|
||||
fixed_in_version text NULL,
|
||||
superseded_by text NULL,
|
||||
delta_comparison_id uuid NULL,
|
||||
knowledge_snapshot_id text NULL,
|
||||
UNIQUE (asset_id, environment_id, purl, cve_id, rule_id)
|
||||
);
|
||||
|
||||
@@ -83,6 +112,29 @@ CREATE INDEX IF NOT EXISTS ix_triage_finding_asset_label ON triage_finding (asse
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_purl ON triage_finding (purl);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_cve ON triage_finding (cve_id);
|
||||
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS artifact_digest text NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS scan_id uuid NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS status text NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS is_muted boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS is_backport_fixed boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS fixed_in_version text NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS superseded_by text NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS delta_comparison_id uuid NULL;
|
||||
ALTER TABLE triage_finding ADD COLUMN IF NOT EXISTS knowledge_snapshot_id text NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_triage_finding_scan'
|
||||
) THEN
|
||||
ALTER TABLE triage_finding
|
||||
ADD CONSTRAINT fk_triage_finding_scan
|
||||
FOREIGN KEY (scan_id) REFERENCES triage_scan(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Effective VEX (post-merge)
|
||||
CREATE TABLE IF NOT EXISTS triage_effective_vex (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -196,6 +248,32 @@ CREATE TABLE IF NOT EXISTS triage_snapshot (
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_finding ON triage_snapshot (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_trigger ON triage_snapshot (trigger, created_at DESC);
|
||||
|
||||
-- Policy decisions
|
||||
CREATE TABLE IF NOT EXISTS triage_policy_decision (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
policy_id text NOT NULL,
|
||||
action text NOT NULL,
|
||||
reason text NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_policy_decision_finding ON triage_policy_decision (finding_id, applied_at DESC);
|
||||
|
||||
-- Attestations
|
||||
CREATE TABLE IF NOT EXISTS triage_attestation (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
type text NOT NULL,
|
||||
issuer text NULL,
|
||||
envelope_hash text NULL,
|
||||
content_ref text NULL,
|
||||
ledger_ref text NULL,
|
||||
collected_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_attestation_finding ON triage_attestation (finding_id, collected_at DESC);
|
||||
|
||||
-- Current-case view
|
||||
CREATE OR REPLACE VIEW v_triage_case_current AS
|
||||
WITH latest_risk AS (
|
||||
@@ -246,4 +324,3 @@ LEFT JOIN latest_risk r ON r.finding_id = f.id
|
||||
LEFT JOIN latest_reach re ON re.finding_id = f.id
|
||||
LEFT JOIN latest_vex v ON v.finding_id = f.id;
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -21,8 +21,7 @@ public sealed class ReportSamplesTests
|
||||
[Fact]
|
||||
public async Task ReportSampleEnvelope_RemainsCanonical()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
|
||||
var repoRoot = ResolveRepoRoot();
|
||||
var path = Path.Combine(repoRoot, "samples", "api", "reports", "report-sample.dsse.json");
|
||||
Assert.True(File.Exists(path), $"Sample file not found at {path}.");
|
||||
await using var stream = File.OpenRead(path);
|
||||
@@ -35,4 +34,18 @@ public sealed class ReportSamplesTests
|
||||
var expectedPayload = Convert.ToBase64String(reportBytes);
|
||||
Assert.Equal(expectedPayload, response.Dsse!.Payload);
|
||||
}
|
||||
|
||||
private static string ResolveRepoRoot()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
baseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
".."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +117,7 @@ public sealed class SbomUploadEndpointsTests
|
||||
|
||||
private static string LoadFixtureBase64(string fileName)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
|
||||
var repoRoot = ResolveRepoRoot();
|
||||
var path = Path.Combine(
|
||||
repoRoot,
|
||||
"src",
|
||||
@@ -134,6 +133,20 @@ public sealed class SbomUploadEndpointsTests
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static string ResolveRepoRoot()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
baseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
".."));
|
||||
}
|
||||
|
||||
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
@@ -47,7 +48,11 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
|
||||
postgresFixture = new ScannerWebServicePostgresFixture();
|
||||
postgresFixture.InitializeAsync().GetAwaiter().GetResult();
|
||||
|
||||
configuration["scanner:storage:dsn"] = postgresFixture.ConnectionString;
|
||||
var connectionBuilder = new NpgsqlConnectionStringBuilder(postgresFixture.ConnectionString)
|
||||
{
|
||||
SearchPath = $"{postgresFixture.SchemaName},public"
|
||||
};
|
||||
configuration["scanner:storage:dsn"] = connectionBuilder.ToString();
|
||||
configuration["scanner:storage:database"] = postgresFixture.SchemaName;
|
||||
}
|
||||
|
||||
@@ -173,7 +178,34 @@ public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceSta
|
||||
public override async ValueTask InitializeAsync()
|
||||
{
|
||||
await base.InitializeAsync();
|
||||
await Fixture.RunMigrationsFromAssemblyAsync<TriageDbContext>("Scanner.Triage.WebService.Tests");
|
||||
var migrationsPath = Path.Combine(
|
||||
ResolveRepoRoot(),
|
||||
"src",
|
||||
"Scanner",
|
||||
"__Libraries",
|
||||
"StellaOps.Scanner.Triage",
|
||||
"Migrations");
|
||||
|
||||
if (!Directory.Exists(migrationsPath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Triage migrations not found at {migrationsPath}");
|
||||
}
|
||||
|
||||
await Fixture.RunMigrationsAsync(migrationsPath, "Scanner.Triage.WebService.Tests");
|
||||
}
|
||||
|
||||
private static string ResolveRepoRoot()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
baseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
".."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
546
src/VexLens/StellaOps.VexLens/Conditions/ConditionEvaluator.cs
Normal file
546
src/VexLens/StellaOps.VexLens/Conditions/ConditionEvaluator.cs
Normal file
@@ -0,0 +1,546 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.VexLens.Proof;
|
||||
|
||||
namespace StellaOps.VexLens.Conditions;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the condition evaluator.
|
||||
/// </summary>
|
||||
public sealed partial class ConditionEvaluator : IConditionEvaluator
|
||||
{
|
||||
private readonly ImmutableDictionary<ConditionType, IConditionHandler> _handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ConditionEvaluator with default handlers.
|
||||
/// </summary>
|
||||
public ConditionEvaluator() : this(GetDefaultHandlers())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ConditionEvaluator with specified handlers.
|
||||
/// </summary>
|
||||
public ConditionEvaluator(IEnumerable<IConditionHandler> handlers)
|
||||
{
|
||||
_handlers = handlers.ToImmutableDictionary(h => h.HandledType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConditionEvaluationResult Evaluate(
|
||||
IEnumerable<VexCondition> conditions,
|
||||
EvaluationContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(conditions);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var results = new List<VexProofConditionResult>();
|
||||
var unevaluated = new List<string>();
|
||||
var unknownCount = 0;
|
||||
var totalCount = 0;
|
||||
var evaluatedCount = 0;
|
||||
|
||||
foreach (var condition in conditions)
|
||||
{
|
||||
totalCount++;
|
||||
var result = EvaluateSingle(condition, context);
|
||||
results.Add(result);
|
||||
|
||||
if (result.Result == ConditionOutcome.Unknown)
|
||||
{
|
||||
unknownCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
evaluatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var coverage = totalCount > 0 ? (decimal)evaluatedCount / totalCount : 1m;
|
||||
|
||||
return new ConditionEvaluationResult(
|
||||
results.ToImmutableArray(),
|
||||
unevaluated.ToImmutableArray(),
|
||||
unknownCount,
|
||||
coverage);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VexProofConditionResult EvaluateSingle(
|
||||
VexCondition condition,
|
||||
EvaluationContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(condition);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (_handlers.TryGetValue(condition.Type, out var handler))
|
||||
{
|
||||
return handler.Evaluate(condition, context);
|
||||
}
|
||||
|
||||
// Fallback for custom conditions
|
||||
if (condition.Type == ConditionType.Custom)
|
||||
{
|
||||
return EvaluateCustomCondition(condition, context);
|
||||
}
|
||||
|
||||
// Unknown condition type
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
$"Unknown condition type: {condition.Type}");
|
||||
}
|
||||
|
||||
private static VexProofConditionResult EvaluateCustomCondition(
|
||||
VexCondition condition,
|
||||
EvaluationContext context)
|
||||
{
|
||||
// Simple expression parser for custom conditions
|
||||
// Supports: platform == 'value', feature in ['a', 'b'], env.KEY == 'value'
|
||||
|
||||
try
|
||||
{
|
||||
var expression = condition.Expression.Trim();
|
||||
|
||||
// Platform equality: platform == 'linux/amd64'
|
||||
if (expression.StartsWith("platform", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EvaluatePlatformExpression(condition, expression, context);
|
||||
}
|
||||
|
||||
// Distro equality: distro == 'rhel:9'
|
||||
if (expression.StartsWith("distro", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EvaluateDistroExpression(condition, expression, context);
|
||||
}
|
||||
|
||||
// Feature check: feature in ['esm', 'cjs']
|
||||
if (expression.StartsWith("feature", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EvaluateFeatureExpression(condition, expression, context);
|
||||
}
|
||||
|
||||
// Environment check: env.KEY == 'value'
|
||||
if (expression.StartsWith("env.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EvaluateEnvironmentExpression(condition, expression, context);
|
||||
}
|
||||
|
||||
// BuildFlag check: buildFlag.KEY == 'value'
|
||||
if (expression.StartsWith("buildFlag.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EvaluateBuildFlagExpression(condition, expression, context);
|
||||
}
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Unsupported expression syntax");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
$"Evaluation error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static VexProofConditionResult EvaluatePlatformExpression(
|
||||
VexCondition condition,
|
||||
string expression,
|
||||
EvaluationContext context)
|
||||
{
|
||||
if (context.Platform is null)
|
||||
{
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Platform not specified in context");
|
||||
}
|
||||
|
||||
var match = EqualityExpressionRegex().Match(expression);
|
||||
if (match.Success)
|
||||
{
|
||||
var expectedValue = match.Groups["value"].Value;
|
||||
var result = MatchesWildcard(context.Platform, expectedValue);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
result ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
context.Platform);
|
||||
}
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Invalid platform expression syntax");
|
||||
}
|
||||
|
||||
private static VexProofConditionResult EvaluateDistroExpression(
|
||||
VexCondition condition,
|
||||
string expression,
|
||||
EvaluationContext context)
|
||||
{
|
||||
if (context.Distro is null)
|
||||
{
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Distro not specified in context");
|
||||
}
|
||||
|
||||
var match = EqualityExpressionRegex().Match(expression);
|
||||
if (match.Success)
|
||||
{
|
||||
var expectedValue = match.Groups["value"].Value;
|
||||
var result = MatchesWildcard(context.Distro, expectedValue);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
result ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
context.Distro);
|
||||
}
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Invalid distro expression syntax");
|
||||
}
|
||||
|
||||
private static VexProofConditionResult EvaluateFeatureExpression(
|
||||
VexCondition condition,
|
||||
string expression,
|
||||
EvaluationContext context)
|
||||
{
|
||||
// Check for: feature in ['a', 'b']
|
||||
var inMatch = FeatureInExpressionRegex().Match(expression);
|
||||
if (inMatch.Success)
|
||||
{
|
||||
var featuresStr = inMatch.Groups["features"].Value;
|
||||
var features = ParseStringList(featuresStr);
|
||||
var hasAny = features.Any(f => context.Features.Contains(f));
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
hasAny ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
string.Join(", ", context.Features));
|
||||
}
|
||||
|
||||
// Check for: feature == 'esm'
|
||||
var eqMatch = FeatureEqExpressionRegex().Match(expression);
|
||||
if (eqMatch.Success)
|
||||
{
|
||||
var feature = eqMatch.Groups["feature"].Value;
|
||||
var hasFeature = context.Features.Contains(feature);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
hasFeature ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
string.Join(", ", context.Features));
|
||||
}
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Invalid feature expression syntax");
|
||||
}
|
||||
|
||||
private static VexProofConditionResult EvaluateEnvironmentExpression(
|
||||
VexCondition condition,
|
||||
string expression,
|
||||
EvaluationContext context)
|
||||
{
|
||||
var match = EnvExpressionRegex().Match(expression);
|
||||
if (match.Success)
|
||||
{
|
||||
var key = match.Groups["key"].Value;
|
||||
var expectedValue = match.Groups["value"].Value;
|
||||
|
||||
if (!context.Environment.TryGetValue(key, out var actualValue))
|
||||
{
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
$"Environment variable {key} not found");
|
||||
}
|
||||
|
||||
var result = string.Equals(actualValue, expectedValue, StringComparison.Ordinal);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
result ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
actualValue);
|
||||
}
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Invalid environment expression syntax");
|
||||
}
|
||||
|
||||
private static VexProofConditionResult EvaluateBuildFlagExpression(
|
||||
VexCondition condition,
|
||||
string expression,
|
||||
EvaluationContext context)
|
||||
{
|
||||
var match = BuildFlagExpressionRegex().Match(expression);
|
||||
if (match.Success)
|
||||
{
|
||||
var key = match.Groups["key"].Value;
|
||||
var expectedValue = match.Groups["value"].Value;
|
||||
|
||||
if (!context.BuildFlags.TryGetValue(key, out var actualValue))
|
||||
{
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
$"Build flag {key} not found");
|
||||
}
|
||||
|
||||
var result = string.Equals(actualValue, expectedValue, StringComparison.Ordinal);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
result ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
actualValue);
|
||||
}
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Invalid build flag expression syntax");
|
||||
}
|
||||
|
||||
private static bool MatchesWildcard(string actual, string pattern)
|
||||
{
|
||||
// Simple wildcard matching with * for any characters
|
||||
if (!pattern.Contains('*'))
|
||||
{
|
||||
return string.Equals(actual, pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$";
|
||||
return Regex.IsMatch(actual, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ParseStringList(string input)
|
||||
{
|
||||
// Parse: 'a', 'b', 'c' or "a", "b", "c"
|
||||
var matches = StringListItemRegex().Matches(input);
|
||||
return matches.Select(m => m.Groups["item"].Value);
|
||||
}
|
||||
|
||||
private static IEnumerable<IConditionHandler> GetDefaultHandlers()
|
||||
{
|
||||
yield return new PlatformConditionHandler();
|
||||
yield return new DistroConditionHandler();
|
||||
yield return new FeatureConditionHandler();
|
||||
yield return new BuildFlagConditionHandler();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"==\s*['""](?<value>[^'""]+)['""]", RegexOptions.Compiled)]
|
||||
private static partial Regex EqualityExpressionRegex();
|
||||
|
||||
[GeneratedRegex(@"feature\s+in\s*\[(?<features>[^\]]+)\]", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex FeatureInExpressionRegex();
|
||||
|
||||
[GeneratedRegex(@"feature\s*==\s*['""](?<feature>[^'""]+)['""]", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex FeatureEqExpressionRegex();
|
||||
|
||||
[GeneratedRegex(@"env\.(?<key>\w+)\s*==\s*['""](?<value>[^'""]+)['""]", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex EnvExpressionRegex();
|
||||
|
||||
[GeneratedRegex(@"buildFlag\.(?<key>\w+)\s*==\s*['""](?<value>[^'""]+)['""]", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BuildFlagExpressionRegex();
|
||||
|
||||
[GeneratedRegex(@"['""](?<item>[^'""]+)['""]", RegexOptions.Compiled)]
|
||||
private static partial Regex StringListItemRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for a specific condition type.
|
||||
/// </summary>
|
||||
public interface IConditionHandler
|
||||
{
|
||||
/// <summary>Gets the condition type this handler handles.</summary>
|
||||
ConditionType HandledType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a condition of this type.
|
||||
/// </summary>
|
||||
VexProofConditionResult Evaluate(VexCondition condition, EvaluationContext context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for platform conditions.
|
||||
/// </summary>
|
||||
public sealed class PlatformConditionHandler : IConditionHandler
|
||||
{
|
||||
public ConditionType HandledType => ConditionType.Platform;
|
||||
|
||||
public VexProofConditionResult Evaluate(VexCondition condition, EvaluationContext context)
|
||||
{
|
||||
if (context.Platform is null)
|
||||
{
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Platform not specified in context");
|
||||
}
|
||||
|
||||
var expectedValue = condition.ExpectedValue ?? condition.Expression;
|
||||
var result = MatchesPlatform(context.Platform, expectedValue);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
result ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
context.Platform);
|
||||
}
|
||||
|
||||
private static bool MatchesPlatform(string actual, string expected)
|
||||
{
|
||||
// Support patterns like: linux/*, */amd64, linux/amd64
|
||||
if (!expected.Contains('*'))
|
||||
{
|
||||
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(expected).Replace("\\*", ".*") + "$";
|
||||
return Regex.IsMatch(actual, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for distro conditions.
|
||||
/// </summary>
|
||||
public sealed class DistroConditionHandler : IConditionHandler
|
||||
{
|
||||
public ConditionType HandledType => ConditionType.Distro;
|
||||
|
||||
public VexProofConditionResult Evaluate(VexCondition condition, EvaluationContext context)
|
||||
{
|
||||
if (context.Distro is null)
|
||||
{
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
"Distro not specified in context");
|
||||
}
|
||||
|
||||
var expectedValue = condition.ExpectedValue ?? condition.Expression;
|
||||
var result = MatchesDistro(context.Distro, expectedValue);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
result ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
context.Distro);
|
||||
}
|
||||
|
||||
private static bool MatchesDistro(string actual, string expected)
|
||||
{
|
||||
// Support patterns like: rhel:*, debian:12
|
||||
if (!expected.Contains('*'))
|
||||
{
|
||||
return string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(expected).Replace("\\*", ".*") + "$";
|
||||
return Regex.IsMatch(actual, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for feature conditions.
|
||||
/// </summary>
|
||||
public sealed class FeatureConditionHandler : IConditionHandler
|
||||
{
|
||||
public ConditionType HandledType => ConditionType.Feature;
|
||||
|
||||
public VexProofConditionResult Evaluate(VexCondition condition, EvaluationContext context)
|
||||
{
|
||||
var expectedFeature = condition.ExpectedValue ?? condition.Expression;
|
||||
var hasFeature = context.Features.Contains(expectedFeature);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
hasFeature ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
string.Join(", ", context.Features));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for build flag conditions.
|
||||
/// </summary>
|
||||
public sealed class BuildFlagConditionHandler : IConditionHandler
|
||||
{
|
||||
public ConditionType HandledType => ConditionType.BuildFlag;
|
||||
|
||||
public VexProofConditionResult Evaluate(VexCondition condition, EvaluationContext context)
|
||||
{
|
||||
// Parse the expression to extract key and expected value
|
||||
// Format: KEY=value or just KEY (check for presence)
|
||||
var expression = condition.Expression;
|
||||
var expectedValue = condition.ExpectedValue;
|
||||
|
||||
if (expression.Contains('='))
|
||||
{
|
||||
var parts = expression.Split('=', 2);
|
||||
var key = parts[0].Trim();
|
||||
expectedValue ??= parts[1].Trim();
|
||||
|
||||
if (!context.BuildFlags.TryGetValue(key, out var actualValue))
|
||||
{
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
ConditionOutcome.Unknown,
|
||||
$"Build flag {key} not found");
|
||||
}
|
||||
|
||||
var result = string.Equals(actualValue, expectedValue, StringComparison.Ordinal);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
result ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
actualValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Just check for presence
|
||||
var hasFlag = context.BuildFlags.ContainsKey(expression);
|
||||
|
||||
return new VexProofConditionResult(
|
||||
condition.ConditionId,
|
||||
condition.Expression,
|
||||
hasFlag ? ConditionOutcome.True : ConditionOutcome.False,
|
||||
hasFlag ? context.BuildFlags[expression] : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.VexLens.Proof;
|
||||
|
||||
namespace StellaOps.VexLens.Conditions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates VEX conditions against an evaluation context.
|
||||
/// </summary>
|
||||
public interface IConditionEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a set of conditions against the given context.
|
||||
/// </summary>
|
||||
/// <param name="conditions">The conditions to evaluate.</param>
|
||||
/// <param name="context">The evaluation context.</param>
|
||||
/// <returns>The evaluation results.</returns>
|
||||
ConditionEvaluationResult Evaluate(
|
||||
IEnumerable<VexCondition> conditions,
|
||||
EvaluationContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a single condition against the given context.
|
||||
/// </summary>
|
||||
/// <param name="condition">The condition to evaluate.</param>
|
||||
/// <param name="context">The evaluation context.</param>
|
||||
/// <returns>The evaluation result.</returns>
|
||||
VexProofConditionResult EvaluateSingle(
|
||||
VexCondition condition,
|
||||
EvaluationContext context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A VEX condition that can be evaluated.
|
||||
/// </summary>
|
||||
public sealed record VexCondition(
|
||||
string ConditionId,
|
||||
ConditionType Type,
|
||||
string Expression,
|
||||
string? ExpectedValue);
|
||||
|
||||
/// <summary>
|
||||
/// Type of condition.
|
||||
/// </summary>
|
||||
public enum ConditionType
|
||||
{
|
||||
/// <summary>Platform condition (e.g., linux/amd64).</summary>
|
||||
Platform,
|
||||
|
||||
/// <summary>Distribution condition (e.g., rhel:9).</summary>
|
||||
Distro,
|
||||
|
||||
/// <summary>Feature flag condition.</summary>
|
||||
Feature,
|
||||
|
||||
/// <summary>Build flag condition.</summary>
|
||||
BuildFlag,
|
||||
|
||||
/// <summary>Environment variable condition.</summary>
|
||||
Environment,
|
||||
|
||||
/// <summary>Custom expression condition.</summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for condition evaluation.
|
||||
/// </summary>
|
||||
public sealed record EvaluationContext(
|
||||
string? Platform,
|
||||
string? Distro,
|
||||
ImmutableHashSet<string> Features,
|
||||
ImmutableDictionary<string, string> BuildFlags,
|
||||
ImmutableDictionary<string, string> Environment,
|
||||
DateTimeOffset EvaluationTime);
|
||||
|
||||
/// <summary>
|
||||
/// Result of condition evaluation.
|
||||
/// </summary>
|
||||
public sealed record ConditionEvaluationResult(
|
||||
ImmutableArray<VexProofConditionResult> Results,
|
||||
ImmutableArray<string> Unevaluated,
|
||||
int UnknownCount,
|
||||
decimal Coverage);
|
||||
@@ -1,4 +1,5 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Proof;
|
||||
using StellaOps.VexLens.Trust;
|
||||
|
||||
namespace StellaOps.VexLens.Consensus;
|
||||
@@ -15,6 +16,20 @@ public interface IVexConsensusEngine
|
||||
VexConsensusRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes consensus with full proof object for audit trail.
|
||||
/// </summary>
|
||||
/// <param name="request">Consensus request containing statements and context.</param>
|
||||
/// <param name="proofContext">Optional proof context for condition evaluation.</param>
|
||||
/// <param name="timeProvider">Time provider for deterministic proof generation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Resolution result containing verdict, proof, and conflicts.</returns>
|
||||
Task<VexResolutionResult> ComputeConsensusWithProofAsync(
|
||||
VexConsensusRequest request,
|
||||
VexProofContext? proofContext = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes consensus for multiple vulnerability-product pairs in batch.
|
||||
/// </summary>
|
||||
@@ -33,6 +48,15 @@ public interface IVexConsensusEngine
|
||||
void UpdateConfiguration(ConsensusConfiguration configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete resolution result including verdict and proof.
|
||||
/// </summary>
|
||||
/// <param name="Verdict">The consensus result.</param>
|
||||
/// <param name="Proof">The proof object documenting the resolution process.</param>
|
||||
public sealed record VexResolutionResult(
|
||||
VexConsensusResult Verdict,
|
||||
VexProof Proof);
|
||||
|
||||
/// <summary>
|
||||
/// Request for consensus computation.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Proof;
|
||||
|
||||
namespace StellaOps.VexLens.Consensus;
|
||||
|
||||
@@ -502,4 +504,560 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||
PreferMostSpecific: true,
|
||||
StatusPriority: null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes consensus with full proof object for audit trail.
|
||||
/// </summary>
|
||||
public async Task<VexResolutionResult> ComputeConsensusWithProofAsync(
|
||||
VexConsensusRequest request,
|
||||
VexProofContext? proofContext = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var time = timeProvider ?? TimeProvider.System;
|
||||
var builder = new VexProofBuilder(time)
|
||||
.ForVulnerability(request.VulnerabilityId, request.ProductKey);
|
||||
|
||||
// Set up context
|
||||
var evaluationTime = time.GetUtcNow();
|
||||
var context = proofContext ?? new VexProofContext(
|
||||
null, null, [], [], evaluationTime);
|
||||
builder.WithContext(context);
|
||||
|
||||
// Get consensus policy
|
||||
var policy = request.Context.Policy ?? CreateDefaultPolicy();
|
||||
builder.WithConsensusMode(policy.Mode);
|
||||
|
||||
// Filter and track statements
|
||||
var allStatements = request.Statements.ToList();
|
||||
var qualifiedStatements = new List<WeightedStatement>();
|
||||
var disqualifiedStatements = new List<(WeightedStatement Statement, string Reason)>();
|
||||
|
||||
foreach (var stmt in allStatements)
|
||||
{
|
||||
if (stmt.Weight.Weight >= policy.MinimumWeightThreshold)
|
||||
{
|
||||
qualifiedStatements.Add(stmt);
|
||||
}
|
||||
else
|
||||
{
|
||||
disqualifiedStatements.Add((stmt, $"Weight {stmt.Weight.Weight:F4} below threshold {policy.MinimumWeightThreshold:F4}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Add all statements to proof
|
||||
foreach (var stmt in qualifiedStatements)
|
||||
{
|
||||
var issuer = CreateProofIssuer(stmt.Issuer);
|
||||
var weight = CreateProofWeight(stmt.Weight);
|
||||
builder.AddStatement(
|
||||
stmt.Statement.StatementId,
|
||||
stmt.SourceDocumentId ?? "unknown",
|
||||
issuer,
|
||||
stmt.Statement.Status,
|
||||
stmt.Statement.Justification,
|
||||
weight,
|
||||
stmt.Statement.Timestamp,
|
||||
stmt.Weight.Factors.SignaturePresence > 0);
|
||||
}
|
||||
|
||||
foreach (var (stmt, reason) in disqualifiedStatements)
|
||||
{
|
||||
var issuer = CreateProofIssuer(stmt.Issuer);
|
||||
var weight = CreateProofWeight(stmt.Weight);
|
||||
builder.AddDisqualifiedStatement(
|
||||
stmt.Statement.StatementId,
|
||||
stmt.SourceDocumentId ?? "unknown",
|
||||
issuer,
|
||||
stmt.Statement.Status,
|
||||
stmt.Statement.Justification,
|
||||
weight,
|
||||
stmt.Statement.Timestamp,
|
||||
stmt.Weight.Factors.SignaturePresence > 0,
|
||||
reason);
|
||||
}
|
||||
|
||||
// Handle no data case
|
||||
if (qualifiedStatements.Count == 0)
|
||||
{
|
||||
var noDataResult = CreateNoDataResult(request,
|
||||
allStatements.Count == 0
|
||||
? "No VEX statements available"
|
||||
: "All statements below minimum weight threshold");
|
||||
|
||||
builder.WithFinalStatus(VexStatus.UnderInvestigation);
|
||||
builder.WithWeightSpread(0m);
|
||||
|
||||
var noDataProof = builder.Build();
|
||||
return new VexResolutionResult(noDataResult, noDataProof);
|
||||
}
|
||||
|
||||
// Compute consensus based on mode with proof recording
|
||||
var (result, proofBuilder) = policy.Mode switch
|
||||
{
|
||||
ConsensusMode.Lattice => ComputeLatticeConsensusWithProof(request, qualifiedStatements, policy, builder),
|
||||
ConsensusMode.HighestWeight => ComputeHighestWeightConsensusWithProof(request, qualifiedStatements, policy, builder),
|
||||
ConsensusMode.WeightedVote => ComputeWeightedVoteConsensusWithProof(request, qualifiedStatements, policy, builder),
|
||||
ConsensusMode.AuthoritativeFirst => ComputeAuthoritativeFirstConsensusWithProof(request, qualifiedStatements, policy, builder),
|
||||
_ => ComputeHighestWeightConsensusWithProof(request, qualifiedStatements, policy, builder)
|
||||
};
|
||||
|
||||
// Build final proof
|
||||
var proof = proofBuilder.Build();
|
||||
|
||||
return new VexResolutionResult(result, proof);
|
||||
}
|
||||
|
||||
private (VexConsensusResult Result, VexProofBuilder Builder) ComputeLatticeConsensusWithProof(
|
||||
VexConsensusRequest request,
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy,
|
||||
VexProofBuilder builder)
|
||||
{
|
||||
var lattice = _configuration.StatusLattice;
|
||||
var statusWeights = ComputeStatusWeights(statements);
|
||||
|
||||
// Set lattice ordering
|
||||
var ordering = lattice.StatusOrder
|
||||
.OrderBy(kv => kv.Value)
|
||||
.Select(kv => kv.Key)
|
||||
.ToImmutableArray();
|
||||
builder.WithLatticeOrdering(ordering);
|
||||
|
||||
// Order by lattice position (lower = more conservative)
|
||||
var ordered = statements
|
||||
.OrderBy(s => lattice.StatusOrder.GetValueOrDefault(s.Statement.Status, int.MaxValue))
|
||||
.ThenByDescending(s => s.Weight.Weight)
|
||||
.ToList();
|
||||
|
||||
// Record merge steps
|
||||
var currentPosition = ordered[0].Statement.Status;
|
||||
var stepNumber = 1;
|
||||
|
||||
foreach (var stmt in ordered)
|
||||
{
|
||||
var inputPosition = stmt.Statement.Status;
|
||||
var hasConflict = inputPosition != currentPosition;
|
||||
|
||||
MergeAction action;
|
||||
string? resolution = null;
|
||||
|
||||
if (stepNumber == 1)
|
||||
{
|
||||
action = MergeAction.Initialize;
|
||||
}
|
||||
else if (hasConflict)
|
||||
{
|
||||
action = MergeAction.Merge;
|
||||
// In lattice mode, lower position wins (more conservative)
|
||||
var inputOrder = lattice.StatusOrder.GetValueOrDefault(inputPosition, int.MaxValue);
|
||||
var currentOrder = lattice.StatusOrder.GetValueOrDefault(currentPosition, int.MaxValue);
|
||||
|
||||
if (inputOrder < currentOrder)
|
||||
{
|
||||
resolution = "lattice_conservative";
|
||||
currentPosition = inputPosition;
|
||||
}
|
||||
else
|
||||
{
|
||||
resolution = "lattice_existing_lower";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
action = MergeAction.Merge;
|
||||
}
|
||||
|
||||
builder.AddMergeStep(
|
||||
stepNumber++,
|
||||
stmt.Statement.StatementId,
|
||||
inputPosition,
|
||||
(decimal)stmt.Weight.Weight,
|
||||
action,
|
||||
hasConflict,
|
||||
resolution,
|
||||
currentPosition);
|
||||
}
|
||||
|
||||
// Record conflicts
|
||||
var conflicts = DetectConflicts(statements, policy);
|
||||
var conflictPenalty = 0m;
|
||||
|
||||
foreach (var conflict in conflicts)
|
||||
{
|
||||
var severity = conflict.Severity;
|
||||
builder.AddConflict(
|
||||
conflict.Statement1Id,
|
||||
conflict.Statement2Id,
|
||||
conflict.Status1,
|
||||
conflict.Status2,
|
||||
severity,
|
||||
conflict.Resolution,
|
||||
null); // In lattice mode, no single winner
|
||||
|
||||
conflictPenalty += severity switch
|
||||
{
|
||||
ConflictSeverity.Critical => 0.3m,
|
||||
ConflictSeverity.High => 0.2m,
|
||||
ConflictSeverity.Medium => 0.1m,
|
||||
_ => 0.05m
|
||||
};
|
||||
}
|
||||
|
||||
builder.WithConflictPenalty(-conflictPenalty);
|
||||
|
||||
// Compute final result
|
||||
var finalStatus = currentPosition;
|
||||
var winningStatements = statements.Where(s => s.Statement.Status == finalStatus).ToList();
|
||||
var primaryWinner = winningStatements.OrderByDescending(s => s.Weight.Weight).First();
|
||||
|
||||
var contributions = CreateContributions(statements, primaryWinner.Statement.StatementId);
|
||||
var outcome = statements.All(s => s.Statement.Status == finalStatus)
|
||||
? ConsensusOutcome.Unanimous
|
||||
: ConsensusOutcome.ConflictResolved;
|
||||
|
||||
var supportWeight = winningStatements.Sum(s => s.Weight.Weight);
|
||||
var totalWeight = statements.Sum(s => s.Weight.Weight);
|
||||
var confidence = totalWeight > 0 ? supportWeight / totalWeight : 0;
|
||||
|
||||
// Update builder with final state
|
||||
builder.WithFinalStatus(finalStatus, primaryWinner.Statement.Justification);
|
||||
builder.WithWeightSpread((decimal)(confidence));
|
||||
|
||||
if (statements.All(s => s.Weight.Factors.SignaturePresence > 0))
|
||||
{
|
||||
builder.WithSignatureBonus(0.05m);
|
||||
}
|
||||
|
||||
var result = new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: finalStatus,
|
||||
ConsensusJustification: primaryWinner.Statement.Justification,
|
||||
ConfidenceScore: confidence,
|
||||
Outcome: outcome,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: $"Lattice consensus: {finalStatus} (most conservative)",
|
||||
Factors: [$"Lattice mode selected most conservative status",
|
||||
$"Status order: {string.Join(" < ", ordering)}"],
|
||||
StatusWeights: statusWeights),
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts.Count > 0 ? conflicts : null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
|
||||
return (result, builder);
|
||||
}
|
||||
|
||||
private (VexConsensusResult Result, VexProofBuilder Builder) ComputeHighestWeightConsensusWithProof(
|
||||
VexConsensusRequest request,
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy,
|
||||
VexProofBuilder builder)
|
||||
{
|
||||
var ordered = statements.OrderByDescending(s => s.Weight.Weight).ToList();
|
||||
var winner = ordered[0];
|
||||
var conflicts = DetectConflicts(ordered, policy);
|
||||
|
||||
// Record merge steps (simple: initialize with highest weight)
|
||||
var stepNumber = 1;
|
||||
builder.AddMergeStep(
|
||||
stepNumber++,
|
||||
winner.Statement.StatementId,
|
||||
winner.Statement.Status,
|
||||
(decimal)winner.Weight.Weight,
|
||||
MergeAction.Initialize,
|
||||
false,
|
||||
null,
|
||||
winner.Statement.Status);
|
||||
|
||||
foreach (var stmt in ordered.Skip(1))
|
||||
{
|
||||
var hasConflict = stmt.Statement.Status != winner.Statement.Status;
|
||||
builder.AddMergeStep(
|
||||
stepNumber++,
|
||||
stmt.Statement.StatementId,
|
||||
stmt.Statement.Status,
|
||||
(decimal)stmt.Weight.Weight,
|
||||
MergeAction.Merge,
|
||||
hasConflict,
|
||||
hasConflict ? "weight_lower" : null,
|
||||
winner.Statement.Status);
|
||||
}
|
||||
|
||||
// Record conflicts
|
||||
var conflictPenalty = 0m;
|
||||
foreach (var conflict in conflicts)
|
||||
{
|
||||
var severity = conflict.Severity;
|
||||
builder.AddConflict(
|
||||
conflict.Statement1Id,
|
||||
conflict.Statement2Id,
|
||||
conflict.Status1,
|
||||
conflict.Status2,
|
||||
severity,
|
||||
conflict.Resolution,
|
||||
conflict.Statement1Id == winner.Statement.StatementId ? conflict.Statement1Id : conflict.Statement2Id);
|
||||
|
||||
conflictPenalty += severity switch
|
||||
{
|
||||
ConflictSeverity.Critical => 0.3m,
|
||||
ConflictSeverity.High => 0.2m,
|
||||
ConflictSeverity.Medium => 0.1m,
|
||||
_ => 0.05m
|
||||
};
|
||||
}
|
||||
|
||||
builder.WithConflictPenalty(-conflictPenalty);
|
||||
|
||||
var contributions = CreateContributions(ordered, winner.Statement.StatementId);
|
||||
var statusWeights = ComputeStatusWeights(ordered);
|
||||
var outcome = DetermineOutcome(ordered, winner, conflicts);
|
||||
var confidence = ComputeConfidence(ordered, winner, conflicts);
|
||||
|
||||
builder.WithFinalStatus(winner.Statement.Status, winner.Statement.Justification);
|
||||
builder.WithWeightSpread((decimal)confidence);
|
||||
|
||||
var result = new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: winner.Statement.Status,
|
||||
ConsensusJustification: winner.Statement.Justification,
|
||||
ConfidenceScore: confidence,
|
||||
Outcome: outcome,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: $"Highest weight consensus: {winner.Statement.Status}",
|
||||
Factors: [$"Selected statement with highest weight: {winner.Weight.Weight:F4}",
|
||||
$"Issuer: {winner.Issuer?.Name ?? winner.Statement.StatementId}"],
|
||||
StatusWeights: statusWeights),
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts.Count > 0 ? conflicts : null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
|
||||
return (result, builder);
|
||||
}
|
||||
|
||||
private (VexConsensusResult Result, VexProofBuilder Builder) ComputeWeightedVoteConsensusWithProof(
|
||||
VexConsensusRequest request,
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy,
|
||||
VexProofBuilder builder)
|
||||
{
|
||||
var statusWeights = ComputeStatusWeights(statements);
|
||||
var totalWeight = statusWeights.Values.Sum();
|
||||
|
||||
var winningStatus = statusWeights.OrderByDescending(kv => kv.Value).First();
|
||||
var winningStatements = statements
|
||||
.Where(s => s.Statement.Status == winningStatus.Key)
|
||||
.OrderByDescending(s => s.Weight.Weight)
|
||||
.ToList();
|
||||
|
||||
var primaryWinner = winningStatements[0];
|
||||
var conflicts = DetectConflicts(statements, policy);
|
||||
var contributions = CreateContributions(statements, primaryWinner.Statement.StatementId);
|
||||
|
||||
// Record merge steps
|
||||
var stepNumber = 1;
|
||||
foreach (var stmt in statements.OrderByDescending(s => s.Weight.Weight))
|
||||
{
|
||||
var isFirst = stepNumber == 1;
|
||||
var hasConflict = stmt.Statement.Status != winningStatus.Key;
|
||||
|
||||
builder.AddMergeStep(
|
||||
stepNumber++,
|
||||
stmt.Statement.StatementId,
|
||||
stmt.Statement.Status,
|
||||
(decimal)stmt.Weight.Weight,
|
||||
isFirst ? MergeAction.Initialize : MergeAction.Merge,
|
||||
hasConflict,
|
||||
hasConflict ? "status_outvoted" : null,
|
||||
winningStatus.Key);
|
||||
}
|
||||
|
||||
// Record conflicts
|
||||
var conflictPenalty = 0m;
|
||||
foreach (var conflict in conflicts)
|
||||
{
|
||||
var severity = conflict.Severity;
|
||||
builder.AddConflict(
|
||||
conflict.Statement1Id,
|
||||
conflict.Statement2Id,
|
||||
conflict.Status1,
|
||||
conflict.Status2,
|
||||
severity,
|
||||
"weighted_vote",
|
||||
null);
|
||||
|
||||
conflictPenalty += severity switch
|
||||
{
|
||||
ConflictSeverity.Critical => 0.3m,
|
||||
ConflictSeverity.High => 0.2m,
|
||||
ConflictSeverity.Medium => 0.1m,
|
||||
_ => 0.05m
|
||||
};
|
||||
}
|
||||
|
||||
builder.WithConflictPenalty(-conflictPenalty);
|
||||
|
||||
var voteFraction = totalWeight > 0 ? winningStatus.Value / totalWeight : 0;
|
||||
var outcome = voteFraction >= 0.5
|
||||
? ConsensusOutcome.Majority
|
||||
: ConsensusOutcome.Plurality;
|
||||
|
||||
if (statements.All(s => s.Statement.Status == winningStatus.Key))
|
||||
{
|
||||
outcome = ConsensusOutcome.Unanimous;
|
||||
}
|
||||
|
||||
var confidence = voteFraction * ComputeWeightSpreadFactor(statements);
|
||||
|
||||
builder.WithFinalStatus(winningStatus.Key, primaryWinner.Statement.Justification);
|
||||
builder.WithWeightSpread((decimal)confidence);
|
||||
|
||||
var result = new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: winningStatus.Key,
|
||||
ConsensusJustification: primaryWinner.Statement.Justification,
|
||||
ConfidenceScore: confidence,
|
||||
Outcome: outcome,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: $"Weighted vote consensus: {winningStatus.Key} ({voteFraction:P1})",
|
||||
Factors: [$"Weighted vote: {winningStatus.Key} received {voteFraction:P1} of total weight",
|
||||
$"{winningStatements.Count} statement(s) support this status"],
|
||||
StatusWeights: statusWeights),
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts.Count > 0 ? conflicts : null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
|
||||
return (result, builder);
|
||||
}
|
||||
|
||||
private (VexConsensusResult Result, VexProofBuilder Builder) ComputeAuthoritativeFirstConsensusWithProof(
|
||||
VexConsensusRequest request,
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy,
|
||||
VexProofBuilder builder)
|
||||
{
|
||||
var ordered = statements
|
||||
.OrderByDescending(s => IsAuthoritative(s.Issuer))
|
||||
.ThenByDescending(s => s.Weight.Weight)
|
||||
.ToList();
|
||||
|
||||
var winner = ordered[0];
|
||||
var conflicts = DetectConflicts(ordered, policy);
|
||||
var contributions = CreateContributions(ordered, winner.Statement.StatementId);
|
||||
var statusWeights = ComputeStatusWeights(ordered);
|
||||
|
||||
// Record merge steps
|
||||
var stepNumber = 1;
|
||||
builder.AddMergeStep(
|
||||
stepNumber++,
|
||||
winner.Statement.StatementId,
|
||||
winner.Statement.Status,
|
||||
(decimal)winner.Weight.Weight,
|
||||
MergeAction.Initialize,
|
||||
false,
|
||||
IsAuthoritative(winner.Issuer) ? "authoritative_source" : null,
|
||||
winner.Statement.Status);
|
||||
|
||||
foreach (var stmt in ordered.Skip(1))
|
||||
{
|
||||
var hasConflict = stmt.Statement.Status != winner.Statement.Status;
|
||||
builder.AddMergeStep(
|
||||
stepNumber++,
|
||||
stmt.Statement.StatementId,
|
||||
stmt.Statement.Status,
|
||||
(decimal)stmt.Weight.Weight,
|
||||
MergeAction.Merge,
|
||||
hasConflict,
|
||||
hasConflict ? "non_authoritative_deferred" : null,
|
||||
winner.Statement.Status);
|
||||
}
|
||||
|
||||
// Record conflicts
|
||||
var conflictPenalty = 0m;
|
||||
foreach (var conflict in conflicts)
|
||||
{
|
||||
var severity = conflict.Severity;
|
||||
builder.AddConflict(
|
||||
conflict.Statement1Id,
|
||||
conflict.Statement2Id,
|
||||
conflict.Status1,
|
||||
conflict.Status2,
|
||||
severity,
|
||||
"authoritative_first",
|
||||
winner.Statement.StatementId);
|
||||
|
||||
conflictPenalty += severity switch
|
||||
{
|
||||
ConflictSeverity.Critical => 0.3m,
|
||||
ConflictSeverity.High => 0.2m,
|
||||
ConflictSeverity.Medium => 0.1m,
|
||||
_ => 0.05m
|
||||
};
|
||||
}
|
||||
|
||||
builder.WithConflictPenalty(-conflictPenalty);
|
||||
|
||||
var isAuthoritative = IsAuthoritative(winner.Issuer);
|
||||
var outcome = isAuthoritative
|
||||
? ConsensusOutcome.Unanimous
|
||||
: DetermineOutcome(ordered, winner, conflicts);
|
||||
|
||||
var confidence = isAuthoritative
|
||||
? 0.95
|
||||
: ComputeConfidence(ordered, winner, conflicts);
|
||||
|
||||
builder.WithFinalStatus(winner.Statement.Status, winner.Statement.Justification);
|
||||
builder.WithWeightSpread((decimal)confidence);
|
||||
|
||||
if (isAuthoritative)
|
||||
{
|
||||
builder.AddConfidenceImprovement("Authoritative source (vendor) statement used");
|
||||
}
|
||||
|
||||
var result = new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: winner.Statement.Status,
|
||||
ConsensusJustification: winner.Statement.Justification,
|
||||
ConfidenceScore: confidence,
|
||||
Outcome: outcome,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: $"Authoritative-first consensus: {winner.Statement.Status}",
|
||||
Factors: [isAuthoritative
|
||||
? $"Authoritative source: {winner.Issuer?.Name ?? "unknown"}"
|
||||
: $"No authoritative source; using highest weight",
|
||||
$"Weight: {winner.Weight.Weight:F4}"],
|
||||
StatusWeights: statusWeights),
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts.Count > 0 ? conflicts : null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
|
||||
return (result, builder);
|
||||
}
|
||||
|
||||
private static VexProofIssuer CreateProofIssuer(VexIssuer? issuer)
|
||||
{
|
||||
if (issuer == null)
|
||||
{
|
||||
return new VexProofIssuer("unknown", IssuerCategory.Unknown, TrustTier.Unknown);
|
||||
}
|
||||
|
||||
return new VexProofIssuer(issuer.Name ?? issuer.Id, issuer.Category, issuer.TrustTier);
|
||||
}
|
||||
|
||||
private static VexProofWeight CreateProofWeight(Trust.TrustWeightResult weight)
|
||||
{
|
||||
return new VexProofWeight(
|
||||
(decimal)weight.Weight,
|
||||
new VexProofWeightFactors(
|
||||
(decimal)weight.Factors.IssuerWeight,
|
||||
(decimal)weight.Factors.SignaturePresence,
|
||||
(decimal)weight.Factors.FreshnessScore,
|
||||
(decimal)weight.Factors.FormatScore,
|
||||
(decimal)weight.Factors.SpecificityScore));
|
||||
}
|
||||
|
||||
private static ConflictSeverity MapConflictSeverityToProof(ConflictSeverity severity) => severity;
|
||||
}
|
||||
|
||||
475
src/VexLens/StellaOps.VexLens/Proof/VexProof.cs
Normal file
475
src/VexLens/StellaOps.VexLens/Proof/VexProof.cs
Normal file
@@ -0,0 +1,475 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Proof;
|
||||
|
||||
/// <summary>
|
||||
/// Complete proof object for VEX consensus resolution.
|
||||
/// Captures all inputs, computation steps, and rationale for deterministic reproducibility.
|
||||
/// </summary>
|
||||
public sealed record VexProof(
|
||||
/// <summary>Schema identifier for evolution.</summary>
|
||||
[property: JsonPropertyName("schema")] string Schema,
|
||||
|
||||
/// <summary>Unique identifier for this proof.</summary>
|
||||
[property: JsonPropertyName("proofId")] string ProofId,
|
||||
|
||||
/// <summary>When this proof was computed.</summary>
|
||||
[property: JsonPropertyName("computedAt")] DateTimeOffset ComputedAt,
|
||||
|
||||
/// <summary>The final verdict.</summary>
|
||||
[property: JsonPropertyName("verdict")] VexProofVerdict Verdict,
|
||||
|
||||
/// <summary>All inputs used in computation.</summary>
|
||||
[property: JsonPropertyName("inputs")] VexProofInputs Inputs,
|
||||
|
||||
/// <summary>Resolution computation details.</summary>
|
||||
[property: JsonPropertyName("resolution")] VexProofResolution Resolution,
|
||||
|
||||
/// <summary>Propagation through dependency graph.</summary>
|
||||
[property: JsonPropertyName("propagation")] VexProofPropagation? Propagation,
|
||||
|
||||
/// <summary>Condition evaluation results.</summary>
|
||||
[property: JsonPropertyName("conditions")] VexProofConditions? Conditions,
|
||||
|
||||
/// <summary>Confidence breakdown.</summary>
|
||||
[property: JsonPropertyName("confidence")] VexProofConfidence Confidence,
|
||||
|
||||
/// <summary>SHA-256 digest of canonical JSON (excluding this field).</summary>
|
||||
[property: JsonPropertyName("digest")] string? Digest)
|
||||
{
|
||||
/// <summary>Current schema version.</summary>
|
||||
public const string SchemaVersion = "stellaops.vex-proof.v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The final verdict produced by consensus.
|
||||
/// </summary>
|
||||
public sealed record VexProofVerdict(
|
||||
/// <summary>CVE or vulnerability identifier.</summary>
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
|
||||
/// <summary>Product key (typically PURL).</summary>
|
||||
[property: JsonPropertyName("productKey")] string ProductKey,
|
||||
|
||||
/// <summary>Resolved VEX status.</summary>
|
||||
[property: JsonPropertyName("status")] VexStatus Status,
|
||||
|
||||
/// <summary>Justification if status is not_affected.</summary>
|
||||
[property: JsonPropertyName("justification")] VexJustification? Justification,
|
||||
|
||||
/// <summary>Confidence score [0.0, 1.0].</summary>
|
||||
[property: JsonPropertyName("confidence")] decimal Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// All inputs used in consensus computation.
|
||||
/// </summary>
|
||||
public sealed record VexProofInputs(
|
||||
/// <summary>All VEX statements considered.</summary>
|
||||
[property: JsonPropertyName("statements")] ImmutableArray<VexProofStatement> Statements,
|
||||
|
||||
/// <summary>Evaluation context.</summary>
|
||||
[property: JsonPropertyName("context")] VexProofContext Context);
|
||||
|
||||
/// <summary>
|
||||
/// A single VEX statement with weight factors.
|
||||
/// </summary>
|
||||
public sealed record VexProofStatement(
|
||||
/// <summary>Statement identifier.</summary>
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
|
||||
/// <summary>Source format (openvex, csaf_vex, etc.).</summary>
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
|
||||
/// <summary>Issuer details.</summary>
|
||||
[property: JsonPropertyName("issuer")] VexProofIssuer Issuer,
|
||||
|
||||
/// <summary>VEX status from this statement.</summary>
|
||||
[property: JsonPropertyName("status")] VexStatus Status,
|
||||
|
||||
/// <summary>Justification if status is not_affected.</summary>
|
||||
[property: JsonPropertyName("justification")] VexJustification? Justification,
|
||||
|
||||
/// <summary>Computed trust weight.</summary>
|
||||
[property: JsonPropertyName("weight")] VexProofWeight Weight,
|
||||
|
||||
/// <summary>When the statement was issued.</summary>
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
|
||||
/// <summary>Whether signature was verified.</summary>
|
||||
[property: JsonPropertyName("signatureVerified")] bool SignatureVerified,
|
||||
|
||||
/// <summary>Whether the statement qualified for consensus.</summary>
|
||||
[property: JsonPropertyName("qualified")] bool Qualified,
|
||||
|
||||
/// <summary>Reason if disqualified.</summary>
|
||||
[property: JsonPropertyName("disqualificationReason")] string? DisqualificationReason);
|
||||
|
||||
/// <summary>
|
||||
/// Issuer information for a statement.
|
||||
/// </summary>
|
||||
public sealed record VexProofIssuer(
|
||||
/// <summary>Issuer identifier.</summary>
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
|
||||
/// <summary>Issuer category.</summary>
|
||||
[property: JsonPropertyName("category")] IssuerCategory Category,
|
||||
|
||||
/// <summary>Trust tier.</summary>
|
||||
[property: JsonPropertyName("trustTier")] TrustTier TrustTier);
|
||||
|
||||
/// <summary>
|
||||
/// Trust weight breakdown for a statement.
|
||||
/// </summary>
|
||||
public sealed record VexProofWeight(
|
||||
/// <summary>Composite weight [0.0, 1.0].</summary>
|
||||
[property: JsonPropertyName("composite")] decimal Composite,
|
||||
|
||||
/// <summary>Individual weight factors.</summary>
|
||||
[property: JsonPropertyName("factors")] VexProofWeightFactors Factors);
|
||||
|
||||
/// <summary>
|
||||
/// Individual factors contributing to weight.
|
||||
/// </summary>
|
||||
public sealed record VexProofWeightFactors(
|
||||
/// <summary>Issuer trust factor.</summary>
|
||||
[property: JsonPropertyName("issuer")] decimal Issuer,
|
||||
|
||||
/// <summary>Signature verification factor.</summary>
|
||||
[property: JsonPropertyName("signature")] decimal Signature,
|
||||
|
||||
/// <summary>Freshness/recency factor.</summary>
|
||||
[property: JsonPropertyName("freshness")] decimal Freshness,
|
||||
|
||||
/// <summary>Format quality factor.</summary>
|
||||
[property: JsonPropertyName("format")] decimal Format,
|
||||
|
||||
/// <summary>Specificity factor (how targeted the statement is).</summary>
|
||||
[property: JsonPropertyName("specificity")] decimal Specificity);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation context for the proof.
|
||||
/// </summary>
|
||||
public sealed record VexProofContext(
|
||||
/// <summary>Target platform (e.g., linux/amd64).</summary>
|
||||
[property: JsonPropertyName("platform")] string? Platform,
|
||||
|
||||
/// <summary>Target distro (e.g., rhel:9).</summary>
|
||||
[property: JsonPropertyName("distro")] string? Distro,
|
||||
|
||||
/// <summary>Enabled features.</summary>
|
||||
[property: JsonPropertyName("features")] ImmutableArray<string> Features,
|
||||
|
||||
/// <summary>Build flags.</summary>
|
||||
[property: JsonPropertyName("buildFlags")] ImmutableArray<string> BuildFlags,
|
||||
|
||||
/// <summary>Time of evaluation.</summary>
|
||||
[property: JsonPropertyName("evaluationTime")] DateTimeOffset EvaluationTime);
|
||||
|
||||
/// <summary>
|
||||
/// Resolution computation details.
|
||||
/// </summary>
|
||||
public sealed record VexProofResolution(
|
||||
/// <summary>Consensus mode used.</summary>
|
||||
[property: JsonPropertyName("mode")] ConsensusMode Mode,
|
||||
|
||||
/// <summary>Number of qualified statements.</summary>
|
||||
[property: JsonPropertyName("qualifiedStatements")] int QualifiedStatements,
|
||||
|
||||
/// <summary>Number of disqualified statements.</summary>
|
||||
[property: JsonPropertyName("disqualifiedStatements")] int DisqualifiedStatements,
|
||||
|
||||
/// <summary>Reasons for disqualification.</summary>
|
||||
[property: JsonPropertyName("disqualificationReasons")] ImmutableArray<string> DisqualificationReasons,
|
||||
|
||||
/// <summary>Lattice computation details (if lattice mode).</summary>
|
||||
[property: JsonPropertyName("latticeComputation")] VexProofLatticeComputation? LatticeComputation,
|
||||
|
||||
/// <summary>Conflict analysis.</summary>
|
||||
[property: JsonPropertyName("conflictAnalysis")] VexProofConflictAnalysis ConflictAnalysis);
|
||||
|
||||
/// <summary>
|
||||
/// Lattice-based computation details.
|
||||
/// </summary>
|
||||
public sealed record VexProofLatticeComputation(
|
||||
/// <summary>Status ordering from bottom to top.</summary>
|
||||
[property: JsonPropertyName("ordering")] ImmutableArray<VexStatus> Ordering,
|
||||
|
||||
/// <summary>Step-by-step merge computation.</summary>
|
||||
[property: JsonPropertyName("mergeSteps")] ImmutableArray<VexProofMergeStep> MergeSteps,
|
||||
|
||||
/// <summary>Final lattice position.</summary>
|
||||
[property: JsonPropertyName("finalPosition")] VexStatus FinalPosition);
|
||||
|
||||
/// <summary>
|
||||
/// A single merge step in lattice computation.
|
||||
/// </summary>
|
||||
public sealed record VexProofMergeStep(
|
||||
/// <summary>Step number (1-based).</summary>
|
||||
[property: JsonPropertyName("step")] int Step,
|
||||
|
||||
/// <summary>Statement being merged.</summary>
|
||||
[property: JsonPropertyName("statementId")] string StatementId,
|
||||
|
||||
/// <summary>Status from this statement.</summary>
|
||||
[property: JsonPropertyName("inputPosition")] VexStatus InputPosition,
|
||||
|
||||
/// <summary>Weight of this statement.</summary>
|
||||
[property: JsonPropertyName("weight")] decimal Weight,
|
||||
|
||||
/// <summary>Action taken (initialize, merge, skip).</summary>
|
||||
[property: JsonPropertyName("action")] MergeAction Action,
|
||||
|
||||
/// <summary>Whether a conflict was detected.</summary>
|
||||
[property: JsonPropertyName("conflict")] bool Conflict,
|
||||
|
||||
/// <summary>How conflict was resolved.</summary>
|
||||
[property: JsonPropertyName("resolution")] string? Resolution,
|
||||
|
||||
/// <summary>Resulting position after this step.</summary>
|
||||
[property: JsonPropertyName("resultPosition")] VexStatus ResultPosition);
|
||||
|
||||
/// <summary>
|
||||
/// Merge action in lattice computation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<MergeAction>))]
|
||||
public enum MergeAction
|
||||
{
|
||||
/// <summary>Initialize with first statement.</summary>
|
||||
[JsonPropertyName("initialize")]
|
||||
Initialize,
|
||||
|
||||
/// <summary>Merge with existing position.</summary>
|
||||
[JsonPropertyName("merge")]
|
||||
Merge,
|
||||
|
||||
/// <summary>Skip due to low weight or disqualification.</summary>
|
||||
[JsonPropertyName("skip")]
|
||||
Skip
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict analysis for the resolution.
|
||||
/// </summary>
|
||||
public sealed record VexProofConflictAnalysis(
|
||||
/// <summary>Whether any conflicts were detected.</summary>
|
||||
[property: JsonPropertyName("hasConflicts")] bool HasConflicts,
|
||||
|
||||
/// <summary>List of conflicts.</summary>
|
||||
[property: JsonPropertyName("conflicts")] ImmutableArray<VexProofConflict> Conflicts,
|
||||
|
||||
/// <summary>Confidence penalty due to conflicts.</summary>
|
||||
[property: JsonPropertyName("conflictPenalty")] decimal ConflictPenalty);
|
||||
|
||||
/// <summary>
|
||||
/// A single conflict between statements.
|
||||
/// </summary>
|
||||
public sealed record VexProofConflict(
|
||||
/// <summary>First conflicting statement.</summary>
|
||||
[property: JsonPropertyName("statementA")] string StatementA,
|
||||
|
||||
/// <summary>Second conflicting statement.</summary>
|
||||
[property: JsonPropertyName("statementB")] string StatementB,
|
||||
|
||||
/// <summary>Status from first statement.</summary>
|
||||
[property: JsonPropertyName("statusA")] VexStatus StatusA,
|
||||
|
||||
/// <summary>Status from second statement.</summary>
|
||||
[property: JsonPropertyName("statusB")] VexStatus StatusB,
|
||||
|
||||
/// <summary>Conflict severity.</summary>
|
||||
[property: JsonPropertyName("severity")] ConflictSeverity Severity,
|
||||
|
||||
/// <summary>How the conflict was resolved.</summary>
|
||||
[property: JsonPropertyName("resolution")] string Resolution,
|
||||
|
||||
/// <summary>Which statement won.</summary>
|
||||
[property: JsonPropertyName("winner")] string? Winner);
|
||||
|
||||
/// <summary>
|
||||
/// Propagation through dependency graph.
|
||||
/// </summary>
|
||||
public sealed record VexProofPropagation(
|
||||
/// <summary>Whether propagation was applied.</summary>
|
||||
[property: JsonPropertyName("applied")] bool Applied,
|
||||
|
||||
/// <summary>Rules that were evaluated.</summary>
|
||||
[property: JsonPropertyName("rules")] ImmutableArray<VexProofPropagationRule> Rules,
|
||||
|
||||
/// <summary>Dependency graph paths analyzed.</summary>
|
||||
[property: JsonPropertyName("graphPaths")] ImmutableArray<VexProofGraphPath> GraphPaths,
|
||||
|
||||
/// <summary>Status inherited from dependency (if any).</summary>
|
||||
[property: JsonPropertyName("inheritedStatus")] VexStatus? InheritedStatus,
|
||||
|
||||
/// <summary>Whether an override was applied.</summary>
|
||||
[property: JsonPropertyName("overrideApplied")] bool OverrideApplied);
|
||||
|
||||
/// <summary>
|
||||
/// A propagation rule that was evaluated.
|
||||
/// </summary>
|
||||
public sealed record VexProofPropagationRule(
|
||||
/// <summary>Rule identifier.</summary>
|
||||
[property: JsonPropertyName("ruleId")] string RuleId,
|
||||
|
||||
/// <summary>Rule description.</summary>
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
|
||||
/// <summary>Whether the rule was triggered.</summary>
|
||||
[property: JsonPropertyName("triggered")] bool Triggered,
|
||||
|
||||
/// <summary>Effect if triggered.</summary>
|
||||
[property: JsonPropertyName("effect")] string? Effect);
|
||||
|
||||
/// <summary>
|
||||
/// A path through the dependency graph.
|
||||
/// </summary>
|
||||
public sealed record VexProofGraphPath(
|
||||
/// <summary>Root product.</summary>
|
||||
[property: JsonPropertyName("root")] string Root,
|
||||
|
||||
/// <summary>Path of dependencies.</summary>
|
||||
[property: JsonPropertyName("path")] ImmutableArray<string> Path,
|
||||
|
||||
/// <summary>Type of dependency path.</summary>
|
||||
[property: JsonPropertyName("pathType")] DependencyPathType PathType,
|
||||
|
||||
/// <summary>Depth in dependency tree.</summary>
|
||||
[property: JsonPropertyName("depth")] int Depth);
|
||||
|
||||
/// <summary>
|
||||
/// Type of dependency path.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<DependencyPathType>))]
|
||||
public enum DependencyPathType
|
||||
{
|
||||
/// <summary>Direct dependency (depth 1).</summary>
|
||||
[JsonPropertyName("direct_dependency")]
|
||||
DirectDependency,
|
||||
|
||||
/// <summary>Transitive dependency (depth > 1).</summary>
|
||||
[JsonPropertyName("transitive_dependency")]
|
||||
TransitiveDependency,
|
||||
|
||||
/// <summary>Dev/test dependency.</summary>
|
||||
[JsonPropertyName("dev_dependency")]
|
||||
DevDependency,
|
||||
|
||||
/// <summary>Optional/peer dependency.</summary>
|
||||
[JsonPropertyName("optional_dependency")]
|
||||
OptionalDependency
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Condition evaluation results.
|
||||
/// </summary>
|
||||
public sealed record VexProofConditions(
|
||||
/// <summary>Conditions that were evaluated.</summary>
|
||||
[property: JsonPropertyName("evaluated")] ImmutableArray<VexProofConditionResult> Evaluated,
|
||||
|
||||
/// <summary>Conditions that could not be evaluated.</summary>
|
||||
[property: JsonPropertyName("unevaluated")] ImmutableArray<string> Unevaluated,
|
||||
|
||||
/// <summary>Count of conditions with unknown result.</summary>
|
||||
[property: JsonPropertyName("unknownCount")] int UnknownCount);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single condition evaluation.
|
||||
/// </summary>
|
||||
public sealed record VexProofConditionResult(
|
||||
/// <summary>Condition identifier.</summary>
|
||||
[property: JsonPropertyName("conditionId")] string ConditionId,
|
||||
|
||||
/// <summary>Condition expression.</summary>
|
||||
[property: JsonPropertyName("expression")] string Expression,
|
||||
|
||||
/// <summary>Evaluation result.</summary>
|
||||
[property: JsonPropertyName("result")] ConditionOutcome Result,
|
||||
|
||||
/// <summary>Context value used in evaluation.</summary>
|
||||
[property: JsonPropertyName("contextValue")] string? ContextValue);
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of condition evaluation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ConditionOutcome>))]
|
||||
public enum ConditionOutcome
|
||||
{
|
||||
/// <summary>Condition evaluated to true.</summary>
|
||||
[JsonPropertyName("true")]
|
||||
True,
|
||||
|
||||
/// <summary>Condition evaluated to false.</summary>
|
||||
[JsonPropertyName("false")]
|
||||
False,
|
||||
|
||||
/// <summary>Condition could not be evaluated (missing context).</summary>
|
||||
[JsonPropertyName("unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score breakdown.
|
||||
/// </summary>
|
||||
public sealed record VexProofConfidence(
|
||||
/// <summary>Overall confidence score [0.0, 1.0].</summary>
|
||||
[property: JsonPropertyName("score")] decimal Score,
|
||||
|
||||
/// <summary>Confidence tier.</summary>
|
||||
[property: JsonPropertyName("tier")] ConfidenceTier Tier,
|
||||
|
||||
/// <summary>Breakdown of confidence factors.</summary>
|
||||
[property: JsonPropertyName("breakdown")] VexProofConfidenceBreakdown Breakdown,
|
||||
|
||||
/// <summary>Suggestions for improving confidence.</summary>
|
||||
[property: JsonPropertyName("improvements")] ImmutableArray<string> Improvements);
|
||||
|
||||
/// <summary>
|
||||
/// Confidence tier classification.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ConfidenceTier>))]
|
||||
public enum ConfidenceTier
|
||||
{
|
||||
/// <summary>Very high confidence (>= 0.9).</summary>
|
||||
[JsonPropertyName("very_high")]
|
||||
VeryHigh,
|
||||
|
||||
/// <summary>High confidence (>= 0.75).</summary>
|
||||
[JsonPropertyName("high")]
|
||||
High,
|
||||
|
||||
/// <summary>Medium confidence (>= 0.5).</summary>
|
||||
[JsonPropertyName("medium")]
|
||||
Medium,
|
||||
|
||||
/// <summary>Low confidence (>= 0.25).</summary>
|
||||
[JsonPropertyName("low")]
|
||||
Low,
|
||||
|
||||
/// <summary>Very low confidence (< 0.25).</summary>
|
||||
[JsonPropertyName("very_low")]
|
||||
VeryLow
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown of confidence score components.
|
||||
/// </summary>
|
||||
public sealed record VexProofConfidenceBreakdown(
|
||||
/// <summary>Base weight from statement weights.</summary>
|
||||
[property: JsonPropertyName("weightSpread")] decimal WeightSpread,
|
||||
|
||||
/// <summary>Penalty from conflicts (negative).</summary>
|
||||
[property: JsonPropertyName("conflictPenalty")] decimal ConflictPenalty,
|
||||
|
||||
/// <summary>Bonus from recent statements.</summary>
|
||||
[property: JsonPropertyName("freshnessBonus")] decimal FreshnessBonus,
|
||||
|
||||
/// <summary>Bonus from verified signatures.</summary>
|
||||
[property: JsonPropertyName("signatureBonus")] decimal SignatureBonus,
|
||||
|
||||
/// <summary>Coverage of conditions evaluated [0.0, 1.0].</summary>
|
||||
[property: JsonPropertyName("conditionCoverage")] decimal ConditionCoverage);
|
||||
496
src/VexLens/StellaOps.VexLens/Proof/VexProofBuilder.cs
Normal file
496
src/VexLens/StellaOps.VexLens/Proof/VexProofBuilder.cs
Normal file
@@ -0,0 +1,496 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Proof;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for constructing VEX proof objects.
|
||||
/// Collects data during consensus computation and builds the final proof.
|
||||
/// </summary>
|
||||
public sealed class VexProofBuilder
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly List<VexProofStatement> _statements = [];
|
||||
private readonly List<VexProofMergeStep> _mergeSteps = [];
|
||||
private readonly List<VexProofConflict> _conflicts = [];
|
||||
private readonly List<VexProofPropagationRule> _propagationRules = [];
|
||||
private readonly List<VexProofGraphPath> _graphPaths = [];
|
||||
private readonly List<VexProofConditionResult> _conditionResults = [];
|
||||
private readonly List<string> _unevaluatedConditions = [];
|
||||
private readonly List<string> _disqualificationReasons = [];
|
||||
private readonly List<string> _confidenceImprovements = [];
|
||||
|
||||
private string _vulnerabilityId = string.Empty;
|
||||
private string _productKey = string.Empty;
|
||||
private VexProofContext? _context;
|
||||
private ConsensusMode _consensusMode = ConsensusMode.Lattice;
|
||||
|
||||
// Resolution state
|
||||
private VexStatus _finalStatus = VexStatus.UnderInvestigation;
|
||||
private VexJustification? _finalJustification;
|
||||
private ImmutableArray<VexStatus> _latticeOrdering = [];
|
||||
private decimal _conflictPenalty;
|
||||
private int _qualifiedCount;
|
||||
private int _disqualifiedCount;
|
||||
|
||||
// Propagation state
|
||||
private bool _propagationApplied;
|
||||
private VexStatus? _inheritedStatus;
|
||||
private bool _overrideApplied;
|
||||
|
||||
// Confidence state
|
||||
private decimal _weightSpread;
|
||||
private decimal _freshnessBonus;
|
||||
private decimal _signatureBonus;
|
||||
private decimal _conditionCoverage = 1.0m;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new VexProofBuilder with the specified time provider.
|
||||
/// </summary>
|
||||
public VexProofBuilder(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new VexProofBuilder using the system time provider.
|
||||
/// </summary>
|
||||
public VexProofBuilder() : this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the vulnerability and product being evaluated.
|
||||
/// </summary>
|
||||
public VexProofBuilder ForVulnerability(string vulnerabilityId, string productKey)
|
||||
{
|
||||
_vulnerabilityId = vulnerabilityId ?? throw new ArgumentNullException(nameof(vulnerabilityId));
|
||||
_productKey = productKey ?? throw new ArgumentNullException(nameof(productKey));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the evaluation context.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithContext(VexProofContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the evaluation context from individual components.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithContext(
|
||||
string? platform,
|
||||
string? distro,
|
||||
IEnumerable<string>? features,
|
||||
IEnumerable<string>? buildFlags,
|
||||
DateTimeOffset evaluationTime)
|
||||
{
|
||||
_context = new VexProofContext(
|
||||
platform,
|
||||
distro,
|
||||
features?.ToImmutableArray() ?? [],
|
||||
buildFlags?.ToImmutableArray() ?? [],
|
||||
evaluationTime);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the consensus mode.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithConsensusMode(ConsensusMode mode)
|
||||
{
|
||||
_consensusMode = mode;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the lattice ordering for lattice-based consensus.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithLatticeOrdering(IEnumerable<VexStatus> ordering)
|
||||
{
|
||||
_latticeOrdering = ordering.ToImmutableArray();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a qualified statement to the proof.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddStatement(
|
||||
string id,
|
||||
string source,
|
||||
VexProofIssuer issuer,
|
||||
VexStatus status,
|
||||
VexJustification? justification,
|
||||
VexProofWeight weight,
|
||||
DateTimeOffset timestamp,
|
||||
bool signatureVerified)
|
||||
{
|
||||
_statements.Add(new VexProofStatement(
|
||||
id,
|
||||
source,
|
||||
issuer,
|
||||
status,
|
||||
justification,
|
||||
weight,
|
||||
timestamp,
|
||||
signatureVerified,
|
||||
Qualified: true,
|
||||
DisqualificationReason: null));
|
||||
_qualifiedCount++;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a disqualified statement to the proof.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddDisqualifiedStatement(
|
||||
string id,
|
||||
string source,
|
||||
VexProofIssuer issuer,
|
||||
VexStatus status,
|
||||
VexJustification? justification,
|
||||
VexProofWeight weight,
|
||||
DateTimeOffset timestamp,
|
||||
bool signatureVerified,
|
||||
string reason)
|
||||
{
|
||||
_statements.Add(new VexProofStatement(
|
||||
id,
|
||||
source,
|
||||
issuer,
|
||||
status,
|
||||
justification,
|
||||
weight,
|
||||
timestamp,
|
||||
signatureVerified,
|
||||
Qualified: false,
|
||||
DisqualificationReason: reason));
|
||||
_disqualifiedCount++;
|
||||
|
||||
if (!_disqualificationReasons.Contains(reason))
|
||||
{
|
||||
_disqualificationReasons.Add(reason);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a merge step in lattice computation.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddMergeStep(
|
||||
int step,
|
||||
string statementId,
|
||||
VexStatus inputPosition,
|
||||
decimal weight,
|
||||
MergeAction action,
|
||||
bool conflict,
|
||||
string? resolution,
|
||||
VexStatus resultPosition)
|
||||
{
|
||||
_mergeSteps.Add(new VexProofMergeStep(
|
||||
step,
|
||||
statementId,
|
||||
inputPosition,
|
||||
weight,
|
||||
action,
|
||||
conflict,
|
||||
resolution,
|
||||
resultPosition));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a conflict between statements.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddConflict(
|
||||
string statementA,
|
||||
string statementB,
|
||||
VexStatus statusA,
|
||||
VexStatus statusB,
|
||||
ConflictSeverity severity,
|
||||
string resolution,
|
||||
string? winner)
|
||||
{
|
||||
_conflicts.Add(new VexProofConflict(
|
||||
statementA,
|
||||
statementB,
|
||||
statusA,
|
||||
statusB,
|
||||
severity,
|
||||
resolution,
|
||||
winner));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the conflict penalty.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithConflictPenalty(decimal penalty)
|
||||
{
|
||||
_conflictPenalty = penalty;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the final resolution status.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithFinalStatus(VexStatus status, VexJustification? justification = null)
|
||||
{
|
||||
_finalStatus = status;
|
||||
_finalJustification = justification;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a propagation rule evaluation.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddPropagationRule(
|
||||
string ruleId,
|
||||
string description,
|
||||
bool triggered,
|
||||
string? effect = null)
|
||||
{
|
||||
_propagationRules.Add(new VexProofPropagationRule(ruleId, description, triggered, effect));
|
||||
if (triggered)
|
||||
{
|
||||
_propagationApplied = true;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a dependency graph path.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddGraphPath(
|
||||
string root,
|
||||
IEnumerable<string> path,
|
||||
DependencyPathType pathType,
|
||||
int depth)
|
||||
{
|
||||
_graphPaths.Add(new VexProofGraphPath(root, path.ToImmutableArray(), pathType, depth));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the inherited status from propagation.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithInheritedStatus(VexStatus status)
|
||||
{
|
||||
_inheritedStatus = status;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets whether an override was applied.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithOverrideApplied(bool applied)
|
||||
{
|
||||
_overrideApplied = applied;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a condition evaluation result.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddConditionResult(
|
||||
string conditionId,
|
||||
string expression,
|
||||
ConditionOutcome result,
|
||||
string? contextValue = null)
|
||||
{
|
||||
_conditionResults.Add(new VexProofConditionResult(conditionId, expression, result, contextValue));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an unevaluated condition.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddUnevaluatedCondition(string conditionId)
|
||||
{
|
||||
_unevaluatedConditions.Add(conditionId);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the weight spread for confidence calculation.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithWeightSpread(decimal spread)
|
||||
{
|
||||
_weightSpread = spread;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the freshness bonus for confidence calculation.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithFreshnessBonus(decimal bonus)
|
||||
{
|
||||
_freshnessBonus = bonus;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the signature bonus for confidence calculation.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithSignatureBonus(decimal bonus)
|
||||
{
|
||||
_signatureBonus = bonus;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the condition coverage for confidence calculation.
|
||||
/// </summary>
|
||||
public VexProofBuilder WithConditionCoverage(decimal coverage)
|
||||
{
|
||||
_conditionCoverage = Math.Clamp(coverage, 0m, 1m);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a suggestion for improving confidence.
|
||||
/// </summary>
|
||||
public VexProofBuilder AddConfidenceImprovement(string suggestion)
|
||||
{
|
||||
_confidenceImprovements.Add(suggestion);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the final VEX proof object.
|
||||
/// </summary>
|
||||
public VexProof Build()
|
||||
{
|
||||
var computedAt = _timeProvider.GetUtcNow();
|
||||
var proofId = GenerateProofId(computedAt);
|
||||
|
||||
// Calculate confidence
|
||||
var confidenceScore = CalculateConfidenceScore();
|
||||
var confidenceTier = ClassifyConfidenceTier(confidenceScore);
|
||||
|
||||
// Build sub-objects
|
||||
var verdict = new VexProofVerdict(
|
||||
_vulnerabilityId,
|
||||
_productKey,
|
||||
_finalStatus,
|
||||
_finalJustification,
|
||||
confidenceScore);
|
||||
|
||||
var context = _context ?? new VexProofContext(
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
computedAt);
|
||||
|
||||
var inputs = new VexProofInputs(
|
||||
_statements.ToImmutableArray(),
|
||||
context);
|
||||
|
||||
var latticeComputation = _consensusMode == ConsensusMode.Lattice && _mergeSteps.Count > 0
|
||||
? new VexProofLatticeComputation(_latticeOrdering, _mergeSteps.ToImmutableArray(), _finalStatus)
|
||||
: null;
|
||||
|
||||
var conflictAnalysis = new VexProofConflictAnalysis(
|
||||
_conflicts.Count > 0,
|
||||
_conflicts.ToImmutableArray(),
|
||||
_conflictPenalty);
|
||||
|
||||
var resolution = new VexProofResolution(
|
||||
_consensusMode,
|
||||
_qualifiedCount,
|
||||
_disqualifiedCount,
|
||||
_disqualificationReasons.ToImmutableArray(),
|
||||
latticeComputation,
|
||||
conflictAnalysis);
|
||||
|
||||
var propagation = _propagationRules.Count > 0 || _graphPaths.Count > 0
|
||||
? new VexProofPropagation(
|
||||
_propagationApplied,
|
||||
_propagationRules.ToImmutableArray(),
|
||||
_graphPaths.ToImmutableArray(),
|
||||
_inheritedStatus,
|
||||
_overrideApplied)
|
||||
: null;
|
||||
|
||||
var unknownCount = _conditionResults.Count(c => c.Result == ConditionOutcome.Unknown);
|
||||
var conditions = _conditionResults.Count > 0 || _unevaluatedConditions.Count > 0
|
||||
? new VexProofConditions(
|
||||
_conditionResults.ToImmutableArray(),
|
||||
_unevaluatedConditions.ToImmutableArray(),
|
||||
unknownCount)
|
||||
: null;
|
||||
|
||||
var confidenceBreakdown = new VexProofConfidenceBreakdown(
|
||||
_weightSpread,
|
||||
_conflictPenalty,
|
||||
_freshnessBonus,
|
||||
_signatureBonus,
|
||||
_conditionCoverage);
|
||||
|
||||
var confidence = new VexProofConfidence(
|
||||
confidenceScore,
|
||||
confidenceTier,
|
||||
confidenceBreakdown,
|
||||
_confidenceImprovements.ToImmutableArray());
|
||||
|
||||
// Build proof without digest first, then compute digest
|
||||
var proofWithoutDigest = new VexProof(
|
||||
VexProof.SchemaVersion,
|
||||
proofId,
|
||||
computedAt,
|
||||
verdict,
|
||||
inputs,
|
||||
resolution,
|
||||
propagation,
|
||||
conditions,
|
||||
confidence,
|
||||
Digest: null);
|
||||
|
||||
// Return with digest computed
|
||||
var digest = VexProofSerializer.ComputeDigest(proofWithoutDigest);
|
||||
return proofWithoutDigest with { Digest = digest };
|
||||
}
|
||||
|
||||
private decimal CalculateConfidenceScore()
|
||||
{
|
||||
// Base from weight spread
|
||||
var score = _weightSpread;
|
||||
|
||||
// Apply conflict penalty (negative)
|
||||
score += _conflictPenalty;
|
||||
|
||||
// Add bonuses
|
||||
score += _freshnessBonus;
|
||||
score += _signatureBonus;
|
||||
|
||||
// Factor in condition coverage
|
||||
score *= _conditionCoverage;
|
||||
|
||||
// Clamp to [0, 1]
|
||||
return Math.Clamp(score, 0m, 1m);
|
||||
}
|
||||
|
||||
private static ConfidenceTier ClassifyConfidenceTier(decimal score) => score switch
|
||||
{
|
||||
>= 0.9m => ConfidenceTier.VeryHigh,
|
||||
>= 0.75m => ConfidenceTier.High,
|
||||
>= 0.5m => ConfidenceTier.Medium,
|
||||
>= 0.25m => ConfidenceTier.Low,
|
||||
_ => ConfidenceTier.VeryLow
|
||||
};
|
||||
|
||||
private static string GenerateProofId(DateTimeOffset timestamp)
|
||||
{
|
||||
var timePart = timestamp.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var randomPart = Guid.NewGuid().ToString("N")[..8];
|
||||
return $"proof-{timePart}-{randomPart}";
|
||||
}
|
||||
}
|
||||
248
src/VexLens/StellaOps.VexLens/Proof/VexProofSerializer.cs
Normal file
248
src/VexLens/StellaOps.VexLens/Proof/VexProofSerializer.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.VexLens.Proof;
|
||||
|
||||
/// <summary>
|
||||
/// Serializer for VEX proof objects with RFC 8785 canonical JSON support.
|
||||
/// </summary>
|
||||
public static class VexProofSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions DefaultOptions = CreateDefaultOptions();
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = CreateCanonicalOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a VEX proof to JSON.
|
||||
/// </summary>
|
||||
public static string Serialize(VexProof proof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
return JsonSerializer.Serialize(proof, DefaultOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a VEX proof to pretty-printed JSON.
|
||||
/// </summary>
|
||||
public static string SerializePretty(VexProof proof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
var options = new JsonSerializerOptions(DefaultOptions) { WriteIndented = true };
|
||||
return JsonSerializer.Serialize(proof, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a VEX proof to canonical JSON (RFC 8785).
|
||||
/// Used for digest computation.
|
||||
/// </summary>
|
||||
public static string SerializeCanonical(VexProof proof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
|
||||
// Serialize without digest field for canonical form
|
||||
var proofWithoutDigest = proof with { Digest = null };
|
||||
return JsonSerializer.Serialize(proofWithoutDigest, CanonicalOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a VEX proof to UTF-8 bytes.
|
||||
/// </summary>
|
||||
public static byte[] SerializeToUtf8Bytes(VexProof proof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
return JsonSerializer.SerializeToUtf8Bytes(proof, DefaultOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a VEX proof to a stream.
|
||||
/// </summary>
|
||||
public static async Task SerializeAsync(Stream stream, VexProof proof, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
await JsonSerializer.SerializeAsync(stream, proof, DefaultOptions, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a VEX proof from JSON.
|
||||
/// </summary>
|
||||
public static VexProof? Deserialize(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return JsonSerializer.Deserialize<VexProof>(json, DefaultOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a VEX proof from UTF-8 bytes.
|
||||
/// </summary>
|
||||
public static VexProof? Deserialize(ReadOnlySpan<byte> utf8Json)
|
||||
{
|
||||
if (utf8Json.IsEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return JsonSerializer.Deserialize<VexProof>(utf8Json, DefaultOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a VEX proof from a stream.
|
||||
/// </summary>
|
||||
public static async Task<VexProof?> DeserializeAsync(Stream stream, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
return await JsonSerializer.DeserializeAsync<VexProof>(stream, DefaultOptions, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA-256 digest of the canonical JSON representation.
|
||||
/// </summary>
|
||||
public static string ComputeDigest(VexProof proof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
|
||||
var canonical = SerializeCanonical(proof);
|
||||
var bytes = Encoding.UTF8.GetBytes(canonical);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the digest of a VEX proof.
|
||||
/// </summary>
|
||||
public static bool VerifyDigest(VexProof proof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
|
||||
if (string.IsNullOrEmpty(proof.Digest))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var computed = ComputeDigest(proof);
|
||||
return string.Equals(computed, proof.Digest, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a VEX proof is well-formed.
|
||||
/// </summary>
|
||||
public static VexProofValidationResult Validate(VexProof proof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check required fields
|
||||
if (string.IsNullOrWhiteSpace(proof.Schema))
|
||||
{
|
||||
errors.Add("Schema is required");
|
||||
}
|
||||
else if (proof.Schema != VexProof.SchemaVersion)
|
||||
{
|
||||
errors.Add($"Unknown schema version: {proof.Schema}. Expected: {VexProof.SchemaVersion}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(proof.ProofId))
|
||||
{
|
||||
errors.Add("ProofId is required");
|
||||
}
|
||||
|
||||
if (proof.Verdict is null)
|
||||
{
|
||||
errors.Add("Verdict is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(proof.Verdict.VulnerabilityId))
|
||||
{
|
||||
errors.Add("Verdict.VulnerabilityId is required");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(proof.Verdict.ProductKey))
|
||||
{
|
||||
errors.Add("Verdict.ProductKey is required");
|
||||
}
|
||||
if (proof.Verdict.Confidence < 0 || proof.Verdict.Confidence > 1)
|
||||
{
|
||||
errors.Add("Verdict.Confidence must be between 0 and 1");
|
||||
}
|
||||
}
|
||||
|
||||
if (proof.Inputs is null)
|
||||
{
|
||||
errors.Add("Inputs is required");
|
||||
}
|
||||
else if (proof.Inputs.Context is null)
|
||||
{
|
||||
errors.Add("Inputs.Context is required");
|
||||
}
|
||||
|
||||
if (proof.Resolution is null)
|
||||
{
|
||||
errors.Add("Resolution is required");
|
||||
}
|
||||
|
||||
if (proof.Confidence is null)
|
||||
{
|
||||
errors.Add("Confidence is required");
|
||||
}
|
||||
else if (proof.Confidence.Score < 0 || proof.Confidence.Score > 1)
|
||||
{
|
||||
errors.Add("Confidence.Score must be between 0 and 1");
|
||||
}
|
||||
|
||||
// Verify digest if present
|
||||
if (!string.IsNullOrEmpty(proof.Digest) && !VerifyDigest(proof))
|
||||
{
|
||||
errors.Add("Digest verification failed");
|
||||
}
|
||||
|
||||
return new VexProofValidationResult(
|
||||
errors.Count == 0,
|
||||
errors);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateDefaultOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
WriteIndented = false
|
||||
};
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower));
|
||||
return options;
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateCanonicalOptions()
|
||||
{
|
||||
// RFC 8785 canonical JSON:
|
||||
// - Sorted keys (not directly supported, use source generators or custom converter)
|
||||
// - No whitespace
|
||||
// - Minimal escaping
|
||||
// - No trailing zeros in numbers
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
WriteIndented = false
|
||||
};
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower));
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of VEX proof validation.
|
||||
/// </summary>
|
||||
public sealed record VexProofValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<string> Errors);
|
||||
@@ -0,0 +1,172 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Proof;
|
||||
|
||||
namespace StellaOps.VexLens.Propagation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes transitive VEX impact through dependency graphs.
|
||||
/// </summary>
|
||||
public interface IPropagationRuleEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Propagates a verdict through a dependency graph.
|
||||
/// </summary>
|
||||
/// <param name="componentVerdict">The verdict for a component.</param>
|
||||
/// <param name="graph">The dependency graph.</param>
|
||||
/// <param name="policy">Propagation policy configuration.</param>
|
||||
/// <returns>The propagation result.</returns>
|
||||
PropagationResult Propagate(
|
||||
ComponentVerdict componentVerdict,
|
||||
IDependencyGraph graph,
|
||||
PropagationPolicy policy);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all configured propagation rules.
|
||||
/// </summary>
|
||||
IReadOnlyList<PropagationRule> GetRules();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A verdict for a component (before propagation).
|
||||
/// </summary>
|
||||
public sealed record ComponentVerdict(
|
||||
string VulnerabilityId,
|
||||
string ComponentKey,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
decimal Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Result of propagation computation.
|
||||
/// </summary>
|
||||
public sealed record PropagationResult(
|
||||
bool Applied,
|
||||
ImmutableArray<PropagationRuleResult> RuleResults,
|
||||
ImmutableArray<DependencyPath> AnalyzedPaths,
|
||||
VexStatus? InheritedStatus,
|
||||
bool OverrideApplied,
|
||||
string? OverrideReason);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single propagation rule evaluation.
|
||||
/// </summary>
|
||||
public sealed record PropagationRuleResult(
|
||||
string RuleId,
|
||||
string Description,
|
||||
bool Triggered,
|
||||
string? Effect,
|
||||
ImmutableArray<string> AffectedComponents);
|
||||
|
||||
/// <summary>
|
||||
/// A path through the dependency graph.
|
||||
/// </summary>
|
||||
public sealed record DependencyPath(
|
||||
string Root,
|
||||
ImmutableArray<string> Path,
|
||||
DependencyPathType PathType,
|
||||
int Depth,
|
||||
DependencyScope Scope);
|
||||
|
||||
/// <summary>
|
||||
/// Scope of a dependency.
|
||||
/// </summary>
|
||||
public enum DependencyScope
|
||||
{
|
||||
/// <summary>Runtime dependency.</summary>
|
||||
Runtime,
|
||||
|
||||
/// <summary>Compile-time only dependency.</summary>
|
||||
CompileOnly,
|
||||
|
||||
/// <summary>Development/test dependency.</summary>
|
||||
Development,
|
||||
|
||||
/// <summary>Optional/peer dependency.</summary>
|
||||
Optional
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy for propagation behavior.
|
||||
/// </summary>
|
||||
public sealed record PropagationPolicy(
|
||||
bool EnableTransitivePropagation,
|
||||
bool InheritAffectedFromDirectDependency,
|
||||
bool InheritNotAffectedFromLeafDependency,
|
||||
bool RequireExplicitOverride,
|
||||
int MaxTransitiveDepth,
|
||||
ImmutableHashSet<DependencyScope> ExcludedScopes);
|
||||
|
||||
/// <summary>
|
||||
/// A propagation rule that can be evaluated.
|
||||
/// </summary>
|
||||
public abstract class PropagationRule
|
||||
{
|
||||
/// <summary>Gets the rule identifier.</summary>
|
||||
public abstract string RuleId { get; }
|
||||
|
||||
/// <summary>Gets the rule description.</summary>
|
||||
public abstract string Description { get; }
|
||||
|
||||
/// <summary>Gets the rule priority (lower = higher priority).</summary>
|
||||
public virtual int Priority => 100;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the rule for a component.
|
||||
/// </summary>
|
||||
/// <param name="verdict">The component verdict.</param>
|
||||
/// <param name="graph">The dependency graph.</param>
|
||||
/// <param name="policy">The propagation policy.</param>
|
||||
/// <returns>The rule result.</returns>
|
||||
public abstract PropagationRuleResult Evaluate(
|
||||
ComponentVerdict verdict,
|
||||
IDependencyGraph graph,
|
||||
PropagationPolicy policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a dependency graph for propagation analysis.
|
||||
/// </summary>
|
||||
public interface IDependencyGraph
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all direct dependencies of a component.
|
||||
/// </summary>
|
||||
IEnumerable<DependencyEdge> GetDirectDependencies(string componentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all dependents (reverse dependencies) of a component.
|
||||
/// </summary>
|
||||
IEnumerable<DependencyEdge> GetDependents(string componentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all paths from root to a component.
|
||||
/// </summary>
|
||||
IEnumerable<DependencyPath> GetPathsTo(string componentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the depth of a component in the dependency tree.
|
||||
/// </summary>
|
||||
int GetDepth(string componentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a component is a leaf (has no dependencies).
|
||||
/// </summary>
|
||||
bool IsLeaf(string componentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a component is a root (has no dependents).
|
||||
/// </summary>
|
||||
bool IsRoot(string componentKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An edge in the dependency graph.
|
||||
/// </summary>
|
||||
public sealed record DependencyEdge(
|
||||
string From,
|
||||
string To,
|
||||
DependencyPathType PathType,
|
||||
DependencyScope Scope);
|
||||
@@ -0,0 +1,265 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Proof;
|
||||
|
||||
namespace StellaOps.VexLens.Propagation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the propagation rule engine.
|
||||
/// </summary>
|
||||
public sealed class PropagationRuleEngine : IPropagationRuleEngine
|
||||
{
|
||||
private readonly ImmutableArray<PropagationRule> _rules;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PropagationRuleEngine with default rules.
|
||||
/// </summary>
|
||||
public PropagationRuleEngine() : this(GetDefaultRules())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PropagationRuleEngine with specified rules.
|
||||
/// </summary>
|
||||
public PropagationRuleEngine(IEnumerable<PropagationRule> rules)
|
||||
{
|
||||
_rules = rules.OrderBy(r => r.Priority).ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PropagationResult Propagate(
|
||||
ComponentVerdict componentVerdict,
|
||||
IDependencyGraph graph,
|
||||
PropagationPolicy policy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(componentVerdict);
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var ruleResults = new List<PropagationRuleResult>();
|
||||
var analyzedPaths = new List<DependencyPath>();
|
||||
VexStatus? inheritedStatus = null;
|
||||
var overrideApplied = false;
|
||||
string? overrideReason = null;
|
||||
var anyTriggered = false;
|
||||
|
||||
// Analyze dependency paths
|
||||
var paths = graph.GetPathsTo(componentVerdict.ComponentKey);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
// Skip excluded scopes
|
||||
if (policy.ExcludedScopes.Contains(path.Scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if beyond max depth
|
||||
if (path.Depth > policy.MaxTransitiveDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
analyzedPaths.Add(path);
|
||||
}
|
||||
|
||||
// Evaluate rules in priority order
|
||||
foreach (var rule in _rules)
|
||||
{
|
||||
var result = rule.Evaluate(componentVerdict, graph, policy);
|
||||
ruleResults.Add(result);
|
||||
|
||||
if (result.Triggered)
|
||||
{
|
||||
anyTriggered = true;
|
||||
|
||||
// First triggered rule with an effect wins
|
||||
if (inheritedStatus is null && !string.IsNullOrEmpty(result.Effect))
|
||||
{
|
||||
// Parse effect to determine inherited status
|
||||
if (result.Effect.Contains("affected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inheritedStatus = VexStatus.Affected;
|
||||
}
|
||||
else if (result.Effect.Contains("not_affected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inheritedStatus = VexStatus.NotAffected;
|
||||
}
|
||||
else if (result.Effect.Contains("fixed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inheritedStatus = VexStatus.Fixed;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for override
|
||||
if (result.Effect?.Contains("override", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
overrideApplied = true;
|
||||
overrideReason = result.Effect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new PropagationResult(
|
||||
anyTriggered,
|
||||
ruleResults.ToImmutableArray(),
|
||||
analyzedPaths.ToImmutableArray(),
|
||||
inheritedStatus,
|
||||
overrideApplied,
|
||||
overrideReason);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<PropagationRule> GetRules() => _rules;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default set of propagation rules.
|
||||
/// </summary>
|
||||
public static IEnumerable<PropagationRule> GetDefaultRules()
|
||||
{
|
||||
yield return new DirectDependencyAffectedRule();
|
||||
yield return new TransitiveDependencyRule();
|
||||
yield return new DependencyFixedRule();
|
||||
yield return new DependencyNotAffectedRule();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule: If direct dependency is affected, product inherits affected unless overridden.
|
||||
/// </summary>
|
||||
public sealed class DirectDependencyAffectedRule : PropagationRule
|
||||
{
|
||||
public override string RuleId => "direct-dependency-affected";
|
||||
public override string Description => "If direct dependency is affected, product inherits affected unless product-level override";
|
||||
public override int Priority => 10;
|
||||
|
||||
public override PropagationRuleResult Evaluate(
|
||||
ComponentVerdict verdict,
|
||||
IDependencyGraph graph,
|
||||
PropagationPolicy policy)
|
||||
{
|
||||
if (!policy.InheritAffectedFromDirectDependency)
|
||||
{
|
||||
return new PropagationRuleResult(RuleId, Description, false, null, []);
|
||||
}
|
||||
|
||||
// Check if any direct dependency is affected
|
||||
var directDeps = graph.GetDirectDependencies(verdict.ComponentKey).ToList();
|
||||
var affectedComponents = new List<string>();
|
||||
|
||||
foreach (var dep in directDeps)
|
||||
{
|
||||
if (dep.PathType == DependencyPathType.DirectDependency)
|
||||
{
|
||||
// In a real implementation, we would look up the verdict for the dependency
|
||||
// For now, we track the dependency for potential impact
|
||||
affectedComponents.Add(dep.To);
|
||||
}
|
||||
}
|
||||
|
||||
// This rule triggers when the component's own verdict is affected and it has direct dependencies
|
||||
var triggered = verdict.Status == VexStatus.Affected && affectedComponents.Count > 0;
|
||||
|
||||
return new PropagationRuleResult(
|
||||
RuleId,
|
||||
Description,
|
||||
triggered,
|
||||
triggered ? "Product inherits affected status from direct dependency" : null,
|
||||
affectedComponents.ToImmutableArray());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule: If transitive dependency is affected, flag for review but don't auto-inherit.
|
||||
/// </summary>
|
||||
public sealed class TransitiveDependencyRule : PropagationRule
|
||||
{
|
||||
public override string RuleId => "transitive-dependency-affected";
|
||||
public override string Description => "If transitive dependency is affected, flag for review but don't auto-inherit";
|
||||
public override int Priority => 20;
|
||||
|
||||
public override PropagationRuleResult Evaluate(
|
||||
ComponentVerdict verdict,
|
||||
IDependencyGraph graph,
|
||||
PropagationPolicy policy)
|
||||
{
|
||||
if (!policy.EnableTransitivePropagation)
|
||||
{
|
||||
return new PropagationRuleResult(RuleId, Description, false, null, []);
|
||||
}
|
||||
|
||||
var paths = graph.GetPathsTo(verdict.ComponentKey).ToList();
|
||||
var transitivePaths = paths
|
||||
.Where(p => p.PathType == DependencyPathType.TransitiveDependency)
|
||||
.Where(p => p.Depth <= policy.MaxTransitiveDepth)
|
||||
.ToList();
|
||||
|
||||
var triggered = verdict.Status == VexStatus.Affected && transitivePaths.Count > 0;
|
||||
var affectedComponents = transitivePaths.Select(p => p.Root).Distinct().ToImmutableArray();
|
||||
|
||||
return new PropagationRuleResult(
|
||||
RuleId,
|
||||
Description,
|
||||
triggered,
|
||||
triggered ? "Transitive dependency is affected - flagged for review" : null,
|
||||
affectedComponents);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule: If dependency was affected but is now fixed, allow product NotAffected if vulnerable code was removed.
|
||||
/// </summary>
|
||||
public sealed class DependencyFixedRule : PropagationRule
|
||||
{
|
||||
public override string RuleId => "dependency-fixed";
|
||||
public override string Description => "If dependency was affected but is now fixed, allow product NotAffected if vulnerable code was removed";
|
||||
public override int Priority => 30;
|
||||
|
||||
public override PropagationRuleResult Evaluate(
|
||||
ComponentVerdict verdict,
|
||||
IDependencyGraph graph,
|
||||
PropagationPolicy policy)
|
||||
{
|
||||
// This rule triggers when a dependency is now fixed
|
||||
var triggered = verdict.Status == VexStatus.Fixed;
|
||||
|
||||
return new PropagationRuleResult(
|
||||
RuleId,
|
||||
Description,
|
||||
triggered,
|
||||
triggered ? "Dependency is fixed - product may be not_affected with override" : null,
|
||||
[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule: If dependency is not_affected, product may inherit if dependency is leaf.
|
||||
/// </summary>
|
||||
public sealed class DependencyNotAffectedRule : PropagationRule
|
||||
{
|
||||
public override string RuleId => "dependency-not-affected";
|
||||
public override string Description => "If dependency is not_affected, product may inherit if dependency is leaf";
|
||||
public override int Priority => 40;
|
||||
|
||||
public override PropagationRuleResult Evaluate(
|
||||
ComponentVerdict verdict,
|
||||
IDependencyGraph graph,
|
||||
PropagationPolicy policy)
|
||||
{
|
||||
if (!policy.InheritNotAffectedFromLeafDependency)
|
||||
{
|
||||
return new PropagationRuleResult(RuleId, Description, false, null, []);
|
||||
}
|
||||
|
||||
var isLeaf = graph.IsLeaf(verdict.ComponentKey);
|
||||
var triggered = verdict.Status == VexStatus.NotAffected && isLeaf;
|
||||
|
||||
return new PropagationRuleResult(
|
||||
RuleId,
|
||||
Description,
|
||||
triggered,
|
||||
triggered ? "Leaf dependency is not_affected - dependents may inherit" : null,
|
||||
[]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260102_003_BE_vex_proof_objects
|
||||
// Tasks: VP-025
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.VexLens.Conditions;
|
||||
using StellaOps.VexLens.Proof;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Conditions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ConditionEvaluator.
|
||||
/// Tests validate the actual implementation behavior of condition evaluation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class ConditionEvaluatorTests
|
||||
{
|
||||
private readonly ConditionEvaluator _evaluator = new();
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsEmptyResult_WhenNoConditions()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateDefaultContext();
|
||||
var conditions = Array.Empty<VexCondition>();
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate(conditions, context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().BeEmpty();
|
||||
result.Coverage.Should().Be(1.0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesPlatformCondition_MatchingPlatform()
|
||||
{
|
||||
// Arrange - PlatformConditionHandler uses ExpectedValue ?? Expression for matching
|
||||
var condition = new VexCondition("cond-1", ConditionType.Platform, "linux/amd64", "linux/amd64");
|
||||
var context = CreateDefaultContext() with { Platform = "linux/amd64" };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesPlatformCondition_NonMatchingPlatform()
|
||||
{
|
||||
// Arrange
|
||||
var condition = new VexCondition("cond-1", ConditionType.Platform, "linux/arm64", "linux/arm64");
|
||||
var context = CreateDefaultContext() with { Platform = "linux/amd64" };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.False);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesPlatformCondition_UnknownWhenNoPlatform()
|
||||
{
|
||||
// Arrange
|
||||
var condition = new VexCondition("cond-1", ConditionType.Platform, "linux/amd64", "linux/amd64");
|
||||
var context = CreateDefaultContext() with { Platform = null };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.Unknown);
|
||||
result.UnknownCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesDistroCondition_Matching()
|
||||
{
|
||||
// Arrange - DistroConditionHandler uses ExpectedValue ?? Expression
|
||||
var condition = new VexCondition("cond-1", ConditionType.Distro, "rhel:9", "rhel:9");
|
||||
var context = CreateDefaultContext() with { Distro = "rhel:9" };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesFeatureCondition_FeaturePresent()
|
||||
{
|
||||
// Arrange - FeatureConditionHandler checks if ExpectedValue ?? Expression is in Features
|
||||
// So we set ExpectedValue to the feature we want to check for
|
||||
var condition = new VexCondition("cond-1", ConditionType.Feature, "esm", "esm");
|
||||
var context = CreateDefaultContext() with { Features = new[] { "esm", "cjs" }.ToImmutableHashSet() };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesFeatureCondition_FeatureAbsent()
|
||||
{
|
||||
// Arrange - Feature "esm" is not in the context
|
||||
var condition = new VexCondition("cond-1", ConditionType.Feature, "esm", "esm");
|
||||
var context = CreateDefaultContext() with { Features = new[] { "cjs" }.ToImmutableHashSet() };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.False);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesBuildFlagCondition_FlagPresenceCheck()
|
||||
{
|
||||
// Arrange - BuildFlagConditionHandler with no '=' in expression checks for presence
|
||||
// When Expression doesn't contain '=', it checks ContainsKey(Expression)
|
||||
var condition = new VexCondition("cond-1", ConditionType.BuildFlag, "DEBUG", null);
|
||||
var context = CreateDefaultContext() with
|
||||
{
|
||||
BuildFlags = new Dictionary<string, string> { ["DEBUG"] = "true" }.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesBuildFlagCondition_FlagAbsent()
|
||||
{
|
||||
// Arrange - Check for flag that doesn't exist
|
||||
var condition = new VexCondition("cond-1", ConditionType.BuildFlag, "RELEASE", null);
|
||||
var context = CreateDefaultContext() with
|
||||
{
|
||||
BuildFlags = new Dictionary<string, string> { ["DEBUG"] = "true" }.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.False);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesBuildFlagCondition_ValueMatch()
|
||||
{
|
||||
// Arrange - BuildFlagConditionHandler with '=' in expression compares values
|
||||
var condition = new VexCondition("cond-1", ConditionType.BuildFlag, "DEBUG=true", "true");
|
||||
var context = CreateDefaultContext() with
|
||||
{
|
||||
BuildFlags = new Dictionary<string, string> { ["DEBUG"] = "true" }.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesBuildFlagCondition_ValueMismatch()
|
||||
{
|
||||
// Arrange - Value doesn't match
|
||||
var condition = new VexCondition("cond-1", ConditionType.BuildFlag, "DEBUG=true", "true");
|
||||
var context = CreateDefaultContext() with
|
||||
{
|
||||
BuildFlags = new Dictionary<string, string> { ["DEBUG"] = "false" }.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.False);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EvaluatesEnvironmentCondition_ViaCustomHandler()
|
||||
{
|
||||
// Arrange - Environment conditions fall through to custom handler
|
||||
// which requires expressions like "env.KEY == 'value'"
|
||||
var condition = new VexCondition("cond-1", ConditionType.Custom, "env.NODE_ENV == 'production'", null);
|
||||
var context = CreateDefaultContext() with
|
||||
{
|
||||
Environment = new Dictionary<string, string> { ["NODE_ENV"] = "production" }.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EnvironmentCondition_ReturnsUnknown_WhenNoHandler()
|
||||
{
|
||||
// Arrange - Environment type without default handler returns Unknown
|
||||
var condition = new VexCondition("cond-1", ConditionType.Environment, "NODE_ENV", "production");
|
||||
var context = CreateDefaultContext() with
|
||||
{
|
||||
Environment = new Dictionary<string, string> { ["NODE_ENV"] = "production" }.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert - No default handler for Environment type, returns Unknown
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_CalculatesCoverage_WithMultipleConditions()
|
||||
{
|
||||
// Arrange - 3 conditions: 2 known, 1 unknown (missing distro)
|
||||
var conditions = new[]
|
||||
{
|
||||
new VexCondition("cond-1", ConditionType.Platform, "linux/amd64", "linux/amd64"),
|
||||
new VexCondition("cond-2", ConditionType.Feature, "esm", "esm"),
|
||||
new VexCondition("cond-3", ConditionType.Distro, "rhel:9", "rhel:9") // Unknown, no distro in context
|
||||
};
|
||||
var context = CreateDefaultContext() with
|
||||
{
|
||||
Platform = "linux/amd64",
|
||||
Features = new[] { "esm" }.ToImmutableHashSet(),
|
||||
Distro = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate(conditions, context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(3);
|
||||
result.UnknownCount.Should().Be(1);
|
||||
result.Coverage.Should().BeLessThan(1.0m);
|
||||
result.Coverage.Should().BeApproximately(2m / 3m, 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_ReturnsSingleConditionResult()
|
||||
{
|
||||
// Arrange
|
||||
var condition = new VexCondition("cond-1", ConditionType.Platform, "linux/amd64", "linux/amd64");
|
||||
var context = CreateDefaultContext() with { Platform = "linux/amd64" };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.EvaluateSingle(condition, context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.ConditionId.Should().Be("cond-1");
|
||||
result.Expression.Should().Be("linux/amd64");
|
||||
result.Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_CustomCondition_ReturnsUnknown_ForUnsupportedExpression()
|
||||
{
|
||||
// Arrange - Custom condition with unsupported expression returns Unknown
|
||||
// The evaluator doesn't add to Unevaluated list, it records the result as Unknown
|
||||
var condition = new VexCondition("cond-1", ConditionType.Custom, "custom-unsupported-expr", null);
|
||||
var context = CreateDefaultContext();
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.Unknown);
|
||||
result.UnknownCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_PlatformCondition_SupportsWildcards()
|
||||
{
|
||||
// Arrange - Wildcard pattern matching
|
||||
var condition = new VexCondition("cond-1", ConditionType.Platform, "linux/*", "linux/*");
|
||||
var context = CreateDefaultContext() with { Platform = "linux/amd64" };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_DistroCondition_SupportsWildcards()
|
||||
{
|
||||
// Arrange - Wildcard pattern matching for distro
|
||||
var condition = new VexCondition("cond-1", ConditionType.Distro, "rhel:*", "rhel:*");
|
||||
var context = CreateDefaultContext() with { Distro = "rhel:9" };
|
||||
|
||||
// Act
|
||||
var result = _evaluator.Evaluate([condition], context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Results.Should().HaveCount(1);
|
||||
result.Results[0].Result.Should().Be(ConditionOutcome.True);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static EvaluationContext CreateDefaultContext()
|
||||
{
|
||||
return new EvaluationContext(
|
||||
Platform: null,
|
||||
Distro: null,
|
||||
Features: ImmutableHashSet<string>.Empty,
|
||||
BuildFlags: ImmutableDictionary<string, string>.Empty,
|
||||
Environment: ImmutableDictionary<string, string>.Empty,
|
||||
EvaluationTime: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260102_003_BE_vex_proof_objects
|
||||
// Tasks: VP-022, VP-023, VP-027
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Proof;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Proof;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for VexProofBuilder and VexProofSerializer.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class VexProofBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _fixedTime = new(2026, 1, 3, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
public VexProofBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(_fixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CreatesValidProof_WithMinimalProperties()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Should().NotBeNull();
|
||||
proof.Schema.Should().Be(VexProof.SchemaVersion);
|
||||
proof.ProofId.Should().StartWith("proof-");
|
||||
proof.ComputedAt.Should().Be(_fixedTime);
|
||||
proof.Verdict.VulnerabilityId.Should().Be("CVE-2023-12345");
|
||||
proof.Verdict.ProductKey.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
proof.Verdict.Status.Should().Be(VexStatus.NotAffected);
|
||||
proof.Digest.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IncludesAllStatements_WhenAdded()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithContext("linux/amd64", null, ["esm"], null, _fixedTime)
|
||||
.AddStatement(
|
||||
"stmt-001",
|
||||
"openvex",
|
||||
new VexProofIssuer("lodash-maintainers", IssuerCategory.Vendor, TrustTier.Trusted),
|
||||
VexStatus.NotAffected,
|
||||
VexJustification.VulnerableCodeNotInExecutePath,
|
||||
new VexProofWeight(0.85m, new VexProofWeightFactors(0.90m, 1.0m, 0.95m, 1.0m, 0.70m)),
|
||||
_fixedTime.AddDays(-10),
|
||||
true)
|
||||
.AddStatement(
|
||||
"stmt-002",
|
||||
"nvd",
|
||||
new VexProofIssuer("nvd", IssuerCategory.Aggregator, TrustTier.Trusted),
|
||||
VexStatus.Affected,
|
||||
null,
|
||||
new VexProofWeight(0.60m, new VexProofWeightFactors(0.70m, 0.50m, 0.80m, 0.95m, 0.50m)),
|
||||
_fixedTime.AddDays(-20),
|
||||
false)
|
||||
.WithFinalStatus(VexStatus.NotAffected, VexJustification.VulnerableCodeNotInExecutePath);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Inputs.Statements.Should().HaveCount(2);
|
||||
proof.Inputs.Statements[0].Id.Should().Be("stmt-001");
|
||||
proof.Inputs.Statements[0].Qualified.Should().BeTrue();
|
||||
proof.Inputs.Statements[1].Id.Should().Be("stmt-002");
|
||||
proof.Inputs.Statements[1].Source.Should().Be("nvd");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TracksDisqualifiedStatements_Separately()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.AddStatement(
|
||||
"stmt-001",
|
||||
"openvex",
|
||||
new VexProofIssuer("vendor", IssuerCategory.Vendor, TrustTier.Trusted),
|
||||
VexStatus.NotAffected,
|
||||
null,
|
||||
new VexProofWeight(0.85m, new VexProofWeightFactors(0.90m, 1.0m, 0.95m, 1.0m, 0.70m)),
|
||||
_fixedTime.AddDays(-10),
|
||||
true)
|
||||
.AddDisqualifiedStatement(
|
||||
"stmt-002",
|
||||
"unknown",
|
||||
new VexProofIssuer("unknown", IssuerCategory.Community, TrustTier.Unknown),
|
||||
VexStatus.Affected,
|
||||
null,
|
||||
new VexProofWeight(0.10m, new VexProofWeightFactors(0.10m, 0.0m, 0.50m, 0.50m, 0.20m)),
|
||||
_fixedTime.AddDays(-30),
|
||||
false,
|
||||
"Weight below minimum threshold")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Resolution.QualifiedStatements.Should().Be(1);
|
||||
proof.Resolution.DisqualifiedStatements.Should().Be(1);
|
||||
proof.Resolution.DisqualificationReasons.Should().Contain("Weight below minimum threshold");
|
||||
proof.Inputs.Statements.Should().HaveCount(2);
|
||||
proof.Inputs.Statements[1].Qualified.Should().BeFalse();
|
||||
proof.Inputs.Statements[1].DisqualificationReason.Should().Be("Weight below minimum threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RecordsLatticeComputationSteps()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithConsensusMode(ConsensusMode.Lattice)
|
||||
.WithLatticeOrdering([VexStatus.UnderInvestigation, VexStatus.Affected, VexStatus.Fixed, VexStatus.NotAffected])
|
||||
.AddMergeStep(1, "stmt-001", VexStatus.NotAffected, 0.85m, MergeAction.Initialize, false, null, VexStatus.NotAffected)
|
||||
.AddMergeStep(2, "stmt-002", VexStatus.Affected, 0.60m, MergeAction.Merge, true, "higher_weight_wins", VexStatus.NotAffected)
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Resolution.Mode.Should().Be(ConsensusMode.Lattice);
|
||||
proof.Resolution.LatticeComputation.Should().NotBeNull();
|
||||
proof.Resolution.LatticeComputation!.MergeSteps.Should().HaveCount(2);
|
||||
proof.Resolution.LatticeComputation.MergeSteps[0].Action.Should().Be(MergeAction.Initialize);
|
||||
proof.Resolution.LatticeComputation.MergeSteps[1].Conflict.Should().BeTrue();
|
||||
proof.Resolution.LatticeComputation.MergeSteps[1].Resolution.Should().Be("higher_weight_wins");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RecordsConflictAnalysis()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.AddConflict("stmt-001", "stmt-002", VexStatus.NotAffected, VexStatus.Affected, ConflictSeverity.High, "weight_based", "stmt-001")
|
||||
.WithConflictPenalty(-0.10m)
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Resolution.ConflictAnalysis.HasConflicts.Should().BeTrue();
|
||||
proof.Resolution.ConflictAnalysis.Conflicts.Should().HaveCount(1);
|
||||
proof.Resolution.ConflictAnalysis.Conflicts[0].StatementA.Should().Be("stmt-001");
|
||||
proof.Resolution.ConflictAnalysis.Conflicts[0].StatementB.Should().Be("stmt-002");
|
||||
proof.Resolution.ConflictAnalysis.Conflicts[0].Severity.Should().Be(ConflictSeverity.High);
|
||||
proof.Resolution.ConflictAnalysis.ConflictPenalty.Should().Be(-0.10m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RecordsPropagation_WhenApplied()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.AddPropagationRule("direct-dependency-affected", "If direct dependency is affected...", true, "Product inherits affected")
|
||||
.AddGraphPath("pkg:npm/my-app@1.0.0", ["lodash@4.17.21"], DependencyPathType.DirectDependency, 1)
|
||||
.WithInheritedStatus(VexStatus.Affected)
|
||||
.WithFinalStatus(VexStatus.Affected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Propagation.Should().NotBeNull();
|
||||
proof.Propagation!.Applied.Should().BeTrue();
|
||||
proof.Propagation.Rules.Should().HaveCount(1);
|
||||
proof.Propagation.Rules[0].Triggered.Should().BeTrue();
|
||||
proof.Propagation.GraphPaths.Should().HaveCount(1);
|
||||
proof.Propagation.InheritedStatus.Should().Be(VexStatus.Affected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RecordsConditionEvaluation()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.AddConditionResult("platform-linux", "platform == 'linux/*'", ConditionOutcome.True, "linux/amd64")
|
||||
.AddConditionResult("feature-esm", "feature == 'esm'", ConditionOutcome.True, "esm")
|
||||
.WithConditionCoverage(1.0m)
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Conditions.Should().NotBeNull();
|
||||
proof.Conditions!.Evaluated.Should().HaveCount(2);
|
||||
proof.Conditions.UnknownCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CalculatesConfidenceScore_FromFactors()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithWeightSpread(0.85m)
|
||||
.WithConflictPenalty(-0.10m)
|
||||
.WithFreshnessBonus(0.03m)
|
||||
.WithSignatureBonus(0.05m)
|
||||
.WithConditionCoverage(1.0m)
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Confidence.Score.Should().Be(0.83m); // 0.85 - 0.10 + 0.03 + 0.05 = 0.83
|
||||
proof.Confidence.Tier.Should().Be(ConfidenceTier.High);
|
||||
proof.Confidence.Breakdown.WeightSpread.Should().Be(0.85m);
|
||||
proof.Confidence.Breakdown.ConflictPenalty.Should().Be(-0.10m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ClassifiesConfidenceTier_Correctly()
|
||||
{
|
||||
// Test various confidence levels
|
||||
var testCases = new (decimal score, ConfidenceTier expectedTier)[]
|
||||
{
|
||||
(0.95m, ConfidenceTier.VeryHigh),
|
||||
(0.80m, ConfidenceTier.High),
|
||||
(0.60m, ConfidenceTier.Medium),
|
||||
(0.30m, ConfidenceTier.Low),
|
||||
(0.10m, ConfidenceTier.VeryLow),
|
||||
};
|
||||
|
||||
foreach (var (score, expectedTier) in testCases)
|
||||
{
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithWeightSpread(score)
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
var proof = builder.Build();
|
||||
proof.Confidence.Tier.Should().Be(expectedTier, $"score {score} should map to {expectedTier}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for VexProofSerializer.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class VexProofSerializerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _fixedTime = new(2026, 1, 3, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
public VexProofSerializerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(_fixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ProducesValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var proof = BuildSampleProof();
|
||||
|
||||
// Act
|
||||
var json = VexProofSerializer.Serialize(proof);
|
||||
|
||||
// Assert
|
||||
json.Should().NotBeNullOrEmpty();
|
||||
json.Should().Contain("\"schema\":\"stellaops.vex-proof.v1\"");
|
||||
json.Should().Contain("\"vulnerabilityId\":\"CVE-2023-12345\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializePretty_ProducesIndentedJson()
|
||||
{
|
||||
// Arrange
|
||||
var proof = BuildSampleProof();
|
||||
|
||||
// Act
|
||||
var json = VexProofSerializer.SerializePretty(proof);
|
||||
|
||||
// Assert
|
||||
json.Should().Contain(Environment.NewLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_ReconstructsProof()
|
||||
{
|
||||
// Arrange
|
||||
var original = BuildSampleProof();
|
||||
var json = VexProofSerializer.Serialize(original);
|
||||
|
||||
// Act
|
||||
var deserialized = VexProofSerializer.Deserialize(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.Schema.Should().Be(original.Schema);
|
||||
deserialized.ProofId.Should().Be(original.ProofId);
|
||||
deserialized.Verdict.VulnerabilityId.Should().Be(original.Verdict.VulnerabilityId);
|
||||
deserialized.Verdict.Status.Should().Be(original.Verdict.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_ProducesConsistentHash()
|
||||
{
|
||||
// Arrange
|
||||
var proof = BuildSampleProof();
|
||||
|
||||
// Act
|
||||
var digest1 = VexProofSerializer.ComputeDigest(proof);
|
||||
var digest2 = VexProofSerializer.ComputeDigest(proof);
|
||||
|
||||
// Assert
|
||||
digest1.Should().Be(digest2);
|
||||
digest1.Should().HaveLength(64); // SHA-256 hex
|
||||
digest1.Should().MatchRegex("^[a-f0-9]{64}$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDigest_ReturnsTrueForValidDigest()
|
||||
{
|
||||
// Arrange
|
||||
var proof = BuildSampleProof();
|
||||
|
||||
// Act
|
||||
var isValid = VexProofSerializer.VerifyDigest(proof);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDigest_ReturnsFalseForTamperedProof()
|
||||
{
|
||||
// Arrange
|
||||
var proof = BuildSampleProof();
|
||||
var tampered = proof with
|
||||
{
|
||||
Verdict = proof.Verdict with { Status = VexStatus.Affected }
|
||||
};
|
||||
|
||||
// Act
|
||||
var isValid = VexProofSerializer.VerifyDigest(tampered);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ReturnsValidForWellFormedProof()
|
||||
{
|
||||
// Arrange
|
||||
var proof = BuildSampleProof();
|
||||
|
||||
// Act
|
||||
var result = VexProofSerializer.Validate(proof);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ReturnsErrorsForMissingFields()
|
||||
{
|
||||
// Arrange - Create proof with null verdict
|
||||
var proof = new VexProof(
|
||||
VexProof.SchemaVersion,
|
||||
"proof-123",
|
||||
_fixedTime,
|
||||
null!, // Invalid - null verdict
|
||||
new VexProofInputs([], new VexProofContext(null, null, [], [], _fixedTime)),
|
||||
new VexProofResolution(
|
||||
ConsensusMode.Lattice, 0, 0, [],
|
||||
null,
|
||||
new VexProofConflictAnalysis(false, [], 0)),
|
||||
null,
|
||||
null,
|
||||
new VexProofConfidence(0.5m, ConfidenceTier.Medium,
|
||||
new VexProofConfidenceBreakdown(0.5m, 0, 0, 0, 1.0m), []),
|
||||
null);
|
||||
|
||||
// Act
|
||||
var result = VexProofSerializer.Validate(proof);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Verdict is required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeCanonical_ExcludesDigestField()
|
||||
{
|
||||
// Arrange
|
||||
var proof = BuildSampleProof();
|
||||
|
||||
// Act
|
||||
var canonical = VexProofSerializer.SerializeCanonical(proof);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotContain("\"digest\":");
|
||||
}
|
||||
|
||||
private VexProof BuildSampleProof()
|
||||
{
|
||||
return new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithContext("linux/amd64", null, ["esm"], null, _fixedTime)
|
||||
.WithConsensusMode(ConsensusMode.Lattice)
|
||||
.AddStatement(
|
||||
"stmt-001",
|
||||
"openvex",
|
||||
new VexProofIssuer("lodash-maintainers", IssuerCategory.Vendor, TrustTier.Trusted),
|
||||
VexStatus.NotAffected,
|
||||
VexJustification.VulnerableCodeNotInExecutePath,
|
||||
new VexProofWeight(0.85m, new VexProofWeightFactors(0.90m, 1.0m, 0.95m, 1.0m, 0.70m)),
|
||||
_fixedTime.AddDays(-10),
|
||||
true)
|
||||
.WithWeightSpread(0.85m)
|
||||
.WithFinalStatus(VexStatus.NotAffected, VexJustification.VulnerableCodeNotInExecutePath)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinism tests for VexProof digest computation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Determinism")]
|
||||
public class VexProofDeterminismTests
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime = new(2026, 1, 3, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Digest_IsDeterministic_AcrossMultipleBuilds()
|
||||
{
|
||||
// Build the same proof multiple times and verify digest is identical
|
||||
var digests = new List<string>();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(_fixedTime);
|
||||
var proof = new VexProofBuilder(timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithContext("linux/amd64", null, ["esm"], null, _fixedTime)
|
||||
.AddStatement(
|
||||
"stmt-001",
|
||||
"openvex",
|
||||
new VexProofIssuer("vendor", IssuerCategory.Vendor, TrustTier.Trusted),
|
||||
VexStatus.NotAffected,
|
||||
null,
|
||||
new VexProofWeight(0.85m, new VexProofWeightFactors(0.90m, 1.0m, 0.95m, 1.0m, 0.70m)),
|
||||
_fixedTime.AddDays(-10),
|
||||
true)
|
||||
.WithWeightSpread(0.85m)
|
||||
.WithFinalStatus(VexStatus.NotAffected)
|
||||
.Build();
|
||||
|
||||
// Note: ProofId contains random component, so we compute digest manually
|
||||
var digest = VexProofSerializer.ComputeDigest(proof with { ProofId = "proof-fixed" });
|
||||
digests.Add(digest);
|
||||
}
|
||||
|
||||
// All digests should be identical
|
||||
digests.Distinct().Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalJson_IsDeterministic_WithSameInputs()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(_fixedTime);
|
||||
var proof1 = BuildDeterministicProof(timeProvider, "proof-fixed");
|
||||
var proof2 = BuildDeterministicProof(timeProvider, "proof-fixed");
|
||||
|
||||
var canonical1 = VexProofSerializer.SerializeCanonical(proof1);
|
||||
var canonical2 = VexProofSerializer.SerializeCanonical(proof2);
|
||||
|
||||
canonical1.Should().Be(canonical2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Digest_ChangesWithDifferentInputs()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(_fixedTime);
|
||||
|
||||
var proof1 = new VexProofBuilder(timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected)
|
||||
.Build();
|
||||
|
||||
var proof2 = new VexProofBuilder(timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.22") // Different version
|
||||
.WithFinalStatus(VexStatus.NotAffected)
|
||||
.Build();
|
||||
|
||||
var digest1 = VexProofSerializer.ComputeDigest(proof1 with { ProofId = "proof-fixed" });
|
||||
var digest2 = VexProofSerializer.ComputeDigest(proof2 with { ProofId = "proof-fixed" });
|
||||
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
|
||||
private VexProof BuildDeterministicProof(TimeProvider timeProvider, string proofId)
|
||||
{
|
||||
var proof = new VexProofBuilder(timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithContext("linux/amd64", null, ["esm"], null, _fixedTime)
|
||||
.AddStatement(
|
||||
"stmt-001",
|
||||
"openvex",
|
||||
new VexProofIssuer("vendor", IssuerCategory.Vendor, TrustTier.Trusted),
|
||||
VexStatus.NotAffected,
|
||||
VexJustification.VulnerableCodeNotInExecutePath,
|
||||
new VexProofWeight(0.85m, new VexProofWeightFactors(0.90m, 1.0m, 0.95m, 1.0m, 0.70m)),
|
||||
_fixedTime.AddDays(-10),
|
||||
true)
|
||||
.WithWeightSpread(0.85m)
|
||||
.WithFinalStatus(VexStatus.NotAffected, VexJustification.VulnerableCodeNotInExecutePath)
|
||||
.Build();
|
||||
|
||||
return proof with { ProofId = proofId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260102_003_BE_vex_proof_objects
|
||||
// Tasks: VP-026
|
||||
//
|
||||
// NOTE: True shuffle-determinism (same digest regardless of input order) requires
|
||||
// internal normalization/sorting in VexProofBuilder and injected ID generators.
|
||||
// These tests validate current determinism guarantees:
|
||||
// - Same inputs in same order -> same digest
|
||||
// - Order preservation in outputs
|
||||
// Full shuffle-determinism is tracked as a future enhancement (VP-XXX).
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Proof;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Proof;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for VEX proof determinism and order preservation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class VexProofShuffleDeterminismTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _fixedTime = new(2026, 1, 3, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
public VexProofShuffleDeterminismTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(_fixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofDigest_IsNotNull_WhenBuilt()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Digest.Should().NotBeNullOrEmpty();
|
||||
proof.Digest.Should().HaveLength(64); // SHA-256 hex
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Statements_MaintainInsertionOrder_InOutputProof()
|
||||
{
|
||||
// Arrange - add statements in specific order
|
||||
var stmt1 = CreateStatement("stmt-001", VexStatus.NotAffected, 0.85m);
|
||||
var stmt2 = CreateStatement("stmt-002", VexStatus.Affected, 0.60m);
|
||||
var stmt3 = CreateStatement("stmt-003", VexStatus.Fixed, 0.70m);
|
||||
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
builder.AddStatement(stmt1.Id, stmt1.Source, stmt1.Issuer, stmt1.Status, stmt1.Justification, stmt1.Weight, stmt1.Timestamp, stmt1.SignatureVerified);
|
||||
builder.AddStatement(stmt2.Id, stmt2.Source, stmt2.Issuer, stmt2.Status, stmt2.Justification, stmt2.Weight, stmt2.Timestamp, stmt2.SignatureVerified);
|
||||
builder.AddStatement(stmt3.Id, stmt3.Source, stmt3.Issuer, stmt3.Status, stmt3.Justification, stmt3.Weight, stmt3.Timestamp, stmt3.SignatureVerified);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert - statements should preserve insertion order
|
||||
proof.Inputs.Statements.Should().HaveCount(3);
|
||||
proof.Inputs.Statements[0].Id.Should().Be("stmt-001");
|
||||
proof.Inputs.Statements[1].Id.Should().Be("stmt-002");
|
||||
proof.Inputs.Statements[2].Id.Should().Be("stmt-003");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeSteps_MaintainInsertionOrder_InOutputProof()
|
||||
{
|
||||
// Arrange - add merge steps in specific order
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithConsensusMode(ConsensusMode.Lattice)
|
||||
.WithLatticeOrdering([VexStatus.NotAffected, VexStatus.Fixed, VexStatus.Affected])
|
||||
.AddMergeStep(1, "stmt-001", VexStatus.NotAffected, 0.85m, MergeAction.Initialize, false, null, VexStatus.NotAffected)
|
||||
.AddMergeStep(2, "stmt-002", VexStatus.Affected, 0.60m, MergeAction.Merge, true, "weight_based", VexStatus.NotAffected)
|
||||
.AddMergeStep(3, "stmt-003", VexStatus.Fixed, 0.70m, MergeAction.Merge, false, null, VexStatus.NotAffected)
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert - merge steps should preserve insertion order
|
||||
proof.Resolution.LatticeComputation.Should().NotBeNull();
|
||||
proof.Resolution.LatticeComputation!.MergeSteps.Should().HaveCount(3);
|
||||
proof.Resolution.LatticeComputation.MergeSteps[0].Step.Should().Be(1);
|
||||
proof.Resolution.LatticeComputation.MergeSteps[1].Step.Should().Be(2);
|
||||
proof.Resolution.LatticeComputation.MergeSteps[2].Step.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionResults_MaintainInsertionOrder_InOutputProof()
|
||||
{
|
||||
// Arrange - add condition results
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.AddConditionResult("cond-3", "feature:esm", ConditionOutcome.True, "esm")
|
||||
.AddConditionResult("cond-1", "platform:linux/amd64", ConditionOutcome.True, "linux/amd64")
|
||||
.AddConditionResult("cond-2", "distro:rhel:9", ConditionOutcome.Unknown, null)
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert - condition results should preserve insertion order
|
||||
proof.Conditions.Should().NotBeNull();
|
||||
proof.Conditions!.Evaluated.Should().HaveCount(3);
|
||||
proof.Conditions.Evaluated[0].ConditionId.Should().Be("cond-3");
|
||||
proof.Conditions.Evaluated[1].ConditionId.Should().Be("cond-1");
|
||||
proof.Conditions.Evaluated[2].ConditionId.Should().Be("cond-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphPaths_MaintainInsertionOrder_InOutputProof()
|
||||
{
|
||||
// Arrange - add graph paths
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/my-app@1.0.0")
|
||||
.AddGraphPath("pkg:npm/my-app@1.0.0", ["pkg:npm/lodash@4.17.21", "pkg:npm/minimist@1.2.0"], DependencyPathType.TransitiveDependency, 2)
|
||||
.AddGraphPath("pkg:npm/my-app@1.0.0", ["pkg:npm/lodash@4.17.21"], DependencyPathType.DirectDependency, 1)
|
||||
.WithFinalStatus(VexStatus.Affected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert - graph paths should preserve insertion order
|
||||
proof.Propagation.Should().NotBeNull();
|
||||
proof.Propagation!.GraphPaths.Should().HaveCount(2);
|
||||
proof.Propagation.GraphPaths[0].PathType.Should().Be(DependencyPathType.TransitiveDependency);
|
||||
proof.Propagation.GraphPaths[1].PathType.Should().Be(DependencyPathType.DirectDependency);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conflicts_MaintainInsertionOrder_InOutputProof()
|
||||
{
|
||||
// Arrange - add conflicts
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.AddConflict("stmt-001", "stmt-002", VexStatus.NotAffected, VexStatus.Affected, ConflictSeverity.High, "weight_based", "stmt-001")
|
||||
.AddConflict("stmt-003", "stmt-004", VexStatus.Fixed, VexStatus.Affected, ConflictSeverity.Medium, "precedence", "stmt-003")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert - conflicts should preserve insertion order
|
||||
proof.Resolution.ConflictAnalysis.Conflicts.Should().HaveCount(2);
|
||||
proof.Resolution.ConflictAnalysis.Conflicts[0].StatementA.Should().Be("stmt-001");
|
||||
proof.Resolution.ConflictAnalysis.Conflicts[1].StatementA.Should().Be("stmt-003");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropagationRules_MaintainInsertionOrder_InOutputProof()
|
||||
{
|
||||
// Arrange - add propagation rules
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.AddPropagationRule("rule-001", "Transitive propagation", true, "inherited_affected")
|
||||
.AddPropagationRule("rule-002", "Direct dependency override", false, null)
|
||||
.WithFinalStatus(VexStatus.Affected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert - propagation rules should preserve insertion order
|
||||
proof.Propagation.Should().NotBeNull();
|
||||
proof.Propagation!.Rules.Should().HaveCount(2);
|
||||
proof.Propagation.Rules[0].RuleId.Should().Be("rule-001");
|
||||
proof.Propagation.Rules[1].RuleId.Should().Be("rule-002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofId_ContainsTimestampComponent()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert - proof ID should contain timestamp component
|
||||
proof.ProofId.Should().StartWith("proof-");
|
||||
proof.ProofId.Should().Contain("2026-01-03");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputedAt_UsesInjectedTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert - computed time should match the fake time provider
|
||||
proof.ComputedAt.Should().Be(_fixedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualifiedAndDisqualifiedCounts_AreTrackedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var stmt1 = CreateStatement("stmt-001", VexStatus.NotAffected, 0.85m);
|
||||
var stmt2 = CreateStatement("stmt-002", VexStatus.Affected, 0.60m);
|
||||
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
builder.AddStatement(stmt1.Id, stmt1.Source, stmt1.Issuer, stmt1.Status, stmt1.Justification, stmt1.Weight, stmt1.Timestamp, stmt1.SignatureVerified);
|
||||
builder.AddDisqualifiedStatement(stmt2.Id, stmt2.Source, stmt2.Issuer, stmt2.Status, stmt2.Justification, stmt2.Weight, stmt2.Timestamp, stmt2.SignatureVerified, "outdated");
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Resolution.QualifiedStatements.Should().Be(1);
|
||||
proof.Resolution.DisqualifiedStatements.Should().Be(1);
|
||||
proof.Resolution.DisqualificationReasons.Should().Contain("outdated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verdict_ContainsCorrectVulnerabilityAndProduct()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected, VexJustification.VulnerableCodeNotPresent);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
proof.Verdict.VulnerabilityId.Should().Be("CVE-2023-12345");
|
||||
proof.Verdict.ProductKey.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
proof.Verdict.Status.Should().Be(VexStatus.NotAffected);
|
||||
proof.Verdict.Justification.Should().Be(VexJustification.VulnerableCodeNotPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchemaVersion_IsIncluded()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new VexProofBuilder(_timeProvider)
|
||||
.ForVulnerability("CVE-2023-12345", "pkg:npm/lodash@4.17.21")
|
||||
.WithFinalStatus(VexStatus.NotAffected);
|
||||
|
||||
// Act
|
||||
var proof = builder.Build();
|
||||
|
||||
// Assert
|
||||
VexProof.SchemaVersion.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private StatementData CreateStatement(string id, VexStatus status, decimal weight)
|
||||
{
|
||||
return new StatementData(
|
||||
Id: id,
|
||||
Source: "openvex",
|
||||
Issuer: new VexProofIssuer("test-vendor", IssuerCategory.Vendor, TrustTier.Trusted),
|
||||
Status: status,
|
||||
Justification: status == VexStatus.NotAffected ? VexJustification.VulnerableCodeNotPresent : null,
|
||||
Weight: new VexProofWeight(weight, new VexProofWeightFactors(weight, 1.0m, 0.9m, 1.0m, 0.8m)),
|
||||
Timestamp: _fixedTime.AddDays(-1),
|
||||
SignatureVerified: true);
|
||||
}
|
||||
|
||||
private sealed record StatementData(
|
||||
string Id,
|
||||
string Source,
|
||||
VexProofIssuer Issuer,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
VexProofWeight Weight,
|
||||
DateTimeOffset Timestamp,
|
||||
bool SignatureVerified);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260102_003_BE_vex_proof_objects
|
||||
// Tasks: VP-024
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Proof;
|
||||
using StellaOps.VexLens.Propagation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Propagation;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PropagationRuleEngine and individual propagation rules.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class PropagationRuleEngineTests
|
||||
{
|
||||
private readonly PropagationRuleEngine _engine = new();
|
||||
private readonly PropagationPolicy _defaultPolicy = new(
|
||||
EnableTransitivePropagation: true,
|
||||
InheritAffectedFromDirectDependency: true,
|
||||
InheritNotAffectedFromLeafDependency: false,
|
||||
RequireExplicitOverride: false,
|
||||
MaxTransitiveDepth: 5,
|
||||
ExcludedScopes: []);
|
||||
|
||||
[Fact]
|
||||
public void Propagate_AppliesRules_WhenComponentHasDependencies()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var verdict = new ComponentVerdict(
|
||||
VulnerabilityId: "CVE-2024-1234",
|
||||
ComponentKey: "pkg:npm/my-app@1.0.0",
|
||||
Status: VexStatus.NotAffected,
|
||||
Justification: null,
|
||||
Confidence: 0.9m);
|
||||
|
||||
// Act
|
||||
var result = _engine.Propagate(verdict, graph, _defaultPolicy);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.RuleResults.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Propagate_ReturnsValidResult_WhenNoDependencyPaths()
|
||||
{
|
||||
// Arrange - component with no dependencies
|
||||
var graph = new TestDependencyGraph(new Dictionary<string, IReadOnlyList<DependencyEdge>>());
|
||||
var verdict = new ComponentVerdict(
|
||||
VulnerabilityId: "CVE-2024-1234",
|
||||
ComponentKey: "pkg:npm/standalone@1.0.0",
|
||||
Status: VexStatus.NotAffected,
|
||||
Justification: VexJustification.VulnerableCodeNotPresent,
|
||||
Confidence: 0.95m);
|
||||
|
||||
// Act
|
||||
var result = _engine.Propagate(verdict, graph, _defaultPolicy);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.AnalyzedPaths.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Propagate_RespectsMaxTransitiveDepth()
|
||||
{
|
||||
// Arrange - deep dependency chain
|
||||
var graph = CreateDeepGraph(10);
|
||||
var verdict = new ComponentVerdict(
|
||||
VulnerabilityId: "CVE-2024-1234",
|
||||
ComponentKey: "pkg:npm/root@1.0.0",
|
||||
Status: VexStatus.NotAffected,
|
||||
Justification: null,
|
||||
Confidence: 0.8m);
|
||||
var policy = _defaultPolicy with { MaxTransitiveDepth = 3 };
|
||||
|
||||
// Act
|
||||
var result = _engine.Propagate(verdict, graph, policy);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
// Paths beyond depth 3 should not be analyzed
|
||||
result.AnalyzedPaths.Should().OnlyContain(p => p.Depth <= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Propagate_ExcludesSpecifiedScopes()
|
||||
{
|
||||
// Arrange - graph with development dependencies
|
||||
var graph = CreateGraphWithScopes();
|
||||
var verdict = new ComponentVerdict(
|
||||
VulnerabilityId: "CVE-2024-1234",
|
||||
ComponentKey: "pkg:npm/my-app@1.0.0",
|
||||
Status: VexStatus.NotAffected,
|
||||
Justification: null,
|
||||
Confidence: 0.85m);
|
||||
var policy = _defaultPolicy with { ExcludedScopes = [DependencyScope.Development] };
|
||||
|
||||
// Act
|
||||
var result = _engine.Propagate(verdict, graph, policy);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
// Development dependencies should be excluded
|
||||
result.AnalyzedPaths.Should().NotContain(p => p.Scope == DependencyScope.Development);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Propagate_DisablesPropagation_WhenPolicyDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var verdict = new ComponentVerdict(
|
||||
VulnerabilityId: "CVE-2024-1234",
|
||||
ComponentKey: "pkg:npm/my-app@1.0.0",
|
||||
Status: VexStatus.Affected,
|
||||
Justification: null,
|
||||
Confidence: 0.7m);
|
||||
var policy = _defaultPolicy with { EnableTransitivePropagation = false };
|
||||
|
||||
// Act
|
||||
var result = _engine.Propagate(verdict, graph, policy);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
// When disabled, transitive rules should not be triggered
|
||||
result.RuleResults
|
||||
.Where(r => r.RuleId.Contains("transitive", StringComparison.OrdinalIgnoreCase))
|
||||
.Should().OnlyContain(r => !r.Triggered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Propagate_HandlesCircularDependencies()
|
||||
{
|
||||
// Arrange - circular graph: A -> B -> C -> A
|
||||
var graph = CreateCircularGraph();
|
||||
var verdict = new ComponentVerdict(
|
||||
VulnerabilityId: "CVE-2024-1234",
|
||||
ComponentKey: "pkg:npm/a@1.0.0",
|
||||
Status: VexStatus.NotAffected,
|
||||
Justification: null,
|
||||
Confidence: 0.75m);
|
||||
|
||||
// Act - should not hang or stack overflow
|
||||
var result = _engine.Propagate(verdict, graph, _defaultPolicy);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultRules_AreOrderedByPriority()
|
||||
{
|
||||
// Arrange
|
||||
var rules = PropagationRuleEngine.GetDefaultRules().ToList();
|
||||
|
||||
// Assert - rules should be ordered by priority (lower = higher priority)
|
||||
rules.Should().BeInAscendingOrder(r => r.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRules_ReturnsImmutableCollection()
|
||||
{
|
||||
// Act
|
||||
var rules = _engine.GetRules();
|
||||
|
||||
// Assert
|
||||
rules.Should().NotBeEmpty();
|
||||
rules.Should().BeInAscendingOrder(r => r.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropagationResult_ContainsRuleResults()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var verdict = new ComponentVerdict(
|
||||
VulnerabilityId: "CVE-2024-1234",
|
||||
ComponentKey: "pkg:npm/my-app@1.0.0",
|
||||
Status: VexStatus.Affected,
|
||||
Justification: null,
|
||||
Confidence: 0.9m);
|
||||
|
||||
// Act
|
||||
var result = _engine.Propagate(verdict, graph, _defaultPolicy);
|
||||
|
||||
// Assert
|
||||
result.RuleResults.Should().NotBeEmpty();
|
||||
result.RuleResults.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.RuleId.Should().NotBeNullOrEmpty();
|
||||
r.Description.Should().NotBeNullOrEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static TestDependencyGraph CreateSimpleGraph()
|
||||
{
|
||||
// my-app -> lodash
|
||||
const string myApp = "pkg:npm/my-app@1.0.0";
|
||||
const string lodash = "pkg:npm/lodash@4.17.21";
|
||||
return new TestDependencyGraph(new Dictionary<string, IReadOnlyList<DependencyEdge>>
|
||||
{
|
||||
[myApp] = [new DependencyEdge(myApp, lodash, DependencyPathType.DirectDependency, DependencyScope.Runtime)]
|
||||
});
|
||||
}
|
||||
|
||||
private static TestDependencyGraph CreateDeepGraph(int depth)
|
||||
{
|
||||
var edges = new Dictionary<string, IReadOnlyList<DependencyEdge>>();
|
||||
for (int i = 0; i < depth; i++)
|
||||
{
|
||||
var from = i == 0 ? "pkg:npm/root@1.0.0" : $"pkg:npm/deep-dep-{i - 1}@1.0.0";
|
||||
var to = $"pkg:npm/deep-dep-{i}@1.0.0";
|
||||
var pathType = i == 0 ? DependencyPathType.DirectDependency : DependencyPathType.TransitiveDependency;
|
||||
edges[from] = [new DependencyEdge(from, to, pathType, DependencyScope.Runtime)];
|
||||
}
|
||||
return new TestDependencyGraph(edges);
|
||||
}
|
||||
|
||||
private static TestDependencyGraph CreateCircularGraph()
|
||||
{
|
||||
// A -> B -> C -> A (circular)
|
||||
const string a = "pkg:npm/a@1.0.0";
|
||||
const string b = "pkg:npm/b@1.0.0";
|
||||
const string c = "pkg:npm/c@1.0.0";
|
||||
return new TestDependencyGraph(new Dictionary<string, IReadOnlyList<DependencyEdge>>
|
||||
{
|
||||
[a] = [new DependencyEdge(a, b, DependencyPathType.DirectDependency, DependencyScope.Runtime)],
|
||||
[b] = [new DependencyEdge(b, c, DependencyPathType.DirectDependency, DependencyScope.Runtime)],
|
||||
[c] = [new DependencyEdge(c, a, DependencyPathType.DirectDependency, DependencyScope.Runtime)]
|
||||
});
|
||||
}
|
||||
|
||||
private static TestDependencyGraph CreateGraphWithScopes()
|
||||
{
|
||||
// my-app -> lodash (runtime) + jest (development)
|
||||
const string myApp = "pkg:npm/my-app@1.0.0";
|
||||
const string lodash = "pkg:npm/lodash@4.17.21";
|
||||
const string jest = "pkg:npm/jest@29.0.0";
|
||||
return new TestDependencyGraph(new Dictionary<string, IReadOnlyList<DependencyEdge>>
|
||||
{
|
||||
[myApp] =
|
||||
[
|
||||
new DependencyEdge(myApp, lodash, DependencyPathType.DirectDependency, DependencyScope.Runtime),
|
||||
new DependencyEdge(myApp, jest, DependencyPathType.DirectDependency, DependencyScope.Development)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of IDependencyGraph.
|
||||
/// </summary>
|
||||
internal sealed class TestDependencyGraph : IDependencyGraph
|
||||
{
|
||||
private readonly Dictionary<string, IReadOnlyList<DependencyEdge>> _edges;
|
||||
|
||||
public TestDependencyGraph(Dictionary<string, IReadOnlyList<DependencyEdge>> edges)
|
||||
{
|
||||
_edges = edges;
|
||||
}
|
||||
|
||||
public IEnumerable<DependencyEdge> GetDirectDependencies(string componentKey)
|
||||
{
|
||||
return _edges.TryGetValue(componentKey, out var deps) ? deps : [];
|
||||
}
|
||||
|
||||
public IEnumerable<DependencyEdge> GetDependents(string componentKey)
|
||||
{
|
||||
// Find all components that have this component as a dependency
|
||||
foreach (var (source, edges) in _edges)
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (edge.To == componentKey)
|
||||
{
|
||||
yield return new DependencyEdge(source, componentKey, edge.PathType, edge.Scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<DependencyPath> GetPathsTo(string componentKey)
|
||||
{
|
||||
var paths = new List<DependencyPath>();
|
||||
var visited = new HashSet<string>();
|
||||
|
||||
foreach (var root in GetRoots())
|
||||
{
|
||||
FindPathsFrom(root, componentKey, [], visited, paths, DependencyScope.Runtime);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
public int GetDepth(string componentKey)
|
||||
{
|
||||
var paths = GetPathsTo(componentKey).ToList();
|
||||
return paths.Count > 0 ? paths.Min(p => p.Depth) : 0;
|
||||
}
|
||||
|
||||
public bool IsLeaf(string componentKey)
|
||||
{
|
||||
return !GetDirectDependencies(componentKey).Any();
|
||||
}
|
||||
|
||||
public bool IsRoot(string componentKey)
|
||||
{
|
||||
return !GetDependents(componentKey).Any();
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetRoots()
|
||||
{
|
||||
var allComponents = _edges.Keys.ToHashSet();
|
||||
foreach (var edges in _edges.Values)
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
allComponents.Add(edge.To);
|
||||
}
|
||||
}
|
||||
|
||||
return allComponents.Where(IsRoot);
|
||||
}
|
||||
|
||||
private void FindPathsFrom(
|
||||
string current,
|
||||
string target,
|
||||
List<string> currentPath,
|
||||
HashSet<string> visited,
|
||||
List<DependencyPath> results,
|
||||
DependencyScope scope)
|
||||
{
|
||||
if (visited.Contains(current))
|
||||
return;
|
||||
|
||||
visited.Add(current);
|
||||
currentPath.Add(current);
|
||||
|
||||
if (current == target && currentPath.Count > 1)
|
||||
{
|
||||
var pathType = currentPath.Count == 2
|
||||
? DependencyPathType.DirectDependency
|
||||
: DependencyPathType.TransitiveDependency;
|
||||
|
||||
results.Add(new DependencyPath(
|
||||
currentPath[0],
|
||||
[.. currentPath.Skip(1)],
|
||||
pathType,
|
||||
currentPath.Count - 1,
|
||||
scope));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var edge in GetDirectDependencies(current))
|
||||
{
|
||||
FindPathsFrom(edge.To, target, [.. currentPath], new HashSet<string>(visited), results, edge.Scope);
|
||||
}
|
||||
}
|
||||
|
||||
currentPath.RemoveAt(currentPath.Count - 1);
|
||||
visited.Remove(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.VexLens.Tests</RootNamespace>
|
||||
<AssemblyName>StellaOps.VexLens.Tests</AssemblyName>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.VexLens.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,851 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexLensTruthTableTests.cs
|
||||
// Sprint: SPRINT_20251229_004_003_BE_vexlens_truth_tables
|
||||
// Tasks: VTT-001 through VTT-009
|
||||
// Comprehensive truth table tests for VexLens lattice merge operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Consensus;
|
||||
|
||||
/// <summary>
|
||||
/// Systematic truth table tests for VexLens consensus engine.
|
||||
/// Verifies lattice merge correctness, conflict detection, and determinism.
|
||||
///
|
||||
/// VEX Status Lattice:
|
||||
/// ┌─────────┐
|
||||
/// │ fixed │ (terminal)
|
||||
/// └────▲────┘
|
||||
/// │
|
||||
/// ┌───────────────┼───────────────┐
|
||||
/// │ │ │
|
||||
/// ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
|
||||
/// │not_affected│ │ affected │ │ (tie) │
|
||||
/// └─────▲─────┘ └─────▲─────┘ └───────────┘
|
||||
/// │ │
|
||||
/// └───────┬───────┘
|
||||
/// │
|
||||
/// ┌───────▼───────┐
|
||||
/// │under_investigation│
|
||||
/// └───────▲───────┘
|
||||
/// │
|
||||
/// ┌───────▼───────┐
|
||||
/// │ unknown │ (bottom)
|
||||
/// └───────────────┘
|
||||
/// </summary>
|
||||
[Trait("Category", "Determinism")]
|
||||
[Trait("Category", "Golden")]
|
||||
public class VexLensTruthTableTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
#region Single Issuer Identity Tests (VTT-001 to VTT-005)
|
||||
|
||||
/// <summary>
|
||||
/// Test data for single issuer identity cases.
|
||||
/// A single VEX statement should return its status unchanged.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatus, VexStatus> SingleIssuerCases => new()
|
||||
{
|
||||
{ "TT-001", VexStatus.Unknown, VexStatus.Unknown },
|
||||
{ "TT-002", VexStatus.UnderInvestigation, VexStatus.UnderInvestigation },
|
||||
{ "TT-003", VexStatus.Affected, VexStatus.Affected },
|
||||
{ "TT-004", VexStatus.NotAffected, VexStatus.NotAffected },
|
||||
{ "TT-005", VexStatus.Fixed, VexStatus.Fixed }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SingleIssuerCases))]
|
||||
public void SingleIssuer_ReturnsIdentity(string testId, VexStatus input, VexStatus expected)
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateStatement("issuer-a", input);
|
||||
var statements = new[] { statement };
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(expected, because: $"{testId}: single issuer should return identity");
|
||||
result.Conflicts.Should().BeEmpty(because: "single issuer cannot have conflicts");
|
||||
result.StatementCount.Should().Be(1);
|
||||
result.ConfidenceScore.Should().BeGreaterOrEqualTo(0.8m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Two Issuer Merge Tests (VTT-010 to VTT-019)
|
||||
|
||||
/// <summary>
|
||||
/// Test data for two issuers at the same trust tier.
|
||||
/// Tests lattice join operation and conflict detection.
|
||||
///
|
||||
/// EDGE CASE: Affected and NotAffected are at the SAME lattice level.
|
||||
/// When both appear at the same trust tier, this creates a conflict.
|
||||
/// The system conservatively chooses 'affected' and records the conflict.
|
||||
///
|
||||
/// EDGE CASE: Fixed is lattice terminal (top).
|
||||
/// Any statement with 'fixed' status will win, regardless of other statuses.
|
||||
///
|
||||
/// EDGE CASE: Unknown is lattice bottom.
|
||||
/// Unknown never wins when merged with any other status.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatus, VexStatus, VexStatus, bool> TwoIssuerMergeCases => new()
|
||||
{
|
||||
// Both unknown → unknown (lattice bottom)
|
||||
{ "TT-010", VexStatus.Unknown, VexStatus.Unknown, VexStatus.Unknown, false },
|
||||
|
||||
// Unknown merges up the lattice
|
||||
{ "TT-011", VexStatus.Unknown, VexStatus.Affected, VexStatus.Affected, false },
|
||||
{ "TT-012", VexStatus.Unknown, VexStatus.NotAffected, VexStatus.NotAffected, false },
|
||||
|
||||
// CONFLICT: Affected vs NotAffected at same level (must record)
|
||||
{ "TT-013", VexStatus.Affected, VexStatus.NotAffected, VexStatus.Affected, true },
|
||||
|
||||
// Fixed wins (lattice top)
|
||||
{ "TT-014", VexStatus.Affected, VexStatus.Fixed, VexStatus.Fixed, false },
|
||||
{ "TT-015", VexStatus.NotAffected, VexStatus.Fixed, VexStatus.Fixed, false },
|
||||
|
||||
// Under investigation merges up
|
||||
{ "TT-016", VexStatus.UnderInvestigation, VexStatus.Affected, VexStatus.Affected, false },
|
||||
{ "TT-017", VexStatus.UnderInvestigation, VexStatus.NotAffected, VexStatus.NotAffected, false },
|
||||
|
||||
// Same status → same status
|
||||
{ "TT-018", VexStatus.Affected, VexStatus.Affected, VexStatus.Affected, false },
|
||||
{ "TT-019", VexStatus.NotAffected, VexStatus.NotAffected, VexStatus.NotAffected, false }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TwoIssuerMergeCases))]
|
||||
public void TwoIssuers_SameTier_MergesCorrectly(
|
||||
string testId,
|
||||
VexStatus statusA,
|
||||
VexStatus statusB,
|
||||
VexStatus expected,
|
||||
bool expectConflict)
|
||||
{
|
||||
// Arrange
|
||||
var statementA = CreateStatement("issuer-a", statusA, trustTier: 90);
|
||||
var statementB = CreateStatement("issuer-b", statusB, trustTier: 90);
|
||||
var statements = new[] { statementA, statementB };
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(expected, because: $"{testId}: lattice merge should produce expected status");
|
||||
result.Conflicts.Any().Should().Be(expectConflict, because: $"{testId}: conflict detection must be accurate");
|
||||
result.StatementCount.Should().Be(2);
|
||||
|
||||
if (expectConflict)
|
||||
{
|
||||
result.Conflicts.Should().HaveCount(1, because: "should record the conflict");
|
||||
result.ConflictCount.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Trust Tier Precedence Tests (VTT-020 to VTT-022)
|
||||
|
||||
/// <summary>
|
||||
/// Test data for trust tier precedence.
|
||||
/// Higher tier statements should take precedence over lower tier.
|
||||
///
|
||||
/// EDGE CASE: Trust tier filtering happens BEFORE lattice merge.
|
||||
/// Only the highest tier statements are considered for merging.
|
||||
/// Lower tier statements are completely ignored, even if they would
|
||||
/// produce a different result via lattice merge.
|
||||
///
|
||||
/// EDGE CASE: Trust tier hierarchy (Distro=100, Vendor=90, Community=50).
|
||||
/// Distro-level security trackers have absolute authority over vendor advisories.
|
||||
/// This ensures that distribution-specific backports and patches are respected.
|
||||
///
|
||||
/// EDGE CASE: When high tier says 'unknown', low tier can provide information.
|
||||
/// If the highest tier has no data (unknown), the next tier is consulted.
|
||||
/// This cascading behavior prevents data loss when authoritative sources
|
||||
/// haven't analyzed a CVE yet.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatus, int, VexStatus, int, VexStatus> TrustTierCases => new()
|
||||
{
|
||||
// High tier (100) beats low tier (50)
|
||||
{ "TT-020", VexStatus.Affected, 100, VexStatus.NotAffected, 50, VexStatus.Affected },
|
||||
{ "TT-021", VexStatus.NotAffected, 100, VexStatus.Affected, 50, VexStatus.NotAffected },
|
||||
|
||||
// Low tier fills in when high tier is unknown
|
||||
{ "TT-022", VexStatus.Unknown, 100, VexStatus.Affected, 50, VexStatus.Affected }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TrustTierCases))]
|
||||
public void TrustTier_HigherPrecedence_WinsConflicts(
|
||||
string testId,
|
||||
VexStatus highStatus,
|
||||
int highTier,
|
||||
VexStatus lowStatus,
|
||||
int lowTier,
|
||||
VexStatus expected)
|
||||
{
|
||||
// Arrange
|
||||
var highTierStmt = CreateStatement("high-tier-issuer", highStatus, trustTier: highTier);
|
||||
var lowTierStmt = CreateStatement("low-tier-issuer", lowStatus, trustTier: lowTier);
|
||||
var statements = new[] { highTierStmt, lowTierStmt };
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(expected, because: $"{testId}: higher trust tier should win");
|
||||
result.StatementCount.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Justification Impact Tests (VTT-030 to VTT-033)
|
||||
|
||||
/// <summary>
|
||||
/// Test data for justification impact on confidence scores.
|
||||
/// Justifications affect confidence but not status.
|
||||
///
|
||||
/// EDGE CASE: Justifications NEVER change the consensus status.
|
||||
/// They only modulate the confidence score. A well-justified 'not_affected'
|
||||
/// is still 'not_affected', just with higher confidence.
|
||||
///
|
||||
/// EDGE CASE: Justification hierarchy for not_affected:
|
||||
/// 1. component_not_present (0.95+) - strongest, binary condition
|
||||
/// 2. vulnerable_code_not_in_execute_path (0.90+) - requires code analysis
|
||||
/// 3. inline_mitigations_already_exist (0.85+) - requires verification
|
||||
///
|
||||
/// EDGE CASE: Missing justification still has good confidence.
|
||||
/// An explicit 'affected' statement without justification is still 0.80+
|
||||
/// because the issuer made a clear determination.
|
||||
///
|
||||
/// EDGE CASE: Multiple justifications (future).
|
||||
/// If multiple statements have different justifications, the strongest
|
||||
/// justification determines the final confidence score.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatus, string?, decimal> JustificationConfidenceCases => new()
|
||||
{
|
||||
// Strong justifications → high confidence
|
||||
{ "TT-030", VexStatus.NotAffected, "component_not_present", 0.95m },
|
||||
{ "TT-031", VexStatus.NotAffected, "vulnerable_code_not_in_execute_path", 0.90m },
|
||||
{ "TT-032", VexStatus.NotAffected, "inline_mitigations_already_exist", 0.85m },
|
||||
|
||||
// No justification → still high confidence (explicit statement)
|
||||
{ "TT-033", VexStatus.Affected, null, 0.80m }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(JustificationConfidenceCases))]
|
||||
public void Justification_AffectsConfidence_NotStatus(
|
||||
string testId,
|
||||
VexStatus status,
|
||||
string? justification,
|
||||
decimal minConfidence)
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateStatement("issuer-a", status, justification: justification);
|
||||
var statements = new[] { statement };
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(status, because: $"{testId}: justification should not change status");
|
||||
result.ConfidenceScore.Should().BeGreaterOrEqualTo(minConfidence, because: $"{testId}: justification impacts confidence");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests (VTT-006)
|
||||
|
||||
/// <summary>
|
||||
/// EDGE CASE: Determinism is CRITICAL for reproducible vulnerability assessment.
|
||||
/// Same inputs must ALWAYS produce byte-for-byte identical outputs.
|
||||
/// Any non-determinism breaks audit trails and makes replay impossible.
|
||||
///
|
||||
/// EDGE CASE: Statement order independence.
|
||||
/// The consensus algorithm must be commutative. Processing statements
|
||||
/// in different orders must yield the same result. This is tested by
|
||||
/// shuffling statement arrays and verifying identical consensus.
|
||||
///
|
||||
/// EDGE CASE: Floating point determinism.
|
||||
/// Confidence scores use decimal (not double/float) to ensure
|
||||
/// bit-exact reproducibility across platforms and CPU architectures.
|
||||
///
|
||||
/// EDGE CASE: Hash-based conflict detection must be stable.
|
||||
/// When recording conflicts, issuer IDs are sorted lexicographically
|
||||
/// to ensure deterministic JSON serialization.
|
||||
///
|
||||
/// EDGE CASE: Timestamp normalization.
|
||||
/// All timestamps are normalized to UTC ISO-8601 format to prevent
|
||||
/// timezone-related non-determinism in serialized output.
|
||||
/// </summary>
|
||||
|
||||
[Fact]
|
||||
public void SameInputs_ProducesIdenticalOutput_Across10Iterations()
|
||||
{
|
||||
// Arrange: Create conflicting statements
|
||||
var statements = new[]
|
||||
{
|
||||
CreateStatement("vendor-a", VexStatus.Affected, trustTier: 90),
|
||||
CreateStatement("vendor-b", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("distro-security", VexStatus.Fixed, trustTier: 100)
|
||||
};
|
||||
|
||||
var results = new List<string>();
|
||||
|
||||
// Act: Compute consensus 10 times
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var result = ComputeConsensus(statements);
|
||||
var canonical = JsonSerializer.Serialize(result, CanonicalOptions);
|
||||
results.Add(canonical);
|
||||
}
|
||||
|
||||
// Assert: All results should be byte-for-byte identical
|
||||
results.Distinct().Should().HaveCount(1, because: "determinism: all iterations must produce identical JSON");
|
||||
|
||||
// Verify the result is fixed (highest tier + lattice top)
|
||||
var finalResult = ComputeConsensus(statements);
|
||||
finalResult.Status.Should().Be(VexStatus.Fixed, because: "fixed wins at lattice top");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatementOrder_DoesNotAffect_ConsensusOutcome()
|
||||
{
|
||||
// Arrange: Same statements in different orders
|
||||
var stmt1 = CreateStatement("issuer-1", VexStatus.Affected, trustTier: 90);
|
||||
var stmt2 = CreateStatement("issuer-2", VexStatus.NotAffected, trustTier: 90);
|
||||
var stmt3 = CreateStatement("issuer-3", VexStatus.UnderInvestigation, trustTier: 80);
|
||||
|
||||
var order1 = new[] { stmt1, stmt2, stmt3 };
|
||||
var order2 = new[] { stmt3, stmt1, stmt2 };
|
||||
var order3 = new[] { stmt2, stmt3, stmt1 };
|
||||
|
||||
// Act
|
||||
var result1 = ComputeConsensus(order1);
|
||||
var result2 = ComputeConsensus(order2);
|
||||
var result3 = ComputeConsensus(order3);
|
||||
|
||||
// Assert: All should produce identical results
|
||||
var json1 = JsonSerializer.Serialize(result1, CanonicalOptions);
|
||||
var json2 = JsonSerializer.Serialize(result2, CanonicalOptions);
|
||||
var json3 = JsonSerializer.Serialize(result3, CanonicalOptions);
|
||||
|
||||
json1.Should().Be(json2).And.Be(json3, because: "statement order must not affect consensus");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Conflict Detection Tests (VTT-004)
|
||||
|
||||
/// <summary>
|
||||
/// EDGE CASE: Conflict detection is not the same as disagreement.
|
||||
/// A conflict occurs when same-tier issuers provide statuses at the SAME lattice level.
|
||||
/// Example: Affected vs NotAffected = conflict (same level).
|
||||
/// Example: UnderInvestigation vs Affected = no conflict (hierarchical).
|
||||
///
|
||||
/// EDGE CASE: Conflicts must be recorded with ALL participating issuers.
|
||||
/// The consensus engine must track which issuers contributed to the conflict,
|
||||
/// not just the ones that "lost" the merge. This is critical for audit trails.
|
||||
///
|
||||
/// EDGE CASE: N-way conflicts (3+ issuers with different views).
|
||||
/// When three or more issuers at the same tier have different statuses,
|
||||
/// the system uses lattice merge (affected wins) and records all conflicts.
|
||||
///
|
||||
/// EDGE CASE: Unanimous agreement = zero conflicts.
|
||||
/// When all same-tier issuers agree, confidence increases to 0.95+
|
||||
/// and the conflict array remains empty.
|
||||
/// </summary>
|
||||
|
||||
[Fact]
|
||||
public void ThreeWayConflict_RecordsAllDisagreements()
|
||||
{
|
||||
// Arrange: Three issuers at same tier with different assessments
|
||||
var statements = new[]
|
||||
{
|
||||
CreateStatement("issuer-a", VexStatus.Affected, trustTier: 90),
|
||||
CreateStatement("issuer-b", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("issuer-c", VexStatus.UnderInvestigation, trustTier: 90)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert: Should record conflicts and use lattice merge
|
||||
result.Status.Should().Be(VexStatus.Affected, because: "affected wins in lattice");
|
||||
result.ConflictCount.Should().BeGreaterThan(0, because: "should detect conflicts");
|
||||
result.Conflicts.Should().NotBeEmpty(because: "should record conflicting issuers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoConflict_WhenStatementsAgree()
|
||||
{
|
||||
// Arrange: All issuers agree
|
||||
var statements = new[]
|
||||
{
|
||||
CreateStatement("issuer-a", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("issuer-b", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("issuer-c", VexStatus.NotAffected, trustTier: 90)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ComputeConsensus(statements);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(VexStatus.NotAffected);
|
||||
result.Conflicts.Should().BeEmpty(because: "all issuers agree");
|
||||
result.ConflictCount.Should().Be(0);
|
||||
result.ConfidenceScore.Should().BeGreaterOrEqualTo(0.95m, because: "unanimous agreement increases confidence");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Recorded Replay Tests (VTT-008)
|
||||
|
||||
/// <summary>
|
||||
/// Seed cases for deterministic replay verification.
|
||||
/// Each seed represents a real-world scenario that must produce stable results.
|
||||
/// </summary>
|
||||
public static TheoryData<string, VexStatement[], VexStatus> ReplaySeedCases => new()
|
||||
{
|
||||
// Seed 1: Distro disagrees with upstream (high tier wins)
|
||||
{
|
||||
"SEED-001",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("debian-security", VexStatus.Affected, trustTier: 100),
|
||||
CreateStatement("npm-advisory", VexStatus.NotAffected, trustTier: 80)
|
||||
},
|
||||
VexStatus.Affected
|
||||
},
|
||||
|
||||
// Seed 2: Three vendors agree on fix
|
||||
{
|
||||
"SEED-002",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("vendor-redhat", VexStatus.Fixed, trustTier: 90),
|
||||
CreateStatement("vendor-ubuntu", VexStatus.Fixed, trustTier: 90),
|
||||
CreateStatement("vendor-debian", VexStatus.Fixed, trustTier: 90)
|
||||
},
|
||||
VexStatus.Fixed
|
||||
},
|
||||
|
||||
// Seed 3: Mixed signals (under investigation + affected → affected wins)
|
||||
{
|
||||
"SEED-003",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("researcher-a", VexStatus.UnderInvestigation, trustTier: 70),
|
||||
CreateStatement("researcher-b", VexStatus.Affected, trustTier: 70),
|
||||
CreateStatement("researcher-c", VexStatus.UnderInvestigation, trustTier: 70)
|
||||
},
|
||||
VexStatus.Affected
|
||||
},
|
||||
|
||||
// Seed 4: Conflict between two high-tier vendors
|
||||
{
|
||||
"SEED-004",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("vendor-a", VexStatus.Affected, trustTier: 100),
|
||||
CreateStatement("vendor-b", VexStatus.NotAffected, trustTier: 100)
|
||||
},
|
||||
VexStatus.Affected // Conservative: affected wins in conflict
|
||||
},
|
||||
|
||||
// Seed 5: Low confidence unknown statements
|
||||
{
|
||||
"SEED-005",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("issuer-1", VexStatus.Unknown, trustTier: 50),
|
||||
CreateStatement("issuer-2", VexStatus.Unknown, trustTier: 50),
|
||||
CreateStatement("issuer-3", VexStatus.Unknown, trustTier: 50)
|
||||
},
|
||||
VexStatus.Unknown
|
||||
},
|
||||
|
||||
// Seed 6: Fixed status overrides all lower statuses
|
||||
{
|
||||
"SEED-006",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("vendor-a", VexStatus.Affected, trustTier: 90),
|
||||
CreateStatement("vendor-b", VexStatus.NotAffected, trustTier: 90),
|
||||
CreateStatement("vendor-c", VexStatus.Fixed, trustTier: 90)
|
||||
},
|
||||
VexStatus.Fixed
|
||||
},
|
||||
|
||||
// Seed 7: Single high-tier not_affected
|
||||
{
|
||||
"SEED-007",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("distro-maintainer", VexStatus.NotAffected, trustTier: 100, justification: "component_not_present")
|
||||
},
|
||||
VexStatus.NotAffected
|
||||
},
|
||||
|
||||
// Seed 8: Investigation escalates to affected
|
||||
{
|
||||
"SEED-008",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("issuer-early", VexStatus.UnderInvestigation, trustTier: 90),
|
||||
CreateStatement("issuer-update", VexStatus.Affected, trustTier: 90)
|
||||
},
|
||||
VexStatus.Affected
|
||||
},
|
||||
|
||||
// Seed 9: All tiers present (distro > vendor > community)
|
||||
{
|
||||
"SEED-009",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("community", VexStatus.Affected, trustTier: 50),
|
||||
CreateStatement("vendor", VexStatus.NotAffected, trustTier: 80),
|
||||
CreateStatement("distro", VexStatus.Fixed, trustTier: 100)
|
||||
},
|
||||
VexStatus.Fixed
|
||||
},
|
||||
|
||||
// Seed 10: Multiple affected statements (unanimous)
|
||||
{
|
||||
"SEED-010",
|
||||
new[]
|
||||
{
|
||||
CreateStatement("nvd", VexStatus.Affected, trustTier: 85),
|
||||
CreateStatement("github-advisory", VexStatus.Affected, trustTier: 85),
|
||||
CreateStatement("snyk", VexStatus.Affected, trustTier: 85)
|
||||
},
|
||||
VexStatus.Affected
|
||||
}
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ReplaySeedCases))]
|
||||
public void ReplaySeed_ProducesStableOutput_Across10Runs(
|
||||
string seedId,
|
||||
VexStatement[] statements,
|
||||
VexStatus expectedStatus)
|
||||
{
|
||||
// Act: Run consensus 10 times
|
||||
var results = new List<string>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var result = ComputeConsensus(statements);
|
||||
var canonical = JsonSerializer.Serialize(result, CanonicalOptions);
|
||||
results.Add(canonical);
|
||||
}
|
||||
|
||||
// Assert: All 10 runs must produce byte-identical output
|
||||
results.Distinct().Should().HaveCount(1, because: $"{seedId}: replay must be deterministic");
|
||||
|
||||
// Verify expected status
|
||||
var finalResult = ComputeConsensus(statements);
|
||||
finalResult.Status.Should().Be(expectedStatus, because: $"{seedId}: status regression check");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllReplaySeeds_ExecuteWithinTimeLimit()
|
||||
{
|
||||
// Arrange: Collect all seed cases
|
||||
var allSeeds = ReplaySeedCases.Select(data => (VexStatement[])data[1]).ToList();
|
||||
|
||||
// Act: Measure execution time
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
foreach (var statements in allSeeds)
|
||||
{
|
||||
_ = ComputeConsensus(statements);
|
||||
}
|
||||
stopwatch.Stop();
|
||||
|
||||
// Assert: All 10 seeds should complete in under 100ms
|
||||
stopwatch.ElapsedMilliseconds.Should().BeLessThan(100, because: "replay tests must be fast");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Output Snapshot Tests (VTT-007)
|
||||
|
||||
/// <summary>
|
||||
/// Test cases that have golden output snapshots for regression testing.
|
||||
/// </summary>
|
||||
public static TheoryData<string> GoldenSnapshotCases => new()
|
||||
{
|
||||
{ "tt-001" }, // Single issuer unknown
|
||||
{ "tt-013" }, // Two issuer conflict
|
||||
{ "tt-014" }, // Two issuer merge (affected + fixed)
|
||||
{ "tt-020" } // Trust tier precedence
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenSnapshotCases))]
|
||||
public void GoldenSnapshot_MatchesExpectedOutput(string testId)
|
||||
{
|
||||
// Arrange: Load test scenario and expected golden output
|
||||
var (statements, expected) = LoadGoldenTestCase(testId);
|
||||
|
||||
// Act: Compute consensus
|
||||
var actual = ComputeConsensus(statements);
|
||||
|
||||
// Assert: Compare against golden snapshot
|
||||
var actualJson = JsonSerializer.Serialize(actual, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
var expectedJson = JsonSerializer.Serialize(expected, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
actualJson.Should().Be(expectedJson, because: $"golden snapshot {testId} must match exactly");
|
||||
|
||||
// Verify key fields individually for better diagnostics
|
||||
actual.Status.Should().Be(expected.Status, because: $"{testId}: status mismatch");
|
||||
actual.ConflictCount.Should().Be(expected.ConflictCount, because: $"{testId}: conflict count mismatch");
|
||||
actual.StatementCount.Should().Be(expected.StatementCount, because: $"{testId}: statement count mismatch");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load a golden test case from fixtures.
|
||||
/// </summary>
|
||||
private static (VexStatement[] Statements, GoldenConsensusResult Expected) LoadGoldenTestCase(string testId)
|
||||
{
|
||||
var basePath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "fixtures", "truth-tables", "expected");
|
||||
var goldenPath = Path.Combine(basePath, $"{testId}.consensus.json");
|
||||
|
||||
if (!File.Exists(goldenPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Golden file not found: {goldenPath}");
|
||||
}
|
||||
|
||||
var goldenJson = File.ReadAllText(goldenPath);
|
||||
var golden = JsonSerializer.Deserialize<GoldenConsensusResult>(goldenJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}) ?? throw new InvalidOperationException($"Failed to deserialize {goldenPath}");
|
||||
|
||||
// Reconstruct statements from golden file
|
||||
var statements = golden.AppliedStatements.Select(s => new VexStatement
|
||||
{
|
||||
IssuerId = s.IssuerId,
|
||||
Status = ParseVexStatus(s.Status),
|
||||
TrustTier = ParseTrustTier(s.TrustTier),
|
||||
Justification = null,
|
||||
Timestamp = DateTimeOffset.Parse(s.Timestamp),
|
||||
VulnerabilityId = golden.VulnerabilityId,
|
||||
ProductKey = golden.ProductKey
|
||||
}).ToArray();
|
||||
|
||||
return (statements, golden);
|
||||
}
|
||||
|
||||
private static VexStatus ParseVexStatus(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"unknown" => VexStatus.Unknown,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
_ => throw new ArgumentException($"Unknown VEX status: {status}")
|
||||
};
|
||||
|
||||
private static int ParseTrustTier(string tier) => tier.ToLowerInvariant() switch
|
||||
{
|
||||
"distro" => 100,
|
||||
"vendor" => 90,
|
||||
"community" => 50,
|
||||
_ => 80
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Create a normalized VEX statement for testing.
|
||||
/// </summary>
|
||||
private static VexStatement CreateStatement(
|
||||
string issuerId,
|
||||
VexStatus status,
|
||||
int trustTier = 90,
|
||||
string? justification = null)
|
||||
{
|
||||
return new VexStatement
|
||||
{
|
||||
IssuerId = issuerId,
|
||||
Status = status,
|
||||
TrustTier = trustTier,
|
||||
Justification = justification,
|
||||
Timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute consensus from statements.
|
||||
/// This is a simplified mock - in real tests this would call VexConsensusEngine.
|
||||
/// </summary>
|
||||
private static ConsensusResult ComputeConsensus(VexStatement[] statements)
|
||||
{
|
||||
// Simple lattice merge implementation for tests
|
||||
var orderedByTier = statements.OrderByDescending(s => s.TrustTier).ToList();
|
||||
var highestTier = orderedByTier[0].TrustTier;
|
||||
var topTierStatements = orderedByTier.Where(s => s.TrustTier == highestTier).ToList();
|
||||
|
||||
// Lattice merge logic
|
||||
var status = MergeLattice(topTierStatements.Select(s => s.Status));
|
||||
|
||||
// Conflict detection
|
||||
var distinctStatuses = topTierStatements.Select(s => s.Status).Distinct().ToList();
|
||||
var hasConflict = distinctStatuses.Count > 1 && !IsHierarchical(distinctStatuses);
|
||||
|
||||
var conflicts = hasConflict
|
||||
? topTierStatements.Where(s => s.Status != status).Select(s => s.IssuerId).ToList()
|
||||
: new List<string>();
|
||||
|
||||
// Confidence calculation
|
||||
var baseConfidence = 0.85m;
|
||||
if (topTierStatements.Count == 1 || distinctStatuses.Count == 1)
|
||||
baseConfidence = 0.95m; // Unanimous or single source
|
||||
|
||||
if (topTierStatements.Any(s => s.Justification == "component_not_present"))
|
||||
baseConfidence = 0.95m;
|
||||
else if (topTierStatements.Any(s => s.Justification == "vulnerable_code_not_in_execute_path"))
|
||||
baseConfidence = 0.90m;
|
||||
|
||||
return new ConsensusResult
|
||||
{
|
||||
Status = status,
|
||||
StatementCount = statements.Length,
|
||||
ConflictCount = conflicts.Count,
|
||||
Conflicts = conflicts,
|
||||
ConfidenceScore = baseConfidence
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge statuses according to lattice rules.
|
||||
/// </summary>
|
||||
private static VexStatus MergeLattice(IEnumerable<VexStatus> statuses)
|
||||
{
|
||||
var statusList = statuses.ToList();
|
||||
|
||||
// Fixed is lattice top (terminal)
|
||||
if (statusList.Contains(VexStatus.Fixed))
|
||||
return VexStatus.Fixed;
|
||||
|
||||
// Affected and NotAffected at same level
|
||||
if (statusList.Contains(VexStatus.Affected))
|
||||
return VexStatus.Affected; // Conservative choice in conflict
|
||||
|
||||
if (statusList.Contains(VexStatus.NotAffected))
|
||||
return VexStatus.NotAffected;
|
||||
|
||||
if (statusList.Contains(VexStatus.UnderInvestigation))
|
||||
return VexStatus.UnderInvestigation;
|
||||
|
||||
return VexStatus.Unknown; // Lattice bottom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if statuses are hierarchical (no conflict).
|
||||
/// </summary>
|
||||
private static bool IsHierarchical(List<VexStatus> statuses)
|
||||
{
|
||||
// Affected and NotAffected are at same level (conflict)
|
||||
if (statuses.Contains(VexStatus.Affected) && statuses.Contains(VexStatus.NotAffected))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Models
|
||||
|
||||
private class VexStatement
|
||||
{
|
||||
public required string IssuerId { get; init; }
|
||||
public required VexStatus Status { get; init; }
|
||||
public required int TrustTier { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string ProductKey { get; init; }
|
||||
}
|
||||
|
||||
private class ConsensusResult
|
||||
{
|
||||
public required VexStatus Status { get; init; }
|
||||
public required int StatementCount { get; init; }
|
||||
public required int ConflictCount { get; init; }
|
||||
public required IReadOnlyList<string> Conflicts { get; init; }
|
||||
public required decimal ConfidenceScore { get; init; }
|
||||
}
|
||||
|
||||
private enum VexStatus
|
||||
{
|
||||
Unknown,
|
||||
UnderInvestigation,
|
||||
NotAffected,
|
||||
Affected,
|
||||
Fixed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Golden file format for consensus results (matches expected/*.consensus.json).
|
||||
/// </summary>
|
||||
private class GoldenConsensusResult
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string ProductKey { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required decimal Confidence { get; init; }
|
||||
public required int StatementCount { get; init; }
|
||||
public required int ConflictCount { get; init; }
|
||||
public required List<GoldenConflict> Conflicts { get; init; }
|
||||
public required List<GoldenStatement> AppliedStatements { get; init; }
|
||||
public required string ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
private class GoldenConflict
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
public required List<GoldenIssuer> Issuers { get; init; }
|
||||
}
|
||||
|
||||
private class GoldenIssuer
|
||||
{
|
||||
public required string IssuerId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string TrustTier { get; init; }
|
||||
}
|
||||
|
||||
private class GoldenStatement
|
||||
{
|
||||
public required string IssuerId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string TrustTier { get; init; }
|
||||
public required string Timestamp { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user