Sprints completed: - SPRINT_20260110_012_* (golden set diff layer - 10 sprints) - SPRINT_20260110_013_* (advisory chat - 4 sprints) Build fixes applied: - Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create - Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite) - Fix VexSchemaValidationTests FluentAssertions method name - Fix FixChainGateIntegrationTests ambiguous type references - Fix AdvisoryAI test files required properties and namespace aliases - Add stub types for CveMappingController (ICveSymbolMappingService) - Fix VerdictBuilderService static context issue Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
387 lines
13 KiB
C#
387 lines
13 KiB
C#
// 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().BeGreaterThanOrEqualTo(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";
|
|
}
|