up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-14 15:50:38 +02:00
parent f1a39c4ce3
commit 233873f620
249 changed files with 29746 additions and 154 deletions

View File

@@ -0,0 +1,339 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using MsOptions = Microsoft.Extensions.Options.Options;
using Moq;
using Moq.Protected;
using StellaOps.Policy.Engine.ReachabilityFacts;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.ReachabilityFacts;
public sealed class ReachabilityFactsSignalsClientTests
{
private readonly Mock<HttpMessageHandler> _mockHandler;
private readonly ReachabilityFactsSignalsClientOptions _options;
private readonly ReachabilityFactsSignalsClient _client;
public ReachabilityFactsSignalsClientTests()
{
_mockHandler = new Mock<HttpMessageHandler>();
_options = new ReachabilityFactsSignalsClientOptions
{
BaseUri = new Uri("https://signals.example.com/"),
MaxConcurrentRequests = 5,
Timeout = TimeSpan.FromSeconds(30)
};
var httpClient = new HttpClient(_mockHandler.Object)
{
BaseAddress = _options.BaseUri
};
_client = new ReachabilityFactsSignalsClient(
httpClient,
MsOptions.Create(_options),
NullLogger<ReachabilityFactsSignalsClient>.Instance);
}
[Fact]
public async Task GetBySubjectAsync_ReturnsNull_WhenNotFound()
{
SetupMockResponse(HttpStatusCode.NotFound);
var result = await _client.GetBySubjectAsync("pkg:maven/foo@1.0|CVE-2025-001");
Assert.Null(result);
}
[Fact]
public async Task GetBySubjectAsync_ReturnsFact_WhenFound()
{
var response = CreateSignalsResponse("fact-1", 0.85);
SetupMockResponse(HttpStatusCode.OK, response);
var result = await _client.GetBySubjectAsync("pkg:maven/foo@1.0|CVE-2025-001");
Assert.NotNull(result);
Assert.Equal("fact-1", result.Id);
Assert.Equal(0.85, result.Score);
}
[Fact]
public async Task GetBySubjectAsync_CallsCorrectEndpoint()
{
var response = CreateSignalsResponse("fact-1", 0.85);
string? capturedUri = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
{
capturedUri = req.RequestUri?.ToString();
})
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(response)
});
await _client.GetBySubjectAsync("pkg:maven/foo@1.0|CVE-2025-001");
Assert.NotNull(capturedUri);
Assert.Contains("signals/facts/", capturedUri);
}
[Fact]
public async Task GetBySubjectAsync_ThrowsOnServerError()
{
SetupMockResponse(HttpStatusCode.InternalServerError);
await Assert.ThrowsAsync<HttpRequestException>(
() => _client.GetBySubjectAsync("pkg:maven/foo@1.0|CVE-2025-001"));
}
[Fact]
public async Task GetBatchBySubjectsAsync_ReturnsEmptyDict_WhenNoKeys()
{
var result = await _client.GetBatchBySubjectsAsync([]);
Assert.Empty(result);
}
[Fact]
public async Task GetBatchBySubjectsAsync_FetchesInParallel()
{
var responses = new Dictionary<string, SignalsReachabilityFactResponse>
{
["pkg:maven/foo@1.0|CVE-2025-001"] = CreateSignalsResponse("fact-1", 0.9),
["pkg:maven/bar@2.0|CVE-2025-002"] = CreateSignalsResponse("fact-2", 0.8)
};
int callCount = 0;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
{
Interlocked.Increment(ref callCount);
var path = req.RequestUri?.AbsolutePath ?? "";
// Decode the path to find the key
foreach (var kvp in responses)
{
var encodedKey = Uri.EscapeDataString(kvp.Key);
if (path.Contains(encodedKey))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(kvp.Value)
};
}
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var keys = responses.Keys.ToList();
var result = await _client.GetBatchBySubjectsAsync(keys);
Assert.Equal(2, result.Count);
Assert.Equal(2, callCount);
}
[Fact]
public async Task GetBatchBySubjectsAsync_ReturnsOnlyFound()
{
var response = CreateSignalsResponse("fact-1", 0.9);
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
{
var path = req.RequestUri?.AbsolutePath ?? "";
if (path.Contains(Uri.EscapeDataString("pkg:maven/foo@1.0|CVE-2025-001")))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(response)
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var keys = new List<string>
{
"pkg:maven/foo@1.0|CVE-2025-001",
"pkg:maven/bar@2.0|CVE-2025-002"
};
var result = await _client.GetBatchBySubjectsAsync(keys);
Assert.Single(result);
Assert.True(result.ContainsKey("pkg:maven/foo@1.0|CVE-2025-001"));
}
[Fact]
public async Task TriggerRecomputeAsync_ReturnsTrue_OnSuccess()
{
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var request = new SignalsRecomputeRequest
{
SubjectKey = "pkg:maven/foo@1.0|CVE-2025-001",
TenantId = "tenant-1"
};
var result = await _client.TriggerRecomputeAsync(request);
Assert.True(result);
}
[Fact]
public async Task TriggerRecomputeAsync_ReturnsFalse_OnFailure()
{
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.BadRequest));
var request = new SignalsRecomputeRequest
{
SubjectKey = "pkg:maven/foo@1.0|CVE-2025-001",
TenantId = "tenant-1"
};
var result = await _client.TriggerRecomputeAsync(request);
Assert.False(result);
}
[Fact]
public async Task TriggerRecomputeAsync_PostsToCorrectEndpoint()
{
string? capturedUri = null;
string? capturedBody = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
{
capturedUri = req.RequestUri?.ToString();
if (req.Content is not null)
{
capturedBody = await req.Content.ReadAsStringAsync();
}
})
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var request = new SignalsRecomputeRequest
{
SubjectKey = "pkg:maven/foo@1.0|CVE-2025-001",
TenantId = "tenant-1"
};
await _client.TriggerRecomputeAsync(request);
Assert.NotNull(capturedUri);
Assert.Contains("signals/reachability/recompute", capturedUri);
Assert.NotNull(capturedBody);
Assert.Contains("subjectKey", capturedBody);
Assert.Contains("tenantId", capturedBody);
}
[Fact]
public async Task TriggerRecomputeAsync_ReturnsFalse_OnException()
{
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection failed"));
var request = new SignalsRecomputeRequest
{
SubjectKey = "pkg:maven/foo@1.0|CVE-2025-001",
TenantId = "tenant-1"
};
var result = await _client.TriggerRecomputeAsync(request);
Assert.False(result);
}
// Options Tests
[Fact]
public void Options_HasCorrectDefaults()
{
var options = new ReachabilityFactsSignalsClientOptions();
Assert.Null(options.BaseUri);
Assert.Equal(10, options.MaxConcurrentRequests);
Assert.Equal(TimeSpan.FromSeconds(30), options.Timeout);
Assert.Equal(3, options.RetryCount);
}
[Fact]
public void Options_SectionName_IsCorrect()
{
Assert.Equal("ReachabilitySignals", ReachabilityFactsSignalsClientOptions.SectionName);
}
private void SetupMockResponse(HttpStatusCode statusCode, SignalsReachabilityFactResponse? content = null)
{
var response = new HttpResponseMessage(statusCode);
if (content is not null)
{
response.Content = JsonContent.Create(content);
}
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
}
private static SignalsReachabilityFactResponse CreateSignalsResponse(string id, double score)
{
return new SignalsReachabilityFactResponse
{
Id = id,
CallgraphId = "cg-test",
Score = score,
States = new List<SignalsReachabilityState>
{
new()
{
Target = "test_method",
Reachable = true,
Confidence = 0.9,
Bucket = "reachable"
}
},
ComputedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,369 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Policy.Engine.ReachabilityFacts;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.ReachabilityFacts;
public sealed class SignalsBackedReachabilityFactsStoreTests
{
private readonly Mock<IReachabilityFactsSignalsClient> _mockClient;
private readonly SignalsBackedReachabilityFactsStore _store;
public SignalsBackedReachabilityFactsStoreTests()
{
_mockClient = new Mock<IReachabilityFactsSignalsClient>();
_store = new SignalsBackedReachabilityFactsStore(
_mockClient.Object,
NullLogger<SignalsBackedReachabilityFactsStore>.Instance,
TimeProvider.System);
}
[Fact]
public async Task GetAsync_ReturnsNull_WhenSignalsReturnsNull()
{
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((SignalsReachabilityFactResponse?)null);
var result = await _store.GetAsync("tenant-1", "pkg:maven/com.example/foo@1.0.0", "CVE-2025-12345");
Assert.Null(result);
}
[Fact]
public async Task GetAsync_MapsSignalsResponse_ToReachabilityFact()
{
var signalsResponse = CreateSignalsResponse(reachable: true, confidence: 0.95);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(signalsResponse);
var result = await _store.GetAsync("tenant-1", "pkg:maven/com.example/foo@1.0.0", "CVE-2025-12345");
Assert.NotNull(result);
Assert.Equal("tenant-1", result.TenantId);
Assert.Equal("pkg:maven/com.example/foo@1.0.0", result.ComponentPurl);
Assert.Equal("CVE-2025-12345", result.AdvisoryId);
Assert.Equal(ReachabilityState.Reachable, result.State);
Assert.Equal("signals", result.Source);
}
[Fact]
public async Task GetAsync_BuildsCorrectSubjectKey()
{
var signalsResponse = CreateSignalsResponse(reachable: true, confidence: 0.9);
string? capturedKey = null;
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, CancellationToken>((key, _) => capturedKey = key)
.ReturnsAsync(signalsResponse);
await _store.GetAsync("tenant-1", "pkg:maven/com.example/foo@1.0.0", "CVE-2025-12345");
Assert.Equal("pkg:maven/com.example/foo@1.0.0|CVE-2025-12345", capturedKey);
}
[Fact]
public async Task GetBatchAsync_ReturnsEmptyDict_WhenNoKeysProvided()
{
var result = await _store.GetBatchAsync([]);
Assert.Empty(result);
_mockClient.Verify(c => c.GetBatchBySubjectsAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetBatchAsync_MapsBatchResponse()
{
var keys = new List<ReachabilityFactKey>
{
new("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001"),
new("tenant-1", "pkg:maven/bar@2.0", "CVE-2025-002")
};
var responses = new Dictionary<string, SignalsReachabilityFactResponse>
{
["pkg:maven/foo@1.0|CVE-2025-001"] = CreateSignalsResponse(reachable: true, confidence: 0.9),
["pkg:maven/bar@2.0|CVE-2025-002"] = CreateSignalsResponse(reachable: false, confidence: 0.8)
};
_mockClient.Setup(c => c.GetBatchBySubjectsAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(responses);
var result = await _store.GetBatchAsync(keys);
Assert.Equal(2, result.Count);
Assert.Contains(keys[0], result.Keys);
Assert.Contains(keys[1], result.Keys);
}
[Fact]
public async Task GetBatchAsync_OnlyReturnsFound()
{
var keys = new List<ReachabilityFactKey>
{
new("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001"),
new("tenant-1", "pkg:maven/bar@2.0", "CVE-2025-002")
};
// Only return first key
var responses = new Dictionary<string, SignalsReachabilityFactResponse>
{
["pkg:maven/foo@1.0|CVE-2025-001"] = CreateSignalsResponse(reachable: true, confidence: 0.9)
};
_mockClient.Setup(c => c.GetBatchBySubjectsAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(responses);
var result = await _store.GetBatchAsync(keys);
Assert.Single(result);
Assert.Contains(keys[0], result.Keys);
Assert.DoesNotContain(keys[1], result.Keys);
}
// State Determination Tests
[Fact]
public async Task DeterminesState_Reachable_WhenHasReachableStates()
{
var response = CreateSignalsResponse(reachable: true, confidence: 0.9);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(ReachabilityState.Reachable, result.State);
}
[Fact]
public async Task DeterminesState_Unreachable_WhenHighConfidenceUnreachable()
{
var response = CreateSignalsResponse(reachable: false, confidence: 0.8);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(ReachabilityState.Unreachable, result.State);
}
[Fact]
public async Task DeterminesState_UnderInvestigation_WhenLowConfidenceUnreachable()
{
var response = CreateSignalsResponse(reachable: false, confidence: 0.5);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(ReachabilityState.UnderInvestigation, result.State);
}
[Fact]
public async Task DeterminesState_Unknown_WhenNoStates()
{
var response = new SignalsReachabilityFactResponse
{
Id = "fact-1",
CallgraphId = "cg-1",
States = null,
Score = 0,
ComputedAt = DateTimeOffset.UtcNow
};
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(ReachabilityState.Unknown, result.State);
}
// Analysis Method Tests
[Fact]
public async Task DeterminesMethod_Hybrid_WhenBothStaticAndRuntime()
{
var response = CreateSignalsResponse(reachable: true, confidence: 0.9);
response = response with
{
RuntimeFacts = new List<SignalsRuntimeFact>
{
new() { SymbolId = "sym1", HitCount = 5 }
}
};
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(AnalysisMethod.Hybrid, result.Method);
Assert.True(result.HasRuntimeEvidence);
}
[Fact]
public async Task DeterminesMethod_Static_WhenOnlyStates()
{
var response = CreateSignalsResponse(reachable: true, confidence: 0.9);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(AnalysisMethod.Static, result.Method);
Assert.False(result.HasRuntimeEvidence);
}
// Metadata Extraction Tests
[Fact]
public async Task ExtractsMetadata_FromSignalsResponse()
{
var response = new SignalsReachabilityFactResponse
{
Id = "fact-1",
CallgraphId = "cg-123",
Subject = new SignalsSubject
{
ScanId = "scan-456",
ImageDigest = "sha256:abc"
},
States = new List<SignalsReachabilityState>
{
new()
{
Target = "vulnerable_method",
Reachable = true,
Confidence = 0.9,
Path = new List<string> { "main", "handler", "vulnerable_method" },
LatticeState = "CR"
}
},
EntryPoints = new List<string> { "main" },
Uncertainty = new SignalsUncertainty { AggregateTier = "T3", RiskScore = 0.2 },
UnknownsCount = 5,
UnknownsPressure = 0.1,
RiskScore = 0.3,
Score = 0.85,
ComputedAt = DateTimeOffset.UtcNow
};
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.NotNull(result.Metadata);
Assert.Equal("cg-123", result.Metadata["callgraph_id"]);
Assert.Equal("scan-456", result.Metadata["scan_id"]);
Assert.Equal("sha256:abc", result.Metadata["image_digest"]);
Assert.Equal("T3", result.Metadata["uncertainty_tier"]);
Assert.Equal(5, result.Metadata["unknowns_count"]);
}
// Read-only Store Tests
[Fact]
public async Task SaveAsync_DoesNotCallClient()
{
var fact = CreateReachabilityFact();
await _store.SaveAsync(fact);
_mockClient.Verify(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SaveBatchAsync_DoesNotCallClient()
{
var facts = new List<ReachabilityFact> { CreateReachabilityFact() };
await _store.SaveBatchAsync(facts);
_mockClient.Verify(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task DeleteAsync_DoesNotCallClient()
{
await _store.DeleteAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
_mockClient.Verify(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task CountAsync_ReturnsZero()
{
var count = await _store.CountAsync("tenant-1");
Assert.Equal(0L, count);
}
[Fact]
public async Task QueryAsync_ReturnsEmpty()
{
var query = new ReachabilityFactsQuery { TenantId = "tenant-1" };
var result = await _store.QueryAsync(query);
Assert.Empty(result);
}
// TriggerRecompute Tests
[Fact]
public async Task TriggerRecomputeAsync_DelegatesToClient()
{
_mockClient.Setup(c => c.TriggerRecomputeAsync(It.IsAny<SignalsRecomputeRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var result = await _store.TriggerRecomputeAsync("tenant-1", "pkg:maven/foo@1.0|CVE-2025-001");
Assert.True(result);
_mockClient.Verify(c => c.TriggerRecomputeAsync(
It.Is<SignalsRecomputeRequest>(r => r.SubjectKey == "pkg:maven/foo@1.0|CVE-2025-001" && r.TenantId == "tenant-1"),
It.IsAny<CancellationToken>()), Times.Once);
}
private static SignalsReachabilityFactResponse CreateSignalsResponse(bool reachable, double confidence)
{
return new SignalsReachabilityFactResponse
{
Id = $"fact-{Guid.NewGuid():N}",
CallgraphId = "cg-test",
States = new List<SignalsReachabilityState>
{
new()
{
Target = "vulnerable_method",
Reachable = reachable,
Confidence = confidence,
Bucket = reachable ? "reachable" : "unreachable"
}
},
Score = reachable ? 0.9 : 0.1,
ComputedAt = DateTimeOffset.UtcNow
};
}
private static ReachabilityFact CreateReachabilityFact()
{
return new ReachabilityFact
{
Id = "fact-1",
TenantId = "tenant-1",
ComponentPurl = "pkg:maven/foo@1.0",
AdvisoryId = "CVE-2025-001",
State = ReachabilityState.Reachable,
Confidence = 0.9m,
Source = "test",
Method = AnalysisMethod.Static,
ComputedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -24,6 +24,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,606 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Vex;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Vex;
public class VexDecisionEmitterTests
{
private const string TestTenantId = "test-tenant";
private const string TestVulnId = "CVE-2021-44228";
private const string TestPurl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1";
[Fact]
public async Task EmitAsync_WithUnreachableFact_EmitsNotAffected()
{
// Arrange
var fact = CreateFact(ReachabilityState.Unreachable, hasRuntime: true, confidence: 0.95m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.NotNull(result.Document);
Assert.Single(result.Document.Statements);
var statement = result.Document.Statements[0];
Assert.Equal("not_affected", statement.Status);
Assert.Equal(VexJustification.VulnerableCodeNotInExecutePath, statement.Justification);
Assert.Empty(result.Blocked);
}
[Fact]
public async Task EmitAsync_WithReachableFact_EmitsAffected()
{
// Arrange
var fact = CreateFact(ReachabilityState.Reachable, hasRuntime: true, confidence: 0.9m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.NotNull(result.Document);
Assert.Single(result.Document.Statements);
var statement = result.Document.Statements[0];
Assert.Equal("affected", statement.Status);
Assert.Null(statement.Justification);
Assert.Empty(result.Blocked);
}
[Fact]
public async Task EmitAsync_WithUnknownFact_EmitsUnderInvestigation()
{
// Arrange
var fact = CreateFact(ReachabilityState.Unknown, hasRuntime: false, confidence: 0.0m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.NotNull(result.Document);
Assert.Single(result.Document.Statements);
var statement = result.Document.Statements[0];
Assert.Equal("under_investigation", statement.Status);
Assert.Empty(result.Blocked);
}
[Fact]
public async Task EmitAsync_WhenGateBlocks_FallsBackToUnderInvestigation()
{
// Arrange
var fact = CreateFact(ReachabilityState.Unreachable, hasRuntime: false, confidence: 0.5m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Block, blockedBy: "EvidenceCompleteness", reason: "graphHash required");
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.Single(result.Blocked);
Assert.Equal(TestVulnId, result.Blocked[0].VulnId);
Assert.Equal("EvidenceCompleteness", result.Blocked[0].BlockedBy);
// With FallbackToUnderInvestigation=true (default), still emits under_investigation
Assert.Single(result.Document.Statements);
Assert.Equal("under_investigation", result.Document.Statements[0].Status);
}
[Fact]
public async Task EmitAsync_WithOverride_UsesOverrideStatus()
{
// Arrange
var fact = CreateFact(ReachabilityState.Reachable, hasRuntime: true, confidence: 0.9m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput
{
VulnId = TestVulnId,
Purl = TestPurl,
OverrideStatus = "not_affected",
OverrideJustification = "Manual review confirmed unreachable"
}
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.Single(result.Document.Statements);
Assert.Equal("not_affected", result.Document.Statements[0].Status);
}
[Fact]
public async Task EmitAsync_IncludesEvidenceBlock()
{
// Arrange
var fact = CreateFact(ReachabilityState.Unreachable, hasRuntime: true, confidence: 0.95m);
fact = fact with
{
EvidenceHash = "blake3:abc123",
Metadata = new Dictionary<string, object?>
{
["call_path"] = new List<object> { "main", "svc", "target" },
["entry_points"] = new List<object> { "main" },
["runtime_hits"] = new List<object> { "main", "svc" },
["uncertainty_tier"] = "T3",
["risk_score"] = 0.25
}
};
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
IncludeEvidence = true,
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
var statement = result.Document.Statements[0];
Assert.NotNull(statement.Evidence);
Assert.Equal("CU", statement.Evidence.LatticeState);
Assert.Equal(0.95, statement.Evidence.Confidence);
Assert.Equal("blake3:abc123", statement.Evidence.GraphHash);
Assert.Equal("T3", statement.Evidence.UncertaintyTier);
Assert.Equal(0.25, statement.Evidence.RiskScore);
Assert.NotNull(statement.Evidence.CallPath);
Assert.Equal(new[] { "main", "svc", "target" }, statement.Evidence.CallPath.Value.ToArray());
}
[Fact]
public async Task EmitAsync_WithMultipleFindings_EmitsMultipleStatements()
{
// Arrange
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
{
[new(TestTenantId, TestPurl, "CVE-2021-44228")] = CreateFact(ReachabilityState.Unreachable, hasRuntime: true, confidence: 0.95m, vulnId: "CVE-2021-44228"),
[new(TestTenantId, "pkg:npm/lodash@4.17.20", "CVE-2021-23337")] = CreateFact(ReachabilityState.Reachable, hasRuntime: false, confidence: 0.8m, vulnId: "CVE-2021-23337", purl: "pkg:npm/lodash@4.17.20")
};
var factsService = CreateMockFactsService(facts);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = "CVE-2021-44228", Purl = TestPurl },
new VexFindingInput { VulnId = "CVE-2021-23337", Purl = "pkg:npm/lodash@4.17.20" }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.Equal(2, result.Document.Statements.Length);
Assert.Contains(result.Document.Statements, s => s.Status == "not_affected");
Assert.Contains(result.Document.Statements, s => s.Status == "affected");
}
[Fact]
public async Task EmitAsync_DocumentHasCorrectMetadata()
{
// Arrange
var factsService = CreateMockFactsService((ReachabilityFact?)null);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "security-team@company.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.StartsWith("urn:uuid:", result.Document.Id);
Assert.Equal("https://openvex.dev/ns/v0.2.0", result.Document.Context);
Assert.Equal("security-team@company.com", result.Document.Author);
Assert.Equal("policy_engine", result.Document.Role);
Assert.Equal("stellaops/policy-engine", result.Document.Tooling);
Assert.Equal(1, result.Document.Version);
}
[Fact]
public async Task DetermineStatusAsync_ReturnsCorrectBucket()
{
// Arrange
var fact = CreateFact(ReachabilityState.Reachable, hasRuntime: true, confidence: 0.9m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
// Act
var determination = await emitter.DetermineStatusAsync(TestTenantId, TestVulnId, TestPurl);
// Assert
Assert.Equal("affected", determination.Status);
Assert.Equal("runtime", determination.Bucket);
Assert.Equal("CR", determination.LatticeState);
Assert.Equal(0.9, determination.Confidence);
Assert.NotNull(determination.Fact);
}
[Fact]
public async Task EmitAsync_WithSymbolId_IncludesSubcomponent()
{
// Arrange
var factsService = CreateMockFactsService((ReachabilityFact?)null);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput
{
VulnId = TestVulnId,
Purl = TestPurl,
SymbolId = "org.apache.logging.log4j.core.lookup.JndiLookup.lookup"
}
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
var statement = result.Document.Statements[0];
Assert.NotNull(statement.Products[0].Subcomponents);
var subcomponents = statement.Products[0].Subcomponents!.Value;
Assert.Single(subcomponents);
Assert.Equal("org.apache.logging.log4j.core.lookup.JndiLookup.lookup", subcomponents[0].Id);
}
private static ReachabilityFact CreateFact(
ReachabilityState state,
bool hasRuntime,
decimal confidence,
string? vulnId = null,
string? purl = null)
{
return new ReachabilityFact
{
Id = Guid.NewGuid().ToString("N"),
TenantId = TestTenantId,
ComponentPurl = purl ?? TestPurl,
AdvisoryId = vulnId ?? TestVulnId,
State = state,
Confidence = confidence,
Score = (decimal)(hasRuntime ? 0.9 : 0.5),
HasRuntimeEvidence = hasRuntime,
Source = "stellaops/signals",
Method = hasRuntime ? AnalysisMethod.Hybrid : AnalysisMethod.Static,
ComputedAt = DateTimeOffset.UtcNow,
EvidenceRef = "cas://reachability/graphs/test"
};
}
private static ReachabilityFactsJoiningService CreateMockFactsService(ReachabilityFact? fact)
{
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
if (fact is not null)
{
facts[new(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId)] = fact;
}
return CreateMockFactsService(facts);
}
private static ReachabilityFactsJoiningService CreateMockFactsService(Dictionary<ReachabilityFactKey, ReachabilityFact> facts)
{
var store = new InMemoryReachabilityFactsStore(facts);
var cache = new InMemoryReachabilityFactsOverlayCache();
return new ReachabilityFactsJoiningService(
store,
cache,
NullLogger<ReachabilityFactsJoiningService>.Instance,
TimeProvider.System);
}
private static IPolicyGateEvaluator CreateMockGateEvaluator(
PolicyGateDecisionType decision,
string? blockedBy = null,
string? reason = null)
{
return new MockPolicyGateEvaluator(decision, blockedBy, reason);
}
private static VexDecisionEmitter CreateEmitter(
ReachabilityFactsJoiningService factsService,
IPolicyGateEvaluator gateEvaluator)
{
var options = new TestOptionsMonitor<VexDecisionEmitterOptions>(new VexDecisionEmitterOptions());
return new VexDecisionEmitter(
factsService,
gateEvaluator,
options,
TimeProvider.System,
NullLogger<VexDecisionEmitter>.Instance);
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
{
public TestOptionsMonitor(T currentValue)
{
CurrentValue = currentValue;
}
public T CurrentValue { get; }
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
private sealed class InMemoryReachabilityFactsStore : IReachabilityFactsStore
{
private readonly Dictionary<ReachabilityFactKey, ReachabilityFact> _facts;
public InMemoryReachabilityFactsStore(Dictionary<ReachabilityFactKey, ReachabilityFact> facts)
{
_facts = facts;
}
public Task<ReachabilityFact?> GetAsync(string tenantId, string componentPurl, string advisoryId, CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
_facts.TryGetValue(key, out var fact);
return Task.FromResult(fact);
}
public Task<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>> GetBatchAsync(IReadOnlyList<ReachabilityFactKey> keys, CancellationToken cancellationToken = default)
{
var result = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
foreach (var key in keys)
{
if (_facts.TryGetValue(key, out var fact))
{
result[key] = fact;
}
}
return Task.FromResult<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>>(result);
}
public Task<IReadOnlyList<ReachabilityFact>> QueryAsync(ReachabilityFactsQuery query, CancellationToken cancellationToken = default)
{
var results = _facts.Values
.Where(f => f.TenantId == query.TenantId)
.Where(f => query.ComponentPurls == null || query.ComponentPurls.Contains(f.ComponentPurl))
.Where(f => query.AdvisoryIds == null || query.AdvisoryIds.Contains(f.AdvisoryId))
.Where(f => query.States == null || query.States.Contains(f.State))
.Where(f => !query.MinConfidence.HasValue || f.Confidence >= query.MinConfidence.Value)
.Skip(query.Skip)
.Take(query.Limit)
.ToList();
return Task.FromResult<IReadOnlyList<ReachabilityFact>>(results);
}
public Task SaveAsync(ReachabilityFact fact, CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
_facts[key] = fact;
return Task.CompletedTask;
}
public Task SaveBatchAsync(IReadOnlyList<ReachabilityFact> facts, CancellationToken cancellationToken = default)
{
foreach (var fact in facts)
{
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
_facts[key] = fact;
}
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string componentPurl, string advisoryId, CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
_facts.Remove(key);
return Task.CompletedTask;
}
public Task<long> CountAsync(string tenantId, CancellationToken cancellationToken = default)
{
var count = _facts.Values.Count(f => f.TenantId == tenantId);
return Task.FromResult((long)count);
}
}
private sealed class InMemoryReachabilityFactsOverlayCache : IReachabilityFactsOverlayCache
{
private readonly Dictionary<ReachabilityFactKey, ReachabilityFact> _cache = new();
public Task<(ReachabilityFact? Fact, bool CacheHit)> GetAsync(ReachabilityFactKey key, CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(key, out var fact))
{
return Task.FromResult<(ReachabilityFact?, bool)>((fact, true));
}
return Task.FromResult<(ReachabilityFact?, bool)>((null, false));
}
public Task<ReachabilityFactsBatch> GetBatchAsync(IReadOnlyList<ReachabilityFactKey> keys, CancellationToken cancellationToken = default)
{
var found = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
var notFound = new List<ReachabilityFactKey>();
foreach (var key in keys)
{
if (_cache.TryGetValue(key, out var fact))
{
found[key] = fact;
}
else
{
notFound.Add(key);
}
}
return Task.FromResult(new ReachabilityFactsBatch
{
Found = found,
NotFound = notFound,
CacheHits = found.Count,
CacheMisses = notFound.Count
});
}
public Task SetAsync(ReachabilityFactKey key, ReachabilityFact fact, CancellationToken cancellationToken = default)
{
_cache[key] = fact;
return Task.CompletedTask;
}
public Task SetBatchAsync(IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> facts, CancellationToken cancellationToken = default)
{
foreach (var (key, fact) in facts)
{
_cache[key] = fact;
}
return Task.CompletedTask;
}
public Task InvalidateAsync(ReachabilityFactKey key, CancellationToken cancellationToken = default)
{
_cache.Remove(key);
return Task.CompletedTask;
}
public Task InvalidateTenantAsync(string tenantId, CancellationToken cancellationToken = default)
{
var keysToRemove = _cache.Keys.Where(k => k.TenantId == tenantId).ToList();
foreach (var key in keysToRemove)
{
_cache.Remove(key);
}
return Task.CompletedTask;
}
public ReachabilityFactsCacheStats GetStats()
{
return new ReachabilityFactsCacheStats { ItemCount = _cache.Count };
}
}
private sealed class MockPolicyGateEvaluator : IPolicyGateEvaluator
{
private readonly PolicyGateDecisionType _decision;
private readonly string? _blockedBy;
private readonly string? _reason;
public MockPolicyGateEvaluator(PolicyGateDecisionType decision, string? blockedBy, string? reason)
{
_decision = decision;
_blockedBy = blockedBy;
_reason = reason;
}
public Task<PolicyGateDecision> EvaluateAsync(PolicyGateRequest request, CancellationToken cancellationToken = default)
{
return Task.FromResult(new PolicyGateDecision
{
GateId = $"gate:vex:{request.RequestedStatus}:{DateTimeOffset.UtcNow:O}",
RequestedStatus = request.RequestedStatus,
Subject = new PolicyGateSubject
{
VulnId = request.VulnId,
Purl = request.Purl
},
Evidence = new PolicyGateEvidence
{
LatticeState = request.LatticeState,
Confidence = request.Confidence
},
Gates = ImmutableArray<PolicyGateResult>.Empty,
Decision = _decision,
BlockedBy = _decision == PolicyGateDecisionType.Block ? _blockedBy : null,
BlockReason = _decision == PolicyGateDecisionType.Block ? _reason : null,
DecidedAt = DateTimeOffset.UtcNow
});
}
}
}

View File

@@ -0,0 +1,470 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Policy.Engine.Vex;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Vex;
public sealed class VexDecisionSigningServiceTests
{
private readonly Mock<IVexSignerClient> _mockSignerClient;
private readonly Mock<IVexRekorClient> _mockRekorClient;
private readonly VexSigningOptions _options;
private readonly VexDecisionSigningService _service;
public VexDecisionSigningServiceTests()
{
_mockSignerClient = new Mock<IVexSignerClient>();
_mockRekorClient = new Mock<IVexRekorClient>();
_options = new VexSigningOptions
{
UseSignerService = true,
RekorEnabled = true
};
var optionsMonitor = new Mock<IOptionsMonitor<VexSigningOptions>>();
optionsMonitor.Setup(o => o.CurrentValue).Returns(_options);
_service = new VexDecisionSigningService(
_mockSignerClient.Object,
_mockRekorClient.Object,
optionsMonitor.Object,
TimeProvider.System,
NullLogger<VexDecisionSigningService>.Instance);
}
[Fact]
public async Task SignAsync_WithSignerService_ReturnsEnvelope()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
var result = await _service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.Envelope);
Assert.NotNull(result.EnvelopeDigest);
Assert.StartsWith("sha256:", result.EnvelopeDigest);
}
[Fact]
public async Task SignAsync_WithSignerServiceFailure_ReturnsFailed()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = false,
Error = "Signing failed"
});
var result = await _service.SignAsync(request);
Assert.False(result.Success);
Assert.Null(result.Envelope);
Assert.Contains("Signing failed", result.Error);
}
[Fact]
public async Task SignAsync_WithLocalSigning_ReturnsEnvelope()
{
var localOptions = new VexSigningOptions
{
UseSignerService = false,
RekorEnabled = false
};
var optionsMonitor = new Mock<IOptionsMonitor<VexSigningOptions>>();
optionsMonitor.Setup(o => o.CurrentValue).Returns(localOptions);
var service = new VexDecisionSigningService(
null,
null,
optionsMonitor.Object,
TimeProvider.System,
NullLogger<VexDecisionSigningService>.Instance);
var document = CreateTestDocument();
var request = CreateSigningRequest(document, submitToRekor: false);
var result = await service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.Envelope);
Assert.Single(result.Envelope.Signatures);
Assert.Equal("local:sha256", result.Envelope.Signatures[0].KeyId);
}
[Fact]
public async Task SignAsync_WithRekorEnabled_SubmitsToRekor()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document, submitToRekor: true);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
_mockRekorClient.Setup(c => c.SubmitAsync(It.IsAny<VexRekorSubmitRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexRekorSubmitResult
{
Success = true,
Metadata = new VexRekorMetadata
{
Uuid = "rekor-uuid-123",
Index = 12345,
LogUrl = "https://rekor.sigstore.dev",
IntegratedAt = DateTimeOffset.UtcNow
}
});
var result = await _service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.RekorMetadata);
Assert.Equal("rekor-uuid-123", result.RekorMetadata.Uuid);
Assert.Equal(12345, result.RekorMetadata.Index);
}
[Fact]
public async Task SignAsync_WithRekorFailure_StillSucceeds()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document, submitToRekor: true);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
_mockRekorClient.Setup(c => c.SubmitAsync(It.IsAny<VexRekorSubmitRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexRekorSubmitResult
{
Success = false,
Error = "Rekor unavailable"
});
var result = await _service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.Envelope);
Assert.Null(result.RekorMetadata);
}
[Fact]
public async Task SignAsync_WithRekorDisabled_DoesNotSubmit()
{
var disabledOptions = new VexSigningOptions
{
UseSignerService = true,
RekorEnabled = false
};
var optionsMonitor = new Mock<IOptionsMonitor<VexSigningOptions>>();
optionsMonitor.Setup(o => o.CurrentValue).Returns(disabledOptions);
var service = new VexDecisionSigningService(
_mockSignerClient.Object,
_mockRekorClient.Object,
optionsMonitor.Object,
TimeProvider.System,
NullLogger<VexDecisionSigningService>.Instance);
var document = CreateTestDocument();
var request = CreateSigningRequest(document, submitToRekor: true);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
var result = await service.SignAsync(request);
Assert.True(result.Success);
Assert.Null(result.RekorMetadata);
_mockRekorClient.Verify(c => c.SubmitAsync(It.IsAny<VexRekorSubmitRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SignAsync_SetsCorrectPayloadType()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document);
VexSignerRequest? capturedRequest = null;
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.Callback<VexSignerRequest, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
await _service.SignAsync(request);
Assert.NotNull(capturedRequest);
Assert.Equal(VexPredicateTypes.VexDecision, capturedRequest.PayloadType);
}
// Verification Tests
[Fact]
public async Task VerifyAsync_WithValidEnvelope_ReturnsValid()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = Convert.ToBase64String(payload),
Signatures = [new VexDsseSignature { KeyId = "test", Sig = Convert.ToBase64String(new byte[32]) }]
};
var request = new VexVerificationRequest
{
Envelope = envelope,
VerifyRekorInclusion = false
};
var result = await _service.VerifyAsync(request);
Assert.True(result.Valid);
Assert.NotNull(result.Document);
Assert.Equal(document.Id, result.Document.Id);
}
[Fact]
public async Task VerifyAsync_WithInvalidPayloadType_ReturnsInvalid()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = "invalid/type@v1",
Payload = Convert.ToBase64String(payload),
Signatures = [new VexDsseSignature { KeyId = "test", Sig = Convert.ToBase64String(new byte[32]) }]
};
var request = new VexVerificationRequest
{
Envelope = envelope,
VerifyRekorInclusion = false
};
var result = await _service.VerifyAsync(request);
Assert.False(result.Valid);
Assert.NotNull(result.Errors);
Assert.Contains(result.Errors, e => e.Contains("Invalid payload type"));
}
[Fact]
public async Task VerifyAsync_WithNoSignatures_ReturnsInvalid()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = Convert.ToBase64String(payload),
Signatures = []
};
var request = new VexVerificationRequest
{
Envelope = envelope,
VerifyRekorInclusion = false
};
var result = await _service.VerifyAsync(request);
Assert.False(result.Valid);
Assert.NotNull(result.Errors);
Assert.Contains(result.Errors, e => e.Contains("no signatures"));
}
[Fact]
public async Task VerifyAsync_WithInvalidBase64Signature_ReturnsInvalid()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = Convert.ToBase64String(payload),
Signatures = [new VexDsseSignature { KeyId = "test", Sig = "not-valid-base64!!!" }]
};
var request = new VexVerificationRequest
{
Envelope = envelope,
VerifyRekorInclusion = false
};
var result = await _service.VerifyAsync(request);
Assert.False(result.Valid);
Assert.NotNull(result.Errors);
Assert.Contains(result.Errors, e => e.Contains("Invalid base64"));
}
[Fact]
public async Task VerifyAsync_WithRekorVerification_CallsGetProof()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = Convert.ToBase64String(payload),
Signatures = [new VexDsseSignature { KeyId = "test", Sig = Convert.ToBase64String(new byte[32]) }]
};
var rekorMetadata = new VexRekorMetadata
{
Uuid = "rekor-uuid-123",
Index = 12345,
LogUrl = "https://rekor.sigstore.dev",
IntegratedAt = DateTimeOffset.UtcNow
};
_mockRekorClient.Setup(c => c.GetProofAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(rekorMetadata);
var request = new VexVerificationRequest
{
Envelope = envelope,
ExpectedRekorMetadata = rekorMetadata,
VerifyRekorInclusion = true
};
var result = await _service.VerifyAsync(request);
Assert.True(result.Valid);
Assert.NotNull(result.RekorMetadata);
_mockRekorClient.Verify(c => c.GetProofAsync("rekor-uuid-123", It.IsAny<CancellationToken>()), Times.Once);
}
// Options Tests
[Fact]
public void VexSigningOptions_HasCorrectDefaults()
{
var options = new VexSigningOptions();
Assert.True(options.UseSignerService);
Assert.True(options.RekorEnabled);
Assert.Null(options.DefaultKeyId);
Assert.Null(options.RekorUrl);
Assert.Equal(TimeSpan.FromSeconds(30), options.RekorTimeout);
}
[Fact]
public void VexSigningOptions_SectionName_IsCorrect()
{
Assert.Equal("VexSigning", VexSigningOptions.SectionName);
}
// Predicate Types Tests
[Fact]
public void VexPredicateTypes_HasCorrectValues()
{
Assert.Equal("stella.ops/vexDecision@v1", VexPredicateTypes.VexDecision);
Assert.Equal("stella.ops/vex@v1", VexPredicateTypes.VexDocument);
Assert.Equal("https://openvex.dev/ns", VexPredicateTypes.OpenVex);
}
// Evidence Reference Tests
[Fact]
public async Task SignAsync_WithEvidenceRefs_IncludesInRequest()
{
var document = CreateTestDocument();
var evidenceRefs = new List<VexEvidenceReference>
{
new() { Type = "sbom", Digest = "sha256:abc123" },
new() { Type = "callgraph", Digest = "sha256:def456", CasUri = "cas://example/cg/1" }
};
var request = new VexSigningRequest
{
Document = document,
TenantId = "tenant-1",
SubmitToRekor = false,
EvidenceRefs = evidenceRefs
};
var localOptions = new VexSigningOptions { UseSignerService = false, RekorEnabled = false };
var optionsMonitor = new Mock<IOptionsMonitor<VexSigningOptions>>();
optionsMonitor.Setup(o => o.CurrentValue).Returns(localOptions);
var service = new VexDecisionSigningService(
null,
null,
optionsMonitor.Object,
TimeProvider.System,
NullLogger<VexDecisionSigningService>.Instance);
var result = await service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.Envelope);
}
private static VexDecisionDocument CreateTestDocument()
{
var now = DateTimeOffset.UtcNow;
return new VexDecisionDocument
{
Id = $"https://stellaops.io/vex/{Guid.NewGuid():N}",
Author = "https://stellaops.io/policy-engine",
Timestamp = now,
Statements = ImmutableArray.Create(
new VexStatement
{
Vulnerability = new VexVulnerability { Id = "CVE-2025-12345" },
Status = "not_affected",
Justification = VexJustification.VulnerableCodeNotInExecutePath,
Timestamp = now,
Products = ImmutableArray.Create(
new VexProduct { Id = "pkg:maven/com.example/app@1.0.0" }
)
}
)
};
}
private static VexSigningRequest CreateSigningRequest(VexDecisionDocument document, bool submitToRekor = true)
{
return new VexSigningRequest
{
Document = document,
TenantId = "tenant-1",
SubmitToRekor = submitToRekor
};
}
}