sprints work
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025-2026 StellaOps
|
||||
// Sprint: SPRINT_20260109_009_005_BE_vex_decision_integration
|
||||
// Task: Integration tests for VEX decision with hybrid reachability
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for VEX decision emission with hybrid reachability evidence.
|
||||
/// Tests the full pipeline from reachability facts to VEX document generation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "009_005")]
|
||||
public sealed class VexDecisionReachabilityIntegrationTests
|
||||
{
|
||||
private const string TestTenantId = "integration-test-tenant";
|
||||
private const string TestAuthor = "vex-emitter@stellaops.test";
|
||||
|
||||
#region End-to-End Pipeline Tests
|
||||
|
||||
[Fact(DisplayName = "Pipeline emits VEX for multiple findings with varying reachability states")]
|
||||
public async Task Pipeline_EmitsVex_ForMultipleFindingsWithVaryingStates()
|
||||
{
|
||||
// Arrange: Create findings with different reachability states
|
||||
var findings = new[]
|
||||
{
|
||||
new VexFindingInput { VulnId = "CVE-2024-0001", Purl = "pkg:npm/lodash@4.17.20" },
|
||||
new VexFindingInput { VulnId = "CVE-2024-0002", Purl = "pkg:maven/log4j/log4j-core@2.14.1" },
|
||||
new VexFindingInput { VulnId = "CVE-2024-0003", Purl = "pkg:pypi/requests@2.25.0" }
|
||||
};
|
||||
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/lodash@4.17.20", "CVE-2024-0001")] = CreateFact(
|
||||
ReachabilityState.Unreachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.95m,
|
||||
latticeState: "CU"),
|
||||
[new(TestTenantId, "pkg:maven/log4j/log4j-core@2.14.1", "CVE-2024-0002")] = CreateFact(
|
||||
ReachabilityState.Reachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.99m,
|
||||
latticeState: "CR"),
|
||||
[new(TestTenantId, "pkg:pypi/requests@2.25.0", "CVE-2024-0003")] = CreateFact(
|
||||
ReachabilityState.Unknown,
|
||||
hasRuntime: false,
|
||||
confidence: 0.0m,
|
||||
latticeState: "U")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = findings
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Document.Statements.Should().HaveCount(3);
|
||||
result.Blocked.Should().BeEmpty();
|
||||
|
||||
// Verify unreachable -> not_affected
|
||||
var lodashStatement = result.Document.Statements.Single(s => s.VulnId == "CVE-2024-0001");
|
||||
lodashStatement.Status.Should().Be("not_affected");
|
||||
lodashStatement.Justification.Should().Be(VexJustification.VulnerableCodeNotInExecutePath);
|
||||
|
||||
// Verify reachable -> affected
|
||||
var log4jStatement = result.Document.Statements.Single(s => s.VulnId == "CVE-2024-0002");
|
||||
log4jStatement.Status.Should().Be("affected");
|
||||
log4jStatement.Justification.Should().BeNull();
|
||||
|
||||
// Verify unknown -> under_investigation
|
||||
var requestsStatement = result.Document.Statements.Single(s => s.VulnId == "CVE-2024-0003");
|
||||
requestsStatement.Status.Should().Be("under_investigation");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Pipeline preserves evidence hash in VEX metadata")]
|
||||
public async Task Pipeline_PreservesEvidenceHash_InVexMetadata()
|
||||
{
|
||||
// Arrange
|
||||
const string expectedHash = "sha256:abc123def456";
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/vulnerable@1.0.0", "CVE-2024-1000")] = CreateFact(
|
||||
ReachabilityState.Unreachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.92m,
|
||||
latticeState: "CU",
|
||||
evidenceHash: expectedHash)
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-2024-1000", Purl = "pkg:npm/vulnerable@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
var statement = result.Document.Statements.Should().ContainSingle().Subject;
|
||||
statement.EvidenceBlock.Should().NotBeNull();
|
||||
statement.EvidenceBlock!.GraphHash.Should().Be(expectedHash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Gate Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Policy gate blocks emission for high-risk findings")]
|
||||
public async Task PolicyGate_BlocksEmission_ForHighRiskFindings()
|
||||
{
|
||||
// Arrange
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/critical@1.0.0", "CVE-2024-CRITICAL")] = CreateFact(
|
||||
ReachabilityState.Reachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.99m,
|
||||
latticeState: "CR")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(
|
||||
PolicyGateDecisionType.Block,
|
||||
reason: "Requires security review for critical CVEs");
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-2024-CRITICAL", Purl = "pkg:npm/critical@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Blocked.Should().ContainSingle();
|
||||
result.Blocked[0].VulnId.Should().Be("CVE-2024-CRITICAL");
|
||||
result.Blocked[0].Reason.Should().Contain("security review");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Policy gate warns but allows emission when configured")]
|
||||
public async Task PolicyGate_WarnsButAllows_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/medium@1.0.0", "CVE-2024-MEDIUM")] = CreateFact(
|
||||
ReachabilityState.Unreachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.85m,
|
||||
latticeState: "CU")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(
|
||||
PolicyGateDecisionType.Warn,
|
||||
reason: "Confidence below threshold");
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-2024-MEDIUM", Purl = "pkg:npm/medium@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Document.Statements.Should().ContainSingle();
|
||||
result.Blocked.Should().BeEmpty();
|
||||
// Warnings should be logged but emission continues
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lattice State Integration Tests
|
||||
|
||||
[Theory(DisplayName = "All lattice states map to correct VEX status")]
|
||||
[InlineData("U", "under_investigation")]
|
||||
[InlineData("SR", "under_investigation")] // Static-only needs runtime confirmation
|
||||
[InlineData("SU", "not_affected")]
|
||||
[InlineData("RO", "affected")] // Runtime observed = definitely reachable
|
||||
[InlineData("RU", "not_affected")]
|
||||
[InlineData("CR", "affected")]
|
||||
[InlineData("CU", "not_affected")]
|
||||
[InlineData("X", "under_investigation")] // Contested requires manual review
|
||||
public async Task LatticeState_MapsToCorrectVexStatus(string latticeState, string expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var state = latticeState switch
|
||||
{
|
||||
"U" => ReachabilityState.Unknown,
|
||||
"SR" or "RO" or "CR" => ReachabilityState.Reachable,
|
||||
"SU" or "RU" or "CU" => ReachabilityState.Unreachable,
|
||||
"X" => ReachabilityState.Contested,
|
||||
_ => ReachabilityState.Unknown
|
||||
};
|
||||
|
||||
var hasRuntime = latticeState is "RO" or "RU" or "CR" or "CU";
|
||||
var confidence = latticeState switch
|
||||
{
|
||||
"CR" or "CU" => 0.95m,
|
||||
"RO" or "RU" => 0.85m,
|
||||
"SR" or "SU" => 0.70m,
|
||||
_ => 0.0m
|
||||
};
|
||||
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:test/lib@1.0.0", "CVE-TEST")] = CreateFact(
|
||||
state,
|
||||
hasRuntime: hasRuntime,
|
||||
confidence: confidence,
|
||||
latticeState: latticeState)
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-TEST", Purl = "pkg:test/lib@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
var statement = result.Document.Statements.Should().ContainSingle().Subject;
|
||||
statement.Status.Should().Be(expectedStatus);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Override Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Manual override takes precedence over reachability")]
|
||||
public async Task ManualOverride_TakesPrecedence_OverReachability()
|
||||
{
|
||||
// Arrange: Reachable CVE with manual override to not_affected
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/overridden@1.0.0", "CVE-2024-OVERRIDE")] = CreateFact(
|
||||
ReachabilityState.Reachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.99m,
|
||||
latticeState: "CR")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[]
|
||||
{
|
||||
new VexFindingInput
|
||||
{
|
||||
VulnId = "CVE-2024-OVERRIDE",
|
||||
Purl = "pkg:npm/overridden@1.0.0",
|
||||
OverrideStatus = "not_affected",
|
||||
OverrideJustification = "Vulnerable path protected by WAF rules"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
var statement = result.Document.Statements.Should().ContainSingle().Subject;
|
||||
statement.Status.Should().Be("not_affected");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact(DisplayName = "Same inputs produce identical VEX documents")]
|
||||
public async Task Determinism_SameInputs_ProduceIdenticalDocuments()
|
||||
{
|
||||
// Arrange
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/deterministic@1.0.0", "CVE-2024-DET")] = CreateFact(
|
||||
ReachabilityState.Unreachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.95m,
|
||||
latticeState: "CU")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
|
||||
// Use fixed time for determinism
|
||||
var fixedTime = new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(fixedTime);
|
||||
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator, timeProvider);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-2024-DET", Purl = "pkg:npm/deterministic@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await emitter.EmitAsync(request);
|
||||
var result2 = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result1.Document.Should().NotBeNull();
|
||||
result2.Document.Should().NotBeNull();
|
||||
|
||||
// Both documents should have identical content
|
||||
result1.Document.Statements.Should().HaveCount(result2.Document.Statements.Count);
|
||||
|
||||
var stmt1 = result1.Document.Statements[0];
|
||||
var stmt2 = result2.Document.Statements[0];
|
||||
|
||||
stmt1.Status.Should().Be(stmt2.Status);
|
||||
stmt1.Justification.Should().Be(stmt2.Justification);
|
||||
stmt1.VulnId.Should().Be(stmt2.VulnId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ReachabilityFact CreateFact(
|
||||
ReachabilityState state,
|
||||
bool hasRuntime,
|
||||
decimal confidence,
|
||||
string? latticeState = null,
|
||||
string? evidenceHash = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["lattice_state"] = latticeState ?? state.ToString(),
|
||||
["has_runtime_evidence"] = hasRuntime,
|
||||
["confidence"] = confidence
|
||||
};
|
||||
|
||||
return new ReachabilityFact
|
||||
{
|
||||
State = state,
|
||||
HasRuntimeEvidence = hasRuntime,
|
||||
Confidence = confidence,
|
||||
EvidenceHash = evidenceHash,
|
||||
Metadata = metadata.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilityFactsJoiningService CreateMockFactsService(
|
||||
Dictionary<ReachabilityFactKey, ReachabilityFact> facts)
|
||||
{
|
||||
var mockService = new Mock<ReachabilityFactsJoiningService>(
|
||||
MockBehavior.Strict,
|
||||
null!, null!, null!, null!, null!);
|
||||
|
||||
mockService
|
||||
.Setup(s => s.GetFactsBatchAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IReadOnlyList<ReachabilityFactsRequest>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string tenantId, IReadOnlyList<ReachabilityFactsRequest> requests, CancellationToken _) =>
|
||||
{
|
||||
var found = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
|
||||
var notFound = new List<ReachabilityFactKey>();
|
||||
|
||||
foreach (var req in requests)
|
||||
{
|
||||
var key = new ReachabilityFactKey(tenantId, req.Purl, req.VulnId);
|
||||
if (facts.TryGetValue(key, out var fact))
|
||||
{
|
||||
found[key] = fact;
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityFactsBatchResult
|
||||
{
|
||||
Found = found.ToImmutableDictionary(),
|
||||
NotFound = notFound.ToImmutableArray()
|
||||
};
|
||||
});
|
||||
|
||||
return mockService.Object;
|
||||
}
|
||||
|
||||
private static IPolicyGateEvaluator CreateMockGateEvaluator(
|
||||
PolicyGateDecisionType decision,
|
||||
string? reason = null)
|
||||
{
|
||||
var mock = new Mock<IPolicyGateEvaluator>();
|
||||
mock.Setup(e => e.EvaluateAsync(It.IsAny<PolicyGateRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PolicyGateDecision
|
||||
{
|
||||
Decision = decision,
|
||||
Reason = reason
|
||||
});
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static VexDecisionEmitter CreateEmitter(
|
||||
ReachabilityFactsJoiningService factsService,
|
||||
IPolicyGateEvaluator gateEvaluator,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var options = Options.Create(new VexDecisionEmitterOptions
|
||||
{
|
||||
MinimumConfidenceForNotAffected = 0.7m,
|
||||
RequireRuntimeForNotAffected = false,
|
||||
EnableGates = true
|
||||
});
|
||||
|
||||
return new VexDecisionEmitter(
|
||||
factsService,
|
||||
gateEvaluator,
|
||||
new OptionsMonitorWrapper<VexDecisionEmitterOptions>(options.Value),
|
||||
timeProvider ?? TimeProvider.System,
|
||||
NullLogger<VexDecisionEmitter>.Instance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private sealed class OptionsMonitorWrapper<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public OptionsMonitorWrapper(T value) => CurrentValue = value;
|
||||
public T CurrentValue { get; }
|
||||
public T Get(string? name) => CurrentValue;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025-2026 StellaOps
|
||||
// Sprint: SPRINT_20260109_009_005_BE_vex_decision_integration
|
||||
// Task: Schema validation tests for VEX documents
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Schema validation tests for VEX documents with StellaOps evidence extensions.
|
||||
/// Validates OpenVEX compliance and extension schema correctness.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "009_005")]
|
||||
public sealed class VexSchemaValidationTests
|
||||
{
|
||||
#region OpenVEX Schema Compliance
|
||||
|
||||
[Fact(DisplayName = "VexStatement has required OpenVEX fields")]
|
||||
public void VexStatement_HasRequiredOpenVexFields()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new VexStatement
|
||||
{
|
||||
VulnId = "CVE-2024-0001",
|
||||
Status = "not_affected",
|
||||
Justification = VexJustification.VulnerableCodeNotInExecutePath,
|
||||
Products = new[] { "pkg:npm/lodash@4.17.21" },
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert: Required fields present
|
||||
node!["vulnerability"]?.GetValue<string>().Should().Be("CVE-2024-0001");
|
||||
node["status"]?.GetValue<string>().Should().Be("not_affected");
|
||||
node["products"].Should().NotBeNull();
|
||||
node["timestamp"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "VEX status values are valid OpenVEX statuses")]
|
||||
[InlineData("affected")]
|
||||
[InlineData("not_affected")]
|
||||
[InlineData("fixed")]
|
||||
[InlineData("under_investigation")]
|
||||
public void VexStatus_IsValidOpenVexStatus(string status)
|
||||
{
|
||||
// Arrange
|
||||
var validStatuses = new[] { "affected", "not_affected", "fixed", "under_investigation" };
|
||||
|
||||
// Assert
|
||||
validStatuses.Should().Contain(status);
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "VEX justification values are valid OpenVEX justifications")]
|
||||
[InlineData("component_not_present")]
|
||||
[InlineData("vulnerable_code_not_present")]
|
||||
[InlineData("vulnerable_code_not_in_execute_path")]
|
||||
[InlineData("vulnerable_code_cannot_be_controlled_by_adversary")]
|
||||
[InlineData("inline_mitigations_already_exist")]
|
||||
public void VexJustification_IsValidOpenVexJustification(string justification)
|
||||
{
|
||||
// Arrange
|
||||
var validJustifications = new[]
|
||||
{
|
||||
"component_not_present",
|
||||
"vulnerable_code_not_present",
|
||||
"vulnerable_code_not_in_execute_path",
|
||||
"vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
"inline_mitigations_already_exist"
|
||||
};
|
||||
|
||||
// Assert
|
||||
validJustifications.Should().Contain(justification);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StellaOps Evidence Extension Schema
|
||||
|
||||
[Fact(DisplayName = "Evidence extension follows x- prefix convention")]
|
||||
public void EvidenceExtension_FollowsXPrefixConvention()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new VexEvidenceBlock
|
||||
{
|
||||
LatticeState = "CU",
|
||||
Confidence = 0.95m,
|
||||
HasRuntimeEvidence = true,
|
||||
GraphHash = "sha256:abc123"
|
||||
};
|
||||
|
||||
var statement = new VexStatement
|
||||
{
|
||||
VulnId = "CVE-2024-0001",
|
||||
Status = "not_affected",
|
||||
EvidenceBlock = evidence
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
|
||||
// Assert: Extension uses x- prefix
|
||||
json.Should().Contain("\"x-stellaops-evidence\"");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Evidence block has all required fields")]
|
||||
public void EvidenceBlock_HasAllRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new VexEvidenceBlock
|
||||
{
|
||||
LatticeState = "CR",
|
||||
Confidence = 0.99m,
|
||||
HasRuntimeEvidence = true,
|
||||
GraphHash = "sha256:abc123def456",
|
||||
StaticPaths = new[] { "main->vulnerable_func" },
|
||||
RuntimeObservations = new[] { "2026-01-10T12:00:00Z: call observed" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(evidence, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert: All fields present
|
||||
node!["lattice_state"]?.GetValue<string>().Should().Be("CR");
|
||||
node["confidence"]?.GetValue<decimal>().Should().Be(0.99m);
|
||||
node["has_runtime_evidence"]?.GetValue<bool>().Should().BeTrue();
|
||||
node["graph_hash"]?.GetValue<string>().Should().Be("sha256:abc123def456");
|
||||
node["static_paths"].Should().NotBeNull();
|
||||
node["runtime_observations"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "Lattice state values are valid")]
|
||||
[InlineData("U", true)] // Unknown
|
||||
[InlineData("SR", true)] // Statically Reachable
|
||||
[InlineData("SU", true)] // Statically Unreachable
|
||||
[InlineData("RO", true)] // Runtime Observed
|
||||
[InlineData("RU", true)] // Runtime Unobserved
|
||||
[InlineData("CR", true)] // Confirmed Reachable
|
||||
[InlineData("CU", true)] // Confirmed Unreachable
|
||||
[InlineData("X", true)] // Contested
|
||||
[InlineData("INVALID", false)]
|
||||
[InlineData("", false)]
|
||||
public void LatticeState_IsValid(string state, bool expectedValid)
|
||||
{
|
||||
// Arrange
|
||||
var validStates = new[] { "U", "SR", "SU", "RO", "RU", "CR", "CU", "X" };
|
||||
|
||||
// Act
|
||||
var isValid = validStates.Contains(state);
|
||||
|
||||
// Assert
|
||||
isValid.Should().Be(expectedValid);
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "Confidence values are within valid range")]
|
||||
[InlineData(0.0, true)]
|
||||
[InlineData(0.5, true)]
|
||||
[InlineData(1.0, true)]
|
||||
[InlineData(-0.1, false)]
|
||||
[InlineData(1.1, false)]
|
||||
public void Confidence_IsWithinValidRange(decimal value, bool expectedValid)
|
||||
{
|
||||
// Act
|
||||
var isValid = value >= 0.0m && value <= 1.0m;
|
||||
|
||||
// Assert
|
||||
isValid.Should().Be(expectedValid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Document-Level Schema
|
||||
|
||||
[Fact(DisplayName = "VexDocument has required OpenVEX document fields")]
|
||||
public void VexDocument_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var document = new VexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns/v0.2.0",
|
||||
Id = "urn:uuid:12345678-1234-1234-1234-123456789012",
|
||||
Author = "stellaops-vex-emitter@stellaops.io",
|
||||
AuthorRole = "tool",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Version = 1,
|
||||
Statements = new[]
|
||||
{
|
||||
new VexStatement
|
||||
{
|
||||
VulnId = "CVE-2024-0001",
|
||||
Status = "not_affected"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(document, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert: Required fields present
|
||||
node!["@context"]?.GetValue<string>().Should().StartWith("https://openvex.dev/ns/");
|
||||
node["@id"]?.GetValue<string>().Should().StartWith("urn:uuid:");
|
||||
node["author"]?.GetValue<string>().Should().NotBeNullOrWhiteSpace();
|
||||
node["timestamp"].Should().NotBeNull();
|
||||
node["version"]?.GetValue<int>().Should().BeGreaterOrEqualTo(1);
|
||||
node["statements"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Document ID is valid URN format")]
|
||||
public void DocumentId_IsValidUrnFormat()
|
||||
{
|
||||
// Arrange
|
||||
var validUrns = new[]
|
||||
{
|
||||
"urn:uuid:12345678-1234-1234-1234-123456789012",
|
||||
"urn:stellaops:vex:tenant:12345",
|
||||
"https://stellaops.io/vex/12345"
|
||||
};
|
||||
|
||||
// Assert
|
||||
foreach (var urn in validUrns)
|
||||
{
|
||||
var isValid = urn.StartsWith("urn:") || urn.StartsWith("https://");
|
||||
isValid.Should().BeTrue($"URN '{urn}' should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Timestamp is ISO 8601 UTC format")]
|
||||
public void Timestamp_IsIso8601UtcFormat()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new VexStatement
|
||||
{
|
||||
VulnId = "CVE-2024-0001",
|
||||
Status = "not_affected",
|
||||
Timestamp = new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
|
||||
// Assert: Timestamp is ISO 8601 with Z suffix
|
||||
json.Should().Contain("2026-01-10T12:00:00");
|
||||
json.Should().MatchRegex(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Validation
|
||||
|
||||
[Fact(DisplayName = "Serialization is deterministic")]
|
||||
public void Serialization_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new VexEvidenceBlock
|
||||
{
|
||||
LatticeState = "CU",
|
||||
Confidence = 0.95m,
|
||||
HasRuntimeEvidence = true,
|
||||
GraphHash = "sha256:deterministic123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json1 = JsonSerializer.Serialize(evidence, JsonOptions);
|
||||
var json2 = JsonSerializer.Serialize(evidence, JsonOptions);
|
||||
|
||||
// Assert: Both serializations are identical
|
||||
json1.Should().Be(json2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Array ordering is stable")]
|
||||
public void ArrayOrdering_IsStable()
|
||||
{
|
||||
// Arrange
|
||||
var document = new VexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns/v0.2.0",
|
||||
Id = "urn:uuid:stable-order-test",
|
||||
Author = "test",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Version = 1,
|
||||
Statements = new[]
|
||||
{
|
||||
new VexStatement { VulnId = "CVE-A", Status = "affected" },
|
||||
new VexStatement { VulnId = "CVE-B", Status = "not_affected" },
|
||||
new VexStatement { VulnId = "CVE-C", Status = "fixed" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json1 = JsonSerializer.Serialize(document, JsonOptions);
|
||||
var json2 = JsonSerializer.Serialize(document, JsonOptions);
|
||||
|
||||
// Parse and verify order
|
||||
var node1 = JsonNode.Parse(json1)!["statements"]!.AsArray();
|
||||
var node2 = JsonNode.Parse(json2)!["statements"]!.AsArray();
|
||||
|
||||
// Assert: Order is preserved
|
||||
for (var i = 0; i < node1.Count; i++)
|
||||
{
|
||||
node1[i]!["vulnerability"]?.GetValue<string>()
|
||||
.Should().Be(node2[i]!["vulnerability"]?.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Models (simplified for schema testing)
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private sealed record VexDocument
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("@context")]
|
||||
public required string Context { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("@id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string Author { get; init; }
|
||||
public string? AuthorRole { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required int Version { get; init; }
|
||||
public required VexStatement[] Statements { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VexStatement
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("vulnerability")]
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string[]? Products { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("x-stellaops-evidence")]
|
||||
public VexEvidenceBlock? EvidenceBlock { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VexEvidenceBlock
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("lattice_state")]
|
||||
public required string LatticeState { get; init; }
|
||||
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("has_runtime_evidence")]
|
||||
public required bool HasRuntimeEvidence { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("graph_hash")]
|
||||
public string? GraphHash { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("static_paths")]
|
||||
public string[]? StaticPaths { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("runtime_observations")]
|
||||
public string[]? RuntimeObservations { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constants for VEX justification values.
|
||||
/// </summary>
|
||||
public static class VexJustification
|
||||
{
|
||||
public const string ComponentNotPresent = "component_not_present";
|
||||
public const string VulnerableCodeNotPresent = "vulnerable_code_not_present";
|
||||
public const string VulnerableCodeNotInExecutePath = "vulnerable_code_not_in_execute_path";
|
||||
public const string VulnerableCodeCannotBeControlled = "vulnerable_code_cannot_be_controlled_by_adversary";
|
||||
public const string InlineMitigationsExist = "inline_mitigations_already_exist";
|
||||
}
|
||||
Reference in New Issue
Block a user