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
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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user