sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

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