save progress

This commit is contained in:
StellaOps Bot
2026-01-03 12:41:57 +02:00
parent 83c37243e0
commit d486d41a48
48 changed files with 7174 additions and 1086 deletions

View File

@@ -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"
}
}
}

View File

@@ -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"
}

View File

@@ -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"
}
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;

View 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. |

View 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. |

View 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. |

View File

@@ -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.

View File

@@ -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). |

View 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).

View 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). |

View File

@@ -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)
{

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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,
"..",
"..",
"..",
"..",
"..",
"..",
".."));
}
}

View File

@@ -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);

View File

@@ -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,
"..",
"..",
"..",
"..",
"..",
"..",
".."));
}
}
}

View 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);
}
}
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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;
}

View 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);

View 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}";
}
}

View 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);

View File

@@ -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);

View File

@@ -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,
[]);
}
}

View File

@@ -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
}

View File

@@ -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 };
}
}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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
}