Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Moq;
|
||||
using StellaOps.Excititor.ArtifactStores.S3;
|
||||
using StellaOps.Excititor.Export;
|
||||
|
||||
namespace StellaOps.Excititor.ArtifactStores.S3.Tests;
|
||||
|
||||
public sealed class S3ArtifactClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ObjectExistsAsync_ReturnsTrue_WhenMetadataSucceeds()
|
||||
{
|
||||
var mock = new Mock<IAmazonS3>();
|
||||
mock.Setup(x => x.GetObjectMetadataAsync("bucket", "key", default)).ReturnsAsync(new GetObjectMetadataResponse
|
||||
{
|
||||
HttpStatusCode = System.Net.HttpStatusCode.OK,
|
||||
});
|
||||
|
||||
var client = new S3ArtifactClient(mock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger<S3ArtifactClient>.Instance);
|
||||
var exists = await client.ObjectExistsAsync("bucket", "key", default);
|
||||
Assert.True(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutObjectAsync_MapsMetadata()
|
||||
{
|
||||
var mock = new Mock<IAmazonS3>();
|
||||
mock.Setup(x => x.PutObjectAsync(It.IsAny<PutObjectRequest>(), default))
|
||||
.ReturnsAsync(new PutObjectResponse());
|
||||
|
||||
var client = new S3ArtifactClient(mock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger<S3ArtifactClient>.Instance);
|
||||
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
|
||||
await client.PutObjectAsync("bucket", "key", stream, new Dictionary<string, string> { ["a"] = "b" }, default);
|
||||
|
||||
mock.Verify(x => x.PutObjectAsync(It.Is<PutObjectRequest>(r => r.Metadata["a"] == "b"), default), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.ArtifactStores.S3/StellaOps.Excititor.ArtifactStores.S3.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Attestation.Transparency;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Tests;
|
||||
|
||||
public sealed class VexAttestationClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SignAsync_ReturnsEnvelopeDigestAndDiagnostics()
|
||||
{
|
||||
var signer = new FakeSigner();
|
||||
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
|
||||
var options = Options.Create(new VexAttestationClientOptions());
|
||||
var verifier = new FakeVerifier();
|
||||
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier);
|
||||
|
||||
var request = new VexAttestationRequest(
|
||||
ExportId: "exports/456",
|
||||
QuerySignature: new VexQuerySignature("filters"),
|
||||
Artifact: new VexContentAddress("sha256", "deadbeef"),
|
||||
Format: VexExportFormat.Json,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
SourceProviders: ImmutableArray.Create("vendor"),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var response = await client.SignAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(response.Attestation);
|
||||
Assert.NotNull(response.Attestation.EnvelopeDigest);
|
||||
Assert.True(response.Diagnostics.ContainsKey("envelope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_SubmitsToTransparencyLog_WhenConfigured()
|
||||
{
|
||||
var signer = new FakeSigner();
|
||||
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
|
||||
var options = Options.Create(new VexAttestationClientOptions());
|
||||
var transparency = new FakeTransparencyLogClient();
|
||||
var verifier = new FakeVerifier();
|
||||
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparencyLogClient: transparency);
|
||||
|
||||
var request = new VexAttestationRequest(
|
||||
ExportId: "exports/789",
|
||||
QuerySignature: new VexQuerySignature("filters"),
|
||||
Artifact: new VexContentAddress("sha256", "deadbeef"),
|
||||
Format: VexExportFormat.Json,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
SourceProviders: ImmutableArray.Create("vendor"),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var response = await client.SignAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(response.Attestation.Rekor);
|
||||
Assert.True(response.Diagnostics.ContainsKey("rekorLocation"));
|
||||
Assert.True(transparency.SubmitCalled);
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IVexSigner
|
||||
{
|
||||
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
|
||||
}
|
||||
|
||||
private sealed class FakeTransparencyLogClient : ITransparencyLogClient
|
||||
{
|
||||
public bool SubmitCalled { get; private set; }
|
||||
|
||||
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
|
||||
{
|
||||
SubmitCalled = true;
|
||||
return ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "23", null));
|
||||
}
|
||||
|
||||
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private sealed class FakeVerifier : IVexAttestationVerifier
|
||||
{
|
||||
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Attestation.Transparency;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Tests;
|
||||
|
||||
public sealed class VexAttestationVerifierTests : IDisposable
|
||||
{
|
||||
private readonly VexAttestationMetrics _metrics = new();
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches()
|
||||
{
|
||||
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
|
||||
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
|
||||
|
||||
var verification = await verifier.VerifyAsync(
|
||||
new VexAttestationVerificationRequest(request, metadata, envelope),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(verification.IsValid);
|
||||
Assert.Equal("valid", verification.Diagnostics["result"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch()
|
||||
{
|
||||
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
|
||||
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
|
||||
|
||||
var tamperedMetadata = new VexAttestationMetadata(
|
||||
metadata.PredicateType,
|
||||
metadata.Rekor,
|
||||
"sha256:deadbeef",
|
||||
metadata.SignedAt);
|
||||
|
||||
var verification = await verifier.VerifyAsync(
|
||||
new VexAttestationVerificationRequest(request, tamperedMetadata, envelope),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(verification.IsValid);
|
||||
Assert.Equal("invalid", verification.Diagnostics["result"]);
|
||||
Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AllowsOfflineTransparency_WhenConfigured()
|
||||
{
|
||||
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
|
||||
var transparency = new ThrowingTransparencyLogClient();
|
||||
var verifier = CreateVerifier(options =>
|
||||
{
|
||||
options.AllowOfflineTransparency = true;
|
||||
options.RequireTransparencyLog = true;
|
||||
}, transparency);
|
||||
|
||||
var verification = await verifier.VerifyAsync(
|
||||
new VexAttestationVerificationRequest(request, metadata, envelope),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(verification.IsValid);
|
||||
Assert.Equal("offline", verification.Diagnostics["rekor.state"]);
|
||||
}
|
||||
|
||||
private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(bool includeRekor = false)
|
||||
{
|
||||
var signer = new FakeSigner();
|
||||
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
|
||||
var options = Options.Create(new VexAttestationClientOptions());
|
||||
var transparency = includeRekor ? new FakeTransparencyLogClient() : null;
|
||||
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
|
||||
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparency);
|
||||
|
||||
var request = new VexAttestationRequest(
|
||||
ExportId: "exports/unit-test",
|
||||
QuerySignature: new VexQuerySignature("filters"),
|
||||
Artifact: new VexContentAddress("sha256", "cafebabe"),
|
||||
Format: VexExportFormat.Json,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
SourceProviders: ImmutableArray.Create("provider-a"),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var response = await client.SignAsync(request, CancellationToken.None);
|
||||
var envelope = response.Diagnostics["envelope"];
|
||||
return (request, response.Attestation, envelope);
|
||||
}
|
||||
|
||||
private VexAttestationVerifier CreateVerifier(Action<VexAttestationVerificationOptions>? configureOptions = null, ITransparencyLogClient? transparency = null)
|
||||
{
|
||||
var options = new VexAttestationVerificationOptions();
|
||||
configureOptions?.Invoke(options);
|
||||
return new VexAttestationVerifier(
|
||||
NullLogger<VexAttestationVerifier>.Instance,
|
||||
transparency,
|
||||
Options.Create(options),
|
||||
_metrics);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IVexSigner
|
||||
{
|
||||
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
|
||||
}
|
||||
|
||||
private sealed class FakeTransparencyLogClient : ITransparencyLogClient
|
||||
{
|
||||
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "42", null));
|
||||
|
||||
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTransparencyLogClient : ITransparencyLogClient
|
||||
{
|
||||
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
|
||||
=> throw new HttpRequestException("rekor unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Models;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Attestation.Tests;
|
||||
|
||||
public sealed class VexDsseBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateEnvelopeAsync_ProducesDeterministicPayload()
|
||||
{
|
||||
var signer = new FakeSigner("signature-value", "key-1");
|
||||
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
|
||||
|
||||
var request = new VexAttestationRequest(
|
||||
ExportId: "exports/123",
|
||||
QuerySignature: new VexQuerySignature("filters"),
|
||||
Artifact: new VexContentAddress("sha256", "deadbeef"),
|
||||
Format: VexExportFormat.Json,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
SourceProviders: ImmutableArray.Create("vendor"),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var envelope = await builder.CreateEnvelopeAsync(request, request.Metadata, CancellationToken.None);
|
||||
|
||||
Assert.Equal("application/vnd.in-toto+json", envelope.PayloadType);
|
||||
Assert.Single(envelope.Signatures);
|
||||
Assert.Equal("signature-value", envelope.Signatures[0].Signature);
|
||||
Assert.Equal("key-1", envelope.Signatures[0].KeyId);
|
||||
|
||||
var digest = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
|
||||
Assert.StartsWith("sha256:", digest);
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IVexSigner
|
||||
{
|
||||
private readonly string _signature;
|
||||
private readonly string _keyId;
|
||||
|
||||
public FakeSigner(string signature, string keyId)
|
||||
{
|
||||
_signature = signature;
|
||||
_keyId = keyId;
|
||||
}
|
||||
|
||||
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexSignedPayload(_signature, _keyId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
using System.Threading;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.Connectors;
|
||||
|
||||
public sealed class CiscoCsafConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FetchAsync_NewAdvisory_StoresDocumentAndUpdatesState()
|
||||
{
|
||||
var responses = new Dictionary<Uri, Queue<HttpResponseMessage>>
|
||||
{
|
||||
[new Uri("https://api.cisco.test/.well-known/csaf/provider-metadata.json")] = QueueResponses("""
|
||||
{
|
||||
"metadata": {
|
||||
"publisher": {
|
||||
"name": "Cisco",
|
||||
"category": "vendor",
|
||||
"contact_details": { "id": "excititor:cisco" }
|
||||
}
|
||||
},
|
||||
"distributions": {
|
||||
"directories": [ "https://api.cisco.test/csaf/" ]
|
||||
}
|
||||
}
|
||||
"""),
|
||||
[new Uri("https://api.cisco.test/csaf/index.json")] = QueueResponses("""
|
||||
{
|
||||
"advisories": [
|
||||
{
|
||||
"id": "cisco-sa-2025",
|
||||
"url": "https://api.cisco.test/csaf/cisco-sa-2025.json",
|
||||
"published": "2025-10-01T00:00:00Z",
|
||||
"lastModified": "2025-10-02T00:00:00Z",
|
||||
"sha256": "cafebabe"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""),
|
||||
[new Uri("https://api.cisco.test/csaf/cisco-sa-2025.json")] = QueueResponses("{ \"document\": \"payload\" }")
|
||||
};
|
||||
|
||||
var handler = new RoutingHttpMessageHandler(responses);
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleHttpClientFactory(httpClient);
|
||||
var metadataLoader = new CiscoProviderMetadataLoader(
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
Options.Create(new CiscoConnectorOptions
|
||||
{
|
||||
MetadataUri = "https://api.cisco.test/.well-known/csaf/provider-metadata.json",
|
||||
PersistOfflineSnapshot = false,
|
||||
}),
|
||||
NullLogger<CiscoProviderMetadataLoader>.Instance,
|
||||
new MockFileSystem());
|
||||
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var connector = new CiscoCsafConnector(
|
||||
metadataLoader,
|
||||
factory,
|
||||
stateRepository,
|
||||
new[] { new CiscoConnectorOptionsValidator() },
|
||||
NullLogger<CiscoCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var settings = new VexConnectorSettings(ImmutableDictionary<string, string>.Empty);
|
||||
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider(), ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
stateRepository.CurrentState.Should().NotBeNull();
|
||||
stateRepository.CurrentState!.DocumentDigests.Should().HaveCount(1);
|
||||
|
||||
// second run should not refetch documents
|
||||
sink.Documents.Clear();
|
||||
documents.Clear();
|
||||
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static Queue<HttpResponseMessage> QueueResponses(string payload)
|
||||
=> new(new[]
|
||||
{
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
}
|
||||
});
|
||||
|
||||
private sealed class RoutingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<Uri, Queue<HttpResponseMessage>> _responses;
|
||||
|
||||
public RoutingHttpMessageHandler(Dictionary<Uri, Queue<HttpResponseMessage>> responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var queue) && queue.Count > 0)
|
||||
{
|
||||
var response = queue.Peek();
|
||||
return Task.FromResult(response.Clone());
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent($"No response configured for {request.RequestUri}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? CurrentState { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(CurrentState);
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
CurrentState = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HttpResponseMessageExtensions
|
||||
{
|
||||
public static HttpResponseMessage Clone(this HttpResponseMessage response)
|
||||
{
|
||||
var clone = new HttpResponseMessage(response.StatusCode);
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (response.Content is not null)
|
||||
{
|
||||
var payload = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
clone.Content = new StringContent(payload, Encoding.UTF8, response.Content.Headers.ContentType?.MediaType);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.Metadata;
|
||||
|
||||
public sealed class CiscoProviderMetadataLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesFromNetworkWithBearerToken()
|
||||
{
|
||||
var payload = """
|
||||
{
|
||||
"metadata": {
|
||||
"publisher": {
|
||||
"name": "Cisco CSAF",
|
||||
"category": "vendor",
|
||||
"contact_details": {
|
||||
"id": "excititor:cisco"
|
||||
}
|
||||
}
|
||||
},
|
||||
"distributions": {
|
||||
"directories": [
|
||||
"https://api.security.cisco.com/csaf/v2/advisories/"
|
||||
]
|
||||
},
|
||||
"discovery": {
|
||||
"well_known": "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
|
||||
"rolie": "https://api.security.cisco.com/csaf/rolie/feed"
|
||||
},
|
||||
"trust": {
|
||||
"weight": 0.9,
|
||||
"cosign": {
|
||||
"issuer": "https://oidc.security.cisco.com",
|
||||
"identity_pattern": "spiffe://cisco/*"
|
||||
},
|
||||
"pgp_fingerprints": [ "1234ABCD" ]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
HttpRequestMessage? capturedRequest = null;
|
||||
var handler = new FakeHttpMessageHandler(request =>
|
||||
{
|
||||
capturedRequest = request;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"etag1\"") }
|
||||
};
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = Options.Create(new CiscoConnectorOptions
|
||||
{
|
||||
ApiToken = "token-123",
|
||||
MetadataUri = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
|
||||
});
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new CiscoProviderMetadataLoader(factory, cache, options, NullLogger<CiscoProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var result = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
result.Provider.Id.Should().Be("excititor:cisco");
|
||||
result.Provider.BaseUris.Should().ContainSingle(uri => uri.ToString() == "https://api.security.cisco.com/csaf/v2/advisories/");
|
||||
result.Provider.Discovery.RolIeService.Should().Be(new Uri("https://api.security.cisco.com/csaf/rolie/feed"));
|
||||
result.ServedFromCache.Should().BeFalse();
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest!.Headers.Authorization.Should().NotBeNull();
|
||||
capturedRequest.Headers.Authorization!.Parameter.Should().Be("token-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FallsBackToOfflineSnapshot()
|
||||
{
|
||||
var payload = """
|
||||
{
|
||||
"metadata": {
|
||||
"publisher": {
|
||||
"name": "Cisco CSAF",
|
||||
"category": "vendor",
|
||||
"contact_details": {
|
||||
"id": "excititor:cisco"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = Options.Create(new CiscoConnectorOptions
|
||||
{
|
||||
MetadataUri = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
OfflineSnapshotPath = "/snapshots/cisco.json",
|
||||
});
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/snapshots/cisco.json"] = new MockFileData(payload),
|
||||
});
|
||||
var loader = new CiscoProviderMetadataLoader(factory, cache, options, NullLogger<CiscoProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var result = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
result.FromOfflineSnapshot.Should().BeTrue();
|
||||
result.Provider.Id.Should().Be("excititor:cisco");
|
||||
}
|
||||
|
||||
private sealed class SingleHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class FakeHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
|
||||
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
_responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Cisco.CSAF/StellaOps.Excititor.Connectors.Cisco.CSAF.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Authentication;
|
||||
|
||||
public sealed class MsrcTokenProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_CachesUntilExpiry()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new[]
|
||||
{
|
||||
CreateTokenResponse("token-1"),
|
||||
CreateTokenResponse("token-2"),
|
||||
});
|
||||
var client = new HttpClient(handler) { BaseAddress = new Uri("https://login.microsoftonline.com/") };
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = Options.Create(new MsrcConnectorOptions
|
||||
{
|
||||
TenantId = "contoso.onmicrosoft.com",
|
||||
ClientId = "client-id",
|
||||
ClientSecret = "secret",
|
||||
});
|
||||
|
||||
var timeProvider = new AdjustableTimeProvider();
|
||||
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance, timeProvider);
|
||||
|
||||
var first = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
first.Value.Should().Be("token-1");
|
||||
handler.InvocationCount.Should().Be(1);
|
||||
|
||||
var second = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
second.Value.Should().Be("token-1");
|
||||
handler.InvocationCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_RefreshesWhenExpired()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new[]
|
||||
{
|
||||
CreateTokenResponse("token-1", expiresIn: 120),
|
||||
CreateTokenResponse("token-2", expiresIn: 3600),
|
||||
});
|
||||
var client = new HttpClient(handler) { BaseAddress = new Uri("https://login.microsoftonline.com/") };
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = Options.Create(new MsrcConnectorOptions
|
||||
{
|
||||
TenantId = "contoso.onmicrosoft.com",
|
||||
ClientId = "client-id",
|
||||
ClientSecret = "secret",
|
||||
ExpiryLeewaySeconds = 60,
|
||||
});
|
||||
|
||||
var timeProvider = new AdjustableTimeProvider();
|
||||
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance, timeProvider);
|
||||
|
||||
var first = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
first.Value.Should().Be("token-1");
|
||||
handler.InvocationCount.Should().Be(1);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
var second = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
second.Value.Should().Be("token-2");
|
||||
handler.InvocationCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_OfflineStaticToken()
|
||||
{
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = Options.Create(new MsrcConnectorOptions
|
||||
{
|
||||
PreferOfflineToken = true,
|
||||
StaticAccessToken = "offline-token",
|
||||
});
|
||||
|
||||
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance);
|
||||
var token = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
token.Value.Should().Be("offline-token");
|
||||
token.ExpiresAt.Should().Be(DateTimeOffset.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_OfflineFileToken()
|
||||
{
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var offlinePath = fileSystem.Path.Combine("/tokens", "msrc.txt");
|
||||
fileSystem.AddFile(offlinePath, new MockFileData("file-token"));
|
||||
|
||||
var options = Options.Create(new MsrcConnectorOptions
|
||||
{
|
||||
PreferOfflineToken = true,
|
||||
OfflineTokenPath = offlinePath,
|
||||
});
|
||||
|
||||
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance);
|
||||
var token = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
token.Value.Should().Be("file-token");
|
||||
token.ExpiresAt.Should().Be(DateTimeOffset.MaxValue);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateTokenResponse(string token, int expiresIn = 3600)
|
||||
{
|
||||
var json = $"{{\"access_token\":\"{token}\",\"token_type\":\"Bearer\",\"expires_in\":{expiresIn}}}";
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class AdjustableTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan span) => _now = _now.Add(span);
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses;
|
||||
|
||||
public TestHttpMessageHandler(IEnumerable<HttpResponseMessage> responses)
|
||||
{
|
||||
_responses = new Queue<HttpResponseMessage>(responses);
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
if (_responses.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("no responses remaining"),
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(_responses.Dequeue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using Xunit;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors;
|
||||
|
||||
public sealed class MsrcCsafConnectorTests
|
||||
{
|
||||
private static readonly VexConnectorDescriptor Descriptor = new("excititor:msrc", VexProviderKind.Vendor, "MSRC CSAF");
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_EmitsDocumentAndPersistsState()
|
||||
{
|
||||
var summary = """
|
||||
{
|
||||
"value": [
|
||||
{
|
||||
"id": "ADV-0001",
|
||||
"vulnerabilityId": "ADV-0001",
|
||||
"severity": "Critical",
|
||||
"releaseDate": "2025-10-17T00:00:00Z",
|
||||
"lastModifiedDate": "2025-10-18T00:00:00Z",
|
||||
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var csaf = """{"document":{"title":"Example"}}""";
|
||||
var handler = TestHttpMessageHandler.Create(
|
||||
_ => Response(HttpStatusCode.OK, summary, "application/json"),
|
||||
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://example.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var options = Options.Create(CreateOptions());
|
||||
var connector = new MsrcCsafConnector(
|
||||
factory,
|
||||
new StubTokenProvider(),
|
||||
stateRepository,
|
||||
options,
|
||||
NullLogger<MsrcCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
|
||||
|
||||
var sink = new CapturingRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
Settings: VexConnectorSettings.Empty,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: new NoopSignatureVerifier(),
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
var emitted = documents[0];
|
||||
emitted.SourceUri.Should().Be(new Uri("https://example.com/csaf/ADV-0001.json"));
|
||||
emitted.Metadata["msrc.vulnerabilityId"].Should().Be("ADV-0001");
|
||||
emitted.Metadata["msrc.csaf.format"].Should().Be("json");
|
||||
emitted.Metadata.Should().NotContainKey("excititor.quarantine.reason");
|
||||
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 18, 0, 0, 0, TimeSpan.Zero));
|
||||
stateRepository.State.DocumentDigests.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_SkipsDocumentsWithExistingDigest()
|
||||
{
|
||||
var summary = """
|
||||
{
|
||||
"value": [
|
||||
{
|
||||
"id": "ADV-0001",
|
||||
"vulnerabilityId": "ADV-0001",
|
||||
"lastModifiedDate": "2025-10-18T00:00:00Z",
|
||||
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var csaf = """{"document":{"title":"Example"}}""";
|
||||
var handler = TestHttpMessageHandler.Create(
|
||||
_ => Response(HttpStatusCode.OK, summary, "application/json"),
|
||||
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://example.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var options = Options.Create(CreateOptions());
|
||||
var connector = new MsrcCsafConnector(
|
||||
factory,
|
||||
new StubTokenProvider(),
|
||||
stateRepository,
|
||||
options,
|
||||
NullLogger<MsrcCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
|
||||
|
||||
var sink = new CapturingRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
Settings: VexConnectorSettings.Empty,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: new NoopSignatureVerifier(),
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var firstPass = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
firstPass.Add(document);
|
||||
}
|
||||
|
||||
firstPass.Should().HaveCount(1);
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
var persistedState = stateRepository.State!;
|
||||
|
||||
handler.Reset(
|
||||
_ => Response(HttpStatusCode.OK, summary, "application/json"),
|
||||
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
|
||||
|
||||
sink.Documents.Clear();
|
||||
var secondPass = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
secondPass.Add(document);
|
||||
}
|
||||
|
||||
secondPass.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
stateRepository.State!.DocumentDigests.Should().Equal(persistedState.DocumentDigests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_QuarantinesInvalidCsafPayload()
|
||||
{
|
||||
var summary = """
|
||||
{
|
||||
"value": [
|
||||
{
|
||||
"id": "ADV-0002",
|
||||
"vulnerabilityId": "ADV-0002",
|
||||
"lastModifiedDate": "2025-10-19T00:00:00Z",
|
||||
"cvrfUrl": "https://example.com/csaf/ADV-0002.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var csafZip = CreateZip("document.json", "{ invalid json ");
|
||||
var handler = TestHttpMessageHandler.Create(
|
||||
_ => Response(HttpStatusCode.OK, summary, "application/json"),
|
||||
_ => Response(HttpStatusCode.OK, csafZip, "application/zip"));
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://example.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var options = Options.Create(CreateOptions());
|
||||
var connector = new MsrcCsafConnector(
|
||||
factory,
|
||||
new StubTokenProvider(),
|
||||
stateRepository,
|
||||
options,
|
||||
NullLogger<MsrcCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
|
||||
|
||||
var sink = new CapturingRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
Since: new DateTimeOffset(2025, 10, 17, 0, 0, 0, TimeSpan.Zero),
|
||||
Settings: VexConnectorSettings.Empty,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: new NoopSignatureVerifier(),
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
sink.Documents[0].Metadata["excititor.quarantine.reason"].Should().Contain("JSON parse failed");
|
||||
sink.Documents[0].Metadata["msrc.csaf.format"].Should().Be("zip");
|
||||
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
stateRepository.State!.DocumentDigests.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
|
||||
=> new(statusCode)
|
||||
{
|
||||
Content = new StringContent(content, Encoding.UTF8, contentType),
|
||||
};
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode statusCode, byte[] content, string contentType)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode);
|
||||
response.Content = new ByteArrayContent(content);
|
||||
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static MsrcConnectorOptions CreateOptions()
|
||||
=> new()
|
||||
{
|
||||
BaseUri = new Uri("https://example.com/", UriKind.Absolute),
|
||||
TenantId = Guid.NewGuid().ToString(),
|
||||
ClientId = "client-id",
|
||||
ClientSecret = "secret",
|
||||
Scope = MsrcConnectorOptions.DefaultScope,
|
||||
PageSize = 5,
|
||||
MaxAdvisoriesPerFetch = 5,
|
||||
RequestDelay = TimeSpan.Zero,
|
||||
RetryBaseDelay = TimeSpan.FromMilliseconds(10),
|
||||
MaxRetryAttempts = 2,
|
||||
};
|
||||
|
||||
private static byte[] CreateZip(string entryName, string content)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var entry = archive.CreateEntry(entryName);
|
||||
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
|
||||
writer.Write(content);
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private sealed class StubTokenProvider : IMsrcTokenProvider
|
||||
{
|
||||
public ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new MsrcAccessToken("token", "Bearer", DateTimeOffset.MaxValue));
|
||||
}
|
||||
|
||||
private sealed class CapturingRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? State { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(State);
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
State = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
|
||||
|
||||
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
|
||||
{
|
||||
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
|
||||
}
|
||||
|
||||
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
|
||||
=> new(responders);
|
||||
|
||||
public void Reset(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
|
||||
{
|
||||
_responders.Clear();
|
||||
foreach (var responder in responders)
|
||||
{
|
||||
_responders.Enqueue(responder);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_responders.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No responder configured for MSRC connector test request.");
|
||||
}
|
||||
|
||||
var responder = _responders.Count > 1 ? _responders.Dequeue() : _responders.Peek();
|
||||
var response = responder(request);
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.MSRC.CSAF/StellaOps.Excititor.Connectors.MSRC.CSAF.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Configuration;
|
||||
|
||||
public sealed class OciOpenVexAttestationConnectorOptionsValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WithValidConfiguration_Succeeds()
|
||||
{
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/offline/registry.example.com/repo/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||
});
|
||||
|
||||
var validator = new OciOpenVexAttestationConnectorOptionsValidator(fileSystem);
|
||||
var options = new OciOpenVexAttestationConnectorOptions
|
||||
{
|
||||
AllowHttpRegistries = true,
|
||||
};
|
||||
|
||||
options.Images.Add(new OciImageSubscriptionOptions
|
||||
{
|
||||
Reference = "registry.example.com/repo/image:latest",
|
||||
OfflineBundlePath = "/offline/registry.example.com/repo/latest/openvex-attestations.tgz",
|
||||
});
|
||||
|
||||
options.Registry.Username = "user";
|
||||
options.Registry.Password = "pass";
|
||||
|
||||
options.Cosign.Mode = CosignCredentialMode.None;
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenImagesMissing_AddsError()
|
||||
{
|
||||
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
|
||||
var options = new OciOpenVexAttestationConnectorOptions();
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||
|
||||
errors.Should().ContainSingle().Which.Should().Contain("At least one OCI image reference must be configured.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenDigestMalformed_AddsError()
|
||||
{
|
||||
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
|
||||
var options = new OciOpenVexAttestationConnectorOptions();
|
||||
options.Images.Add(new OciImageSubscriptionOptions
|
||||
{
|
||||
Reference = "registry.test/repo/image@sha256:not-a-digest",
|
||||
});
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||
|
||||
errors.Should().ContainSingle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Connector;
|
||||
|
||||
public sealed class OciOpenVexAttestationConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FetchAsync_WithOfflineBundle_EmitsRawDocument()
|
||||
{
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
|
||||
});
|
||||
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var httpClient = new HttpClient(new StubHttpMessageHandler())
|
||||
{
|
||||
BaseAddress = new System.Uri("https://registry.example.com/")
|
||||
};
|
||||
|
||||
var httpFactory = new SingleClientHttpClientFactory(httpClient);
|
||||
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
|
||||
|
||||
var connector = new OciOpenVexAttestationConnector(
|
||||
discovery,
|
||||
fetcher,
|
||||
NullLogger<OciOpenVexAttestationConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var settingsValues = ImmutableDictionary<string, string>.Empty
|
||||
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
|
||||
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
|
||||
.Add("Offline:PreferOffline", "true")
|
||||
.Add("Offline:AllowNetworkFallback", "false")
|
||||
.Add("Cosign:Mode", "None");
|
||||
|
||||
var settings = new VexConnectorSettings(settingsValues);
|
||||
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||
|
||||
var sink = new CapturingRawSink();
|
||||
var verifier = new CapturingSignatureVerifier();
|
||||
var context = new VexConnectorContext(
|
||||
Since: null,
|
||||
Settings: VexConnectorSettings.Empty,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: verifier,
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
documents[0].Format.Should().Be(VexDocumentFormat.OciAttestation);
|
||||
documents[0].Metadata.Should().ContainKey("oci.attestation.sourceKind").WhoseValue.Should().Be("offline");
|
||||
documents[0].Metadata.Should().ContainKey("vex.provenance.sourceKind").WhoseValue.Should().Be("offline");
|
||||
documents[0].Metadata.Should().ContainKey("vex.provenance.registryAuthMode").WhoseValue.Should().Be("Anonymous");
|
||||
verifier.VerifyCalls.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_WithSignatureMetadata_EnrichesProvenance()
|
||||
{
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
|
||||
});
|
||||
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var httpClient = new HttpClient(new StubHttpMessageHandler())
|
||||
{
|
||||
BaseAddress = new System.Uri("https://registry.example.com/")
|
||||
};
|
||||
|
||||
var httpFactory = new SingleClientHttpClientFactory(httpClient);
|
||||
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
|
||||
|
||||
var connector = new OciOpenVexAttestationConnector(
|
||||
discovery,
|
||||
fetcher,
|
||||
NullLogger<OciOpenVexAttestationConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var settingsValues = ImmutableDictionary<string, string>.Empty
|
||||
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
|
||||
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
|
||||
.Add("Offline:PreferOffline", "true")
|
||||
.Add("Offline:AllowNetworkFallback", "false")
|
||||
.Add("Cosign:Mode", "Keyless")
|
||||
.Add("Cosign:Keyless:Issuer", "https://issuer.example.com")
|
||||
.Add("Cosign:Keyless:Subject", "subject@example.com");
|
||||
|
||||
var settings = new VexConnectorSettings(settingsValues);
|
||||
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||
|
||||
var sink = new CapturingRawSink();
|
||||
var verifier = new CapturingSignatureVerifier
|
||||
{
|
||||
Result = new VexSignatureMetadata(
|
||||
type: "cosign",
|
||||
subject: "sig-subject",
|
||||
issuer: "sig-issuer",
|
||||
keyId: "key-id",
|
||||
verifiedAt: DateTimeOffset.UtcNow,
|
||||
transparencyLogReference: "rekor://entry/123")
|
||||
};
|
||||
|
||||
var context = new VexConnectorContext(
|
||||
Since: null,
|
||||
Settings: VexConnectorSettings.Empty,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: verifier,
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
var metadata = documents[0].Metadata;
|
||||
metadata.Should().Contain("vex.signature.type", "cosign");
|
||||
metadata.Should().Contain("vex.signature.subject", "sig-subject");
|
||||
metadata.Should().Contain("vex.signature.issuer", "sig-issuer");
|
||||
metadata.Should().Contain("vex.signature.keyId", "key-id");
|
||||
metadata.Should().ContainKey("vex.signature.verifiedAt");
|
||||
metadata.Should().Contain("vex.signature.transparencyLogReference", "rekor://entry/123");
|
||||
metadata.Should().Contain("vex.provenance.cosign.mode", "Keyless");
|
||||
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
|
||||
metadata.Should().Contain("vex.provenance.cosign.subject", "subject@example.com");
|
||||
verifier.VerifyCalls.Should().Be(1);
|
||||
}
|
||||
|
||||
private sealed class CapturingRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public int VerifyCalls { get; private set; }
|
||||
|
||||
public VexSignatureMetadata? Result { get; set; }
|
||||
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
VerifyCalls++;
|
||||
return ValueTask.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
RequestMessage = request
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Discovery;
|
||||
|
||||
public sealed class OciAttestationDiscoveryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadAsync_ResolvesOfflinePaths()
|
||||
{
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||
});
|
||||
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||
|
||||
var options = new OciOpenVexAttestationConnectorOptions
|
||||
{
|
||||
AllowHttpRegistries = true,
|
||||
};
|
||||
|
||||
options.Images.Add(new OciImageSubscriptionOptions
|
||||
{
|
||||
Reference = "registry.example.com/repo/image:latest",
|
||||
});
|
||||
|
||||
options.Offline.RootDirectory = "/bundles";
|
||||
options.Cosign.Mode = CosignCredentialMode.None;
|
||||
|
||||
var result = await service.LoadAsync(options, CancellationToken.None);
|
||||
|
||||
result.Targets.Should().ContainSingle();
|
||||
result.Targets[0].OfflineBundle.Should().NotBeNull();
|
||||
var offline = result.Targets[0].OfflineBundle!;
|
||||
offline.Exists.Should().BeTrue();
|
||||
var expectedPath = fileSystem.Path.Combine(
|
||||
fileSystem.Path.GetFullPath("/bundles"),
|
||||
"registry.example.com",
|
||||
"repo",
|
||||
"image",
|
||||
"latest",
|
||||
"openvex-attestations.tgz");
|
||||
offline.Path.Should().Be(expectedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_CachesResults()
|
||||
{
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||
});
|
||||
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||
|
||||
var options = new OciOpenVexAttestationConnectorOptions
|
||||
{
|
||||
AllowHttpRegistries = true,
|
||||
};
|
||||
|
||||
options.Images.Add(new OciImageSubscriptionOptions
|
||||
{
|
||||
Reference = "registry.example.com/repo/image:latest",
|
||||
});
|
||||
|
||||
options.Offline.RootDirectory = "/bundles";
|
||||
options.Cosign.Mode = CosignCredentialMode.None;
|
||||
|
||||
var first = await service.LoadAsync(options, CancellationToken.None);
|
||||
var second = await service.LoadAsync(options, CancellationToken.None);
|
||||
|
||||
ReferenceEquals(first, second).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,314 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Oracle.CSAF;
|
||||
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Connectors;
|
||||
|
||||
public sealed class OracleCsafConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FetchAsync_NewEntry_PersistsDocumentAndUpdatesState()
|
||||
{
|
||||
var documentUri = new Uri("https://oracle.example/security/csaf/cpu2025oct.json");
|
||||
var payload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
|
||||
var payloadDigest = ComputeDigest(payload);
|
||||
var snapshotPath = "/snapshots/oracle-catalog.json";
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddFile(snapshotPath, new MockFileData(BuildOfflineSnapshot(documentUri, payloadDigest, "2025-10-15T00:00:00Z")));
|
||||
|
||||
var handler = new StubHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
|
||||
{
|
||||
[documentUri] = CreateResponse(payload),
|
||||
});
|
||||
var httpClient = new HttpClient(handler);
|
||||
var httpFactory = new SingleHttpClientFactory(httpClient);
|
||||
var loader = new OracleCatalogLoader(
|
||||
httpFactory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
fileSystem,
|
||||
NullLogger<OracleCatalogLoader>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var connector = new OracleCsafConnector(
|
||||
loader,
|
||||
httpFactory,
|
||||
stateRepository,
|
||||
new[] { new OracleConnectorOptionsValidator(fileSystem) },
|
||||
NullLogger<OracleCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var settingsValues = ImmutableDictionary<string, string>.Empty
|
||||
.Add(nameof(OracleConnectorOptions.PreferOfflineSnapshot), "true")
|
||||
.Add(nameof(OracleConnectorOptions.OfflineSnapshotPath), snapshotPath)
|
||||
.Add(nameof(OracleConnectorOptions.PersistOfflineSnapshot), "false");
|
||||
var settings = new VexConnectorSettings(settingsValues);
|
||||
|
||||
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
Since: null,
|
||||
Settings: settings,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: new NoopSignatureVerifier(),
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
documents[0].Digest.Should().Be(payloadDigest);
|
||||
documents[0].Metadata["oracle.csaf.entryId"].Should().Be("CPU2025Oct");
|
||||
documents[0].Metadata["oracle.csaf.sha256"].Should().Be(payloadDigest);
|
||||
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
stateRepository.State!.DocumentDigests.Should().ContainSingle().Which.Should().Be(payloadDigest);
|
||||
|
||||
handler.GetCallCount(documentUri).Should().Be(1);
|
||||
|
||||
// second run should short-circuit without downloading again
|
||||
sink.Documents.Clear();
|
||||
documents.Clear();
|
||||
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
handler.GetCallCount(documentUri).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_ChecksumMismatch_SkipsDocument()
|
||||
{
|
||||
var documentUri = new Uri("https://oracle.example/security/csaf/cpu2025oct.json");
|
||||
var payload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
|
||||
var snapshotPath = "/snapshots/oracle-catalog.json";
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddFile(snapshotPath, new MockFileData(BuildOfflineSnapshot(documentUri, "deadbeef", "2025-10-15T00:00:00Z")));
|
||||
|
||||
var handler = new StubHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
|
||||
{
|
||||
[documentUri] = CreateResponse(payload),
|
||||
});
|
||||
var httpClient = new HttpClient(handler);
|
||||
var httpFactory = new SingleHttpClientFactory(httpClient);
|
||||
var loader = new OracleCatalogLoader(
|
||||
httpFactory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
fileSystem,
|
||||
NullLogger<OracleCatalogLoader>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var connector = new OracleCsafConnector(
|
||||
loader,
|
||||
httpFactory,
|
||||
stateRepository,
|
||||
new[] { new OracleConnectorOptionsValidator(fileSystem) },
|
||||
NullLogger<OracleCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var settingsValues = ImmutableDictionary<string, string>.Empty
|
||||
.Add(nameof(OracleConnectorOptions.PreferOfflineSnapshot), "true")
|
||||
.Add(nameof(OracleConnectorOptions.OfflineSnapshotPath), snapshotPath)
|
||||
.Add(nameof(OracleConnectorOptions.PersistOfflineSnapshot), "false");
|
||||
var settings = new VexConnectorSettings(settingsValues);
|
||||
|
||||
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
Since: null,
|
||||
Settings: settings,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: new NoopSignatureVerifier(),
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
stateRepository.State.Should().BeNull();
|
||||
handler.GetCallCount(documentUri).Should().Be(1);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(byte[] payload)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(payload)
|
||||
{
|
||||
Headers =
|
||||
{
|
||||
ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static string ComputeDigest(byte[] payload)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
SHA256.HashData(payload, buffer);
|
||||
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string BuildOfflineSnapshot(Uri documentUri, string sha256, string publishedAt)
|
||||
{
|
||||
var snapshot = new
|
||||
{
|
||||
metadata = new
|
||||
{
|
||||
generatedAt = "2025-10-14T12:00:00Z",
|
||||
entries = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "CPU2025Oct",
|
||||
title = "Oracle Critical Patch Update Advisory - October 2025",
|
||||
documentUri = documentUri.ToString(),
|
||||
publishedAt,
|
||||
revision = publishedAt,
|
||||
sha256,
|
||||
size = 1024,
|
||||
products = new[] { "Oracle Database" }
|
||||
}
|
||||
},
|
||||
cpuSchedule = Array.Empty<object>()
|
||||
},
|
||||
fetchedAt = "2025-10-14T12:00:00Z"
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(snapshot, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
|
||||
private readonly Dictionary<Uri, int> _callCounts = new();
|
||||
|
||||
public StubHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
public int GetCallCount(Uri uri) => _callCounts.TryGetValue(uri, out var count) ? count : 0;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RequestUri is null || !_responses.TryGetValue(request.RequestUri, out var response))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
_callCounts.TryGetValue(request.RequestUri, out var count);
|
||||
_callCounts[request.RequestUri] = count + 1;
|
||||
return Task.FromResult(response.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? State { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(State);
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
State = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
internal static class HttpResponseMessageExtensions
|
||||
{
|
||||
public static HttpResponseMessage Clone(this HttpResponseMessage response)
|
||||
{
|
||||
var clone = new HttpResponseMessage(response.StatusCode);
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (response.Content is not null)
|
||||
{
|
||||
var payload = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
|
||||
var mediaType = response.Content.Headers.ContentType?.MediaType ?? "application/json";
|
||||
clone.Content = new ByteArrayContent(payload);
|
||||
clone.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mediaType);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Metadata;
|
||||
|
||||
public sealed class OracleCatalogLoaderTests
|
||||
{
|
||||
private const string SampleCatalog = """
|
||||
{
|
||||
"generated": "2025-09-30T18:00:00Z",
|
||||
"catalog": [
|
||||
{
|
||||
"id": "CPU2025Oct",
|
||||
"title": "Oracle Critical Patch Update Advisory - October 2025",
|
||||
"published": "2025-10-15T00:00:00Z",
|
||||
"revision": "2025-10-15T00:00:00Z",
|
||||
"document": {
|
||||
"url": "https://updates.oracle.com/cpu/2025-10/cpu2025oct.json",
|
||||
"sha256": "abc123",
|
||||
"size": 1024
|
||||
},
|
||||
"products": ["Oracle Database", "Java SE"]
|
||||
}
|
||||
],
|
||||
"schedule": [
|
||||
{ "window": "2025-Oct", "releaseDate": "2025-10-15T00:00:00Z" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string SampleCalendar = """
|
||||
{
|
||||
"cpuWindows": [
|
||||
{ "name": "2026-Jan", "releaseDate": "2026-01-21T00:00:00Z" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesAndCachesCatalog()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
|
||||
{
|
||||
[new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json")] = CreateResponse(SampleCatalog),
|
||||
[new Uri("https://www.oracle.com/security-alerts/cpu/cal.json")] = CreateResponse(SampleCalendar),
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new OracleConnectorOptions
|
||||
{
|
||||
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
|
||||
CpuCalendarUri = new Uri("https://www.oracle.com/security-alerts/cpu/cal.json"),
|
||||
OfflineSnapshotPath = "/snapshots/oracle-catalog.json",
|
||||
};
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.Metadata.Entries.Should().HaveCount(1);
|
||||
result.Metadata.CpuSchedule.Should().Contain(r => r.Window == "2025-Oct");
|
||||
result.Metadata.CpuSchedule.Should().Contain(r => r.Window == "2026-Jan");
|
||||
result.FromCache.Should().BeFalse();
|
||||
fileSystem.FileExists(options.OfflineSnapshotPath!).Should().BeTrue();
|
||||
|
||||
handler.ResetInvocationCount();
|
||||
var cached = await loader.LoadAsync(options, CancellationToken.None);
|
||||
cached.FromCache.Should().BeTrue();
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesOfflineSnapshotWhenNetworkFails()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var offlineJson = """
|
||||
{
|
||||
"metadata": {
|
||||
"generatedAt": "2025-09-30T18:00:00Z",
|
||||
"entries": [
|
||||
{
|
||||
"id": "CPU2025Oct",
|
||||
"title": "Oracle Critical Patch Update Advisory - October 2025",
|
||||
"documentUri": "https://updates.oracle.com/cpu/2025-10/cpu2025oct.json",
|
||||
"publishedAt": "2025-10-15T00:00:00Z",
|
||||
"revision": "2025-10-15T00:00:00Z",
|
||||
"sha256": "abc123",
|
||||
"size": 1024,
|
||||
"products": [ "Oracle Database" ]
|
||||
}
|
||||
],
|
||||
"cpuSchedule": [
|
||||
{ "window": "2025-Oct", "releaseDate": "2025-10-15T00:00:00Z" }
|
||||
]
|
||||
},
|
||||
"fetchedAt": "2025-10-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
fileSystem.AddFile("/snapshots/oracle-catalog.json", new MockFileData(offlineJson));
|
||||
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new OracleConnectorOptions
|
||||
{
|
||||
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
|
||||
OfflineSnapshotPath = "/snapshots/oracle-catalog.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
};
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.FromOfflineSnapshot.Should().BeTrue();
|
||||
result.Metadata.Entries.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ThrowsWhenOfflinePreferredButMissing()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance);
|
||||
|
||||
var options = new OracleConnectorOptions
|
||||
{
|
||||
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
|
||||
PreferOfflineSnapshot = true,
|
||||
OfflineSnapshotPath = "/missing/oracle-catalog.json",
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(string payload)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class AdjustableTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
|
||||
|
||||
public TestHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public void ResetInvocationCount() => InvocationCount = 0;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var response))
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new HttpResponseMessage(response.StatusCode)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("unexpected request"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Oracle.CSAF/StellaOps.Excititor.Connectors.Oracle.CSAF.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests.Connectors;
|
||||
|
||||
public sealed class RedHatCsafConnectorTests
|
||||
{
|
||||
private static readonly VexConnectorDescriptor Descriptor = new("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF");
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_EmitsDocumentsAfterSince()
|
||||
{
|
||||
var metadata = """
|
||||
{
|
||||
"metadata": {
|
||||
"provider": { "name": "Red Hat Product Security" }
|
||||
},
|
||||
"distributions": [
|
||||
{ "directory": "https://example.com/security/data/csaf/v2/advisories/" }
|
||||
],
|
||||
"rolie": {
|
||||
"feeds": [
|
||||
{ "url": "https://example.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var feed = """
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<id>urn:redhat:1</id>
|
||||
<updated>2025-10-16T10:00:00Z</updated>
|
||||
<link href="https://example.com/doc1.json" rel="enclosure" />
|
||||
</entry>
|
||||
<entry>
|
||||
<id>urn:redhat:2</id>
|
||||
<updated>2025-10-17T10:00:00Z</updated>
|
||||
<link href="https://example.com/doc2.json" rel="enclosure" />
|
||||
</entry>
|
||||
</feed>
|
||||
""";
|
||||
|
||||
var handler = TestHttpMessageHandler.Create(
|
||||
request => Response(HttpStatusCode.OK, metadata, "application/json"),
|
||||
request => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
|
||||
request => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://example.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = Options.Create(new RedHatConnectorOptions());
|
||||
var metadataLoader = new RedHatProviderMetadataLoader(factory, cache, options, NullLogger<RedHatProviderMetadataLoader>.Instance);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var connector = new RedHatCsafConnector(Descriptor, metadataLoader, factory, stateRepository, NullLogger<RedHatCsafConnector>.Instance, TimeProvider.System);
|
||||
|
||||
var rawSink = new CapturingRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
new DateTimeOffset(2025, 10, 16, 12, 0, 0, TimeSpan.Zero),
|
||||
VexConnectorSettings.Empty,
|
||||
rawSink,
|
||||
new NoopSignatureVerifier(),
|
||||
new NoopNormalizerRouter(),
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var results = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
results.Add(document);
|
||||
}
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Single(rawSink.Documents);
|
||||
Assert.Equal("https://example.com/doc2.json", results[0].SourceUri.ToString());
|
||||
Assert.Equal("https://example.com/doc2.json", rawSink.Documents[0].SourceUri.ToString());
|
||||
Assert.Equal(3, handler.CallCount);
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 17, 10, 0, 0, TimeSpan.Zero));
|
||||
stateRepository.State.DocumentDigests.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_UsesStateToSkipDuplicateDocuments()
|
||||
{
|
||||
var metadata = """
|
||||
{
|
||||
"metadata": {
|
||||
"provider": { "name": "Red Hat Product Security" }
|
||||
},
|
||||
"distributions": [
|
||||
{ "directory": "https://example.com/security/data/csaf/v2/advisories/" }
|
||||
],
|
||||
"rolie": {
|
||||
"feeds": [
|
||||
{ "url": "https://example.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var feed = """
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<id>urn:redhat:1</id>
|
||||
<updated>2025-10-17T10:00:00Z</updated>
|
||||
<link href="https://example.com/doc1.json" rel="enclosure" />
|
||||
</entry>
|
||||
</feed>
|
||||
""";
|
||||
|
||||
var handler1 = TestHttpMessageHandler.Create(
|
||||
_ => Response(HttpStatusCode.OK, metadata, "application/json"),
|
||||
_ => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
|
||||
_ => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
|
||||
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
await ExecuteFetchAsync(handler1, stateRepository);
|
||||
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
var previousState = stateRepository.State!;
|
||||
|
||||
var handler2 = TestHttpMessageHandler.Create(
|
||||
_ => Response(HttpStatusCode.OK, metadata, "application/json"),
|
||||
_ => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
|
||||
_ => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
|
||||
|
||||
var (results, rawSink) = await ExecuteFetchAsync(handler2, stateRepository);
|
||||
|
||||
results.Should().BeEmpty();
|
||||
rawSink.Documents.Should().BeEmpty();
|
||||
stateRepository.State!.DocumentDigests.Should().Equal(previousState.DocumentDigests);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
|
||||
=> new(statusCode)
|
||||
{
|
||||
Content = new StringContent(content, Encoding.UTF8, contentType),
|
||||
};
|
||||
|
||||
private sealed class CapturingRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
|
||||
|
||||
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
|
||||
{
|
||||
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
|
||||
=> new(responders);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
if (_responders.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No responder configured for request.");
|
||||
}
|
||||
|
||||
var responder = _responders.Count > 1
|
||||
? _responders.Dequeue()
|
||||
: _responders.Peek();
|
||||
|
||||
var response = responder(request);
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(List<VexRawDocument> Documents, CapturingRawSink Sink)> ExecuteFetchAsync(
|
||||
TestHttpMessageHandler handler,
|
||||
InMemoryConnectorStateRepository stateRepository)
|
||||
{
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://example.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = Options.Create(new RedHatConnectorOptions());
|
||||
var metadataLoader = new RedHatProviderMetadataLoader(factory, cache, options, NullLogger<RedHatProviderMetadataLoader>.Instance);
|
||||
var connector = new RedHatCsafConnector(Descriptor, metadataLoader, factory, stateRepository, NullLogger<RedHatCsafConnector>.Instance, TimeProvider.System);
|
||||
|
||||
var rawSink = new CapturingRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
null,
|
||||
VexConnectorSettings.Empty,
|
||||
rawSink,
|
||||
new NoopSignatureVerifier(),
|
||||
new NoopNormalizerRouter(),
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
return (documents, rawSink);
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? State { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (State is not null && string.Equals(State.ConnectorId, connectorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult<VexConnectorState?>(State);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<VexConnectorState?>(null);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
State = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests.Metadata;
|
||||
|
||||
public sealed class RedHatProviderMetadataLoaderTests
|
||||
{
|
||||
private const string SampleJson = """
|
||||
{
|
||||
"metadata": {
|
||||
"provider": {
|
||||
"name": "Red Hat Product Security"
|
||||
}
|
||||
},
|
||||
"distributions": [
|
||||
{ "directory": "https://access.redhat.com/security/data/csaf/v2/advisories/" }
|
||||
],
|
||||
"rolie": {
|
||||
"feeds": [
|
||||
{ "url": "https://access.redhat.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesMetadataAndCaches()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(SampleJson, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://access.redhat.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = new RedHatConnectorOptions
|
||||
{
|
||||
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
|
||||
OfflineSnapshotPath = fileSystem.Path.Combine("/offline", "redhat-provider.json"),
|
||||
CosignIssuer = "https://sigstore.dev/redhat",
|
||||
CosignIdentityPattern = "^spiffe://redhat/.+$",
|
||||
};
|
||||
options.PgpFingerprints.Add("A1B2C3D4E5F6");
|
||||
options.Validate(fileSystem);
|
||||
|
||||
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var result = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Red Hat Product Security", result.Provider.DisplayName);
|
||||
Assert.False(result.FromCache);
|
||||
Assert.False(result.FromOfflineSnapshot);
|
||||
Assert.Single(result.Provider.BaseUris);
|
||||
Assert.Equal("https://access.redhat.com/security/data/csaf/v2/advisories/", result.Provider.BaseUris[0].ToString());
|
||||
Assert.Equal("https://access.redhat.com/.well-known/csaf/provider-metadata.json", result.Provider.Discovery.WellKnownMetadata?.ToString());
|
||||
Assert.Equal("https://access.redhat.com/security/data/csaf/v2/advisories/rolie/feed.atom", result.Provider.Discovery.RolIeService?.ToString());
|
||||
Assert.Equal(1.0, result.Provider.Trust.Weight);
|
||||
Assert.NotNull(result.Provider.Trust.Cosign);
|
||||
Assert.Equal("https://sigstore.dev/redhat", result.Provider.Trust.Cosign!.Issuer);
|
||||
Assert.Equal("^spiffe://redhat/.+$", result.Provider.Trust.Cosign.IdentityPattern);
|
||||
Assert.Contains("A1B2C3D4E5F6", result.Provider.Trust.PgpFingerprints);
|
||||
Assert.True(fileSystem.FileExists(options.OfflineSnapshotPath));
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
|
||||
var second = await loader.LoadAsync(CancellationToken.None);
|
||||
Assert.True(second.FromCache);
|
||||
Assert.False(second.FromOfflineSnapshot);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesOfflineSnapshotWhenPreferred()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => throw new InvalidOperationException("HTTP should not be called"));
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/snapshots/redhat.json"] = new MockFileData(SampleJson),
|
||||
});
|
||||
|
||||
var options = new RedHatConnectorOptions
|
||||
{
|
||||
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
|
||||
OfflineSnapshotPath = "/snapshots/redhat.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
PersistOfflineSnapshot = false,
|
||||
};
|
||||
options.Validate(fileSystem);
|
||||
|
||||
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var result = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Red Hat Product Security", result.Provider.DisplayName);
|
||||
Assert.False(result.FromCache);
|
||||
Assert.True(result.FromOfflineSnapshot);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
|
||||
var second = await loader.LoadAsync(CancellationToken.None);
|
||||
Assert.True(second.FromCache);
|
||||
Assert.True(second.FromOfflineSnapshot);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesETagForConditionalRequest()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.Create(
|
||||
_ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(SampleJson, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
|
||||
return response;
|
||||
},
|
||||
request =>
|
||||
{
|
||||
Assert.Contains(request.Headers.IfNoneMatch, etag => etag.Tag == "\"abc\"");
|
||||
return new HttpResponseMessage(HttpStatusCode.NotModified);
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = new RedHatConnectorOptions
|
||||
{
|
||||
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
|
||||
OfflineSnapshotPath = "/offline/redhat.json",
|
||||
MetadataCacheDuration = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
options.Validate(fileSystem);
|
||||
|
||||
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var first = await loader.LoadAsync(CancellationToken.None);
|
||||
Assert.False(first.FromCache);
|
||||
Assert.False(first.FromOfflineSnapshot);
|
||||
|
||||
Assert.True(cache.TryGetValue(RedHatProviderMetadataLoader.CacheKey, out var entryObj));
|
||||
Assert.NotNull(entryObj);
|
||||
|
||||
var entryType = entryObj!.GetType();
|
||||
var provider = entryType.GetProperty("Provider")!.GetValue(entryObj);
|
||||
var fetchedAt = entryType.GetProperty("FetchedAt")!.GetValue(entryObj);
|
||||
var etag = entryType.GetProperty("ETag")!.GetValue(entryObj);
|
||||
var fromOffline = entryType.GetProperty("FromOffline")!.GetValue(entryObj);
|
||||
|
||||
var expiredEntry = Activator.CreateInstance(entryType, provider, fetchedAt, DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1), etag, fromOffline);
|
||||
cache.Set(RedHatProviderMetadataLoader.CacheKey, expiredEntry!, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1),
|
||||
});
|
||||
|
||||
var second = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
var third = await loader.LoadAsync(CancellationToken.None);
|
||||
Assert.True(third.FromCache);
|
||||
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
|
||||
|
||||
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
|
||||
{
|
||||
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
=> new(new[] { responder });
|
||||
|
||||
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
|
||||
=> new(responders);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
if (_responders.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No responder configured for request.");
|
||||
}
|
||||
|
||||
var responder = _responders.Count > 1
|
||||
? _responders.Dequeue()
|
||||
: _responders.Peek();
|
||||
|
||||
var response = responder(request);
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.RedHat.CSAF/StellaOps.Excititor.Connectors.RedHat.CSAF.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Authentication;
|
||||
|
||||
public sealed class RancherHubTokenProviderTests
|
||||
{
|
||||
private const string TokenResponse = "{\"access_token\":\"abc123\",\"token_type\":\"Bearer\",\"expires_in\":3600}";
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_RequestsAndCachesToken()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(request =>
|
||||
{
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Content.Should().NotBeNull();
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TokenResponse, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://identity.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
ClientId = "client",
|
||||
ClientSecret = "secret",
|
||||
TokenEndpoint = new Uri("https://identity.suse.com/oauth/token"),
|
||||
Audience = "https://vexhub.suse.com",
|
||||
};
|
||||
options.Scopes.Clear();
|
||||
options.Scopes.Add("hub.read");
|
||||
options.Scopes.Add("hub.events");
|
||||
|
||||
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
|
||||
token.Should().NotBeNull();
|
||||
token!.Value.Should().Be("abc123");
|
||||
|
||||
var cached = await provider.GetAccessTokenAsync(options, CancellationToken.None);
|
||||
cached.Should().NotBeNull();
|
||||
handler.InvocationCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_ReturnsNullWhenOfflinePreferred()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://identity.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
PreferOfflineSnapshot = true,
|
||||
ClientId = "client",
|
||||
ClientSecret = "secret",
|
||||
TokenEndpoint = new Uri("https://identity.suse.com/oauth/token"),
|
||||
};
|
||||
|
||||
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
|
||||
token.Should().BeNull();
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_ReturnsNullWithoutCredentials()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://identity.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var options = new RancherHubConnectorOptions();
|
||||
|
||||
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
|
||||
token.Should().BeNull();
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
private TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
=> new(responseFactory);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Metadata;
|
||||
|
||||
public sealed class RancherHubMetadataLoaderTests
|
||||
{
|
||||
private const string SampleDiscovery = """
|
||||
{
|
||||
"hubId": "excititor:suse.rancher",
|
||||
"title": "SUSE Rancher VEX Hub",
|
||||
"subscription": {
|
||||
"eventsUri": "https://vexhub.suse.com/api/v1/events",
|
||||
"checkpointUri": "https://vexhub.suse.com/api/v1/checkpoints",
|
||||
"requiresAuthentication": true,
|
||||
"channels": ["rke2", "k3s"],
|
||||
"scopes": ["hub.read", "hub.events"]
|
||||
},
|
||||
"authentication": {
|
||||
"tokenUri": "https://identity.suse.com/oauth2/token",
|
||||
"audience": "https://vexhub.suse.com"
|
||||
},
|
||||
"offline": {
|
||||
"snapshotUri": "https://downloads.suse.com/vexhub/snapshot.json",
|
||||
"sha256": "deadbeef",
|
||||
"updated": "2025-10-10T12:00:00Z"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesAndCachesMetadata()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(SampleDiscovery, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
|
||||
return response;
|
||||
});
|
||||
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://vexhub.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var offlinePath = fileSystem.Path.Combine(@"C:\offline", "rancher-hub.json");
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
|
||||
OfflineSnapshotPath = offlinePath,
|
||||
};
|
||||
|
||||
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
|
||||
result.FromCache.Should().BeFalse();
|
||||
result.FromOfflineSnapshot.Should().BeFalse();
|
||||
result.Metadata.Provider.DisplayName.Should().Be("SUSE Rancher VEX Hub");
|
||||
result.Metadata.Subscription.EventsUri.Should().Be(new Uri("https://vexhub.suse.com/api/v1/events"));
|
||||
result.Metadata.Authentication.TokenEndpoint.Should().Be(new Uri("https://identity.suse.com/oauth2/token"));
|
||||
|
||||
// Second call should be served from cache (no additional HTTP invocation).
|
||||
handler.ResetInvocationCount();
|
||||
await loader.LoadAsync(options, CancellationToken.None);
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesOfflineSnapshotWhenNetworkFails()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => throw new HttpRequestException("network down"));
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://vexhub.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var offlinePath = fileSystem.Path.Combine(@"C:\offline", "rancher-hub.json");
|
||||
fileSystem.AddFile(offlinePath, new MockFileData(SampleDiscovery));
|
||||
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
|
||||
OfflineSnapshotPath = offlinePath,
|
||||
};
|
||||
|
||||
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.FromOfflineSnapshot.Should().BeTrue();
|
||||
result.Metadata.Subscription.RequiresAuthentication.Should().BeTrue();
|
||||
result.Metadata.OfflineSnapshot.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ThrowsWhenOfflinePreferredButMissing()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => throw new HttpRequestException("network down"));
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://vexhub.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
|
||||
OfflineSnapshotPath = "/offline/missing.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
};
|
||||
|
||||
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
private TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
=> new(responseFactory);
|
||||
|
||||
public void ResetInvocationCount() => InvocationCount = 0;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors;
|
||||
|
||||
public sealed class UbuntuCsafConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag()
|
||||
{
|
||||
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
|
||||
var indexUri = new Uri(baseUri, "index.json");
|
||||
var catalogUri = new Uri(baseUri, "stable/catalog.json");
|
||||
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json");
|
||||
|
||||
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z");
|
||||
var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
|
||||
var documentSha = ComputeSha256(documentPayload);
|
||||
|
||||
var indexJson = manifest.IndexJson;
|
||||
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal);
|
||||
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123");
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var httpFactory = new SingleClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
|
||||
|
||||
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var connector = new UbuntuCsafConnector(
|
||||
loader,
|
||||
httpFactory,
|
||||
stateRepository,
|
||||
new[] { optionsValidator },
|
||||
NullLogger<UbuntuCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var settings = new VexConnectorSettings(ImmutableDictionary<string, string>.Empty);
|
||||
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider(), ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
var stored = sink.Documents.Single();
|
||||
stored.Digest.Should().Be($"sha256:{documentSha}");
|
||||
stored.Metadata.TryGetValue("ubuntu.etag", out var storedEtag).Should().BeTrue();
|
||||
storedEtag.Should().Be("etag-123");
|
||||
|
||||
stateRepository.CurrentState.Should().NotBeNull();
|
||||
stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}");
|
||||
stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123");
|
||||
stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
|
||||
|
||||
handler.DocumentRequestCount.Should().Be(1);
|
||||
|
||||
// Second run: Expect connector to send If-None-Match and skip download via 304.
|
||||
sink.Documents.Clear();
|
||||
documents.Clear();
|
||||
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
handler.DocumentRequestCount.Should().Be(2);
|
||||
handler.SeenIfNoneMatch.Should().Contain("\"etag-123\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_SkipsWhenChecksumMismatch()
|
||||
{
|
||||
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
|
||||
var indexUri = new Uri(baseUri, "index.json");
|
||||
var catalogUri = new Uri(baseUri, "stable/catalog.json");
|
||||
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json");
|
||||
|
||||
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z");
|
||||
var indexJson = manifest.IndexJson;
|
||||
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal);
|
||||
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999");
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var httpFactory = new SingleClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
|
||||
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
|
||||
var connector = new UbuntuCsafConnector(
|
||||
loader,
|
||||
httpFactory,
|
||||
stateRepository,
|
||||
new[] { optionsValidator },
|
||||
NullLogger<UbuntuCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
await connector.ValidateAsync(new VexConnectorSettings(ImmutableDictionary<string, string>.Empty), CancellationToken.None);
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider(), ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
stateRepository.CurrentState.Should().NotBeNull();
|
||||
stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty();
|
||||
handler.DocumentRequestCount.Should().Be(1);
|
||||
}
|
||||
|
||||
private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp)
|
||||
{
|
||||
var indexJson = """
|
||||
{
|
||||
"generated": "2025-10-18T00:00:00Z",
|
||||
"channels": [
|
||||
{
|
||||
"name": "stable",
|
||||
"catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json",
|
||||
"sha256": "ignore"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var catalogJson = """
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"id": "{{advisoryId}}",
|
||||
"type": "csaf",
|
||||
"url": "{{advisoryUri}}",
|
||||
"last_modified": "{{timestamp}}",
|
||||
"hashes": {
|
||||
"sha256": "{{SHA256}}"
|
||||
},
|
||||
"etag": "\"etag-123\"",
|
||||
"title": "{{advisoryId}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
return (indexJson, catalogJson);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
SHA256.HashData(payload, buffer);
|
||||
return Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class SingleClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class UbuntuTestHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Uri _indexUri;
|
||||
private readonly string _indexPayload;
|
||||
private readonly Uri _catalogUri;
|
||||
private readonly string _catalogPayload;
|
||||
private readonly Uri _documentUri;
|
||||
private readonly byte[] _documentPayload;
|
||||
private readonly string _expectedEtag;
|
||||
|
||||
public int DocumentRequestCount { get; private set; }
|
||||
public List<string> SeenIfNoneMatch { get; } = new();
|
||||
|
||||
public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag)
|
||||
{
|
||||
_indexUri = indexUri;
|
||||
_indexPayload = indexPayload;
|
||||
_catalogUri = catalogUri;
|
||||
_catalogPayload = catalogPayload;
|
||||
_documentUri = documentUri;
|
||||
_documentPayload = documentPayload;
|
||||
_expectedEtag = expectedEtag;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RequestUri == _indexUri)
|
||||
{
|
||||
return Task.FromResult(CreateJsonResponse(_indexPayload));
|
||||
}
|
||||
|
||||
if (request.RequestUri == _catalogUri)
|
||||
{
|
||||
return Task.FromResult(CreateJsonResponse(_catalogPayload));
|
||||
}
|
||||
|
||||
if (request.RequestUri == _documentUri)
|
||||
{
|
||||
DocumentRequestCount++;
|
||||
if (request.Headers.IfNoneMatch is { Count: > 0 })
|
||||
{
|
||||
var header = request.Headers.IfNoneMatch.First().ToString();
|
||||
SeenIfNoneMatch.Add(header);
|
||||
if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"")
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified));
|
||||
}
|
||||
}
|
||||
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(_documentPayload),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\"");
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent($"No response configured for {request.RequestUri}"),
|
||||
});
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string payload)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? CurrentState { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(CurrentState);
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
CurrentState = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Metadata;
|
||||
|
||||
public sealed class UbuntuCatalogLoaderTests
|
||||
{
|
||||
private const string SampleIndex = """
|
||||
{
|
||||
"generated": "2025-10-10T00:00:00Z",
|
||||
"channels": [
|
||||
{
|
||||
"name": "stable",
|
||||
"catalogUrl": "https://ubuntu.com/security/csaf/stable/catalog.json",
|
||||
"sha256": "abc",
|
||||
"lastUpdated": "2025-10-09T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "esm",
|
||||
"catalogUrl": "https://ubuntu.com/security/csaf/esm/catalog.json",
|
||||
"sha256": "def",
|
||||
"lastUpdated": "2025-10-08T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesAndCachesIndex()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
|
||||
{
|
||||
[new Uri("https://ubuntu.com/security/csaf/index.json")] = CreateResponse(SampleIndex),
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new UbuntuConnectorOptions
|
||||
{
|
||||
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
|
||||
OfflineSnapshotPath = "/snapshots/ubuntu-index.json",
|
||||
};
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.Metadata.Channels.Should().HaveCount(1);
|
||||
result.Metadata.Channels[0].Name.Should().Be("stable");
|
||||
fileSystem.FileExists(options.OfflineSnapshotPath!).Should().BeTrue();
|
||||
result.FromCache.Should().BeFalse();
|
||||
|
||||
handler.ResetInvocationCount();
|
||||
var cached = await loader.LoadAsync(options, CancellationToken.None);
|
||||
cached.FromCache.Should().BeTrue();
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesOfflineSnapshotWhenPreferred()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddFile("/snapshots/ubuntu-index.json", new MockFileData($"{{\"metadata\":{SampleIndex},\"fetchedAt\":\"2025-10-10T00:00:00Z\"}}"));
|
||||
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new UbuntuConnectorOptions
|
||||
{
|
||||
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
|
||||
OfflineSnapshotPath = "/snapshots/ubuntu-index.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
Channels = { "stable" }
|
||||
};
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.FromOfflineSnapshot.Should().BeTrue();
|
||||
result.Metadata.Channels.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ThrowsWhenNoChannelsMatch()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
|
||||
{
|
||||
[new Uri("https://ubuntu.com/security/csaf/index.json")] = CreateResponse(SampleIndex),
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new UbuntuConnectorOptions
|
||||
{
|
||||
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
|
||||
};
|
||||
options.Channels.Clear();
|
||||
options.Channels.Add("nonexistent");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(string payload)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class AdjustableTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
|
||||
|
||||
public TestHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public void ResetInvocationCount() => InvocationCount = 0;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var response))
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new HttpResponseMessage(response.StatusCode)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("unexpected request"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Ubuntu.CSAF/StellaOps.Excititor.Connectors.Ubuntu.CSAF.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
using RawVexDocument = StellaOps.Concelier.RawModels.VexRawDocument;
|
||||
using RawSignatureMetadata = StellaOps.Concelier.RawModels.RawSignatureMetadata;
|
||||
using RawSourceMetadata = StellaOps.Concelier.RawModels.RawSourceMetadata;
|
||||
using RawUpstreamMetadata = StellaOps.Concelier.RawModels.RawUpstreamMetadata;
|
||||
using RawContent = StellaOps.Concelier.RawModels.RawContent;
|
||||
using RawLinkset = StellaOps.Concelier.RawModels.RawLinkset;
|
||||
using RawDocumentFactory = StellaOps.Concelier.RawModels.RawDocumentFactory;
|
||||
using VexStatementSummary = StellaOps.Concelier.RawModels.VexStatementSummary;
|
||||
using RawReference = StellaOps.Concelier.RawModels.RawReference;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests.Aoc;
|
||||
|
||||
public sealed class VexRawWriteGuardTests
|
||||
{
|
||||
private static RawVexDocument CreateDocument(bool signaturePresent = false, bool includeSignaturePayload = true)
|
||||
{
|
||||
var signature = signaturePresent
|
||||
? new RawSignatureMetadata(true, "dsse", "key-1", includeSignaturePayload ? "signed" : null)
|
||||
: new RawSignatureMetadata(false);
|
||||
|
||||
using var contentDoc = JsonDocument.Parse("{\"id\":\"VEX-1\"}");
|
||||
|
||||
return RawDocumentFactory.CreateVex(
|
||||
tenant: "tenant-a",
|
||||
source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
|
||||
upstream: new RawUpstreamMetadata(
|
||||
UpstreamId: "VEX-1",
|
||||
DocumentVersion: "1",
|
||||
RetrievedAt: DateTimeOffset.UtcNow,
|
||||
ContentHash: "sha256:abc",
|
||||
Signature: signature,
|
||||
Provenance: ImmutableDictionary<string, string>.Empty),
|
||||
content: new RawContent("CSA" , "2.0", contentDoc.RootElement.Clone()),
|
||||
linkset: new RawLinkset
|
||||
{
|
||||
Aliases = ImmutableArray<string>.Empty,
|
||||
PackageUrls = ImmutableArray<string>.Empty,
|
||||
Cpes = ImmutableArray<string>.Empty,
|
||||
References = ImmutableArray<RawReference>.Empty,
|
||||
ReconciledFrom = ImmutableArray<string>.Empty,
|
||||
Notes = ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
statements: ImmutableArray<VexStatementSummary>.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureValid_AllowsMinimalDocument()
|
||||
{
|
||||
var guard = new VexRawWriteGuard(new AocWriteGuard());
|
||||
var document = CreateDocument();
|
||||
|
||||
guard.EnsureValid(document);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureValid_ThrowsWhenSignatureMissingPayload()
|
||||
{
|
||||
var guard = new VexRawWriteGuard(new AocWriteGuard());
|
||||
var document = CreateDocument(signaturePresent: true, includeSignaturePayload: false);
|
||||
|
||||
var exception = Assert.Throws<ExcititorAocGuardException>(() => guard.EnsureValid(document));
|
||||
Assert.Equal("ERR_AOC_005", exception.PrimaryErrorCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests.Observations;
|
||||
|
||||
public sealed class VexObservationQueryServiceTests
|
||||
{
|
||||
private static readonly TimeProvider TimeProvider = TimeProvider.System;
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_WhenNoFilters_ReturnsSortedObservations()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var observations = new[]
|
||||
{
|
||||
CreateObservation(
|
||||
observationId: "tenant-a:redhat:0001:1",
|
||||
tenant: "tenant-a",
|
||||
providerId: "RedHat",
|
||||
streamId: "csaf",
|
||||
vulnerabilityIds: new[] { "CVE-2025-1000" },
|
||||
productKeys: new[] { "pkg:rpm/redhat/openssl@1.1.1w-12" },
|
||||
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1w-12" },
|
||||
createdAt: now.AddMinutes(-10)),
|
||||
CreateObservation(
|
||||
observationId: "tenant-a:ubuntu:0002:1",
|
||||
tenant: "Tenant-A",
|
||||
providerId: "ubuntu",
|
||||
streamId: "cyclonedx",
|
||||
vulnerabilityIds: new[] { "CVE-2025-1001" },
|
||||
productKeys: new[] { "pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1" },
|
||||
purls: new[] { "pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1" },
|
||||
createdAt: now)
|
||||
};
|
||||
|
||||
var lookup = new InMemoryLookup(observations);
|
||||
var service = new VexObservationQueryService(lookup);
|
||||
|
||||
var result = await service.QueryAsync(new VexObservationQueryOptions("TENANT-A"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.Observations.Length);
|
||||
Assert.Equal("tenant-a:ubuntu:0002:1", result.Observations[0].ObservationId);
|
||||
Assert.Equal("tenant-a:redhat:0001:1", result.Observations[1].ObservationId);
|
||||
|
||||
Assert.Equal(new[] { "CVE-2025-1000", "CVE-2025-1001" }, result.Aggregate.VulnerabilityIds);
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1",
|
||||
"pkg:rpm/redhat/openssl@1.1.1w-12"
|
||||
},
|
||||
result.Aggregate.ProductKeys);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1",
|
||||
"pkg:rpm/redhat/openssl@1.1.1w-12"
|
||||
},
|
||||
result.Aggregate.Purls);
|
||||
|
||||
Assert.Equal(new[] { "redhat", "ubuntu" }, result.Aggregate.ProviderIds);
|
||||
Assert.False(result.HasMore);
|
||||
Assert.Null(result.NextCursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_WithVulnerabilityAndStatusFilters_FiltersStatements()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var observations = new[]
|
||||
{
|
||||
CreateObservation(
|
||||
observationId: "tenant-a:redhat:0001:1",
|
||||
tenant: "tenant-a",
|
||||
providerId: "redhat",
|
||||
streamId: "csaf",
|
||||
vulnerabilityIds: new[] { "CVE-2025-1000" },
|
||||
productKeys: new[] { "pkg:rpm/redhat/openssl@1.1.1w-12" },
|
||||
purls: Array.Empty<string>(),
|
||||
statuses: new[] { VexClaimStatus.NotAffected },
|
||||
createdAt: now),
|
||||
CreateObservation(
|
||||
observationId: "tenant-a:ubuntu:0002:1",
|
||||
tenant: "tenant-a",
|
||||
providerId: "ubuntu",
|
||||
streamId: "cyclonedx",
|
||||
vulnerabilityIds: new[] { "CVE-2025-9999" },
|
||||
productKeys: new[] { "pkg:deb/ubuntu/openssl@1.1.1w-9ubuntu1" },
|
||||
purls: Array.Empty<string>(),
|
||||
statuses: new[] { VexClaimStatus.Affected },
|
||||
createdAt: now.AddMinutes(-5))
|
||||
};
|
||||
|
||||
var lookup = new InMemoryLookup(observations);
|
||||
var service = new VexObservationQueryService(lookup);
|
||||
|
||||
var options = new VexObservationQueryOptions(
|
||||
tenant: "tenant-a",
|
||||
vulnerabilityIds: new[] { "cve-2025-1000" },
|
||||
statuses: new[] { VexClaimStatus.NotAffected });
|
||||
|
||||
var result = await service.QueryAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Observations);
|
||||
Assert.Equal("tenant-a:redhat:0001:1", result.Observations[0].ObservationId);
|
||||
Assert.Equal(new[] { "CVE-2025-1000" }, result.Aggregate.VulnerabilityIds);
|
||||
Assert.Equal(new[] { "pkg:rpm/redhat/openssl@1.1.1w-12" }, result.Aggregate.ProductKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_WithCursorAdvancesPages()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var observations = new[]
|
||||
{
|
||||
CreateObservation(
|
||||
observationId: "tenant-a:alpha",
|
||||
tenant: "tenant-a",
|
||||
providerId: "redhat",
|
||||
streamId: "csaf",
|
||||
vulnerabilityIds: new[] { "CVE-2025-0001" },
|
||||
productKeys: new[] { "pkg:rpm/redhat/foo@1.0.0" },
|
||||
purls: Array.Empty<string>(),
|
||||
statuses: new[] { VexClaimStatus.NotAffected },
|
||||
createdAt: now),
|
||||
CreateObservation(
|
||||
observationId: "tenant-a:beta",
|
||||
tenant: "tenant-a",
|
||||
providerId: "ubuntu",
|
||||
streamId: "cyclonedx",
|
||||
vulnerabilityIds: new[] { "CVE-2025-0002" },
|
||||
productKeys: new[] { "pkg:deb/ubuntu/foo@1.0.0" },
|
||||
purls: Array.Empty<string>(),
|
||||
statuses: new[] { VexClaimStatus.Affected },
|
||||
createdAt: now.AddMinutes(-1)),
|
||||
CreateObservation(
|
||||
observationId: "tenant-a:gamma",
|
||||
tenant: "tenant-a",
|
||||
providerId: "suse",
|
||||
streamId: "openvex",
|
||||
vulnerabilityIds: new[] { "CVE-2025-0003" },
|
||||
productKeys: new[] { "pkg:rpm/suse/foo@1.0.0" },
|
||||
purls: Array.Empty<string>(),
|
||||
statuses: new[] { VexClaimStatus.UnderInvestigation },
|
||||
createdAt: now.AddMinutes(-2))
|
||||
};
|
||||
|
||||
var lookup = new InMemoryLookup(observations);
|
||||
var service = new VexObservationQueryService(lookup);
|
||||
|
||||
var first = await service.QueryAsync(
|
||||
new VexObservationQueryOptions("tenant-a", limit: 2),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, first.Observations.Length);
|
||||
Assert.True(first.HasMore);
|
||||
Assert.NotNull(first.NextCursor);
|
||||
|
||||
var second = await service.QueryAsync(
|
||||
new VexObservationQueryOptions("tenant-a", limit: 2, cursor: first.NextCursor),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Single(second.Observations);
|
||||
Assert.False(second.HasMore);
|
||||
Assert.Null(second.NextCursor);
|
||||
Assert.Equal("tenant-a:gamma", second.Observations[0].ObservationId);
|
||||
}
|
||||
|
||||
private static VexObservation CreateObservation(
|
||||
string observationId,
|
||||
string tenant,
|
||||
string providerId,
|
||||
string streamId,
|
||||
IEnumerable<string> vulnerabilityIds,
|
||||
IEnumerable<string> productKeys,
|
||||
IEnumerable<string> purls,
|
||||
DateTimeOffset createdAt,
|
||||
IEnumerable<VexClaimStatus>? statuses = null)
|
||||
{
|
||||
var vulnerabilityArray = vulnerabilityIds.ToArray();
|
||||
var productArray = productKeys.ToArray();
|
||||
var purlArray = purls.ToArray();
|
||||
var statusArray = (statuses ?? Array.Empty<VexClaimStatus>()).ToArray();
|
||||
|
||||
if (vulnerabilityArray.Length != productArray.Length)
|
||||
{
|
||||
throw new ArgumentException("Vulnerability and product collections must align.");
|
||||
}
|
||||
|
||||
var statements = ImmutableArray.CreateBuilder<VexObservationStatement>(vulnerabilityArray.Length);
|
||||
for (var i = 0; i < vulnerabilityArray.Length; i++)
|
||||
{
|
||||
var status = statusArray.Length switch
|
||||
{
|
||||
0 => VexClaimStatus.NotAffected,
|
||||
_ when i < statusArray.Length => statusArray[i],
|
||||
_ => statusArray[0]
|
||||
};
|
||||
|
||||
var purlValue = purlArray.Length switch
|
||||
{
|
||||
0 => null,
|
||||
_ when i < purlArray.Length => purlArray[i],
|
||||
_ => purlArray[0]
|
||||
};
|
||||
|
||||
statements.Add(new VexObservationStatement(
|
||||
vulnerabilityArray[i],
|
||||
productArray[i],
|
||||
status,
|
||||
lastObserved: createdAt,
|
||||
purl: purlValue,
|
||||
cpe: null,
|
||||
evidence: ImmutableArray<JsonNode>.Empty));
|
||||
}
|
||||
|
||||
var upstream = new VexObservationUpstream(
|
||||
upstreamId: observationId,
|
||||
documentVersion: null,
|
||||
fetchedAt: createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: $"sha256:{Guid.NewGuid():N}",
|
||||
signature: new VexObservationSignature(present: false, null, null, null));
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: vulnerabilityIds,
|
||||
purls: purls,
|
||||
cpes: Array.Empty<string>(),
|
||||
references: new[]
|
||||
{
|
||||
new VexObservationReference("source", $"https://example.test/{observationId}")
|
||||
});
|
||||
|
||||
var content = new VexObservationContent(
|
||||
format: "csaf",
|
||||
specVersion: "2.0",
|
||||
raw: JsonNode.Parse("""{"document":"payload"}""") ?? throw new InvalidOperationException("Raw payload required."));
|
||||
|
||||
return new VexObservation(
|
||||
observationId,
|
||||
tenant,
|
||||
providerId,
|
||||
streamId,
|
||||
upstream,
|
||||
statements.ToImmutable(),
|
||||
content,
|
||||
linkset,
|
||||
createdAt,
|
||||
supersedes: ImmutableArray<string>.Empty,
|
||||
attributes: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
private sealed class InMemoryLookup : IVexObservationLookup
|
||||
{
|
||||
private readonly IReadOnlyList<VexObservation> _observations;
|
||||
|
||||
public InMemoryLookup(IReadOnlyList<VexObservation> observations)
|
||||
{
|
||||
_observations = observations;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexObservation>> ListByTenantAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenant);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(
|
||||
_observations.Where(observation => string.Equals(observation.Tenant, tenant, StringComparison.OrdinalIgnoreCase)).ToList());
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<VexObservation>> FindByFiltersAsync(
|
||||
string tenant,
|
||||
IReadOnlyCollection<string> observationIds,
|
||||
IReadOnlyCollection<string> vulnerabilityIds,
|
||||
IReadOnlyCollection<string> productKeys,
|
||||
IReadOnlyCollection<string> purls,
|
||||
IReadOnlyCollection<string> cpes,
|
||||
IReadOnlyCollection<string> providerIds,
|
||||
IReadOnlyCollection<VexClaimStatus> statuses,
|
||||
VexObservationCursor? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var filtered = _observations
|
||||
.Where(observation => string.Equals(observation.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (cursor is not null)
|
||||
{
|
||||
filtered = filtered
|
||||
.Where(observation =>
|
||||
observation.CreatedAt < cursor.CreatedAt ||
|
||||
(observation.CreatedAt == cursor.CreatedAt &&
|
||||
string.CompareOrdinal(observation.ObservationId, cursor.ObservationId) < 0))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
|
||||
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
|
||||
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Excititor.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests;
|
||||
|
||||
public sealed class VexCanonicalJsonSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeClaim_ProducesDeterministicOrder()
|
||||
{
|
||||
var product = new VexProduct(
|
||||
key: "pkg:redhat/demo",
|
||||
name: "Demo App",
|
||||
version: "1.2.3",
|
||||
purl: "pkg:rpm/redhat/demo@1.2.3",
|
||||
cpe: "cpe:2.3:a:redhat:demo:1.2.3",
|
||||
componentIdentifiers: new[] { "componentB", "componentA" });
|
||||
|
||||
var document = new VexClaimDocument(
|
||||
format: VexDocumentFormat.Csaf,
|
||||
digest: "sha256:6d5a",
|
||||
sourceUri: new Uri("https://example.org/vex/csaf.json"),
|
||||
revision: "2024-09-15",
|
||||
signature: new VexSignatureMetadata(
|
||||
type: "pgp",
|
||||
subject: "CN=Red Hat",
|
||||
issuer: "CN=Red Hat Root",
|
||||
keyId: "0xABCD",
|
||||
verifiedAt: new DateTimeOffset(2025, 10, 14, 9, 30, 0, TimeSpan.Zero)));
|
||||
|
||||
var claim = new VexClaim(
|
||||
vulnerabilityId: "CVE-2025-12345",
|
||||
providerId: "redhat",
|
||||
product: product,
|
||||
status: VexClaimStatus.NotAffected,
|
||||
document: document,
|
||||
firstSeen: new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero),
|
||||
lastSeen: new DateTimeOffset(2025, 10, 11, 12, 0, 0, TimeSpan.Zero),
|
||||
justification: VexJustification.ComponentNotPresent,
|
||||
detail: "Package not shipped in this channel.",
|
||||
confidence: new VexConfidence("high", 0.95, "policy/default"),
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal("CVSS:3.1", 7.5, label: "high", vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"),
|
||||
kev: true,
|
||||
epss: 0.42),
|
||||
additionalMetadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("source", "csaf")
|
||||
.Add("revision", "2024-09-15"));
|
||||
|
||||
var json = VexCanonicalJsonSerializer.Serialize(claim);
|
||||
|
||||
Assert.Equal(
|
||||
"{\"vulnerabilityId\":\"CVE-2025-12345\",\"providerId\":\"redhat\",\"product\":{\"key\":\"pkg:redhat/demo\",\"name\":\"Demo App\",\"version\":\"1.2.3\",\"purl\":\"pkg:rpm/redhat/demo@1.2.3\",\"cpe\":\"cpe:2.3:a:redhat:demo:1.2.3\",\"componentIdentifiers\":[\"componentA\",\"componentB\"]},\"status\":\"not_affected\",\"justification\":\"component_not_present\",\"detail\":\"Package not shipped in this channel.\",\"signals\":{\"severity\":{\"scheme\":\"CVSS:3.1\",\"score\":7.5,\"label\":\"high\",\"vector\":\"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H\"},\"kev\":true,\"epss\":0.42},\"document\":{\"format\":\"csaf\",\"digest\":\"sha256:6d5a\",\"sourceUri\":\"https://example.org/vex/csaf.json\",\"revision\":\"2024-09-15\",\"signature\":{\"type\":\"pgp\",\"subject\":\"CN=Red Hat\",\"issuer\":\"CN=Red Hat Root\",\"keyId\":\"0xABCD\",\"verifiedAt\":\"2025-10-14T09:30:00+00:00\",\"transparencyLogReference\":null}},\"firstSeen\":\"2025-10-10T12:00:00+00:00\",\"lastSeen\":\"2025-10-11T12:00:00+00:00\",\"confidence\":{\"level\":\"high\",\"score\":0.95,\"method\":\"policy/default\"},\"additionalMetadata\":{\"revision\":\"2024-09-15\",\"source\":\"csaf\"}}",
|
||||
json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeConsensus_IncludesSignalsInOrder()
|
||||
{
|
||||
var product = new VexProduct("pkg:demo/app", "Demo App");
|
||||
var sources = new[]
|
||||
{
|
||||
new VexConsensusSource("redhat", VexClaimStatus.Affected, "sha256:redhat", 1.0),
|
||||
};
|
||||
|
||||
var consensus = new VexConsensus(
|
||||
"CVE-2025-9999",
|
||||
product,
|
||||
VexConsensusStatus.Affected,
|
||||
new DateTimeOffset(2025, 10, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
sources,
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal("stellaops:v1", score: 9.1, label: "critical"),
|
||||
kev: false,
|
||||
epss: 0.67),
|
||||
policyVersion: "baseline/v1",
|
||||
summary: "Affected due to vendor advisory.",
|
||||
policyRevisionId: "rev-1",
|
||||
policyDigest: "sha256:abcd");
|
||||
|
||||
var json = VexCanonicalJsonSerializer.Serialize(consensus);
|
||||
|
||||
Assert.Equal(
|
||||
"{\"vulnerabilityId\":\"CVE-2025-9999\",\"product\":{\"key\":\"pkg:demo/app\",\"name\":\"Demo App\",\"version\":null,\"purl\":null,\"cpe\":null,\"componentIdentifiers\":[]},\"status\":\"affected\",\"calculatedAt\":\"2025-10-15T12:00:00+00:00\",\"sources\":[{\"providerId\":\"redhat\",\"status\":\"affected\",\"documentDigest\":\"sha256:redhat\",\"weight\":1,\"justification\":null,\"detail\":null,\"confidence\":null}],\"conflicts\":[],\"signals\":{\"severity\":{\"scheme\":\"stellaops:v1\",\"score\":9.1,\"label\":\"critical\",\"vector\":null},\"kev\":false,\"epss\":0.67},\"policyVersion\":\"baseline/v1\",\"summary\":\"Affected due to vendor advisory.\",\"policyDigest\":\"sha256:abcd\",\"policyRevisionId\":\"rev-1\"}",
|
||||
json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuerySignature_FromFilters_SortsAndNormalizesKeys()
|
||||
{
|
||||
var signature = VexQuerySignature.FromFilters(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>(" provider ", " redhat "),
|
||||
new KeyValuePair<string, string>("vulnId", "CVE-2025-12345"),
|
||||
new KeyValuePair<string, string>("provider", "canonical"),
|
||||
});
|
||||
|
||||
Assert.Equal("provider=canonical&provider=redhat&vulnId=CVE-2025-12345", signature.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeExportManifest_OrdersArraysAndNestedObjects()
|
||||
{
|
||||
var manifest = new VexExportManifest(
|
||||
exportId: "export/2025/10/15/1",
|
||||
querySignature: new VexQuerySignature("provider=redhat&format=consensus"),
|
||||
format: VexExportFormat.OpenVex,
|
||||
createdAt: new DateTimeOffset(2025, 10, 15, 8, 45, 0, TimeSpan.Zero),
|
||||
artifact: new VexContentAddress("sha256", "abcd1234"),
|
||||
claimCount: 42,
|
||||
sourceProviders: new[] { "cisco", "redhat", "redhat" },
|
||||
fromCache: true,
|
||||
consensusRevision: "rev-7",
|
||||
attestation: new VexAttestationMetadata(
|
||||
predicateType: "https://in-toto.io/Statement/v0.1",
|
||||
rekor: new VexRekorReference("v2", "rekor://uuid/1234", "17", new Uri("https://rekor.example/log/17")),
|
||||
envelopeDigest: "sha256:deadbeef",
|
||||
signedAt: new DateTimeOffset(2025, 10, 15, 8, 46, 0, TimeSpan.Zero)),
|
||||
sizeBytes: 4096);
|
||||
|
||||
var json = VexCanonicalJsonSerializer.SerializeIndented(manifest);
|
||||
|
||||
var providersIndex = json.IndexOf("\"sourceProviders\": [", StringComparison.Ordinal);
|
||||
Assert.True(providersIndex >= 0);
|
||||
var ciscoIndex = json.IndexOf("\"cisco\"", providersIndex, StringComparison.Ordinal);
|
||||
var redhatIndex = json.IndexOf("\"redhat\"", providersIndex, StringComparison.Ordinal);
|
||||
Assert.True(ciscoIndex >= 0 && redhatIndex > ciscoIndex);
|
||||
|
||||
var sequence = new[]
|
||||
{
|
||||
"\"consensusRevision\"",
|
||||
"\"policyRevisionId\"",
|
||||
"\"policyDigest\"",
|
||||
"\"consensusDigest\"",
|
||||
"\"scoreDigest\"",
|
||||
"\"attestation\""
|
||||
};
|
||||
|
||||
var lastIndex = -1;
|
||||
foreach (var token in sequence)
|
||||
{
|
||||
var index = json.IndexOf(token, StringComparison.Ordinal);
|
||||
Assert.True(index >= 0, $"Expected to find token {token} in canonical JSON.");
|
||||
Assert.True(index > lastIndex, $"Token {token} appeared out of order.");
|
||||
lastIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Excititor.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests;
|
||||
|
||||
public sealed class VexConsensusResolverTests
|
||||
{
|
||||
private static readonly VexProduct DemoProduct = new(
|
||||
key: "pkg:demo/app",
|
||||
name: "Demo App",
|
||||
version: "1.0.0",
|
||||
purl: "pkg:demo/app@1.0.0",
|
||||
cpe: "cpe:2.3:a:demo:app:1.0.0");
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SingleAcceptedClaim_SelectsStatus()
|
||||
{
|
||||
var provider = CreateProvider("redhat", VexProviderKind.Vendor);
|
||||
var claim = CreateClaim(
|
||||
"CVE-2025-0001",
|
||||
provider.Id,
|
||||
VexClaimStatus.Affected,
|
||||
justification: null);
|
||||
|
||||
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
|
||||
|
||||
var result = resolver.Resolve(new VexConsensusRequest(
|
||||
claim.VulnerabilityId,
|
||||
DemoProduct,
|
||||
new[] { claim },
|
||||
new Dictionary<string, VexProvider> { [provider.Id] = provider },
|
||||
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
|
||||
|
||||
Assert.Equal(VexConsensusStatus.Affected, result.Consensus.Status);
|
||||
Assert.Equal("baseline/v1", result.Consensus.PolicyVersion);
|
||||
Assert.Single(result.Consensus.Sources);
|
||||
Assert.Empty(result.Consensus.Conflicts);
|
||||
Assert.NotNull(result.Consensus.Summary);
|
||||
Assert.Contains("affected", result.Consensus.Summary!, StringComparison.Ordinal);
|
||||
|
||||
var decision = Assert.Single(result.DecisionLog);
|
||||
Assert.True(decision.Included);
|
||||
Assert.Equal(provider.Id, decision.ProviderId);
|
||||
Assert.Null(decision.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NotAffectedWithoutJustification_IsRejected()
|
||||
{
|
||||
var provider = CreateProvider("cisco", VexProviderKind.Vendor);
|
||||
var claim = CreateClaim(
|
||||
"CVE-2025-0002",
|
||||
provider.Id,
|
||||
VexClaimStatus.NotAffected,
|
||||
justification: null);
|
||||
|
||||
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
|
||||
|
||||
var result = resolver.Resolve(new VexConsensusRequest(
|
||||
claim.VulnerabilityId,
|
||||
DemoProduct,
|
||||
new[] { claim },
|
||||
new Dictionary<string, VexProvider> { [provider.Id] = provider },
|
||||
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
|
||||
|
||||
Assert.Equal(VexConsensusStatus.UnderInvestigation, result.Consensus.Status);
|
||||
Assert.Empty(result.Consensus.Sources);
|
||||
var conflict = Assert.Single(result.Consensus.Conflicts);
|
||||
Assert.Equal("missing_justification", conflict.Reason);
|
||||
|
||||
var decision = Assert.Single(result.DecisionLog);
|
||||
Assert.False(decision.Included);
|
||||
Assert.Equal("missing_justification", decision.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MajorityWeightWins_WithConflictingSources()
|
||||
{
|
||||
var vendor = CreateProvider("redhat", VexProviderKind.Vendor);
|
||||
var distro = CreateProvider("fedora", VexProviderKind.Distro);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
CreateClaim(
|
||||
"CVE-2025-0003",
|
||||
vendor.Id,
|
||||
VexClaimStatus.Affected,
|
||||
detail: "Vendor advisory",
|
||||
documentDigest: "sha256:vendor"),
|
||||
CreateClaim(
|
||||
"CVE-2025-0003",
|
||||
distro.Id,
|
||||
VexClaimStatus.NotAffected,
|
||||
justification: VexJustification.ComponentNotPresent,
|
||||
detail: "Distro package not shipped",
|
||||
documentDigest: "sha256:distro"),
|
||||
};
|
||||
|
||||
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
|
||||
|
||||
var result = resolver.Resolve(new VexConsensusRequest(
|
||||
"CVE-2025-0003",
|
||||
DemoProduct,
|
||||
claims,
|
||||
new Dictionary<string, VexProvider>
|
||||
{
|
||||
[vendor.Id] = vendor,
|
||||
[distro.Id] = distro,
|
||||
},
|
||||
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
|
||||
|
||||
Assert.Equal(VexConsensusStatus.Affected, result.Consensus.Status);
|
||||
Assert.Equal(2, result.Consensus.Sources.Length);
|
||||
Assert.Equal(1.0, result.Consensus.Sources.First(s => s.ProviderId == vendor.Id).Weight);
|
||||
Assert.Contains(result.Consensus.Conflicts, c => c.ProviderId == distro.Id && c.Reason == "status_conflict");
|
||||
Assert.NotNull(result.Consensus.Summary);
|
||||
Assert.Contains("affected", result.Consensus.Summary!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_TieFallsBackToUnderInvestigation()
|
||||
{
|
||||
var hub = CreateProvider("hub", VexProviderKind.Hub);
|
||||
var platform = CreateProvider("platform", VexProviderKind.Platform);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
CreateClaim(
|
||||
"CVE-2025-0004",
|
||||
hub.Id,
|
||||
VexClaimStatus.Affected,
|
||||
detail: "Hub escalation",
|
||||
documentDigest: "sha256:hub"),
|
||||
CreateClaim(
|
||||
"CVE-2025-0004",
|
||||
platform.Id,
|
||||
VexClaimStatus.NotAffected,
|
||||
justification: VexJustification.ProtectedByMitigatingControl,
|
||||
detail: "Runtime mitigations",
|
||||
documentDigest: "sha256:platform"),
|
||||
};
|
||||
|
||||
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy(
|
||||
new VexConsensusPolicyOptions(
|
||||
hubWeight: 0.5,
|
||||
platformWeight: 0.5)));
|
||||
|
||||
var result = resolver.Resolve(new VexConsensusRequest(
|
||||
"CVE-2025-0004",
|
||||
DemoProduct,
|
||||
claims,
|
||||
new Dictionary<string, VexProvider>
|
||||
{
|
||||
[hub.Id] = hub,
|
||||
[platform.Id] = platform,
|
||||
},
|
||||
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
|
||||
|
||||
Assert.Equal(VexConsensusStatus.UnderInvestigation, result.Consensus.Status);
|
||||
Assert.Equal(2, result.Consensus.Conflicts.Length);
|
||||
Assert.NotNull(result.Consensus.Summary);
|
||||
Assert.Contains("No majority consensus", result.Consensus.Summary!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_RespectsRaisedWeightCeiling()
|
||||
{
|
||||
var provider = CreateProvider("vendor", VexProviderKind.Vendor);
|
||||
var claim = CreateClaim(
|
||||
"CVE-2025-0100",
|
||||
provider.Id,
|
||||
VexClaimStatus.Affected,
|
||||
documentDigest: "sha256:vendor");
|
||||
|
||||
var policy = new BaselineVexConsensusPolicy(new VexConsensusPolicyOptions(
|
||||
vendorWeight: 1.4,
|
||||
weightCeiling: 2.0));
|
||||
var resolver = new VexConsensusResolver(policy);
|
||||
|
||||
var result = resolver.Resolve(new VexConsensusRequest(
|
||||
claim.VulnerabilityId,
|
||||
DemoProduct,
|
||||
new[] { claim },
|
||||
new Dictionary<string, VexProvider> { [provider.Id] = provider },
|
||||
DateTimeOffset.Parse("2025-10-15T12:00:00Z"),
|
||||
WeightCeiling: 2.0));
|
||||
|
||||
var source = Assert.Single(result.Consensus.Sources);
|
||||
Assert.Equal(1.4, source.Weight);
|
||||
}
|
||||
|
||||
private static VexProvider CreateProvider(string id, VexProviderKind kind)
|
||||
=> new(
|
||||
id,
|
||||
displayName: id.ToUpperInvariant(),
|
||||
kind,
|
||||
baseUris: Array.Empty<Uri>(),
|
||||
trust: new VexProviderTrust(weight: 1.0, cosign: null));
|
||||
|
||||
private static VexClaim CreateClaim(
|
||||
string vulnerabilityId,
|
||||
string providerId,
|
||||
VexClaimStatus status,
|
||||
VexJustification? justification = null,
|
||||
string? detail = null,
|
||||
string? documentDigest = null)
|
||||
=> new(
|
||||
vulnerabilityId,
|
||||
providerId,
|
||||
DemoProduct,
|
||||
status,
|
||||
new VexClaimDocument(
|
||||
VexDocumentFormat.Csaf,
|
||||
documentDigest ?? $"sha256:{providerId}",
|
||||
new Uri($"https://example.org/{providerId}/{vulnerabilityId}.json"),
|
||||
"1"),
|
||||
firstSeen: DateTimeOffset.Parse("2025-10-10T12:00:00Z"),
|
||||
lastSeen: DateTimeOffset.Parse("2025-10-11T12:00:00Z"),
|
||||
justification,
|
||||
detail,
|
||||
confidence: null,
|
||||
additionalMetadata: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using StellaOps.Excititor.Policy;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests;
|
||||
|
||||
public sealed class VexPolicyBinderTests
|
||||
{
|
||||
private const string JsonPolicy = """
|
||||
{
|
||||
"version": "custom/v2",
|
||||
"weights": {
|
||||
"vendor": 1.3,
|
||||
"distro": 0.85,
|
||||
"ceiling": 2.0
|
||||
},
|
||||
"scoring": {
|
||||
"alpha": 0.35,
|
||||
"beta": 0.75
|
||||
},
|
||||
"providerOverrides": {
|
||||
"provider.example": 1.8
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private const string YamlPolicy = """
|
||||
version: custom/v3
|
||||
weights:
|
||||
vendor: 0.8
|
||||
distro: 0.7
|
||||
platform: 0.6
|
||||
providerOverrides:
|
||||
provider-a: 0.4
|
||||
provider-b: 0.3
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void Bind_Json_ReturnsNormalizedOptions()
|
||||
{
|
||||
var result = VexPolicyBinder.Bind(JsonPolicy, VexPolicyDocumentFormat.Json);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Options);
|
||||
Assert.NotNull(result.NormalizedOptions);
|
||||
Assert.Equal("custom/v2", result.Options!.Version);
|
||||
Assert.Equal("custom/v2", result.NormalizedOptions!.Version);
|
||||
Assert.Equal(1.3, result.NormalizedOptions.VendorWeight);
|
||||
Assert.Equal(0.85, result.NormalizedOptions.DistroWeight);
|
||||
Assert.Equal(2.0, result.NormalizedOptions.WeightCeiling);
|
||||
Assert.Equal(0.35, result.NormalizedOptions.Alpha);
|
||||
Assert.Equal(0.75, result.NormalizedOptions.Beta);
|
||||
Assert.Equal(1.8, result.NormalizedOptions.ProviderOverrides["provider.example"]);
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_Yaml_ReturnsOverridesAndWarningsSorted()
|
||||
{
|
||||
var result = VexPolicyBinder.Bind(YamlPolicy, VexPolicyDocumentFormat.Yaml);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.NormalizedOptions);
|
||||
var overrides = result.NormalizedOptions!.ProviderOverrides;
|
||||
Assert.Equal(2, overrides.Count);
|
||||
Assert.Equal(0.4, overrides["provider-a"]);
|
||||
Assert.Equal(0.3, overrides["provider-b"]);
|
||||
Assert.Empty(result.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_InvalidJson_ReturnsError()
|
||||
{
|
||||
const string invalidJson = "{ \"weights\": { \"vendor\": \"not-a-number\" }";
|
||||
|
||||
var result = VexPolicyBinder.Bind(invalidJson, VexPolicyDocumentFormat.Json);
|
||||
|
||||
Assert.False(result.Success);
|
||||
var issue = Assert.Single(result.Issues);
|
||||
Assert.Equal(VexPolicyIssueSeverity.Error, issue.Severity);
|
||||
Assert.StartsWith("policy.parse.json", issue.Code, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_Stream_SupportsEncoding()
|
||||
{
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonPolicy));
|
||||
var result = VexPolicyBinder.Bind(stream, VexPolicyDocumentFormat.Json);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_InvalidWeightsAndScoring_EmitsWarningsAndClamps()
|
||||
{
|
||||
const string policy = """
|
||||
{
|
||||
"weights": {
|
||||
"vendor": 3.5,
|
||||
"ceiling": 0.8
|
||||
},
|
||||
"scoring": {
|
||||
"alpha": -0.1,
|
||||
"beta": 10.0
|
||||
},
|
||||
"providerOverrides": {
|
||||
"bad": 4.0
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = VexPolicyBinder.Bind(policy, VexPolicyDocumentFormat.Json);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.NormalizedOptions);
|
||||
var consensus = result.NormalizedOptions!;
|
||||
Assert.Equal(1.0, consensus.WeightCeiling);
|
||||
Assert.Equal(1.0, consensus.VendorWeight);
|
||||
Assert.Equal(1.0, consensus.ProviderOverrides["bad"]);
|
||||
Assert.Equal(VexConsensusPolicyOptions.DefaultAlpha, consensus.Alpha);
|
||||
Assert.Equal(VexConsensusPolicyOptions.MaxSupportedCoefficient, consensus.Beta);
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "weights.ceiling.minimum");
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "weights.vendor.range");
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "weights.overrides.bad.range");
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "scoring.alpha.range");
|
||||
Assert.Contains(result.Issues, issue => issue.Code == "scoring.beta.maximum");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests;
|
||||
|
||||
public class VexPolicyDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDiagnostics_ReportsCountsRecommendationsAndOverrides()
|
||||
{
|
||||
var overrides = new[]
|
||||
{
|
||||
new KeyValuePair<string, double>("provider-a", 0.8),
|
||||
new KeyValuePair<string, double>("provider-b", 0.6),
|
||||
};
|
||||
|
||||
var snapshot = new VexPolicySnapshot(
|
||||
"custom/v1",
|
||||
new VexConsensusPolicyOptions(
|
||||
version: "custom/v1",
|
||||
providerOverrides: overrides),
|
||||
new BaselineVexConsensusPolicy(),
|
||||
ImmutableArray.Create(
|
||||
new VexPolicyIssue("sample.error", "Blocking issue.", VexPolicyIssueSeverity.Error),
|
||||
new VexPolicyIssue("sample.warning", "Non-blocking issue.", VexPolicyIssueSeverity.Warning)),
|
||||
"rev-test",
|
||||
"ABCDEF");
|
||||
|
||||
var fakeProvider = new FakePolicyProvider(snapshot);
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 16, 17, 0, 0, TimeSpan.Zero));
|
||||
var diagnostics = new VexPolicyDiagnostics(fakeProvider, fakeTime);
|
||||
|
||||
var report = diagnostics.GetDiagnostics();
|
||||
|
||||
Assert.Equal("custom/v1", report.Version);
|
||||
Assert.Equal("rev-test", report.RevisionId);
|
||||
Assert.Equal("ABCDEF", report.Digest);
|
||||
Assert.Equal(1, report.ErrorCount);
|
||||
Assert.Equal(1, report.WarningCount);
|
||||
Assert.Equal(fakeTime.GetUtcNow(), report.GeneratedAt);
|
||||
Assert.Collection(report.Issues,
|
||||
issue => Assert.Equal("sample.error", issue.Code),
|
||||
issue => Assert.Equal("sample.warning", issue.Code));
|
||||
Assert.Equal(new[] { "provider-a", "provider-b" }, report.ActiveOverrides.Keys.OrderBy(static key => key, StringComparer.Ordinal));
|
||||
Assert.Contains(report.Recommendations, message => message.Contains("Resolve policy errors", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(report.Recommendations, message => message.Contains("provider-a", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(report.Recommendations, message => message.Contains("docs/ARCHITECTURE_EXCITITOR.md", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDiagnostics_WhenNoIssues_StillReturnsDefaultRecommendation()
|
||||
{
|
||||
var fakeProvider = new FakePolicyProvider(VexPolicySnapshot.Default);
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 16, 17, 0, 0, TimeSpan.Zero));
|
||||
var diagnostics = new VexPolicyDiagnostics(fakeProvider, fakeTime);
|
||||
|
||||
var report = diagnostics.GetDiagnostics();
|
||||
|
||||
Assert.Equal(0, report.ErrorCount);
|
||||
Assert.Equal(0, report.WarningCount);
|
||||
Assert.Empty(report.ActiveOverrides);
|
||||
Assert.Single(report.Recommendations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyProvider_ComputesRevisionAndDigest_AndEmitsTelemetry()
|
||||
{
|
||||
using var listener = new MeterListener();
|
||||
var reloadMeasurements = 0;
|
||||
string? lastRevision = null;
|
||||
listener.InstrumentPublished += (instrument, _) =>
|
||||
{
|
||||
if (instrument.Meter.Name == "StellaOps.Excititor.Policy" &&
|
||||
instrument.Name == "vex.policy.reloads")
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
reloadMeasurements++;
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (tag.Key is "revision" && tag.Value is string revision)
|
||||
{
|
||||
lastRevision = revision;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
var optionsMonitor = new MutableOptionsMonitor<VexPolicyOptions>(new VexPolicyOptions());
|
||||
var provider = new VexPolicyProvider(optionsMonitor, NullLogger<VexPolicyProvider>.Instance);
|
||||
|
||||
var snapshot1 = provider.GetSnapshot();
|
||||
Assert.Equal("rev-1", snapshot1.RevisionId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(snapshot1.Digest));
|
||||
|
||||
var snapshot2 = provider.GetSnapshot();
|
||||
Assert.Equal("rev-1", snapshot2.RevisionId);
|
||||
Assert.Equal(snapshot1.Digest, snapshot2.Digest);
|
||||
|
||||
optionsMonitor.Update(new VexPolicyOptions
|
||||
{
|
||||
ProviderOverrides = new Dictionary<string, double>
|
||||
{
|
||||
["provider-a"] = 0.4
|
||||
}
|
||||
});
|
||||
|
||||
var snapshot3 = provider.GetSnapshot();
|
||||
Assert.Equal("rev-2", snapshot3.RevisionId);
|
||||
Assert.NotEqual(snapshot1.Digest, snapshot3.Digest);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
Assert.True(reloadMeasurements >= 2);
|
||||
Assert.Equal("rev-2", lastRevision);
|
||||
}
|
||||
|
||||
private sealed class FakePolicyProvider : IVexPolicyProvider
|
||||
{
|
||||
private readonly VexPolicySnapshot _snapshot;
|
||||
|
||||
public FakePolicyProvider(VexPolicySnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public VexPolicySnapshot GetSnapshot() => _snapshot;
|
||||
}
|
||||
|
||||
private sealed class MutableOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private T _value;
|
||||
|
||||
public MutableOptionsMonitor(T value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
|
||||
public T Get(string? name) => _value;
|
||||
|
||||
public void Update(T newValue) => _value = newValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Excititor.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests;
|
||||
|
||||
public sealed class VexQuerySignatureTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromFilters_SortsAlphabetically()
|
||||
{
|
||||
var filters = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("provider", "redhat"),
|
||||
new KeyValuePair<string, string>("vulnId", "CVE-2025-0001"),
|
||||
new KeyValuePair<string, string>("provider", "cisco"),
|
||||
};
|
||||
|
||||
var signature = VexQuerySignature.FromFilters(filters);
|
||||
|
||||
Assert.Equal("provider=cisco&provider=redhat&vulnId=CVE-2025-0001", signature.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromQuery_NormalizesFiltersAndSort()
|
||||
{
|
||||
var query = VexQuery.Create(
|
||||
filters: new[]
|
||||
{
|
||||
new VexQueryFilter(" provider ", " redhat "),
|
||||
new VexQueryFilter("vulnId", "CVE-2025-0002"),
|
||||
},
|
||||
sort: new[]
|
||||
{
|
||||
new VexQuerySort("published", true),
|
||||
new VexQuerySort("severity", false),
|
||||
},
|
||||
limit: 200,
|
||||
offset: 10,
|
||||
view: "consensus");
|
||||
|
||||
var signature = VexQuerySignature.FromQuery(query);
|
||||
|
||||
Assert.Equal(
|
||||
"provider=redhat&vulnId=CVE-2025-0002&sort=-published&sort=+severity&limit=200&offset=10&view=consensus",
|
||||
signature.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_ReturnsStableSha256()
|
||||
{
|
||||
var signature = new VexQuerySignature("provider=redhat&vulnId=CVE-2025-0003");
|
||||
|
||||
var address = signature.ComputeHash();
|
||||
|
||||
Assert.Equal("sha256", address.Algorithm);
|
||||
Assert.Equal("44c9881aaa79050ae943eaaf78afa697b1a4d3e38b03e20db332f2bd1e5b1029", address.Digest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests;
|
||||
|
||||
public sealed class VexSignalSnapshotTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(-0.01)]
|
||||
[InlineData(1.01)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public void Constructor_InvalidEpss_Throws(double value)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new VexSignalSnapshot(epss: value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void VexSeveritySignal_InvalidScheme_Throws(string? scheme)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new VexSeveritySignal(scheme!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.1)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.NegativeInfinity)]
|
||||
public void VexSeveritySignal_InvalidScore_Throws(double value)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new VexSeveritySignal("cvss", value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Export.Tests;
|
||||
|
||||
public sealed class ExportEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportAsync_GeneratesAndCachesManifest()
|
||||
{
|
||||
var store = new InMemoryExportStore();
|
||||
var evaluator = new StaticPolicyEvaluator("baseline/v1");
|
||||
var dataSource = new InMemoryExportDataSource();
|
||||
var exporter = new DummyExporter(VexExportFormat.Json);
|
||||
var engine = new VexExportEngine(store, evaluator, dataSource, new[] { exporter }, NullLogger<VexExportEngine>.Instance);
|
||||
|
||||
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
|
||||
var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow, ForceRefresh: false);
|
||||
|
||||
var manifest = await engine.ExportAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.False(manifest.FromCache);
|
||||
Assert.Equal(VexExportFormat.Json, manifest.Format);
|
||||
Assert.Equal("baseline/v1", manifest.ConsensusRevision);
|
||||
Assert.Equal(1, manifest.ClaimCount);
|
||||
Assert.NotNull(dataSource.LastDataSet);
|
||||
var expectedEnvelopes = VexExportEnvelopeBuilder.Build(
|
||||
dataSource.LastDataSet!,
|
||||
VexPolicySnapshot.Default,
|
||||
context.RequestedAt);
|
||||
Assert.NotNull(manifest.ConsensusDigest);
|
||||
Assert.Equal(expectedEnvelopes.ConsensusDigest.Algorithm, manifest.ConsensusDigest!.Algorithm);
|
||||
Assert.Equal(expectedEnvelopes.ConsensusDigest.Digest, manifest.ConsensusDigest.Digest);
|
||||
Assert.NotNull(manifest.ScoreDigest);
|
||||
Assert.Equal(expectedEnvelopes.ScoreDigest.Algorithm, manifest.ScoreDigest!.Algorithm);
|
||||
Assert.Equal(expectedEnvelopes.ScoreDigest.Digest, manifest.ScoreDigest.Digest);
|
||||
Assert.Empty(manifest.QuietProvenance);
|
||||
|
||||
// second call hits cache
|
||||
var cached = await engine.ExportAsync(context, CancellationToken.None);
|
||||
Assert.True(cached.FromCache);
|
||||
Assert.Equal(manifest.ExportId, cached.ExportId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ForceRefreshInvalidatesCacheEntry()
|
||||
{
|
||||
var store = new InMemoryExportStore();
|
||||
var evaluator = new StaticPolicyEvaluator("baseline/v1");
|
||||
var dataSource = new InMemoryExportDataSource();
|
||||
var exporter = new DummyExporter(VexExportFormat.Json);
|
||||
var cacheIndex = new RecordingCacheIndex();
|
||||
var engine = new VexExportEngine(store, evaluator, dataSource, new[] { exporter }, NullLogger<VexExportEngine>.Instance, cacheIndex);
|
||||
|
||||
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
|
||||
var initialContext = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow);
|
||||
_ = await engine.ExportAsync(initialContext, CancellationToken.None);
|
||||
|
||||
var refreshContext = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow.AddMinutes(1), ForceRefresh: true);
|
||||
var refreshed = await engine.ExportAsync(refreshContext, CancellationToken.None);
|
||||
|
||||
Assert.False(refreshed.FromCache);
|
||||
var signature = VexQuerySignature.FromQuery(refreshContext.Query);
|
||||
Assert.True(cacheIndex.RemoveCalls.TryGetValue((signature.Value, refreshContext.Format), out var removed));
|
||||
Assert.True(removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WritesArtifactsToAllStores()
|
||||
{
|
||||
var store = new InMemoryExportStore();
|
||||
var evaluator = new StaticPolicyEvaluator("baseline/v1");
|
||||
var dataSource = new InMemoryExportDataSource();
|
||||
var exporter = new DummyExporter(VexExportFormat.Json);
|
||||
var recorder1 = new RecordingArtifactStore();
|
||||
var recorder2 = new RecordingArtifactStore();
|
||||
var engine = new VexExportEngine(
|
||||
store,
|
||||
evaluator,
|
||||
dataSource,
|
||||
new[] { exporter },
|
||||
NullLogger<VexExportEngine>.Instance,
|
||||
cacheIndex: null,
|
||||
artifactStores: new[] { recorder1, recorder2 });
|
||||
|
||||
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
|
||||
var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow);
|
||||
|
||||
await engine.ExportAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, recorder1.SaveCount);
|
||||
Assert.Equal(1, recorder2.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_AttachesAttestationMetadata()
|
||||
{
|
||||
var store = new InMemoryExportStore();
|
||||
var evaluator = new StaticPolicyEvaluator("baseline/v1");
|
||||
var dataSource = new InMemoryExportDataSource();
|
||||
var exporter = new DummyExporter(VexExportFormat.Json);
|
||||
var attestation = new RecordingAttestationClient();
|
||||
var engine = new VexExportEngine(
|
||||
store,
|
||||
evaluator,
|
||||
dataSource,
|
||||
new[] { exporter },
|
||||
NullLogger<VexExportEngine>.Instance,
|
||||
cacheIndex: null,
|
||||
artifactStores: null,
|
||||
attestationClient: attestation);
|
||||
|
||||
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
|
||||
var requestedAt = DateTimeOffset.UtcNow;
|
||||
var context = new VexExportRequestContext(query, VexExportFormat.Json, requestedAt);
|
||||
|
||||
var manifest = await engine.ExportAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(attestation.LastRequest);
|
||||
Assert.NotNull(dataSource.LastDataSet);
|
||||
var expectedEnvelopes = VexExportEnvelopeBuilder.Build(
|
||||
dataSource.LastDataSet!,
|
||||
VexPolicySnapshot.Default,
|
||||
requestedAt);
|
||||
Assert.Equal(manifest.ExportId, attestation.LastRequest!.ExportId);
|
||||
var metadata = attestation.LastRequest.Metadata;
|
||||
Assert.True(metadata.ContainsKey("consensusDigest"), "Consensus digest metadata missing");
|
||||
Assert.Equal(expectedEnvelopes.ConsensusDigest.ToUri(), metadata["consensusDigest"]);
|
||||
Assert.True(metadata.ContainsKey("scoreDigest"), "Score digest metadata missing");
|
||||
Assert.Equal(expectedEnvelopes.ScoreDigest.ToUri(), metadata["scoreDigest"]);
|
||||
Assert.Equal(expectedEnvelopes.Consensus.Length.ToString(CultureInfo.InvariantCulture), metadata["consensusEntryCount"]);
|
||||
Assert.Equal(expectedEnvelopes.ScoreEnvelope.Entries.Length.ToString(CultureInfo.InvariantCulture), metadata["scoreEntryCount"]);
|
||||
Assert.Equal(VexPolicySnapshot.Default.RevisionId, metadata["policyRevisionId"]);
|
||||
Assert.Equal(VexPolicySnapshot.Default.Version, metadata["policyVersion"]);
|
||||
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.Alpha.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreAlpha"]);
|
||||
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.Beta.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreBeta"]);
|
||||
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.WeightCeiling.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreWeightCeiling"]);
|
||||
Assert.NotNull(manifest.Attestation);
|
||||
Assert.Equal(attestation.Response.Attestation.EnvelopeDigest, manifest.Attestation!.EnvelopeDigest);
|
||||
Assert.Equal(attestation.Response.Attestation.PredicateType, manifest.Attestation.PredicateType);
|
||||
Assert.NotNull(manifest.ConsensusDigest);
|
||||
Assert.Equal(expectedEnvelopes.ConsensusDigest.Digest, manifest.ConsensusDigest!.Digest);
|
||||
Assert.NotNull(manifest.ScoreDigest);
|
||||
Assert.Equal(expectedEnvelopes.ScoreDigest.Digest, manifest.ScoreDigest!.Digest);
|
||||
Assert.Empty(manifest.QuietProvenance);
|
||||
|
||||
Assert.NotNull(store.LastSavedManifest);
|
||||
Assert.Equal(manifest.Attestation, store.LastSavedManifest!.Attestation);
|
||||
Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_IncludesQuietProvenanceMetadata()
|
||||
{
|
||||
var store = new InMemoryExportStore();
|
||||
var evaluator = new StaticPolicyEvaluator("baseline/v1");
|
||||
var dataSource = new QuietExportDataSource();
|
||||
var exporter = new DummyExporter(VexExportFormat.Json);
|
||||
var attestation = new RecordingAttestationClient();
|
||||
var engine = new VexExportEngine(
|
||||
store,
|
||||
evaluator,
|
||||
dataSource,
|
||||
new[] { exporter },
|
||||
NullLogger<VexExportEngine>.Instance,
|
||||
cacheIndex: null,
|
||||
artifactStores: null,
|
||||
attestationClient: attestation);
|
||||
|
||||
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0002") });
|
||||
var requestedAt = DateTimeOffset.UtcNow;
|
||||
var context = new VexExportRequestContext(query, VexExportFormat.Json, requestedAt);
|
||||
|
||||
var manifest = await engine.ExportAsync(context, CancellationToken.None);
|
||||
|
||||
var quiet = Assert.Single(manifest.QuietProvenance);
|
||||
Assert.Equal("CVE-2025-0002", quiet.VulnerabilityId);
|
||||
Assert.Equal("pkg:demo/app", quiet.ProductKey);
|
||||
var statement = Assert.Single(quiet.Statements);
|
||||
Assert.Equal("vendor", statement.ProviderId);
|
||||
Assert.Equal("sha256:quiet", statement.StatementId);
|
||||
Assert.Equal(VexJustification.ComponentNotPresent, statement.Justification);
|
||||
Assert.NotNull(statement.Signature);
|
||||
Assert.Equal("quiet-signer", statement.Signature!.Subject);
|
||||
Assert.Equal("quiet-key", statement.Signature.KeyId);
|
||||
|
||||
var expectedQuietJson = VexCanonicalJsonSerializer.Serialize(manifest.QuietProvenance);
|
||||
Assert.NotNull(attestation.LastRequest);
|
||||
Assert.True(attestation.LastRequest!.Metadata.TryGetValue("quietedBy", out var quietJson));
|
||||
Assert.Equal(expectedQuietJson, quietJson);
|
||||
Assert.True(attestation.LastRequest.Metadata.TryGetValue("quietedByStatementCount", out var quietCount));
|
||||
Assert.Equal("1", quietCount);
|
||||
|
||||
Assert.NotNull(store.LastSavedManifest);
|
||||
Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance);
|
||||
}
|
||||
|
||||
private sealed class InMemoryExportStore : IVexExportStore
|
||||
{
|
||||
private readonly Dictionary<string, VexExportManifest> _store = new(StringComparer.Ordinal);
|
||||
|
||||
public VexExportManifest? LastSavedManifest { get; private set; }
|
||||
|
||||
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var key = CreateKey(signature.Value, format);
|
||||
_store.TryGetValue(key, out var manifest);
|
||||
return ValueTask.FromResult<VexExportManifest?>(manifest);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var key = CreateKey(manifest.QuerySignature.Value, manifest.Format);
|
||||
_store[key] = manifest;
|
||||
LastSavedManifest = manifest;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static string CreateKey(string signature, VexExportFormat format)
|
||||
=> FormattableString.Invariant($"{signature}|{format}");
|
||||
}
|
||||
|
||||
private sealed class QuietExportDataSource : IVexExportDataSource
|
||||
{
|
||||
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var signature = new VexSignatureMetadata(
|
||||
type: "pgp",
|
||||
subject: "quiet-signer",
|
||||
issuer: "quiet-ca",
|
||||
keyId: "quiet-key",
|
||||
verifiedAt: DateTimeOffset.UnixEpoch,
|
||||
transparencyLogReference: "rekor://quiet");
|
||||
|
||||
var claim = new VexClaim(
|
||||
"CVE-2025-0002",
|
||||
"vendor",
|
||||
new VexProduct("pkg:demo/app", "Demo"),
|
||||
VexClaimStatus.NotAffected,
|
||||
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:quiet", new Uri("https://example.org/quiet"), signature: signature),
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
justification: VexJustification.ComponentNotPresent);
|
||||
|
||||
var consensus = new VexConsensus(
|
||||
"CVE-2025-0002",
|
||||
claim.Product,
|
||||
VexConsensusStatus.NotAffected,
|
||||
DateTimeOffset.UtcNow,
|
||||
new[]
|
||||
{
|
||||
new VexConsensusSource("vendor", VexClaimStatus.NotAffected, "sha256:quiet", 1.0, claim.Justification),
|
||||
},
|
||||
conflicts: null,
|
||||
policyVersion: "baseline/v1",
|
||||
summary: "not_affected");
|
||||
|
||||
return ValueTask.FromResult(new VexExportDataSet(
|
||||
ImmutableArray.Create(consensus),
|
||||
ImmutableArray.Create(claim),
|
||||
ImmutableArray.Create("vendor")));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAttestationClient : IVexAttestationClient
|
||||
{
|
||||
public VexAttestationRequest? LastRequest { get; private set; }
|
||||
|
||||
public VexAttestationResponse Response { get; } = new VexAttestationResponse(
|
||||
new VexAttestationMetadata(
|
||||
predicateType: "https://stella-ops.org/attestations/vex-export",
|
||||
rekor: new VexRekorReference("0.2", "rekor://entry", "123"),
|
||||
envelopeDigest: "sha256:envelope",
|
||||
signedAt: DateTimeOffset.UnixEpoch),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return ValueTask.FromResult(Response);
|
||||
}
|
||||
|
||||
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class RecordingCacheIndex : IVexCacheIndex
|
||||
{
|
||||
public Dictionary<(string Signature, VexExportFormat Format), bool> RemoveCalls { get; } = new();
|
||||
|
||||
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<VexCacheEntry?>(null);
|
||||
|
||||
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
RemoveCalls[(signature.Value, format)] = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingArtifactStore : IVexArtifactStore
|
||||
{
|
||||
public int SaveCount { get; private set; }
|
||||
|
||||
public ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
|
||||
{
|
||||
SaveCount++;
|
||||
return ValueTask.FromResult(new VexStoredArtifact(artifact.ContentAddress, "memory", artifact.Content.Length, artifact.Metadata));
|
||||
}
|
||||
|
||||
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
private sealed class StaticPolicyEvaluator : IVexPolicyEvaluator
|
||||
{
|
||||
public StaticPolicyEvaluator(string version)
|
||||
{
|
||||
Version = version;
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
|
||||
|
||||
public double GetProviderWeight(VexProvider provider) => 1.0;
|
||||
|
||||
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
|
||||
{
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryExportDataSource : IVexExportDataSource
|
||||
{
|
||||
public VexExportDataSet? LastDataSet { get; private set; }
|
||||
|
||||
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var claim = new VexClaim(
|
||||
"CVE-2025-0001",
|
||||
"vendor",
|
||||
new VexProduct("pkg:demo/app", "Demo"),
|
||||
VexClaimStatus.Affected,
|
||||
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:demo", new Uri("https://example.org/demo")),
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
var consensus = new VexConsensus(
|
||||
"CVE-2025-0001",
|
||||
claim.Product,
|
||||
VexConsensusStatus.Affected,
|
||||
DateTimeOffset.UtcNow,
|
||||
new[] { new VexConsensusSource("vendor", VexClaimStatus.Affected, "sha256:demo", 1.0) },
|
||||
conflicts: null,
|
||||
policyVersion: "baseline/v1",
|
||||
summary: "affected");
|
||||
|
||||
var dataSet = new VexExportDataSet(
|
||||
ImmutableArray.Create(consensus),
|
||||
ImmutableArray.Create(claim),
|
||||
ImmutableArray.Create("vendor"));
|
||||
|
||||
LastDataSet = dataSet;
|
||||
return ValueTask.FromResult(dataSet);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DummyExporter : IVexExporter
|
||||
{
|
||||
public DummyExporter(VexExportFormat format)
|
||||
{
|
||||
Format = format;
|
||||
}
|
||||
|
||||
public VexExportFormat Format { get; }
|
||||
|
||||
public VexContentAddress Digest(VexExportRequest request)
|
||||
=> new("sha256", "deadbeef");
|
||||
|
||||
public ValueTask<VexExportResult> SerializeAsync(VexExportRequest request, Stream output, CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("{}");
|
||||
output.Write(bytes);
|
||||
return ValueTask.FromResult(new VexExportResult(new VexContentAddress("sha256", "deadbeef"), bytes.Length, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
|
||||
namespace StellaOps.Excititor.Export.Tests;
|
||||
|
||||
public sealed class FileSystemArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_WritesArtifactToDisk()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
var options = Options.Create(new FileSystemArtifactStoreOptions { RootPath = "/exports" });
|
||||
var store = new FileSystemArtifactStore(options, NullLogger<FileSystemArtifactStore>.Instance, fs);
|
||||
|
||||
var content = new byte[] { 1, 2, 3 };
|
||||
var artifact = new VexExportArtifact(
|
||||
new VexContentAddress("sha256", "deadbeef"),
|
||||
VexExportFormat.Json,
|
||||
content,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var stored = await store.SaveAsync(artifact, CancellationToken.None);
|
||||
|
||||
Assert.Equal(artifact.Content.Length, stored.SizeBytes);
|
||||
var filePath = fs.Path.Combine(options.Value.RootPath, stored.Location);
|
||||
Assert.True(fs.FileExists(filePath));
|
||||
Assert.Equal(content, fs.File.ReadAllBytes(filePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Export.Tests;
|
||||
|
||||
public sealed class MirrorBundlePublisherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishAsync_WritesMirrorArtifacts()
|
||||
{
|
||||
var generatedAt = DateTimeOffset.Parse("2025-10-21T12:00:00Z");
|
||||
var timeProvider = new FixedTimeProvider(generatedAt);
|
||||
var fileSystem = new MockFileSystem();
|
||||
|
||||
var options = new MirrorDistributionOptions
|
||||
{
|
||||
OutputRoot = @"C:\exports",
|
||||
DirectoryName = "mirror",
|
||||
TargetRepository = "s3://mirror/excititor",
|
||||
};
|
||||
|
||||
var domain = new MirrorDomainOptions
|
||||
{
|
||||
Id = "primary",
|
||||
DisplayName = "Primary Mirror",
|
||||
};
|
||||
|
||||
var exportOptions = new MirrorExportOptions
|
||||
{
|
||||
Key = "consensus-json",
|
||||
Format = "json",
|
||||
};
|
||||
exportOptions.Filters["vulnId"] = "CVE-2025-0001";
|
||||
domain.Exports.Add(exportOptions);
|
||||
options.Domains.Add(domain);
|
||||
|
||||
var publisher = new VexMirrorBundlePublisher(
|
||||
new StaticOptionsMonitor<MirrorDistributionOptions>(options),
|
||||
NullLogger<VexMirrorBundlePublisher>.Instance,
|
||||
timeProvider,
|
||||
fileSystem,
|
||||
cryptoRegistry: null,
|
||||
Options.Create(new FileSystemArtifactStoreOptions { RootPath = @"C:\exports" }));
|
||||
|
||||
var sample = CreateSampleExport(generatedAt);
|
||||
var manifest = sample.Manifest;
|
||||
var envelope = sample.Envelope;
|
||||
var dataSet = sample.DataSet;
|
||||
|
||||
await publisher.PublishAsync(manifest, envelope, dataSet, CancellationToken.None);
|
||||
await publisher.PublishAsync(manifest, envelope, dataSet, CancellationToken.None);
|
||||
|
||||
var mirrorRoot = @"C:\exports\mirror";
|
||||
var domainRoot = Path.Combine(mirrorRoot, "primary");
|
||||
var bundlePath = Path.Combine(domainRoot, "bundle.json");
|
||||
var manifestPath = Path.Combine(domainRoot, "manifest.json");
|
||||
var indexPath = Path.Combine(mirrorRoot, "index.json");
|
||||
var signaturePath = Path.Combine(domainRoot, "bundle.json.jws");
|
||||
|
||||
Assert.True(fileSystem.File.Exists(bundlePath));
|
||||
Assert.True(fileSystem.File.Exists(manifestPath));
|
||||
Assert.True(fileSystem.File.Exists(indexPath));
|
||||
Assert.False(fileSystem.File.Exists(signaturePath));
|
||||
|
||||
var bundleBytes = fileSystem.File.ReadAllBytes(bundlePath);
|
||||
var manifestBytes = fileSystem.File.ReadAllBytes(manifestPath);
|
||||
var indexBytes = fileSystem.File.ReadAllBytes(indexPath);
|
||||
|
||||
var expectedBundleDigest = ComputeSha256(bundleBytes);
|
||||
var expectedManifestDigest = ComputeSha256(manifestBytes);
|
||||
var expectedConsensusJson = envelope.ConsensusCanonicalJson;
|
||||
var expectedScoreJson = envelope.ScoreCanonicalJson;
|
||||
var expectedClaimsJson = SerializeClaims(dataSet.Claims);
|
||||
var expectedQuietJson = VexCanonicalJsonSerializer.Serialize(envelope.QuietProvenance);
|
||||
|
||||
using (var bundleDocument = JsonDocument.Parse(bundleBytes))
|
||||
{
|
||||
var root = bundleDocument.RootElement;
|
||||
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal("primary", root.GetProperty("domainId").GetString());
|
||||
Assert.Equal("Primary Mirror", root.GetProperty("displayName").GetString());
|
||||
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
|
||||
|
||||
var exports = root.GetProperty("exports").EnumerateArray().ToArray();
|
||||
Assert.Single(exports);
|
||||
var export = exports[0];
|
||||
Assert.Equal("consensus-json", export.GetProperty("key").GetString());
|
||||
Assert.Equal("json", export.GetProperty("format").GetString());
|
||||
Assert.Equal(manifest.ExportId, export.GetProperty("exportId").GetString());
|
||||
Assert.Equal(manifest.QuerySignature.Value, export.GetProperty("querySignature").GetString());
|
||||
Assert.Equal(manifest.Artifact.ToUri(), export.GetProperty("artifactDigest").GetString());
|
||||
Assert.Equal(manifest.SizeBytes, export.GetProperty("artifactSizeBytes").GetInt64());
|
||||
Assert.Equal(manifest.ConsensusRevision, export.GetProperty("consensusRevision").GetString());
|
||||
Assert.Equal(manifest.PolicyRevisionId, export.GetProperty("policyRevisionId").GetString());
|
||||
Assert.Equal(manifest.PolicyDigest, export.GetProperty("policyDigest").GetString());
|
||||
Assert.Equal(expectedConsensusJson, export.GetProperty("consensusDocument").GetString());
|
||||
Assert.Equal(expectedScoreJson, export.GetProperty("scoreDocument").GetString());
|
||||
Assert.Equal(expectedClaimsJson, export.GetProperty("claimsDocument").GetString());
|
||||
Assert.Equal(expectedQuietJson, export.GetProperty("quietDocument").GetString());
|
||||
var providers = export.GetProperty("sourceProviders").EnumerateArray().Select(p => p.GetString()).ToArray();
|
||||
Assert.Single(providers);
|
||||
Assert.Equal("vendor", providers[0]);
|
||||
}
|
||||
|
||||
using (var manifestDocument = JsonDocument.Parse(manifestBytes))
|
||||
{
|
||||
var root = manifestDocument.RootElement;
|
||||
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal("primary", root.GetProperty("domainId").GetString());
|
||||
Assert.Equal("Primary Mirror", root.GetProperty("displayName").GetString());
|
||||
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
|
||||
|
||||
var bundleDescriptor = root.GetProperty("bundle");
|
||||
Assert.Equal("primary/bundle.json", bundleDescriptor.GetProperty("path").GetString());
|
||||
Assert.Equal(expectedBundleDigest, bundleDescriptor.GetProperty("digest").GetString());
|
||||
Assert.Equal(bundleBytes.LongLength, bundleDescriptor.GetProperty("sizeBytes").GetInt64());
|
||||
Assert.False(bundleDescriptor.TryGetProperty("signature", out _));
|
||||
|
||||
var exports = root.GetProperty("exports").EnumerateArray().ToArray();
|
||||
Assert.Single(exports);
|
||||
var export = exports[0];
|
||||
Assert.Equal("consensus-json", export.GetProperty("key").GetString());
|
||||
Assert.Equal("json", export.GetProperty("format").GetString());
|
||||
Assert.Equal(manifest.ExportId, export.GetProperty("exportId").GetString());
|
||||
Assert.Equal(manifest.QuerySignature.Value, export.GetProperty("querySignature").GetString());
|
||||
Assert.Equal(manifest.Artifact.ToUri(), export.GetProperty("artifactDigest").GetString());
|
||||
Assert.Equal(manifest.SizeBytes, export.GetProperty("artifactSizeBytes").GetInt64());
|
||||
Assert.Equal(manifest.ConsensusRevision, export.GetProperty("consensusRevision").GetString());
|
||||
Assert.Equal(manifest.PolicyRevisionId, export.GetProperty("policyRevisionId").GetString());
|
||||
Assert.Equal(manifest.PolicyDigest, export.GetProperty("policyDigest").GetString());
|
||||
Assert.False(export.TryGetProperty("attestation", out _));
|
||||
}
|
||||
|
||||
using (var indexDocument = JsonDocument.Parse(indexBytes))
|
||||
{
|
||||
var root = indexDocument.RootElement;
|
||||
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
|
||||
|
||||
var domains = root.GetProperty("domains").EnumerateArray().ToArray();
|
||||
Assert.Single(domains);
|
||||
var entry = domains[0];
|
||||
Assert.Equal("primary", entry.GetProperty("domainId").GetString());
|
||||
Assert.Equal("Primary Mirror", entry.GetProperty("displayName").GetString());
|
||||
Assert.Equal(generatedAt, entry.GetProperty("generatedAt").GetDateTimeOffset());
|
||||
Assert.Equal(1, entry.GetProperty("exportCount").GetInt32());
|
||||
|
||||
var manifestDescriptor = entry.GetProperty("manifest");
|
||||
Assert.Equal("primary/manifest.json", manifestDescriptor.GetProperty("path").GetString());
|
||||
Assert.Equal(expectedManifestDigest, manifestDescriptor.GetProperty("digest").GetString());
|
||||
Assert.Equal(manifestBytes.LongLength, manifestDescriptor.GetProperty("sizeBytes").GetInt64());
|
||||
|
||||
var bundleDescriptor = entry.GetProperty("bundle");
|
||||
Assert.Equal("primary/bundle.json", bundleDescriptor.GetProperty("path").GetString());
|
||||
Assert.Equal(expectedBundleDigest, bundleDescriptor.GetProperty("digest").GetString());
|
||||
Assert.Equal(bundleBytes.LongLength, bundleDescriptor.GetProperty("sizeBytes").GetInt64());
|
||||
|
||||
var exportKeys = entry.GetProperty("exportKeys").EnumerateArray().Select(x => x.GetString()).ToArray();
|
||||
Assert.Single(exportKeys);
|
||||
Assert.Equal("consensus-json", exportKeys[0]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_NoMatchingDomain_DoesNotWriteArtifacts()
|
||||
{
|
||||
var generatedAt = DateTimeOffset.Parse("2025-10-21T12:00:00Z");
|
||||
var timeProvider = new FixedTimeProvider(generatedAt);
|
||||
var fileSystem = new MockFileSystem();
|
||||
|
||||
var options = new MirrorDistributionOptions
|
||||
{
|
||||
OutputRoot = @"C:\exports",
|
||||
DirectoryName = "mirror",
|
||||
};
|
||||
|
||||
var domain = new MirrorDomainOptions
|
||||
{
|
||||
Id = "primary",
|
||||
DisplayName = "Primary Mirror",
|
||||
};
|
||||
|
||||
var exportOptions = new MirrorExportOptions
|
||||
{
|
||||
Key = "consensus-json",
|
||||
Format = "json",
|
||||
};
|
||||
exportOptions.Filters["vulnId"] = "CVE-2099-9999";
|
||||
domain.Exports.Add(exportOptions);
|
||||
options.Domains.Add(domain);
|
||||
|
||||
var publisher = new VexMirrorBundlePublisher(
|
||||
new StaticOptionsMonitor<MirrorDistributionOptions>(options),
|
||||
NullLogger<VexMirrorBundlePublisher>.Instance,
|
||||
timeProvider,
|
||||
fileSystem,
|
||||
cryptoRegistry: null,
|
||||
Options.Create(new FileSystemArtifactStoreOptions { RootPath = @"C:\exports" }));
|
||||
|
||||
var sample = CreateSampleExport(generatedAt);
|
||||
await publisher.PublishAsync(sample.Manifest, sample.Envelope, sample.DataSet, CancellationToken.None);
|
||||
|
||||
Assert.False(fileSystem.Directory.Exists(@"C:\exports\mirror"));
|
||||
}
|
||||
|
||||
private static SampleExport CreateSampleExport(DateTimeOffset generatedAt)
|
||||
{
|
||||
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
|
||||
var signature = VexQuerySignature.FromQuery(query);
|
||||
|
||||
var product = new VexProduct("pkg:demo/app", "Demo");
|
||||
var document = new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:quiet", new Uri("https://example.org/vex.json"));
|
||||
var claim = new VexClaim(
|
||||
"CVE-2025-0001",
|
||||
"vendor",
|
||||
product,
|
||||
VexClaimStatus.NotAffected,
|
||||
document,
|
||||
generatedAt.AddDays(-1),
|
||||
generatedAt,
|
||||
justification: VexJustification.ComponentNotPresent);
|
||||
|
||||
var consensus = new VexConsensus(
|
||||
"CVE-2025-0001",
|
||||
product,
|
||||
VexConsensusStatus.NotAffected,
|
||||
generatedAt,
|
||||
new[] { new VexConsensusSource("vendor", VexClaimStatus.NotAffected, document.Digest, 1.0, claim.Justification) },
|
||||
conflicts: null,
|
||||
signals: null,
|
||||
policyVersion: "baseline/v1",
|
||||
summary: "not_affected",
|
||||
policyRevisionId: "policy/v1",
|
||||
policyDigest: "sha256:policy");
|
||||
|
||||
var dataSet = new VexExportDataSet(
|
||||
ImmutableArray.Create(consensus),
|
||||
ImmutableArray.Create(claim),
|
||||
ImmutableArray.Create("vendor"));
|
||||
|
||||
var envelope = VexExportEnvelopeBuilder.Build(dataSet, VexPolicySnapshot.Default, generatedAt);
|
||||
|
||||
var manifest = new VexExportManifest(
|
||||
"exports/20251021T120000000Z/abcdef",
|
||||
signature,
|
||||
VexExportFormat.Json,
|
||||
generatedAt,
|
||||
new VexContentAddress("sha256", "deadbeef"),
|
||||
dataSet.Claims.Length,
|
||||
dataSet.SourceProviders,
|
||||
consensusRevision: "baseline/v1",
|
||||
policyRevisionId: "policy/v1",
|
||||
policyDigest: "sha256:policy",
|
||||
consensusDigest: envelope.ConsensusDigest,
|
||||
scoreDigest: envelope.ScoreDigest,
|
||||
quietProvenance: envelope.QuietProvenance,
|
||||
attestation: null,
|
||||
sizeBytes: 1024);
|
||||
|
||||
return new SampleExport(manifest, envelope, dataSet);
|
||||
}
|
||||
|
||||
private static string SerializeClaims(ImmutableArray<VexClaim> claims)
|
||||
=> VexCanonicalJsonSerializer.Serialize(
|
||||
claims
|
||||
.OrderBy(claim => claim.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(claim => claim.Product.Key, StringComparer.Ordinal)
|
||||
.ThenBy(claim => claim.ProviderId, StringComparer.Ordinal)
|
||||
.ToImmutableArray());
|
||||
|
||||
private static string ComputeSha256(byte[] bytes)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var digest = sha.ComputeHash(bytes);
|
||||
return "sha256:" + Convert.ToHexString(digest).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record SampleExport(
|
||||
VexExportManifest Manifest,
|
||||
VexExportEnvelopeContext Envelope,
|
||||
VexExportDataSet DataSet);
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _value;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset value) => _value = value;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _value;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptionsMonitor(T value) => CurrentValue = value;
|
||||
|
||||
public T CurrentValue { get; private set; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
|
||||
namespace StellaOps.Excititor.Export.Tests;
|
||||
|
||||
public sealed class OfflineBundleArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_WritesArtifactAndManifest()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
var options = Options.Create(new OfflineBundleArtifactStoreOptions { RootPath = "/offline" });
|
||||
var store = new OfflineBundleArtifactStore(options, NullLogger<OfflineBundleArtifactStore>.Instance, fs);
|
||||
|
||||
var content = new byte[] { 1, 2, 3 };
|
||||
var digest = "sha256:" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(content)).ToLowerInvariant();
|
||||
var artifact = new VexExportArtifact(
|
||||
new VexContentAddress("sha256", digest.Split(':')[1]),
|
||||
VexExportFormat.Json,
|
||||
content,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var stored = await store.SaveAsync(artifact, CancellationToken.None);
|
||||
|
||||
var artifactPath = fs.Path.Combine(options.Value.RootPath, stored.Location);
|
||||
Assert.True(fs.FileExists(artifactPath));
|
||||
|
||||
var manifestPath = fs.Path.Combine(options.Value.RootPath, options.Value.ManifestFileName);
|
||||
Assert.True(fs.FileExists(manifestPath));
|
||||
await using var manifestStream = fs.File.OpenRead(manifestPath);
|
||||
using var document = await JsonDocument.ParseAsync(manifestStream);
|
||||
var artifacts = document.RootElement.GetProperty("artifacts");
|
||||
Assert.True(artifacts.GetArrayLength() >= 1);
|
||||
var first = artifacts.EnumerateArray().First();
|
||||
Assert.Equal(digest, first.GetProperty("digest").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ThrowsOnDigestMismatch()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
var options = Options.Create(new OfflineBundleArtifactStoreOptions { RootPath = "/offline" });
|
||||
var store = new OfflineBundleArtifactStore(options, NullLogger<OfflineBundleArtifactStore>.Instance, fs);
|
||||
|
||||
var artifact = new VexExportArtifact(
|
||||
new VexContentAddress("sha256", "deadbeef"),
|
||||
VexExportFormat.Json,
|
||||
new byte[] { 0x01, 0x02 },
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => store.SaveAsync(artifact, CancellationToken.None).AsTask());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
|
||||
namespace StellaOps.Excititor.Export.Tests;
|
||||
|
||||
public sealed class S3ArtifactStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_UploadsContentWithMetadata()
|
||||
{
|
||||
var client = new FakeS3Client();
|
||||
var options = Options.Create(new S3ArtifactStoreOptions { BucketName = "exports", Prefix = "vex" });
|
||||
var store = new S3ArtifactStore(client, options, NullLogger<S3ArtifactStore>.Instance);
|
||||
|
||||
var content = new byte[] { 1, 2, 3, 4 };
|
||||
var artifact = new VexExportArtifact(
|
||||
new VexContentAddress("sha256", "deadbeef"),
|
||||
VexExportFormat.Json,
|
||||
content,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
await store.SaveAsync(artifact, CancellationToken.None);
|
||||
|
||||
Assert.True(client.PutCalls.TryGetValue("exports", out var bucketEntries));
|
||||
Assert.NotNull(bucketEntries);
|
||||
var entry = bucketEntries!.Single();
|
||||
Assert.Equal("vex/json/deadbeef.json", entry.Key);
|
||||
Assert.Equal(content, entry.Content);
|
||||
Assert.Equal("sha256:deadbeef", entry.Metadata["vex-digest"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_ReturnsStoredContent()
|
||||
{
|
||||
var client = new FakeS3Client();
|
||||
var options = Options.Create(new S3ArtifactStoreOptions { BucketName = "exports", Prefix = "vex" });
|
||||
var store = new S3ArtifactStore(client, options, NullLogger<S3ArtifactStore>.Instance);
|
||||
|
||||
var address = new VexContentAddress("sha256", "cafebabe");
|
||||
client.SeedObject("exports", "vex/json/cafebabe.json", new byte[] { 9, 9, 9 });
|
||||
|
||||
var stream = await store.OpenReadAsync(address, CancellationToken.None);
|
||||
Assert.NotNull(stream);
|
||||
using var ms = new MemoryStream();
|
||||
await stream!.CopyToAsync(ms);
|
||||
Assert.Equal(new byte[] { 9, 9, 9 }, ms.ToArray());
|
||||
}
|
||||
|
||||
private sealed class FakeS3Client : IS3ArtifactClient
|
||||
{
|
||||
public ConcurrentDictionary<string, List<S3Entry>> PutCalls { get; } = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<(string Bucket, string Key), byte[]> _storage = new();
|
||||
|
||||
public void SeedObject(string bucket, string key, byte[] content)
|
||||
{
|
||||
PutCalls.GetOrAdd(bucket, _ => new List<S3Entry>()).Add(new S3Entry(key, content, new Dictionary<string, string>()));
|
||||
_storage[(bucket, key)] = content;
|
||||
}
|
||||
|
||||
public Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_storage.ContainsKey((bucketName, key)));
|
||||
|
||||
public Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
content.CopyTo(ms);
|
||||
var bytes = ms.ToArray();
|
||||
PutCalls.GetOrAdd(bucketName, _ => new List<S3Entry>()).Add(new S3Entry(key, bytes, new Dictionary<string, string>(metadata)));
|
||||
_storage[(bucketName, key)] = bytes;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_storage.TryGetValue((bucketName, key), out var bytes))
|
||||
{
|
||||
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
public Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
_storage.TryRemove((bucketName, key), out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public readonly record struct S3Entry(string Key, byte[] Content, IDictionary<string, string> Metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Export/StellaOps.Excititor.Export.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Export.Tests;
|
||||
|
||||
public sealed class VexExportCacheServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvalidateAsync_RemovesEntry()
|
||||
{
|
||||
var cacheIndex = new RecordingIndex();
|
||||
var maintenance = new StubMaintenance();
|
||||
var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance);
|
||||
|
||||
var signature = new VexQuerySignature("format=json|provider=vendor");
|
||||
await service.InvalidateAsync(signature, VexExportFormat.Json, CancellationToken.None);
|
||||
|
||||
Assert.Equal(signature.Value, cacheIndex.LastSignature?.Value);
|
||||
Assert.Equal(VexExportFormat.Json, cacheIndex.LastFormat);
|
||||
Assert.Equal(1, cacheIndex.RemoveCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneExpiredAsync_ReturnsCount()
|
||||
{
|
||||
var cacheIndex = new RecordingIndex();
|
||||
var maintenance = new StubMaintenance { ExpiredCount = 3 };
|
||||
var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance);
|
||||
|
||||
var removed = await service.PruneExpiredAsync(DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneDanglingAsync_ReturnsCount()
|
||||
{
|
||||
var cacheIndex = new RecordingIndex();
|
||||
var maintenance = new StubMaintenance { DanglingCount = 2 };
|
||||
var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance);
|
||||
|
||||
var removed = await service.PruneDanglingAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, removed);
|
||||
}
|
||||
|
||||
private sealed class RecordingIndex : IVexCacheIndex
|
||||
{
|
||||
public VexQuerySignature? LastSignature { get; private set; }
|
||||
public VexExportFormat LastFormat { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
|
||||
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<VexCacheEntry?>(null);
|
||||
|
||||
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
LastSignature = signature;
|
||||
LastFormat = format;
|
||||
RemoveCalls++;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubMaintenance : IVexCacheMaintenance
|
||||
{
|
||||
public int ExpiredCount { get; set; }
|
||||
public int DanglingCount { get; set; }
|
||||
|
||||
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(ExpiredCount);
|
||||
|
||||
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(DanglingCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Formats.CSAF;
|
||||
|
||||
namespace StellaOps.Excititor.Formats.CSAF.Tests;
|
||||
|
||||
public sealed class CsafNormalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NormalizeAsync_ProducesClaimsPerProductStatus()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"document": {
|
||||
"tracking": {
|
||||
"id": "RHSA-2025:0001",
|
||||
"version": "3",
|
||||
"revision": "3",
|
||||
"status": "final",
|
||||
"initial_release_date": "2025-10-01T00:00:00Z",
|
||||
"current_release_date": "2025-10-10T00:00:00Z"
|
||||
},
|
||||
"publisher": {
|
||||
"name": "Red Hat Product Security",
|
||||
"category": "vendor"
|
||||
}
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "CSAFPID-0001",
|
||||
"name": "Red Hat Enterprise Linux 9",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/o:redhat:enterprise_linux:9",
|
||||
"purl": "pkg:rpm/redhat/enterprise-linux@9"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-0001",
|
||||
"title": "Kernel vulnerability",
|
||||
"product_status": {
|
||||
"known_affected": [ "CSAFPID-0001" ]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "VEX-0002",
|
||||
"title": "Library issue",
|
||||
"product_status": {
|
||||
"known_not_affected": [ "CSAFPID-0001" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var rawDocument = new VexRawDocument(
|
||||
ProviderId: "excititor:redhat",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.com/csaf/rhsa-2025-0001.json"),
|
||||
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
|
||||
"sha256:dummydigest",
|
||||
Encoding.UTF8.GetBytes(json),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var provider = new VexProvider("excititor:redhat", "Red Hat CSAF", VexProviderKind.Distro);
|
||||
var normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
|
||||
|
||||
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
|
||||
|
||||
batch.Claims.Should().HaveCount(2);
|
||||
|
||||
var affectedClaim = batch.Claims.First(c => c.VulnerabilityId == "CVE-2025-0001");
|
||||
affectedClaim.Status.Should().Be(VexClaimStatus.Affected);
|
||||
affectedClaim.Product.Key.Should().Be("CSAFPID-0001");
|
||||
affectedClaim.Product.Purl.Should().Be("pkg:rpm/redhat/enterprise-linux@9");
|
||||
affectedClaim.Product.Cpe.Should().Be("cpe:/o:redhat:enterprise_linux:9");
|
||||
affectedClaim.Document.Revision.Should().Be("3");
|
||||
affectedClaim.FirstSeen.Should().Be(new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
affectedClaim.LastSeen.Should().Be(new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero));
|
||||
affectedClaim.AdditionalMetadata.Should().ContainKey("csaf.tracking.id");
|
||||
affectedClaim.AdditionalMetadata["csaf.publisher.name"].Should().Be("Red Hat Product Security");
|
||||
|
||||
var notAffectedClaim = batch.Claims.First(c => c.VulnerabilityId == "VEX-0002");
|
||||
notAffectedClaim.Status.Should().Be(VexClaimStatus.NotAffected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizeAsync_PreservesRedHatSpecificMetadata()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "rhsa-sample.json");
|
||||
var json = await File.ReadAllTextAsync(path);
|
||||
|
||||
var rawDocument = new VexRawDocument(
|
||||
ProviderId: "excititor:redhat",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://security.example.com/rhsa-2025-1001.json"),
|
||||
new DateTimeOffset(2025, 10, 6, 0, 0, 0, TimeSpan.Zero),
|
||||
"sha256:rhdadigest",
|
||||
Encoding.UTF8.GetBytes(json),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var provider = new VexProvider("excititor:redhat", "Red Hat CSAF", VexProviderKind.Distro);
|
||||
var normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
|
||||
|
||||
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
|
||||
batch.Claims.Should().ContainSingle();
|
||||
|
||||
var claim = batch.Claims[0];
|
||||
claim.VulnerabilityId.Should().Be("CVE-2025-1234");
|
||||
claim.Status.Should().Be(VexClaimStatus.Affected);
|
||||
claim.Product.Key.Should().Be("rh-enterprise-linux-9");
|
||||
claim.Product.Name.Should().Be("Red Hat Enterprise Linux 9");
|
||||
claim.Product.Purl.Should().Be("pkg:rpm/redhat/enterprise-linux@9");
|
||||
claim.Product.Cpe.Should().Be("cpe:/o:redhat:enterprise_linux:9");
|
||||
claim.FirstSeen.Should().Be(new DateTimeOffset(2025, 10, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
claim.LastSeen.Should().Be(new DateTimeOffset(2025, 10, 5, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
claim.AdditionalMetadata.Should().ContainKey("csaf.tracking.id");
|
||||
claim.AdditionalMetadata["csaf.tracking.id"].Should().Be("RHSA-2025:1001");
|
||||
claim.AdditionalMetadata["csaf.tracking.status"].Should().Be("final");
|
||||
claim.AdditionalMetadata["csaf.publisher.name"].Should().Be("Red Hat Product Security");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"document": {
|
||||
"publisher": {
|
||||
"name": "Red Hat Product Security",
|
||||
"category": "vendor"
|
||||
},
|
||||
"tracking": {
|
||||
"id": "RHSA-2025:1001",
|
||||
"status": "final",
|
||||
"version": "3",
|
||||
"initial_release_date": "2025-10-01T12:00:00Z",
|
||||
"current_release_date": "2025-10-05T10:00:00Z"
|
||||
}
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "rh-enterprise-linux-9",
|
||||
"name": "Red Hat Enterprise Linux 9",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/o:redhat:enterprise_linux:9",
|
||||
"purl": "pkg:rpm/redhat/enterprise-linux@9"
|
||||
}
|
||||
}
|
||||
],
|
||||
"branches": [
|
||||
{
|
||||
"name": "Red Hat Enterprise Linux",
|
||||
"product": {
|
||||
"product_id": "rh-enterprise-linux-9",
|
||||
"name": "Red Hat Enterprise Linux 9"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-1234",
|
||||
"title": "Kernel privilege escalation",
|
||||
"product_status": {
|
||||
"known_affected": [
|
||||
"rh-enterprise-linux-9"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**\*" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
|
||||
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
|
||||
|
||||
public sealed class CycloneDxNormalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NormalizeAsync_MapsAnalysisStateAndJustification()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.4",
|
||||
"serialNumber": "urn:uuid:1234",
|
||||
"version": "7",
|
||||
"metadata": {
|
||||
"timestamp": "2025-10-15T12:00:00Z"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"bom-ref": "pkg:npm/acme/lib@1.0.0",
|
||||
"name": "acme-lib",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/acme/lib@1.0.0"
|
||||
}
|
||||
],
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2025-1000",
|
||||
"detail": "Library issue",
|
||||
"analysis": {
|
||||
"state": "not_affected",
|
||||
"justification": "code_not_present",
|
||||
"response": [ "can_not_fix", "will_not_fix" ]
|
||||
},
|
||||
"affects": [
|
||||
{ "ref": "pkg:npm/acme/lib@1.0.0" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "CVE-2025-1001",
|
||||
"description": "Investigating impact",
|
||||
"analysis": {
|
||||
"state": "in_triage"
|
||||
},
|
||||
"affects": [
|
||||
{ "ref": "pkg:npm/missing/component@2.0.0" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var rawDocument = new VexRawDocument(
|
||||
"excititor:cyclonedx",
|
||||
VexDocumentFormat.CycloneDx,
|
||||
new Uri("https://example.org/vex.json"),
|
||||
new DateTimeOffset(2025, 10, 16, 0, 0, 0, TimeSpan.Zero),
|
||||
"sha256:dummydigest",
|
||||
Encoding.UTF8.GetBytes(json),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var provider = new VexProvider("excititor:cyclonedx", "CycloneDX Provider", VexProviderKind.Vendor);
|
||||
var normalizer = new CycloneDxNormalizer(NullLogger<CycloneDxNormalizer>.Instance);
|
||||
|
||||
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
|
||||
|
||||
batch.Claims.Should().HaveCount(2);
|
||||
|
||||
var notAffected = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-1000");
|
||||
notAffected.Status.Should().Be(VexClaimStatus.NotAffected);
|
||||
notAffected.Justification.Should().Be(VexJustification.CodeNotPresent);
|
||||
notAffected.Product.Key.Should().Be("pkg:npm/acme/lib@1.0.0");
|
||||
notAffected.Product.Purl.Should().Be("pkg:npm/acme/lib@1.0.0");
|
||||
notAffected.Document.Revision.Should().Be("7");
|
||||
notAffected.AdditionalMetadata["cyclonedx.specVersion"].Should().Be("1.4");
|
||||
notAffected.AdditionalMetadata["cyclonedx.analysis.state"].Should().Be("not_affected");
|
||||
notAffected.AdditionalMetadata["cyclonedx.analysis.response"].Should().Be("can_not_fix,will_not_fix");
|
||||
|
||||
var investigating = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-1001");
|
||||
investigating.Status.Should().Be(VexClaimStatus.UnderInvestigation);
|
||||
investigating.Justification.Should().BeNull();
|
||||
investigating.Product.Key.Should().Be("pkg:npm/missing/component@2.0.0");
|
||||
investigating.Product.Name.Should().Be("pkg:npm/missing/component@2.0.0");
|
||||
investigating.AdditionalMetadata.Should().ContainKey("cyclonedx.specVersion");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CycloneDX/StellaOps.Excititor.Formats.CycloneDX.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Formats.OpenVEX;
|
||||
|
||||
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
|
||||
|
||||
public sealed class OpenVexNormalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NormalizeAsync_ProducesClaimsForStatements()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"document": {
|
||||
"author": "Acme Security",
|
||||
"version": "1",
|
||||
"issued": "2025-10-01T00:00:00Z",
|
||||
"last_updated": "2025-10-05T00:00:00Z"
|
||||
},
|
||||
"statements": [
|
||||
{
|
||||
"id": "statement-1",
|
||||
"vulnerability": "CVE-2025-2000",
|
||||
"status": "not_affected",
|
||||
"justification": "code_not_present",
|
||||
"products": [
|
||||
{
|
||||
"id": "acme-widget@1.2.3",
|
||||
"name": "Acme Widget",
|
||||
"version": "1.2.3",
|
||||
"purl": "pkg:acme/widget@1.2.3",
|
||||
"cpe": "cpe:/a:acme:widget:1.2.3"
|
||||
}
|
||||
],
|
||||
"statement": "The vulnerable code was never shipped."
|
||||
},
|
||||
{
|
||||
"id": "statement-2",
|
||||
"vulnerability": "CVE-2025-2001",
|
||||
"status": "affected",
|
||||
"products": [
|
||||
"pkg:acme/widget@2.0.0"
|
||||
],
|
||||
"remediation": "Upgrade to 2.1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var rawDocument = new VexRawDocument(
|
||||
"excititor:openvex",
|
||||
VexDocumentFormat.OpenVex,
|
||||
new Uri("https://example.com/openvex.json"),
|
||||
new DateTimeOffset(2025, 10, 6, 0, 0, 0, TimeSpan.Zero),
|
||||
"sha256:dummydigest",
|
||||
Encoding.UTF8.GetBytes(json),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var provider = new VexProvider("excititor:openvex", "OpenVEX Provider", VexProviderKind.Vendor);
|
||||
var normalizer = new OpenVexNormalizer(NullLogger<OpenVexNormalizer>.Instance);
|
||||
|
||||
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
|
||||
|
||||
batch.Claims.Should().HaveCount(2);
|
||||
|
||||
var notAffected = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-2000");
|
||||
notAffected.Status.Should().Be(VexClaimStatus.NotAffected);
|
||||
notAffected.Justification.Should().Be(VexJustification.CodeNotPresent);
|
||||
notAffected.Product.Key.Should().Be("acme-widget@1.2.3");
|
||||
notAffected.Product.Purl.Should().Be("pkg:acme/widget@1.2.3");
|
||||
notAffected.Document.Revision.Should().Be("1");
|
||||
notAffected.AdditionalMetadata["openvex.document.author"].Should().Be("Acme Security");
|
||||
notAffected.AdditionalMetadata["openvex.statement.status"].Should().Be("not_affected");
|
||||
notAffected.Detail.Should().Be("The vulnerable code was never shipped.");
|
||||
|
||||
var affected = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-2001");
|
||||
affected.Status.Should().Be(VexClaimStatus.Affected);
|
||||
affected.Justification.Should().BeNull();
|
||||
affected.Product.Key.Should().Be("pkg:acme/widget@2.0.0");
|
||||
affected.Product.Name.Should().Be("pkg:acme/widget@2.0.0");
|
||||
affected.Detail.Should().Be("Upgrade to 2.1.0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.OpenVEX/StellaOps.Excititor.Formats.OpenVEX.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Policy.Tests;
|
||||
|
||||
public sealed class VexPolicyProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetSnapshot_UsesDefaultsWhenOptionsMissing()
|
||||
{
|
||||
var provider = new VexPolicyProvider(
|
||||
new OptionsMonitorStub(new VexPolicyOptions()),
|
||||
NullLogger<VexPolicyProvider>.Instance);
|
||||
|
||||
var snapshot = provider.GetSnapshot();
|
||||
|
||||
Assert.Equal(VexConsensusPolicyOptions.BaselineVersion, snapshot.Version);
|
||||
Assert.Empty(snapshot.Issues);
|
||||
Assert.Equal(VexConsensusPolicyOptions.DefaultWeightCeiling, snapshot.ConsensusOptions.WeightCeiling);
|
||||
Assert.Equal(VexConsensusPolicyOptions.DefaultAlpha, snapshot.ConsensusOptions.Alpha);
|
||||
Assert.Equal(VexConsensusPolicyOptions.DefaultBeta, snapshot.ConsensusOptions.Beta);
|
||||
|
||||
var evaluator = new VexPolicyEvaluator(provider);
|
||||
var consensusProvider = new VexProvider("vendor", "Vendor", VexProviderKind.Vendor);
|
||||
var claim = new VexClaim(
|
||||
"CVE-2025-0001",
|
||||
"vendor",
|
||||
new VexProduct("pkg:vendor/app", "app"),
|
||||
VexClaimStatus.NotAffected,
|
||||
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:test", new Uri("https://example.com")),
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(1.0, evaluator.GetProviderWeight(consensusProvider));
|
||||
Assert.False(evaluator.IsClaimEligible(claim, consensusProvider, out var reason));
|
||||
Assert.Equal("missing_justification", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_AppliesOverridesAndClampsInvalidValues()
|
||||
{
|
||||
var options = new VexPolicyOptions
|
||||
{
|
||||
Version = "custom/v1",
|
||||
Weights = new VexPolicyWeightOptions
|
||||
{
|
||||
Vendor = 1.2,
|
||||
Distro = 0.8,
|
||||
},
|
||||
ProviderOverrides = new Dictionary<string, double>
|
||||
{
|
||||
["vendor"] = 0.95,
|
||||
[" "] = 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
var provider = new VexPolicyProvider(new OptionsMonitorStub(options), NullLogger<VexPolicyProvider>.Instance);
|
||||
|
||||
var snapshot = provider.GetSnapshot();
|
||||
|
||||
Assert.Equal("custom/v1", snapshot.Version);
|
||||
Assert.NotEmpty(snapshot.Issues);
|
||||
Assert.Equal(0.95, snapshot.ConsensusOptions.ProviderOverrides["vendor"]);
|
||||
Assert.Contains(snapshot.Issues, issue => issue.Code == "weights.vendor.range");
|
||||
Assert.Equal(VexConsensusPolicyOptions.DefaultWeightCeiling, snapshot.ConsensusOptions.WeightCeiling);
|
||||
Assert.Equal(1.0, snapshot.ConsensusOptions.VendorWeight);
|
||||
|
||||
var evaluator = new VexPolicyEvaluator(provider);
|
||||
var vendor = new VexProvider("vendor", "Vendor", VexProviderKind.Vendor);
|
||||
Assert.Equal(0.95, evaluator.GetProviderWeight(vendor));
|
||||
}
|
||||
|
||||
private sealed class OptionsMonitorStub : IOptionsMonitor<VexPolicyOptions>
|
||||
{
|
||||
private readonly VexPolicyOptions _value;
|
||||
|
||||
public OptionsMonitorStub(VexPolicyOptions value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public VexPolicyOptions CurrentValue => _value;
|
||||
|
||||
public VexPolicyOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<VexPolicyOptions, string> listener) => DisposableAction.Instance;
|
||||
|
||||
private sealed class DisposableAction : IDisposable
|
||||
{
|
||||
public static readonly DisposableAction Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Mongo.Tests;
|
||||
|
||||
public sealed class MongoVexCacheMaintenanceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
private readonly IMongoDatabase _database;
|
||||
|
||||
public MongoVexCacheMaintenanceTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start();
|
||||
var client = new MongoClient(_runner.ConnectionString);
|
||||
_database = client.GetDatabase("vex-cache-maintenance-tests");
|
||||
VexMongoMappingRegistry.Register();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveExpiredAsync_DeletesEntriesBeforeCutoff()
|
||||
{
|
||||
var collection = _database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await collection.InsertManyAsync(new[]
|
||||
{
|
||||
new VexCacheEntryRecord
|
||||
{
|
||||
Id = "sig-1|json",
|
||||
QuerySignature = "sig-1",
|
||||
Format = "json",
|
||||
ArtifactAlgorithm = "sha256",
|
||||
ArtifactDigest = "deadbeef",
|
||||
CreatedAt = now.AddHours(-2),
|
||||
ExpiresAt = now.AddHours(-1),
|
||||
},
|
||||
new VexCacheEntryRecord
|
||||
{
|
||||
Id = "sig-2|json",
|
||||
QuerySignature = "sig-2",
|
||||
Format = "json",
|
||||
ArtifactAlgorithm = "sha256",
|
||||
ArtifactDigest = "cafebabe",
|
||||
CreatedAt = now,
|
||||
ExpiresAt = now.AddHours(1),
|
||||
},
|
||||
});
|
||||
|
||||
var maintenance = new MongoVexCacheMaintenance(_database, NullLogger<MongoVexCacheMaintenance>.Instance);
|
||||
var removed = await maintenance.RemoveExpiredAsync(DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
|
||||
var remaining = await collection.CountDocumentsAsync(FilterDefinition<VexCacheEntryRecord>.Empty);
|
||||
Assert.Equal(1, remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveMissingManifestReferencesAsync_DropsDanglingEntries()
|
||||
{
|
||||
var cache = _database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache);
|
||||
var exports = _database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports);
|
||||
|
||||
await exports.InsertOneAsync(new VexExportManifestRecord
|
||||
{
|
||||
Id = "manifest-existing",
|
||||
QuerySignature = "sig-keep",
|
||||
Format = "json",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ArtifactAlgorithm = "sha256",
|
||||
ArtifactDigest = "keep",
|
||||
ClaimCount = 1,
|
||||
SourceProviders = new List<string> { "vendor" },
|
||||
});
|
||||
|
||||
await cache.InsertManyAsync(new[]
|
||||
{
|
||||
new VexCacheEntryRecord
|
||||
{
|
||||
Id = "sig-remove|json",
|
||||
QuerySignature = "sig-remove",
|
||||
Format = "json",
|
||||
ArtifactAlgorithm = "sha256",
|
||||
ArtifactDigest = "drop",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ManifestId = "manifest-missing",
|
||||
},
|
||||
new VexCacheEntryRecord
|
||||
{
|
||||
Id = "sig-keep|json",
|
||||
QuerySignature = "sig-keep",
|
||||
Format = "json",
|
||||
ArtifactAlgorithm = "sha256",
|
||||
ArtifactDigest = "keep",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ManifestId = "manifest-existing",
|
||||
},
|
||||
});
|
||||
|
||||
var maintenance = new MongoVexCacheMaintenance(_database, NullLogger<MongoVexCacheMaintenance>.Instance);
|
||||
var removed = await maintenance.RemoveMissingManifestReferencesAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
|
||||
var remainingIds = await cache.Find(Builders<VexCacheEntryRecord>.Filter.Empty)
|
||||
.Project(x => x.Id)
|
||||
.ToListAsync();
|
||||
Assert.Single(remainingIds);
|
||||
Assert.Contains("sig-keep|json", remainingIds);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Mongo.Tests;
|
||||
|
||||
public sealed class MongoVexRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
private readonly MongoClient _client;
|
||||
|
||||
public MongoVexRepositoryTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start();
|
||||
_client = new MongoClient(_runner.ConnectionString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawStore_UsesGridFsForLargePayloads()
|
||||
{
|
||||
var database = _client.GetDatabase($"vex-raw-gridfs-{Guid.NewGuid():N}");
|
||||
var store = CreateRawStore(database, thresholdBytes: 32);
|
||||
|
||||
var payload = CreateJsonPayload(new string('A', 256));
|
||||
var document = new VexRawDocument(
|
||||
"red-hat",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.com/redhat/csaf.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:large",
|
||||
payload,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
await store.StoreAsync(document, CancellationToken.None);
|
||||
|
||||
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
|
||||
var stored = await rawCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", document.Digest))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
Assert.NotNull(stored);
|
||||
Assert.True(stored!.TryGetValue("GridFsObjectId", out var gridId));
|
||||
Assert.False(gridId.IsBsonNull);
|
||||
Assert.Empty(stored["Content"].AsBsonBinaryData.Bytes);
|
||||
|
||||
var filesCollection = database.GetCollection<BsonDocument>("vex.raw.files");
|
||||
var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
Assert.Equal(1, fileCount);
|
||||
|
||||
var fetched = await store.FindByDigestAsync(document.Digest, CancellationToken.None);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(payload, fetched!.Content.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawStore_ReplacesGridFsWithInlinePayload()
|
||||
{
|
||||
var database = _client.GetDatabase($"vex-raw-inline-{Guid.NewGuid():N}");
|
||||
var store = CreateRawStore(database, thresholdBytes: 16);
|
||||
|
||||
var largePayload = CreateJsonPayload(new string('B', 128));
|
||||
var digest = "sha256:inline";
|
||||
var largeDocument = new VexRawDocument(
|
||||
"cisco",
|
||||
VexDocumentFormat.CycloneDx,
|
||||
new Uri("https://example.com/cyclonedx.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
digest,
|
||||
largePayload,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
await store.StoreAsync(largeDocument, CancellationToken.None);
|
||||
|
||||
var smallDocument = largeDocument with
|
||||
{
|
||||
RetrievedAt = DateTimeOffset.UtcNow.AddMinutes(1),
|
||||
Content = CreateJsonPayload("small"),
|
||||
};
|
||||
|
||||
await store.StoreAsync(smallDocument, CancellationToken.None);
|
||||
|
||||
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
|
||||
var stored = await rawCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", digest))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
Assert.NotNull(stored);
|
||||
Assert.True(stored!.TryGetValue("GridFsObjectId", out var gridId));
|
||||
Assert.True(gridId.IsBsonNull);
|
||||
var storedContent = Encoding.UTF8.GetString(stored["Content"].AsBsonBinaryData.Bytes);
|
||||
Assert.Equal(CreateJsonPayloadString("small"), storedContent);
|
||||
|
||||
var filesCollection = database.GetCollection<BsonDocument>("vex.raw.files");
|
||||
var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
Assert.Equal(0, fileCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawStore_WhenGuardRejectsDocument_DoesNotPersist()
|
||||
{
|
||||
var database = _client.GetDatabase($"vex-raw-guard-{Guid.NewGuid():N}");
|
||||
var guard = new RecordingVexRawWriteGuard { ShouldThrow = true };
|
||||
var store = CreateRawStore(database, thresholdBytes: 64, guard);
|
||||
|
||||
var payload = CreateJsonPayload("guard-check");
|
||||
var document = new VexRawDocument(
|
||||
"vendor.guard",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.com/guard.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:guard",
|
||||
payload,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
await Assert.ThrowsAsync<ExcititorAocGuardException>(() => store.StoreAsync(document, CancellationToken.None).AsTask());
|
||||
|
||||
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
|
||||
var count = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
Assert.Equal(0, count);
|
||||
Assert.NotNull(guard.LastDocument);
|
||||
Assert.Equal("tenant-default", guard.LastDocument!.Tenant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportStore_SavesManifestAndCacheTransactionally()
|
||||
{
|
||||
var database = _client.GetDatabase($"vex-export-save-{Guid.NewGuid():N}");
|
||||
var options = Options.Create(new VexMongoStorageOptions
|
||||
{
|
||||
ExportCacheTtl = TimeSpan.FromHours(6),
|
||||
GridFsInlineThresholdBytes = 64,
|
||||
});
|
||||
|
||||
var sessionProvider = new VexMongoSessionProvider(_client, options);
|
||||
var store = new MongoVexExportStore(_client, database, options, sessionProvider);
|
||||
var signature = new VexQuerySignature("format=csaf|provider=redhat");
|
||||
var manifest = new VexExportManifest(
|
||||
"exports/20251016/redhat",
|
||||
signature,
|
||||
VexExportFormat.Csaf,
|
||||
DateTimeOffset.UtcNow,
|
||||
new VexContentAddress("sha256", "abcdef123456"),
|
||||
claimCount: 5,
|
||||
sourceProviders: new[] { "red-hat" },
|
||||
fromCache: false,
|
||||
consensusRevision: "rev-1",
|
||||
attestation: null,
|
||||
sizeBytes: 1024);
|
||||
|
||||
await store.SaveAsync(manifest, CancellationToken.None);
|
||||
|
||||
var exportsCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Exports);
|
||||
var exportKey = BuildExportKey(signature, VexExportFormat.Csaf);
|
||||
var exportDoc = await exportsCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", exportKey))
|
||||
.FirstOrDefaultAsync();
|
||||
Assert.NotNull(exportDoc);
|
||||
|
||||
var cacheCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Cache);
|
||||
var cacheKey = BuildExportKey(signature, VexExportFormat.Csaf);
|
||||
var cacheDoc = await cacheCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", cacheKey))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
Assert.NotNull(cacheDoc);
|
||||
Assert.Equal(manifest.ExportId, cacheDoc!["ManifestId"].AsString);
|
||||
Assert.True(cacheDoc.TryGetValue("ExpiresAt", out var expiresValue));
|
||||
Assert.False(expiresValue.IsBsonNull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportStore_FindAsync_ExpiresCacheEntries()
|
||||
{
|
||||
var database = _client.GetDatabase($"vex-export-expire-{Guid.NewGuid():N}");
|
||||
var options = Options.Create(new VexMongoStorageOptions
|
||||
{
|
||||
ExportCacheTtl = TimeSpan.FromMinutes(5),
|
||||
GridFsInlineThresholdBytes = 64,
|
||||
});
|
||||
|
||||
var sessionProvider = new VexMongoSessionProvider(_client, options);
|
||||
var store = new MongoVexExportStore(_client, database, options, sessionProvider);
|
||||
var signature = new VexQuerySignature("format=json|provider=cisco");
|
||||
var manifest = new VexExportManifest(
|
||||
"exports/20251016/cisco",
|
||||
signature,
|
||||
VexExportFormat.Json,
|
||||
DateTimeOffset.UtcNow,
|
||||
new VexContentAddress("sha256", "deadbeef"),
|
||||
claimCount: 3,
|
||||
sourceProviders: new[] { "cisco" },
|
||||
fromCache: false,
|
||||
consensusRevision: "rev-2",
|
||||
attestation: null,
|
||||
sizeBytes: 2048);
|
||||
|
||||
await store.SaveAsync(manifest, CancellationToken.None);
|
||||
|
||||
var cacheCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Cache);
|
||||
var cacheId = BuildExportKey(signature, VexExportFormat.Json);
|
||||
var update = Builders<BsonDocument>.Update.Set("ExpiresAt", DateTime.UtcNow.AddMinutes(-10));
|
||||
await cacheCollection.UpdateOneAsync(Builders<BsonDocument>.Filter.Eq("_id", cacheId), update);
|
||||
|
||||
var cached = await store.FindAsync(signature, VexExportFormat.Json, CancellationToken.None);
|
||||
Assert.Null(cached);
|
||||
|
||||
var remaining = await cacheCollection.Find(Builders<BsonDocument>.Filter.Eq("_id", cacheId))
|
||||
.FirstOrDefaultAsync();
|
||||
Assert.Null(remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimStore_AppendsAndQueriesStatements()
|
||||
{
|
||||
var database = _client.GetDatabase($"vex-claims-{Guid.NewGuid():N}");
|
||||
var store = new MongoVexClaimStore(database);
|
||||
|
||||
var product = new VexProduct("pkg:demo/app", "Demo App", version: "1.0.0", purl: "pkg:demo/app@1.0.0");
|
||||
var document = new VexClaimDocument(
|
||||
VexDocumentFormat.Csaf,
|
||||
"sha256:claim-1",
|
||||
new Uri("https://example.org/vex/claim-1.json"),
|
||||
revision: "2025-10-19");
|
||||
|
||||
var initialClaim = new VexClaim(
|
||||
vulnerabilityId: "CVE-2025-0101",
|
||||
providerId: "redhat",
|
||||
product: product,
|
||||
status: VexClaimStatus.NotAffected,
|
||||
document: document,
|
||||
firstSeen: DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
lastSeen: DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
justification: VexJustification.ComponentNotPresent,
|
||||
detail: "Package not shipped in this channel.",
|
||||
confidence: new VexConfidence("high", 0.9, "policy/default"),
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal("CVSS:3.1", 5.8, "medium", "CVSS:3.1/..."),
|
||||
kev: false,
|
||||
epss: 0.21),
|
||||
additionalMetadata: ImmutableDictionary<string, string>.Empty.Add("source", "csaf"));
|
||||
|
||||
await store.AppendAsync(new[] { initialClaim }, DateTimeOffset.UtcNow.AddMinutes(-5), CancellationToken.None);
|
||||
|
||||
var secondDocument = new VexClaimDocument(
|
||||
VexDocumentFormat.Csaf,
|
||||
"sha256:claim-2",
|
||||
new Uri("https://example.org/vex/claim-2.json"),
|
||||
revision: "2025-10-19.1");
|
||||
|
||||
var secondClaim = new VexClaim(
|
||||
vulnerabilityId: initialClaim.VulnerabilityId,
|
||||
providerId: initialClaim.ProviderId,
|
||||
product: initialClaim.Product,
|
||||
status: initialClaim.Status,
|
||||
document: secondDocument,
|
||||
firstSeen: initialClaim.FirstSeen,
|
||||
lastSeen: DateTimeOffset.UtcNow,
|
||||
justification: initialClaim.Justification,
|
||||
detail: initialClaim.Detail,
|
||||
confidence: initialClaim.Confidence,
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal("CVSS:3.1", 7.2, "high"),
|
||||
kev: true,
|
||||
epss: 0.43),
|
||||
additionalMetadata: initialClaim.AdditionalMetadata.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value));
|
||||
|
||||
await store.AppendAsync(new[] { secondClaim }, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
var all = await store.FindAsync("CVE-2025-0101", product.Key, since: null, CancellationToken.None);
|
||||
var allList = all.ToList();
|
||||
Assert.Equal(2, allList.Count);
|
||||
Assert.Equal("sha256:claim-2", allList[0].Document.Digest);
|
||||
Assert.True(allList[0].Signals?.Kev);
|
||||
Assert.Equal(0.43, allList[0].Signals?.Epss);
|
||||
Assert.Equal("sha256:claim-1", allList[1].Document.Digest);
|
||||
Assert.Equal("csaf", allList[1].AdditionalMetadata["source"]);
|
||||
|
||||
var recentOnly = await store.FindAsync("CVE-2025-0101", product.Key, DateTimeOffset.UtcNow.AddMinutes(-2), CancellationToken.None);
|
||||
var recentList = recentOnly.ToList();
|
||||
Assert.Single(recentList);
|
||||
Assert.Equal("sha256:claim-2", recentList[0].Document.Digest);
|
||||
}
|
||||
|
||||
private MongoVexRawStore CreateRawStore(IMongoDatabase database, int thresholdBytes, IVexRawWriteGuard? guard = null)
|
||||
{
|
||||
var options = Options.Create(new VexMongoStorageOptions
|
||||
{
|
||||
RawBucketName = "vex.raw",
|
||||
GridFsInlineThresholdBytes = thresholdBytes,
|
||||
ExportCacheTtl = TimeSpan.FromHours(1),
|
||||
});
|
||||
|
||||
var sessionProvider = new VexMongoSessionProvider(_client, options);
|
||||
var guardInstance = guard ?? new PassthroughVexRawWriteGuard();
|
||||
return new MongoVexRawStore(_client, database, options, sessionProvider, guardInstance);
|
||||
}
|
||||
|
||||
private static string BuildExportKey(VexQuerySignature signature, VexExportFormat format)
|
||||
=> string.Format(CultureInfo.InvariantCulture, "{0}|{1}", signature.Value, format.ToString().ToLowerInvariant());
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static byte[] CreateJsonPayload(string value)
|
||||
=> Encoding.UTF8.GetBytes(CreateJsonPayloadString(value));
|
||||
|
||||
private static string CreateJsonPayloadString(string value)
|
||||
=> $"{{\"data\":\"{value}\"}}";
|
||||
|
||||
private sealed class RecordingVexRawWriteGuard : IVexRawWriteGuard
|
||||
{
|
||||
public bool ShouldThrow { get; set; }
|
||||
|
||||
public RawVexDocumentModel? LastDocument { get; private set; }
|
||||
|
||||
public void EnsureValid(RawVexDocumentModel document)
|
||||
{
|
||||
LastDocument = document;
|
||||
if (ShouldThrow)
|
||||
{
|
||||
var violation = AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Guard rejected document.");
|
||||
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PassthroughVexRawWriteGuard : IVexRawWriteGuard
|
||||
{
|
||||
public void EnsureValid(RawVexDocumentModel document)
|
||||
{
|
||||
// No-op guard for unit tests.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Mongo.Tests;
|
||||
|
||||
public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
|
||||
public MongoVexSessionConsistencyTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionProvidesReadYourWrites()
|
||||
{
|
||||
await using var provider = BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
|
||||
var sessionProvider = scope.ServiceProvider.GetRequiredService<IVexMongoSessionProvider>();
|
||||
var providerStore = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
|
||||
|
||||
var session = await sessionProvider.StartSessionAsync();
|
||||
var descriptor = new VexProvider("red-hat", "Red Hat", VexProviderKind.Vendor);
|
||||
|
||||
await providerStore.SaveAsync(descriptor, CancellationToken.None, session);
|
||||
var fetched = await providerStore.FindAsync(descriptor.Id, CancellationToken.None, session);
|
||||
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(descriptor.DisplayName, fetched!.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionMaintainsMonotonicReadsAcrossStepDown()
|
||||
{
|
||||
await using var provider = BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
|
||||
var client = scope.ServiceProvider.GetRequiredService<IMongoClient>();
|
||||
var sessionProvider = scope.ServiceProvider.GetRequiredService<IVexMongoSessionProvider>();
|
||||
var providerStore = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
|
||||
|
||||
var session = await sessionProvider.StartSessionAsync();
|
||||
var initial = new VexProvider("cisco", "Cisco", VexProviderKind.Vendor);
|
||||
|
||||
await providerStore.SaveAsync(initial, CancellationToken.None, session);
|
||||
var baseline = await providerStore.FindAsync(initial.Id, CancellationToken.None, session);
|
||||
Assert.Equal("Cisco", baseline!.DisplayName);
|
||||
|
||||
await ForcePrimaryStepDownAsync(client, CancellationToken.None);
|
||||
await WaitForPrimaryAsync(client, CancellationToken.None);
|
||||
|
||||
await ExecuteWithRetryAsync(async () =>
|
||||
{
|
||||
var updated = new VexProvider(initial.Id, "Cisco Systems", initial.Kind);
|
||||
await providerStore.SaveAsync(updated, CancellationToken.None, session);
|
||||
}, CancellationToken.None);
|
||||
|
||||
var afterFailover = await providerStore.FindAsync(initial.Id, CancellationToken.None, session);
|
||||
Assert.Equal("Cisco Systems", afterFailover!.DisplayName);
|
||||
|
||||
var subsequent = await providerStore.FindAsync(initial.Id, CancellationToken.None, session);
|
||||
Assert.Equal("Cisco Systems", subsequent!.DisplayName);
|
||||
}
|
||||
|
||||
private ServiceProvider BuildServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddDebug());
|
||||
services.Configure<VexMongoStorageOptions>(options =>
|
||||
{
|
||||
options.ConnectionString = _runner.ConnectionString;
|
||||
options.DatabaseName = $"excititor-session-tests-{Guid.NewGuid():N}";
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
options.RawBucketName = "vex.raw";
|
||||
});
|
||||
services.AddExcititorMongoStorage();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static async Task ExecuteWithRetryAsync(Func<Task> action, CancellationToken cancellationToken)
|
||||
{
|
||||
const int maxAttempts = 10;
|
||||
var attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await action();
|
||||
return;
|
||||
}
|
||||
catch (MongoException ex) when (IsStepDownTransient(ex) && attempt++ < maxAttempts)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsStepDownTransient(MongoException ex)
|
||||
{
|
||||
if (ex is MongoConnectionException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ex is MongoCommandException command)
|
||||
{
|
||||
return command.Code is 7 or 89 or 91 or 10107 or 11600
|
||||
|| string.Equals(command.CodeName, "NotPrimaryNoSecondaryOk", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(command.CodeName, "NotWritablePrimary", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(command.CodeName, "PrimarySteppedDown", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(command.CodeName, "NotPrimary", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task ForcePrimaryStepDownAsync(IMongoClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
var admin = client.GetDatabase("admin");
|
||||
var command = new BsonDocument
|
||||
{
|
||||
{ "replSetStepDown", 1 },
|
||||
{ "force", true },
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await admin.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (MongoException ex) when (IsStepDownTransient(ex))
|
||||
{
|
||||
// Expected when the primary closes connections during the step-down sequence.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForPrimaryAsync(IMongoClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
var admin = client.GetDatabase("admin");
|
||||
var helloCommand = new BsonDocument("hello", 1);
|
||||
|
||||
for (var attempt = 0; attempt < 40; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await admin.RunCommandAsync<BsonDocument>(helloCommand, cancellationToken: cancellationToken);
|
||||
if (result.TryGetValue("isWritablePrimary", out var value) && value.IsBoolean && value.AsBoolean)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (MongoException ex) when (IsStepDownTransient(ex))
|
||||
{
|
||||
// Primary still recovering, retry.
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Replica set primary did not recover in time.");
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mongo2Go;
|
||||
using System.Text;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Mongo.Tests;
|
||||
|
||||
public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
|
||||
public MongoVexStatementBackfillServiceTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_BackfillsStatementsFromRawDocuments()
|
||||
{
|
||||
await using var provider = BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
|
||||
var rawStore = scope.ServiceProvider.GetRequiredService<IVexRawStore>();
|
||||
var claimStore = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
|
||||
var backfill = scope.ServiceProvider.GetRequiredService<VexStatementBackfillService>();
|
||||
|
||||
var retrievedAt = DateTimeOffset.UtcNow.AddMinutes(-15);
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("vulnId", "CVE-2025-0001")
|
||||
.Add("productKey", "pkg:test/app");
|
||||
|
||||
var document = new VexRawDocument(
|
||||
"test-provider",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.test/vex.json"),
|
||||
retrievedAt,
|
||||
"sha256:test-doc",
|
||||
CreateJsonPayload("backfill-1"),
|
||||
metadata);
|
||||
|
||||
await rawStore.StoreAsync(document, CancellationToken.None);
|
||||
|
||||
var result = await backfill.RunAsync(new VexStatementBackfillRequest(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.DocumentsEvaluated);
|
||||
Assert.Equal(1, result.DocumentsBackfilled);
|
||||
Assert.Equal(1, result.ClaimsWritten);
|
||||
Assert.Equal(0, result.NormalizationFailures);
|
||||
|
||||
var claims = await claimStore.FindAsync("CVE-2025-0001", "pkg:test/app", since: null, CancellationToken.None);
|
||||
var claim = Assert.Single(claims);
|
||||
Assert.Equal(VexClaimStatus.NotAffected, claim.Status);
|
||||
Assert.Equal("test-provider", claim.ProviderId);
|
||||
Assert.Equal(retrievedAt.ToUnixTimeSeconds(), claim.FirstSeen.ToUnixTimeSeconds());
|
||||
Assert.NotNull(claim.Signals);
|
||||
Assert.Equal(0.2, claim.Signals!.Epss);
|
||||
Assert.Equal("cvss", claim.Signals!.Severity?.Scheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_SkipsExistingDocumentsUnlessForced()
|
||||
{
|
||||
await using var provider = BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
|
||||
var rawStore = scope.ServiceProvider.GetRequiredService<IVexRawStore>();
|
||||
var claimStore = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
|
||||
var backfill = scope.ServiceProvider.GetRequiredService<VexStatementBackfillService>();
|
||||
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("vulnId", "CVE-2025-0002")
|
||||
.Add("productKey", "pkg:test/api");
|
||||
|
||||
var document = new VexRawDocument(
|
||||
"test-provider",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.test/vex-2.json"),
|
||||
DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
"sha256:test-doc-2",
|
||||
CreateJsonPayload("backfill-2"),
|
||||
metadata);
|
||||
|
||||
await rawStore.StoreAsync(document, CancellationToken.None);
|
||||
|
||||
var first = await backfill.RunAsync(new VexStatementBackfillRequest(), CancellationToken.None);
|
||||
Assert.Equal(1, first.DocumentsBackfilled);
|
||||
|
||||
var second = await backfill.RunAsync(new VexStatementBackfillRequest(), CancellationToken.None);
|
||||
Assert.Equal(1, second.DocumentsEvaluated);
|
||||
Assert.Equal(0, second.DocumentsBackfilled);
|
||||
Assert.Equal(1, second.SkippedExisting);
|
||||
|
||||
var forced = await backfill.RunAsync(new VexStatementBackfillRequest(Force: true), CancellationToken.None);
|
||||
Assert.Equal(1, forced.DocumentsBackfilled);
|
||||
|
||||
var claims = await claimStore.FindAsync("CVE-2025-0002", "pkg:test/api", since: null, CancellationToken.None);
|
||||
Assert.Equal(2, claims.Count);
|
||||
}
|
||||
|
||||
private ServiceProvider BuildServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddDebug());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.Configure<VexMongoStorageOptions>(options =>
|
||||
{
|
||||
options.ConnectionString = _runner.ConnectionString;
|
||||
options.DatabaseName = $"excititor-backfill-tests-{Guid.NewGuid():N}";
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
options.RawBucketName = "vex.raw";
|
||||
options.GridFsInlineThresholdBytes = 1024;
|
||||
options.ExportCacheTtl = TimeSpan.FromHours(1);
|
||||
});
|
||||
services.AddExcititorMongoStorage();
|
||||
services.AddExcititorAocGuards();
|
||||
services.AddSingleton<IVexNormalizer, TestNormalizer>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static ReadOnlyMemory<byte> CreateJsonPayload(string value)
|
||||
=> Encoding.UTF8.GetBytes($"{{\"data\":\"{value}\"}}");
|
||||
|
||||
private sealed class TestNormalizer : IVexNormalizer
|
||||
{
|
||||
public string Format => "csaf";
|
||||
|
||||
public bool CanHandle(VexRawDocument document) => true;
|
||||
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, VexProvider provider, CancellationToken cancellationToken)
|
||||
{
|
||||
var productKey = document.Metadata.TryGetValue("productKey", out var value) ? value : "pkg:test/default";
|
||||
var vulnId = document.Metadata.TryGetValue("vulnId", out var vuln) ? vuln : "CVE-TEST-0000";
|
||||
|
||||
var product = new VexProduct(productKey, "Test Product");
|
||||
var claimDocument = new VexClaimDocument(
|
||||
document.Format,
|
||||
document.Digest,
|
||||
document.SourceUri);
|
||||
|
||||
var timestamp = document.RetrievedAt == default ? DateTimeOffset.UtcNow : document.RetrievedAt;
|
||||
|
||||
var claim = new VexClaim(
|
||||
vulnId,
|
||||
provider.Id,
|
||||
product,
|
||||
VexClaimStatus.NotAffected,
|
||||
claimDocument,
|
||||
timestamp,
|
||||
timestamp,
|
||||
VexJustification.ComponentNotPresent,
|
||||
detail: "backfill-test",
|
||||
confidence: new VexConfidence("high", 0.95, "unit-test"),
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal("cvss", 5.4, "medium"),
|
||||
kev: false,
|
||||
epss: 0.2));
|
||||
|
||||
var claims = ImmutableArray.Create(claim);
|
||||
return ValueTask.FromResult(new VexClaimBatch(document, claims, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Globalization;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Mongo.Tests;
|
||||
|
||||
public sealed class MongoVexStoreMappingTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
private readonly IMongoDatabase _database;
|
||||
|
||||
public MongoVexStoreMappingTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start();
|
||||
var client = new MongoClient(_runner.ConnectionString);
|
||||
_database = client.GetDatabase("excititor-storage-mapping-tests");
|
||||
VexMongoMappingRegistry.Register();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderStore_RoundTrips_WithExtraFields()
|
||||
{
|
||||
var providers = _database.GetCollection<BsonDocument>(VexMongoCollectionNames.Providers);
|
||||
var providerId = "red-hat";
|
||||
|
||||
var document = new BsonDocument
|
||||
{
|
||||
{ "_id", providerId },
|
||||
{ "DisplayName", "Red Hat CSAF" },
|
||||
{ "Kind", "vendor" },
|
||||
{ "BaseUris", new BsonArray { "https://example.com/csaf" } },
|
||||
{
|
||||
"Discovery",
|
||||
new BsonDocument
|
||||
{
|
||||
{ "WellKnownMetadata", "https://example.com/.well-known/csaf" },
|
||||
{ "RolIeService", "https://example.com/service/rolie" },
|
||||
{ "UnsupportedField", "ignored" },
|
||||
}
|
||||
},
|
||||
{
|
||||
"Trust",
|
||||
new BsonDocument
|
||||
{
|
||||
{ "Weight", 0.75 },
|
||||
{
|
||||
"Cosign",
|
||||
new BsonDocument
|
||||
{
|
||||
{ "Issuer", "issuer@example.com" },
|
||||
{ "IdentityPattern", "spiffe://example/*" },
|
||||
{ "Unexpected", true },
|
||||
}
|
||||
},
|
||||
{ "PgpFingerprints", new BsonArray { "ABCDEF1234567890" } },
|
||||
{ "AnotherIgnoredField", 123 },
|
||||
}
|
||||
},
|
||||
{ "Enabled", true },
|
||||
{ "UnexpectedRoot", new BsonDocument { { "flag", true } } },
|
||||
};
|
||||
|
||||
await providers.InsertOneAsync(document);
|
||||
|
||||
var store = new MongoVexProviderStore(_database);
|
||||
var result = await store.FindAsync(providerId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(providerId, result!.Id);
|
||||
Assert.Equal("Red Hat CSAF", result.DisplayName);
|
||||
Assert.Equal(VexProviderKind.Vendor, result.Kind);
|
||||
Assert.Single(result.BaseUris);
|
||||
Assert.Equal("https://example.com/csaf", result.BaseUris[0].ToString());
|
||||
Assert.Equal("https://example.com/.well-known/csaf", result.Discovery.WellKnownMetadata?.ToString());
|
||||
Assert.Equal("https://example.com/service/rolie", result.Discovery.RolIeService?.ToString());
|
||||
Assert.Equal(0.75, result.Trust.Weight);
|
||||
Assert.NotNull(result.Trust.Cosign);
|
||||
Assert.Equal("issuer@example.com", result.Trust.Cosign!.Issuer);
|
||||
Assert.Equal("spiffe://example/*", result.Trust.Cosign!.IdentityPattern);
|
||||
Assert.Contains("ABCDEF1234567890", result.Trust.PgpFingerprints);
|
||||
Assert.True(result.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsensusStore_IgnoresUnknownFields()
|
||||
{
|
||||
var consensus = _database.GetCollection<BsonDocument>(VexMongoCollectionNames.Consensus);
|
||||
var vulnerabilityId = "CVE-2025-12345";
|
||||
var productKey = "pkg:maven/org.example/app@1.2.3";
|
||||
var consensusId = string.Format(CultureInfo.InvariantCulture, "{0}|{1}", vulnerabilityId.Trim(), productKey.Trim());
|
||||
|
||||
var document = new BsonDocument
|
||||
{
|
||||
{ "_id", consensusId },
|
||||
{ "VulnerabilityId", vulnerabilityId },
|
||||
{
|
||||
"Product",
|
||||
new BsonDocument
|
||||
{
|
||||
{ "Key", productKey },
|
||||
{ "Name", "Example App" },
|
||||
{ "Version", "1.2.3" },
|
||||
{ "Purl", productKey },
|
||||
{ "Extra", "ignored" },
|
||||
}
|
||||
},
|
||||
{ "Status", "notaffected" },
|
||||
{ "CalculatedAt", DateTime.UtcNow },
|
||||
{
|
||||
"Sources",
|
||||
new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "ProviderId", "red-hat" },
|
||||
{ "Status", "notaffected" },
|
||||
{ "DocumentDigest", "sha256:123" },
|
||||
{ "Weight", 0.9 },
|
||||
{ "Justification", "componentnotpresent" },
|
||||
{ "Detail", "Vendor statement" },
|
||||
{
|
||||
"Confidence",
|
||||
new BsonDocument
|
||||
{
|
||||
{ "Level", "high" },
|
||||
{ "Score", 0.7 },
|
||||
{ "Method", "review" },
|
||||
{ "Unexpected", "ignored" },
|
||||
}
|
||||
},
|
||||
{ "UnknownField", true },
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"Conflicts",
|
||||
new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "ProviderId", "cisco" },
|
||||
{ "Status", "affected" },
|
||||
{ "DocumentDigest", "sha256:999" },
|
||||
{ "Justification", "requiresconfiguration" },
|
||||
{ "Detail", "Different guidance" },
|
||||
{ "Reason", "policy_override" },
|
||||
{ "Other", 1 },
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"Signals",
|
||||
new BsonDocument
|
||||
{
|
||||
{
|
||||
"Severity",
|
||||
new BsonDocument
|
||||
{
|
||||
{ "Scheme", "CVSS:3.1" },
|
||||
{ "Score", 7.5 },
|
||||
{ "Label", "high" },
|
||||
{ "Vector", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
|
||||
}
|
||||
},
|
||||
{ "Kev", true },
|
||||
{ "Epss", 0.42 },
|
||||
}
|
||||
},
|
||||
{ "PolicyVersion", "2025.10" },
|
||||
{ "PolicyRevisionId", "rev-1" },
|
||||
{ "PolicyDigest", "sha256:abc" },
|
||||
{ "Summary", "Vendor confirms not affected." },
|
||||
{ "GeneratedAt", DateTime.UtcNow },
|
||||
{ "Unexpected", new BsonDocument { { "foo", "bar" } } },
|
||||
};
|
||||
|
||||
await consensus.InsertOneAsync(document);
|
||||
|
||||
var store = new MongoVexConsensusStore(_database);
|
||||
var result = await store.FindAsync(vulnerabilityId, productKey, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(vulnerabilityId, result!.VulnerabilityId);
|
||||
Assert.Equal(productKey, result.Product.Key);
|
||||
Assert.Equal("Example App", result.Product.Name);
|
||||
Assert.Equal(VexConsensusStatus.NotAffected, result.Status);
|
||||
Assert.Single(result.Sources);
|
||||
var source = result.Sources[0];
|
||||
Assert.Equal("red-hat", source.ProviderId);
|
||||
Assert.Equal(VexClaimStatus.NotAffected, source.Status);
|
||||
Assert.Equal("sha256:123", source.DocumentDigest);
|
||||
Assert.Equal(0.9, source.Weight);
|
||||
Assert.Equal(VexJustification.ComponentNotPresent, source.Justification);
|
||||
Assert.NotNull(source.Confidence);
|
||||
Assert.Equal("high", source.Confidence!.Level);
|
||||
Assert.Equal(0.7, source.Confidence!.Score);
|
||||
Assert.Equal("review", source.Confidence!.Method);
|
||||
Assert.Single(result.Conflicts);
|
||||
var conflict = result.Conflicts[0];
|
||||
Assert.Equal("cisco", conflict.ProviderId);
|
||||
Assert.Equal(VexClaimStatus.Affected, conflict.Status);
|
||||
Assert.Equal(VexJustification.RequiresConfiguration, conflict.Justification);
|
||||
Assert.Equal("policy_override", conflict.Reason);
|
||||
Assert.Equal("Vendor confirms not affected.", result.Summary);
|
||||
Assert.Equal("2025.10", result.PolicyVersion);
|
||||
Assert.NotNull(result.Signals);
|
||||
Assert.True(result.Signals!.Kev);
|
||||
Assert.Equal(0.42, result.Signals.Epss);
|
||||
Assert.NotNull(result.Signals.Severity);
|
||||
Assert.Equal("CVSS:3.1", result.Signals.Severity!.Scheme);
|
||||
Assert.Equal(7.5, result.Signals.Severity.Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CacheIndex_RoundTripsGridFsMetadata()
|
||||
{
|
||||
var gridObjectId = ObjectId.GenerateNewId().ToString();
|
||||
var index = new MongoVexCacheIndex(_database);
|
||||
var signature = new VexQuerySignature("format=csaf|vendor=redhat");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expires = now.AddHours(12);
|
||||
var entry = new VexCacheEntry(
|
||||
signature,
|
||||
VexExportFormat.Csaf,
|
||||
new VexContentAddress("sha256", "abcdef123456"),
|
||||
now,
|
||||
sizeBytes: 1024,
|
||||
manifestId: "manifest-001",
|
||||
gridFsObjectId: gridObjectId,
|
||||
expiresAt: expires);
|
||||
|
||||
await index.SaveAsync(entry, CancellationToken.None);
|
||||
|
||||
var cacheId = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}|{1}",
|
||||
signature.Value,
|
||||
entry.Format.ToString().ToLowerInvariant());
|
||||
|
||||
var cache = _database.GetCollection<BsonDocument>(VexMongoCollectionNames.Cache);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", cacheId);
|
||||
var update = Builders<BsonDocument>.Update.Set("UnexpectedField", true);
|
||||
await cache.UpdateOneAsync(filter, update);
|
||||
|
||||
var roundTrip = await index.FindAsync(signature, VexExportFormat.Csaf, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(roundTrip);
|
||||
Assert.Equal(entry.QuerySignature.Value, roundTrip!.QuerySignature.Value);
|
||||
Assert.Equal(entry.Format, roundTrip.Format);
|
||||
Assert.Equal(entry.Artifact.Digest, roundTrip.Artifact.Digest);
|
||||
Assert.Equal(entry.ManifestId, roundTrip.ManifestId);
|
||||
Assert.Equal(entry.GridFsObjectId, roundTrip.GridFsObjectId);
|
||||
Assert.Equal(entry.SizeBytes, roundTrip.SizeBytes);
|
||||
Assert.NotNull(roundTrip.ExpiresAt);
|
||||
Assert.Equal(expires.ToUnixTimeMilliseconds(), roundTrip.ExpiresAt!.Value.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
|
||||
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Storage.Mongo.Migrations;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Mongo.Tests;
|
||||
|
||||
public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
private readonly IMongoDatabase _database;
|
||||
|
||||
public VexMongoMigrationRunnerTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start();
|
||||
var client = new MongoClient(_runner.ConnectionString);
|
||||
_database = client.GetDatabase("excititor-migrations-tests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesInitialIndexesOnce()
|
||||
{
|
||||
var migrations = new IVexMongoMigration[]
|
||||
{
|
||||
new VexInitialIndexMigration(),
|
||||
new VexConsensusSignalsMigration(),
|
||||
};
|
||||
var runner = new VexMongoMigrationRunner(_database, migrations, NullLogger<VexMongoMigrationRunner>.Instance);
|
||||
|
||||
await runner.RunAsync(CancellationToken.None);
|
||||
await runner.RunAsync(CancellationToken.None);
|
||||
|
||||
var appliedCollection = _database.GetCollection<VexMigrationRecord>(VexMongoCollectionNames.Migrations);
|
||||
var applied = await appliedCollection.Find(FilterDefinition<VexMigrationRecord>.Empty).ToListAsync();
|
||||
Assert.Equal(2, applied.Count);
|
||||
Assert.Equal(migrations.Select(m => m.Id).OrderBy(id => id, StringComparer.Ordinal), applied.Select(record => record.Id).OrderBy(id => id, StringComparer.Ordinal));
|
||||
|
||||
Assert.True(HasIndex(_database.GetCollection<VexRawDocumentRecord>(VexMongoCollectionNames.Raw), "ProviderId_1_Format_1_RetrievedAt_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexProviderRecord>(VexMongoCollectionNames.Providers), "Kind_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus), "VulnerabilityId_1_Product.Key_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus), "PolicyRevisionId_1_PolicyDigest_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus), "PolicyRevisionId_1_CalculatedAt_-1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports), "QuerySignature_1_Format_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "QuerySignature_1_Format_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "ExpiresAt_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "VulnerabilityId_1_Product.Key_1_InsertedAt_-1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "ProviderId_1_InsertedAt_-1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "Document.Digest_1"));
|
||||
}
|
||||
|
||||
private static bool HasIndex<TDocument>(IMongoCollection<TDocument> collection, string name)
|
||||
{
|
||||
var indexes = collection.Indexes.List().ToList();
|
||||
return indexes.Any(index => index["name"].AsString == name);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Excititor.WebService.Endpoints;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class IngestEndpointsTests
|
||||
{
|
||||
private readonly FakeIngestOrchestrator _orchestrator = new();
|
||||
private readonly TimeProvider _timeProvider = TimeProvider.System;
|
||||
|
||||
[Fact]
|
||||
public async Task InitEndpoint_ReturnsUnauthorized_WhenMissingToken()
|
||||
{
|
||||
var httpContext = CreateHttpContext();
|
||||
var request = new IngestEndpoints.ExcititorInitRequest(null, false);
|
||||
|
||||
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedHttpResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitEndpoint_ReturnsForbidden_WhenScopeMissing()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.read");
|
||||
var request = new IngestEndpoints.ExcititorInitRequest(null, false);
|
||||
|
||||
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
Assert.IsType<ForbidHttpResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitEndpoint_NormalizesProviders_AndReturnsSummary()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var request = new IngestEndpoints.ExcititorInitRequest(new[] { " suse ", "redhat", "REDHAT" }, true);
|
||||
var started = DateTimeOffset.Parse("2025-10-20T12:00:00Z");
|
||||
var completed = started.AddMinutes(2);
|
||||
_orchestrator.InitFactory = options => new InitSummary(
|
||||
Guid.Parse("9a5eb53c-3118-4f78-991e-7d2c1af92a14"),
|
||||
started,
|
||||
completed,
|
||||
ImmutableArray.Create(
|
||||
new InitProviderResult("redhat", "Red Hat", "succeeded", TimeSpan.FromSeconds(12), null),
|
||||
new InitProviderResult("suse", "SUSE", "failed", TimeSpan.FromSeconds(7), "unreachable")));
|
||||
|
||||
var result = await IngestEndpoints.HandleInitAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var ok = Assert.IsType<Ok<object>>(result);
|
||||
Assert.Equal(new[] { "redhat", "suse" }, _orchestrator.LastInitOptions?.Providers);
|
||||
Assert.True(_orchestrator.LastInitOptions?.Resume);
|
||||
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
||||
Assert.Equal("Initialized 2 provider(s); 1 succeeded, 1 failed.", document.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunEndpoint_ReturnsBadRequest_WhenSinceInvalid()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var request = new IngestEndpoints.ExcititorIngestRunRequest(new[] { "redhat" }, "not-a-date", null, false);
|
||||
|
||||
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var bad = Assert.IsType<BadRequest<object>>(result);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
|
||||
Assert.Contains("Invalid 'since'", document.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunEndpoint_ReturnsBadRequest_WhenWindowInvalid()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var request = new IngestEndpoints.ExcititorIngestRunRequest(Array.Empty<string>(), null, "-01:00:00", false);
|
||||
|
||||
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var bad = Assert.IsType<BadRequest<object>>(result);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
|
||||
Assert.Contains("Invalid duration", document.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunEndpoint_PassesOptionsToOrchestrator()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var started = DateTimeOffset.Parse("2025-10-20T14:00:00Z");
|
||||
var completed = started.AddMinutes(5);
|
||||
_orchestrator.RunFactory = options => new IngestRunSummary(
|
||||
Guid.Parse("65bbfa25-82fd-41da-8b6b-9d8bb1e2bb5f"),
|
||||
started,
|
||||
completed,
|
||||
ImmutableArray.Create(
|
||||
new ProviderRunResult(
|
||||
"redhat",
|
||||
"succeeded",
|
||||
12,
|
||||
42,
|
||||
started,
|
||||
completed,
|
||||
completed - started,
|
||||
"sha256:abc",
|
||||
completed.AddHours(-1),
|
||||
"cp1",
|
||||
null,
|
||||
options.Since)));
|
||||
|
||||
var request = new IngestEndpoints.ExcititorIngestRunRequest(new[] { "redhat" }, "2025-10-19T00:00:00Z", "1.00:00:00", true);
|
||||
var result = await IngestEndpoints.HandleRunAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var ok = Assert.IsType<Ok<object>>(result);
|
||||
|
||||
Assert.NotNull(_orchestrator.LastRunOptions);
|
||||
Assert.Equal(new[] { "redhat" }, _orchestrator.LastRunOptions!.Providers);
|
||||
Assert.True(_orchestrator.LastRunOptions.Force);
|
||||
Assert.Equal(TimeSpan.FromDays(1), _orchestrator.LastRunOptions.Window);
|
||||
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
||||
Assert.Equal("cp1", document.RootElement.GetProperty("providers")[0].GetProperty("checkpoint").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeEndpoint_PassesCheckpointToOrchestrator()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var started = DateTimeOffset.Parse("2025-10-20T16:00:00Z");
|
||||
var completed = started.AddMinutes(2);
|
||||
_orchestrator.ResumeFactory = options => new IngestRunSummary(
|
||||
Guid.Parse("88407f25-4b3f-434d-8f8e-1c7f4925c37b"),
|
||||
started,
|
||||
completed,
|
||||
ImmutableArray.Create(
|
||||
new ProviderRunResult(
|
||||
"suse",
|
||||
"succeeded",
|
||||
5,
|
||||
10,
|
||||
started,
|
||||
completed,
|
||||
completed - started,
|
||||
null,
|
||||
null,
|
||||
options.Checkpoint,
|
||||
null,
|
||||
DateTimeOffset.UtcNow.AddDays(-1))));
|
||||
|
||||
var request = new IngestEndpoints.ExcititorIngestResumeRequest(new[] { "suse" }, "resume-token");
|
||||
var result = await IngestEndpoints.HandleResumeAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
Assert.IsType<Ok<object>>(result);
|
||||
Assert.Equal("resume-token", _orchestrator.LastResumeOptions?.Checkpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileEndpoint_ReturnsBadRequest_WhenMaxAgeInvalid()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var request = new IngestEndpoints.ExcititorReconcileRequest(Array.Empty<string>(), "invalid");
|
||||
|
||||
var result = await IngestEndpoints.HandleReconcileAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var bad = Assert.IsType<BadRequest<object>>(result);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(bad.Value));
|
||||
Assert.Contains("Invalid duration", document.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileEndpoint_PassesOptionsAndReturnsSummary()
|
||||
{
|
||||
var httpContext = CreateHttpContext("vex.admin");
|
||||
var started = DateTimeOffset.Parse("2025-10-20T18:00:00Z");
|
||||
var completed = started.AddMinutes(4);
|
||||
_orchestrator.ReconcileFactory = options => new ReconcileSummary(
|
||||
Guid.Parse("a2c2cfe6-c21a-4a62-9db7-2ed2792f4e2d"),
|
||||
started,
|
||||
completed,
|
||||
ImmutableArray.Create(
|
||||
new ReconcileProviderResult(
|
||||
"ubuntu",
|
||||
"succeeded",
|
||||
"reconciled",
|
||||
started.AddDays(-2),
|
||||
started - TimeSpan.FromDays(3),
|
||||
20,
|
||||
18,
|
||||
null)));
|
||||
|
||||
var request = new IngestEndpoints.ExcititorReconcileRequest(new[] { "ubuntu" }, "2.00:00:00");
|
||||
var result = await IngestEndpoints.HandleReconcileAsync(httpContext, request, _orchestrator, _timeProvider, CancellationToken.None);
|
||||
var ok = Assert.IsType<Ok<object>>(result);
|
||||
|
||||
Assert.Equal(TimeSpan.FromDays(2), _orchestrator.LastReconcileOptions?.MaxAge);
|
||||
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
||||
Assert.Equal("reconciled", document.RootElement.GetProperty("providers")[0].GetProperty("action").GetString());
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateHttpContext(params string[] scopes)
|
||||
{
|
||||
var context = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = new ServiceCollection().BuildServiceProvider(),
|
||||
Response = { Body = new MemoryStream() }
|
||||
};
|
||||
|
||||
if (scopes.Length > 0)
|
||||
{
|
||||
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
claims.Add(new Claim("scope", string.Join(' ', scopes)));
|
||||
var identity = new ClaimsIdentity(claims, "Test");
|
||||
context.User = new ClaimsPrincipal(identity);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private sealed class FakeIngestOrchestrator : IVexIngestOrchestrator
|
||||
{
|
||||
public IngestInitOptions? LastInitOptions { get; private set; }
|
||||
public IngestRunOptions? LastRunOptions { get; private set; }
|
||||
public IngestResumeOptions? LastResumeOptions { get; private set; }
|
||||
public ReconcileOptions? LastReconcileOptions { get; private set; }
|
||||
|
||||
public Func<IngestInitOptions, InitSummary>? InitFactory { get; set; }
|
||||
public Func<IngestRunOptions, IngestRunSummary>? RunFactory { get; set; }
|
||||
public Func<IngestResumeOptions, IngestRunSummary>? ResumeFactory { get; set; }
|
||||
public Func<ReconcileOptions, ReconcileSummary>? ReconcileFactory { get; set; }
|
||||
|
||||
public Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastInitOptions = options;
|
||||
return Task.FromResult(InitFactory is null ? CreateDefaultInitSummary() : InitFactory(options));
|
||||
}
|
||||
|
||||
public Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRunOptions = options;
|
||||
return Task.FromResult(RunFactory is null ? CreateDefaultRunSummary() : RunFactory(options));
|
||||
}
|
||||
|
||||
public Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastResumeOptions = options;
|
||||
return Task.FromResult(ResumeFactory is null ? CreateDefaultRunSummary() : ResumeFactory(options));
|
||||
}
|
||||
|
||||
public Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastReconcileOptions = options;
|
||||
return Task.FromResult(ReconcileFactory is null ? CreateDefaultReconcileSummary() : ReconcileFactory(options));
|
||||
}
|
||||
|
||||
private static InitSummary CreateDefaultInitSummary()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new InitSummary(Guid.Empty, now, now, ImmutableArray<InitProviderResult>.Empty);
|
||||
}
|
||||
|
||||
private static IngestRunSummary CreateDefaultRunSummary()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new IngestRunSummary(Guid.Empty, now, now, ImmutableArray<ProviderRunResult>.Empty);
|
||||
}
|
||||
|
||||
private static ReconcileSummary CreateDefaultReconcileSummary()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new ReconcileSummary(Guid.Empty, now, now, ImmutableArray<ReconcileProviderResult>.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using EphemeralMongo;
|
||||
using MongoRunner = EphemeralMongo.MongoRunner;
|
||||
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class MirrorEndpointsTests : IDisposable
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
private readonly IMongoRunner _runner;
|
||||
|
||||
public MirrorEndpointsTests()
|
||||
{
|
||||
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: configuration =>
|
||||
{
|
||||
var data = new Dictionary<string, string?>
|
||||
{
|
||||
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
|
||||
["Excititor:Storage:Mongo:DatabaseName"] = "mirror-tests",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Id"] = "primary",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:DisplayName"] = "Primary Mirror",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:MaxIndexRequestsPerHour"] = "1000",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:MaxDownloadRequestsPerHour"] = "1000",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Key"] = "consensus",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Format"] = "json",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Filters:vulnId"] = "CVE-2025-0001",
|
||||
[$"{MirrorDistributionOptions.SectionName}:Domains:0:Exports:0:Filters:productKey"] = "pkg:test/demo",
|
||||
};
|
||||
|
||||
configuration.AddInMemoryCollection(data!);
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.RemoveAll<IVexExportStore>();
|
||||
services.AddSingleton<IVexExportStore>(provider =>
|
||||
{
|
||||
var timeProvider = provider.GetRequiredService<TimeProvider>();
|
||||
return new FakeExportStore(timeProvider);
|
||||
});
|
||||
services.RemoveAll<IVexArtifactStore>();
|
||||
services.AddSingleton<IVexArtifactStore>(_ => new FakeArtifactStore());
|
||||
services.AddSingleton(new VexConnectorDescriptor("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF"));
|
||||
services.AddSingleton<StellaOps.Excititor.Attestation.Signing.IVexSigner, FakeSigner>();
|
||||
services.AddSingleton<StellaOps.Excititor.Policy.IVexPolicyEvaluator, FakePolicyEvaluator>();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDomains_ReturnsConfiguredDomain()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/excititor/mirror/domains");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var domains = document.RootElement.GetProperty("domains");
|
||||
Assert.Equal(1, domains.GetArrayLength());
|
||||
Assert.Equal("primary", domains[0].GetProperty("id").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DomainIndex_ReturnsManifestMetadata()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/excititor/mirror/domains/primary/index");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var exports = document.RootElement.GetProperty("exports");
|
||||
Assert.Equal(1, exports.GetArrayLength());
|
||||
var entry = exports[0];
|
||||
Assert.Equal("consensus", entry.GetProperty("exportKey").GetString());
|
||||
Assert.Equal("exports/20251019T000000000Z/abcdef", entry.GetProperty("exportId").GetString());
|
||||
var artifact = entry.GetProperty("artifact");
|
||||
Assert.Equal("sha256", artifact.GetProperty("algorithm").GetString());
|
||||
Assert.Equal("deadbeef", artifact.GetProperty("digest").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_ReturnsArtifactContent()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/excititor/mirror/domains/primary/exports/consensus/download");
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal("{\"status\":\"ok\"}", payload);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
_runner.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FakeExportStore : IVexExportStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexExportManifest> _manifests = new();
|
||||
|
||||
public FakeExportStore(TimeProvider timeProvider)
|
||||
{
|
||||
var filters = new[]
|
||||
{
|
||||
new VexQueryFilter("vulnId", "CVE-2025-0001"),
|
||||
new VexQueryFilter("productKey", "pkg:test/demo"),
|
||||
};
|
||||
|
||||
var query = VexQuery.Create(filters, Enumerable.Empty<VexQuerySort>());
|
||||
var signature = VexQuerySignature.FromQuery(query);
|
||||
var createdAt = new DateTimeOffset(2025, 10, 19, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var manifest = new VexExportManifest(
|
||||
"exports/20251019T000000000Z/abcdef",
|
||||
signature,
|
||||
VexExportFormat.Json,
|
||||
createdAt,
|
||||
new VexContentAddress("sha256", "deadbeef"),
|
||||
1,
|
||||
new[] { "primary" },
|
||||
fromCache: false,
|
||||
consensusRevision: "rev-1",
|
||||
attestation: new VexAttestationMetadata("https://stella-ops.org/attestations/vex-export"),
|
||||
sizeBytes: 16);
|
||||
|
||||
_manifests.TryAdd((signature.Value, VexExportFormat.Json), manifest);
|
||||
|
||||
// Seed artifact content for download test.
|
||||
FakeArtifactStore.Seed(manifest.Artifact, "{\"status\":\"ok\"}");
|
||||
}
|
||||
|
||||
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_manifests.TryGetValue((signature.Value, format), out var manifest);
|
||||
return ValueTask.FromResult(manifest);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeArtifactStore : IVexArtifactStore
|
||||
{
|
||||
private static readonly ConcurrentDictionary<VexContentAddress, byte[]> Content = new();
|
||||
|
||||
public static void Seed(VexContentAddress contentAddress, string payload)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
|
||||
Content[contentAddress] = bytes;
|
||||
}
|
||||
|
||||
public ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
|
||||
{
|
||||
Content[artifact.ContentAddress] = artifact.Content.ToArray();
|
||||
return ValueTask.FromResult(new VexStoredArtifact(artifact.ContentAddress, "memory://artifact", artifact.Content.Length, artifact.Metadata));
|
||||
}
|
||||
|
||||
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
|
||||
{
|
||||
Content.TryRemove(contentAddress, out _);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Content.TryGetValue(contentAddress, out var bytes))
|
||||
{
|
||||
return ValueTask.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : StellaOps.Excititor.Attestation.Signing.IVexSigner
|
||||
{
|
||||
public ValueTask<StellaOps.Excititor.Attestation.Signing.VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new StellaOps.Excititor.Attestation.Signing.VexSignedPayload("signature", "key"));
|
||||
}
|
||||
|
||||
private sealed class FakePolicyEvaluator : StellaOps.Excititor.Policy.IVexPolicyEvaluator
|
||||
{
|
||||
public string Version => "test";
|
||||
|
||||
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
|
||||
|
||||
public double GetProviderWeight(VexProvider provider) => 1.0;
|
||||
|
||||
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
|
||||
{
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EphemeralMongo;
|
||||
using MongoRunner = EphemeralMongo.MongoRunner;
|
||||
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class ResolveEndpointTests : IDisposable
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
private readonly IMongoRunner _runner;
|
||||
|
||||
public ResolveEndpointTests()
|
||||
{
|
||||
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
|
||||
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-resolve-tests");
|
||||
Directory.CreateDirectory(rootPath);
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
|
||||
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-resolve-tests",
|
||||
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
|
||||
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
|
||||
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
|
||||
};
|
||||
config.AddInMemoryCollection(settings!);
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.AddTestAuthentication();
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.AddSingleton<IVexSigner, FakeSigner>();
|
||||
services.AddSingleton<IVexPolicyEvaluator, FakePolicyEvaluator>();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ReturnsBadRequest_WhenInputsMissing()
|
||||
{
|
||||
var client = CreateClient("vex.read");
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", new { vulnerabilityIds = new[] { "CVE-2025-0001" } });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ComputesConsensusAndAttestation()
|
||||
{
|
||||
const string vulnerabilityId = "CVE-2025-2222";
|
||||
const string productKey = "pkg:nuget/StellaOps.Demo@1.0.0";
|
||||
const string providerId = "redhat";
|
||||
|
||||
await SeedProviderAsync(providerId);
|
||||
await SeedClaimAsync(vulnerabilityId, productKey, providerId);
|
||||
|
||||
var client = CreateClient("vex.read");
|
||||
var request = new ResolveRequest(
|
||||
new[] { productKey },
|
||||
null,
|
||||
new[] { vulnerabilityId },
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ResolveResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotNull(payload!.Policy);
|
||||
|
||||
var result = Assert.Single(payload.Results);
|
||||
Assert.Equal(vulnerabilityId, result.VulnerabilityId);
|
||||
Assert.Equal(productKey, result.ProductKey);
|
||||
Assert.Equal("not_affected", result.Status);
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.Equal("signature", result.Envelope!.ContentSignature!.Value);
|
||||
Assert.Equal("key", result.Envelope.ContentSignature.KeyId);
|
||||
Assert.NotEqual(default, result.CalculatedAt);
|
||||
|
||||
Assert.NotNull(result.Signals);
|
||||
Assert.True(result.Signals!.Kev);
|
||||
Assert.NotNull(result.Envelope.AttestationSignature);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Envelope.AttestationEnvelope));
|
||||
Assert.Equal(payload.Policy.ActiveRevisionId, result.PolicyRevisionId);
|
||||
Assert.Equal(payload.Policy.Version, result.PolicyVersion);
|
||||
Assert.Equal(payload.Policy.Digest, result.PolicyDigest);
|
||||
|
||||
var decision = Assert.Single(result.Decisions);
|
||||
Assert.True(decision.Included);
|
||||
Assert.Equal(providerId, decision.ProviderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ReturnsConflict_WhenPolicyRevisionMismatch()
|
||||
{
|
||||
const string vulnerabilityId = "CVE-2025-3333";
|
||||
const string productKey = "pkg:docker/demo@sha256:abcd";
|
||||
|
||||
var client = CreateClient("vex.read");
|
||||
var request = new ResolveRequest(
|
||||
new[] { productKey },
|
||||
null,
|
||||
new[] { vulnerabilityId },
|
||||
"rev-0");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ReturnsUnauthorized_WhenMissingToken()
|
||||
{
|
||||
var client = CreateClient();
|
||||
var request = new ResolveRequest(
|
||||
new[] { "pkg:test/demo" },
|
||||
null,
|
||||
new[] { "CVE-2025-0001" },
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveEndpoint_ReturnsForbidden_WhenScopeMissing()
|
||||
{
|
||||
var client = CreateClient("vex.admin");
|
||||
var request = new ResolveRequest(
|
||||
new[] { "pkg:test/demo" },
|
||||
null,
|
||||
new[] { "CVE-2025-0001" },
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/excititor/resolve", request);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task SeedProviderAsync(string providerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
|
||||
var provider = new VexProvider(providerId, "Red Hat", VexProviderKind.Distro);
|
||||
await store.SaveAsync(provider, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task SeedClaimAsync(string vulnerabilityId, string productKey, string providerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
|
||||
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
|
||||
var observedAt = timeProvider.GetUtcNow();
|
||||
|
||||
var claim = new VexClaim(
|
||||
vulnerabilityId,
|
||||
providerId,
|
||||
new VexProduct(productKey, "Demo Component", version: "1.0.0", purl: productKey),
|
||||
VexClaimStatus.NotAffected,
|
||||
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:deadbeef", new Uri("https://example.org/vex/csaf.json")),
|
||||
observedAt.AddDays(-1),
|
||||
observedAt,
|
||||
VexJustification.ProtectedByMitigatingControl,
|
||||
detail: "Test justification",
|
||||
confidence: new VexConfidence("high", 0.9, "unit-test"),
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal("cvss:v3.1", 5.5, "medium"),
|
||||
kev: true,
|
||||
epss: 0.25));
|
||||
|
||||
await store.AppendAsync(new[] { claim }, observedAt, CancellationToken.None);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(params string[] scopes)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
if (scopes.Length > 0)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", string.Join(' ', scopes));
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
_runner.Dispose();
|
||||
}
|
||||
|
||||
private sealed class ResolveRequest
|
||||
{
|
||||
public ResolveRequest(
|
||||
IReadOnlyList<string>? productKeys,
|
||||
IReadOnlyList<string>? purls,
|
||||
IReadOnlyList<string>? vulnerabilityIds,
|
||||
string? policyRevisionId)
|
||||
{
|
||||
ProductKeys = productKeys;
|
||||
Purls = purls;
|
||||
VulnerabilityIds = vulnerabilityIds;
|
||||
PolicyRevisionId = policyRevisionId;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string>? ProductKeys { get; }
|
||||
|
||||
public IReadOnlyList<string>? Purls { get; }
|
||||
|
||||
public IReadOnlyList<string>? VulnerabilityIds { get; }
|
||||
|
||||
public string? PolicyRevisionId { get; }
|
||||
}
|
||||
|
||||
private sealed class ResolveResponse
|
||||
{
|
||||
public required DateTimeOffset ResolvedAt { get; init; }
|
||||
|
||||
public required ResolvePolicy Policy { get; init; }
|
||||
|
||||
public required List<ResolveResult> Results { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolvePolicy
|
||||
{
|
||||
public required string ActiveRevisionId { get; init; }
|
||||
|
||||
public required string Version { get; init; }
|
||||
|
||||
public required string Digest { get; init; }
|
||||
|
||||
public string? RequestedRevisionId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveResult
|
||||
{
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
public required string Status { get; init; }
|
||||
|
||||
public required DateTimeOffset CalculatedAt { get; init; }
|
||||
|
||||
public required List<ResolveSource> Sources { get; init; }
|
||||
|
||||
public required List<ResolveConflict> Conflicts { get; init; }
|
||||
|
||||
public ResolveSignals? Signals { get; init; }
|
||||
|
||||
public string? Summary { get; init; }
|
||||
|
||||
public required string PolicyRevisionId { get; init; }
|
||||
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
public required List<ResolveDecision> Decisions { get; init; }
|
||||
|
||||
public ResolveEnvelope? Envelope { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveSource
|
||||
{
|
||||
public required string ProviderId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveConflict
|
||||
{
|
||||
public string? ProviderId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveSignals
|
||||
{
|
||||
public ResolveSeverity? Severity { get; init; }
|
||||
|
||||
public bool? Kev { get; init; }
|
||||
|
||||
public double? Epss { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveSeverity
|
||||
{
|
||||
public string? Scheme { get; init; }
|
||||
|
||||
public double? Score { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveDecision
|
||||
{
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
public required bool Included { get; init; }
|
||||
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveEnvelope
|
||||
{
|
||||
public required ResolveArtifact Artifact { get; init; }
|
||||
|
||||
public ResolveSignature? ContentSignature { get; init; }
|
||||
|
||||
public ResolveAttestationMetadata? Attestation { get; init; }
|
||||
|
||||
public string? AttestationEnvelope { get; init; }
|
||||
|
||||
public ResolveSignature? AttestationSignature { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveArtifact
|
||||
{
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
public required string Digest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveSignature
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveAttestationMetadata
|
||||
{
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
public ResolveRekorReference? Rekor { get; init; }
|
||||
|
||||
public string? EnvelopeDigest { get; init; }
|
||||
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ResolveRekorReference
|
||||
{
|
||||
public string? Location { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IVexSigner
|
||||
{
|
||||
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
|
||||
}
|
||||
|
||||
private sealed class FakePolicyEvaluator : IVexPolicyEvaluator
|
||||
{
|
||||
public string Version => "test";
|
||||
|
||||
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
|
||||
|
||||
public double GetProviderWeight(VexProvider provider) => 1.0;
|
||||
|
||||
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
|
||||
{
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net.Http.Json;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EphemeralMongo;
|
||||
using MongoRunner = EphemeralMongo.MongoRunner;
|
||||
using MongoRunnerOptions = EphemeralMongo.MongoRunnerOptions;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.WebService;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class StatusEndpointTests : IDisposable
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
private readonly IMongoRunner _runner;
|
||||
|
||||
public StatusEndpointTests()
|
||||
{
|
||||
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), "excititor-offline-tests");
|
||||
Directory.CreateDirectory(rootPath);
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
|
||||
["Excititor:Storage:Mongo:DatabaseName"] = "excititor-web-tests",
|
||||
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
|
||||
["Excititor:Storage:Mongo:GridFsInlineThresholdBytes"] = "256",
|
||||
["Excititor:Artifacts:FileSystem:RootPath"] = rootPath,
|
||||
};
|
||||
config.AddInMemoryCollection(settings!);
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.AddSingleton<IVexSigner, FakeSigner>();
|
||||
services.AddSingleton<IVexPolicyEvaluator, FakePolicyEvaluator>();
|
||||
services.AddSingleton(new VexConnectorDescriptor("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusEndpoint_ReturnsArtifactStores()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/excititor/status");
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, raw);
|
||||
|
||||
var payload = System.Text.Json.JsonSerializer.Deserialize<StatusResponse>(raw);
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotEmpty(payload!.ArtifactStores);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
_runner.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StatusResponse
|
||||
{
|
||||
public string[] ArtifactStores { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IVexSigner
|
||||
{
|
||||
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
|
||||
}
|
||||
|
||||
private sealed class FakePolicyEvaluator : IVexPolicyEvaluator
|
||||
{
|
||||
public string Version => "test";
|
||||
|
||||
public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default;
|
||||
|
||||
public double GetProviderWeight(VexProvider provider) => 1.0;
|
||||
|
||||
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
|
||||
{
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EphemeralMongo" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
internal static class TestAuthenticationExtensions
|
||||
{
|
||||
public const string SchemeName = "TestBearer";
|
||||
|
||||
public static AuthenticationBuilder AddTestAuthentication(this IServiceCollection services)
|
||||
{
|
||||
return services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = SchemeName;
|
||||
options.DefaultChallengeScheme = SchemeName;
|
||||
}).AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(SchemeName, _ => { });
|
||||
}
|
||||
|
||||
private sealed class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public TestAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authorization) || authorization.Count == 0)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var header = authorization[0];
|
||||
if (string.IsNullOrWhiteSpace(header) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid authentication scheme."));
|
||||
}
|
||||
|
||||
var scopeSegment = header.Substring("Bearer ".Length);
|
||||
var scopes = scopeSegment.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
if (scopes.Length > 0)
|
||||
{
|
||||
claims.Add(new Claim("scope", string.Join(' ', scopes)));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Export;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
internal static class TestServiceOverrides
|
||||
{
|
||||
public static void Apply(IServiceCollection services)
|
||||
{
|
||||
services.RemoveAll<IVexConnector>();
|
||||
services.RemoveAll<IVexIngestOrchestrator>();
|
||||
services.RemoveAll<IVexConnectorStateRepository>();
|
||||
services.RemoveAll<IVexExportCacheService>();
|
||||
services.RemoveAll<IVexExportDataSource>();
|
||||
services.RemoveAll<IVexExportStore>();
|
||||
services.RemoveAll<IVexCacheIndex>();
|
||||
services.RemoveAll<IVexCacheMaintenance>();
|
||||
services.RemoveAll<IVexAttestationClient>();
|
||||
|
||||
services.AddSingleton<IVexIngestOrchestrator, StubIngestOrchestrator>();
|
||||
services.AddSingleton<IVexConnectorStateRepository, StubConnectorStateRepository>();
|
||||
services.AddSingleton<IVexExportCacheService, StubExportCacheService>();
|
||||
services.RemoveAll<IExportEngine>();
|
||||
services.AddSingleton<IExportEngine, StubExportEngine>();
|
||||
services.AddSingleton<IVexExportDataSource, StubExportDataSource>();
|
||||
services.AddSingleton<IVexExportStore, StubExportStore>();
|
||||
services.AddSingleton<IVexCacheIndex, StubCacheIndex>();
|
||||
services.AddSingleton<IVexCacheMaintenance, StubCacheMaintenance>();
|
||||
services.AddSingleton<IVexAttestationClient, StubAttestationClient>();
|
||||
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.AddSingleton<IHostedService, NoopHostedService>();
|
||||
}
|
||||
|
||||
private sealed class StubExportCacheService : IVexExportCacheService
|
||||
{
|
||||
public ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(0);
|
||||
|
||||
public ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(0);
|
||||
}
|
||||
|
||||
private sealed class StubExportEngine : IExportEngine
|
||||
{
|
||||
public ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var manifest = new VexExportManifest(
|
||||
exportId: "stub/export",
|
||||
querySignature: VexQuerySignature.FromQuery(context.Query),
|
||||
format: context.Format,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
artifact: new VexContentAddress("sha256", "stub"),
|
||||
claimCount: 0,
|
||||
sourceProviders: Array.Empty<string>());
|
||||
|
||||
return ValueTask.FromResult(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubExportDataSource : IVexExportDataSource
|
||||
{
|
||||
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.FromResult(new VexExportDataSet(
|
||||
ImmutableArray<VexConsensus>.Empty,
|
||||
ImmutableArray<VexClaim>.Empty,
|
||||
ImmutableArray<string>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubExportStore : IVexExportStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexExportManifest> _store = new();
|
||||
|
||||
public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_store.TryGetValue((signature.Value, format), out var manifest);
|
||||
return ValueTask.FromResult(manifest);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_store[(manifest.QuerySignature.Value, manifest.Format)] = manifest;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubCacheIndex : IVexCacheIndex
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Signature, VexExportFormat Format), VexCacheEntry> _entries = new();
|
||||
|
||||
public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_entries.TryGetValue((signature.Value, format), out var entry);
|
||||
return ValueTask.FromResult(entry);
|
||||
}
|
||||
|
||||
public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_entries.TryRemove((signature.Value, format), out _);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_entries[(entry.QuerySignature.Value, entry.Format)] = entry;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubCacheMaintenance : IVexCacheMaintenance
|
||||
{
|
||||
public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(0);
|
||||
|
||||
public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(0);
|
||||
}
|
||||
|
||||
private sealed class StubAttestationClient : IVexAttestationClient
|
||||
{
|
||||
public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var envelope = new DsseEnvelope(
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"stub\":\"payload\"}")),
|
||||
"application/vnd.in-toto+json",
|
||||
new[]
|
||||
{
|
||||
new DsseSignature("attestation-signature", "attestation-key"),
|
||||
});
|
||||
|
||||
var diagnostics = ImmutableDictionary<string, string>.Empty
|
||||
.Add("envelope", JsonSerializer.Serialize(envelope));
|
||||
|
||||
var metadata = new VexAttestationMetadata(
|
||||
"stub",
|
||||
envelopeDigest: VexDsseBuilder.ComputeEnvelopeDigest(envelope),
|
||||
signedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
var response = new VexAttestationResponse(
|
||||
metadata,
|
||||
diagnostics);
|
||||
return ValueTask.FromResult(response);
|
||||
}
|
||||
|
||||
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var verification = new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty);
|
||||
return ValueTask.FromResult(verification);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, VexConnectorState> _states = new(StringComparer.Ordinal);
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_states.TryGetValue(connectorId, out var state);
|
||||
return ValueTask.FromResult(state);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_states[state.ConnectorId] = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubIngestOrchestrator : IVexIngestOrchestrator
|
||||
{
|
||||
public Task<InitSummary> InitializeAsync(IngestInitOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new InitSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<InitProviderResult>.Empty));
|
||||
|
||||
public Task<IngestRunSummary> RunAsync(IngestRunOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
|
||||
|
||||
public Task<IngestRunSummary> ResumeAsync(IngestResumeOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new IngestRunSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ProviderRunResult>.Empty));
|
||||
|
||||
public Task<ReconcileSummary> ReconcileAsync(ReconcileOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new ReconcileSummary(Guid.Empty, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ImmutableArray<ReconcileProviderResult>.Empty));
|
||||
}
|
||||
|
||||
private sealed class NoopHostedService : IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
internal sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly Action<IConfigurationBuilder>? _configureConfiguration;
|
||||
private readonly Action<IServiceCollection>? _configureServices;
|
||||
|
||||
public TestWebApplicationFactory(
|
||||
Action<IConfigurationBuilder>? configureConfiguration,
|
||||
Action<IServiceCollection>? configureServices)
|
||||
{
|
||||
_configureConfiguration = configureConfiguration;
|
||||
_configureServices = configureServices;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Production");
|
||||
if (_configureConfiguration is not null)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) => _configureConfiguration(config));
|
||||
}
|
||||
|
||||
if (_configureServices is not null)
|
||||
{
|
||||
builder.ConfigureServices(services => _configureServices(services));
|
||||
}
|
||||
}
|
||||
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Production");
|
||||
builder.UseDefaultServiceProvider(options => options.ValidateScopes = false);
|
||||
return base.CreateHost(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using StellaOps.Excititor.Worker.Signature;
|
||||
using StellaOps.Plugin;
|
||||
using Xunit;
|
||||
using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Tests;
|
||||
|
||||
public sealed class DefaultVexProviderRunnerIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
private readonly MongoClient _client;
|
||||
|
||||
public DefaultVexProviderRunnerIntegrationTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
_client = new MongoClient(_runner.ConnectionString);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_LargeBatch_IdempotentAcrossRestart()
|
||||
{
|
||||
var specs = CreateDocumentSpecs(count: 48);
|
||||
var databaseName = $"vex-worker-batch-{Guid.NewGuid():N}";
|
||||
var (provider, guard, database, connector) = ConfigureIntegrationServices(databaseName, specs);
|
||||
|
||||
try
|
||||
{
|
||||
var time = new FixedTimeProvider(new DateTimeOffset(2025, 10, 28, 8, 0, 0, TimeSpan.Zero));
|
||||
var runner = CreateRunner(provider, time);
|
||||
var schedule = new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, VexConnectorSettings.Empty);
|
||||
|
||||
await runner.RunAsync(schedule, CancellationToken.None);
|
||||
|
||||
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
|
||||
var stored = await rawCollection.Find(FilterDefinition<BsonDocument>.Empty).ToListAsync();
|
||||
stored.Should().HaveCount(specs.Count);
|
||||
|
||||
// Supersedes metadata is preserved for chained documents.
|
||||
var target = specs[17];
|
||||
var storedTarget = stored.Single(doc => doc["_id"] == target.Digest);
|
||||
storedTarget["Metadata"].AsBsonDocument.TryGetValue("aoc.supersedes", out var supersedesValue)
|
||||
.Should().BeTrue();
|
||||
supersedesValue!.AsString.Should().Be(target.Metadata["aoc.supersedes"]);
|
||||
|
||||
await runner.RunAsync(schedule, CancellationToken.None);
|
||||
|
||||
var afterRestart = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
afterRestart.Should().Be(specs.Count);
|
||||
|
||||
// Guard invoked for every document across both runs.
|
||||
guard.Invocations
|
||||
.GroupBy(doc => doc.Upstream.ContentHash)
|
||||
.Should().OnlyContain(group => group.Count() == 2);
|
||||
|
||||
// Verify provenance still carries supersedes linkage.
|
||||
var provenance = guard.Invocations
|
||||
.Where(doc => doc.Upstream.ContentHash == target.Digest)
|
||||
.Select(doc => doc.Upstream.Provenance["aoc.supersedes"])
|
||||
.ToImmutableArray();
|
||||
provenance.Should().HaveCount(2).And.AllBeEquivalentTo(target.Metadata["aoc.supersedes"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _client.DropDatabaseAsync(databaseName);
|
||||
await provider.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenGuardFails_RestartCompletesSuccessfully()
|
||||
{
|
||||
var specs = CreateDocumentSpecs(count: 24);
|
||||
var failureDigest = specs[9].Digest;
|
||||
var databaseName = $"vex-worker-guard-{Guid.NewGuid():N}";
|
||||
var (provider, guard, database, connector) = ConfigureIntegrationServices(databaseName, specs, failureDigest);
|
||||
|
||||
try
|
||||
{
|
||||
var time = new FixedTimeProvider(new DateTimeOffset(2025, 10, 28, 9, 0, 0, TimeSpan.Zero));
|
||||
var runner = CreateRunner(provider, time);
|
||||
var schedule = new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(5), TimeSpan.Zero, VexConnectorSettings.Empty);
|
||||
|
||||
await Assert.ThrowsAsync<ExcititorAocGuardException>(() => runner.RunAsync(schedule, CancellationToken.None).AsTask());
|
||||
|
||||
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
|
||||
var storedCount = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
storedCount.Should().Be(9); // documents before the failing digest persist
|
||||
|
||||
guard.FailDigest = null;
|
||||
time.Advance(TimeSpan.FromMinutes(10));
|
||||
await runner.RunAsync(schedule, CancellationToken.None);
|
||||
|
||||
var finalCount = await rawCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
finalCount.Should().Be(specs.Count);
|
||||
|
||||
guard.Invocations.Count(doc => doc.Upstream.ContentHash == failureDigest).Should().Be(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _client.DropDatabaseAsync(databaseName);
|
||||
await provider.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private (ServiceProvider Provider, RecordingVexRawWriteGuard Guard, IMongoDatabase Database, BatchingConnector Connector) ConfigureIntegrationServices(
|
||||
string databaseName,
|
||||
IReadOnlyList<DocumentSpec> specs,
|
||||
string? guardFailureDigest = null)
|
||||
{
|
||||
var database = _client.GetDatabase(databaseName);
|
||||
var optionsValue = new VexMongoStorageOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
DatabaseName = databaseName,
|
||||
DefaultTenant = "tenant-integration",
|
||||
GridFsInlineThresholdBytes = 64 * 1024,
|
||||
};
|
||||
var options = Microsoft.Extensions.Options.Options.Create(optionsValue);
|
||||
var sessionProvider = new DirectSessionProvider(_client);
|
||||
var guard = new RecordingVexRawWriteGuard { FailDigest = guardFailureDigest };
|
||||
var rawStore = new MongoVexRawStore(_client, database, options, sessionProvider, guard);
|
||||
var providerStore = new MongoVexProviderStore(database);
|
||||
var stateRepository = new MongoVexConnectorStateRepository(database);
|
||||
var connector = new BatchingConnector("integration:test", specs);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IVexConnector>(connector);
|
||||
services.AddSingleton<IVexRawStore>(rawStore);
|
||||
services.AddSingleton<IVexProviderStore>(providerStore);
|
||||
services.AddSingleton<IVexConnectorStateRepository>(stateRepository);
|
||||
services.AddSingleton<IVexClaimStore>(new NoopClaimStore());
|
||||
services.AddSingleton<IVexNormalizerRouter>(new NoopNormalizerRouter());
|
||||
services.AddSingleton<IVexSignatureVerifier>(new NoopSignatureVerifier());
|
||||
|
||||
return (services.BuildServiceProvider(), guard, database, connector);
|
||||
}
|
||||
|
||||
private static DefaultVexProviderRunner CreateRunner(IServiceProvider services, TimeProvider timeProvider)
|
||||
{
|
||||
var options = new VexWorkerOptions
|
||||
{
|
||||
Retry =
|
||||
{
|
||||
BaseDelay = TimeSpan.FromSeconds(5),
|
||||
MaxDelay = TimeSpan.FromMinutes(1),
|
||||
JitterRatio = 0.1,
|
||||
FailureThreshold = 3,
|
||||
QuarantineDuration = TimeSpan.FromMinutes(30),
|
||||
},
|
||||
};
|
||||
|
||||
return new DefaultVexProviderRunner(
|
||||
services,
|
||||
new PluginCatalog(),
|
||||
NullLogger<DefaultVexProviderRunner>.Instance,
|
||||
timeProvider,
|
||||
Microsoft.Extensions.Options.Options.Create(options));
|
||||
}
|
||||
|
||||
private static List<DocumentSpec> CreateDocumentSpecs(int count)
|
||||
{
|
||||
var specs = new List<DocumentSpec>(capacity: count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = i,
|
||||
title = $"VEX advisory {i}",
|
||||
supersedes = i == 0 ? null : $"sha256:batch-{i - 1:D4}",
|
||||
});
|
||||
|
||||
var digest = ComputeDigest(payload);
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
metadataBuilder["source.vendor"] = "integration-vendor";
|
||||
metadataBuilder["source.connector"] = "integration-connector";
|
||||
metadataBuilder["aoc.supersedes"] = i == 0 ? string.Empty : $"sha256:batch-{i - 1:D4}";
|
||||
|
||||
specs.Add(new DocumentSpec(
|
||||
ProviderId: "integration-provider",
|
||||
Format: VexDocumentFormat.Csaf,
|
||||
SourceUri: new Uri($"https://example.org/vex/{i}.json"),
|
||||
RetrievedAt: new DateTimeOffset(2025, 10, 28, 7, 0, 0, TimeSpan.Zero).AddMinutes(i),
|
||||
Digest: digest,
|
||||
Payload: payload,
|
||||
Metadata: metadataBuilder.ToImmutable()));
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string payload)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(payload);
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
if (SHA256.TryHashData(bytes, buffer, out _))
|
||||
{
|
||||
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record DocumentSpec(
|
||||
string ProviderId,
|
||||
VexDocumentFormat Format,
|
||||
Uri SourceUri,
|
||||
DateTimeOffset RetrievedAt,
|
||||
string Digest,
|
||||
string Payload,
|
||||
ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public VexRawDocument CreateDocument()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes(Payload);
|
||||
return new VexRawDocument(
|
||||
ProviderId,
|
||||
Format,
|
||||
SourceUri,
|
||||
RetrievedAt,
|
||||
Digest,
|
||||
new ReadOnlyMemory<byte>(content),
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BatchingConnector : IVexConnector
|
||||
{
|
||||
private readonly IReadOnlyList<DocumentSpec> _specs;
|
||||
|
||||
public BatchingConnector(string id, IReadOnlyList<DocumentSpec> specs)
|
||||
{
|
||||
Id = id;
|
||||
_specs = specs;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public IReadOnlyList<DocumentSpec> Specs => _specs;
|
||||
|
||||
public VexProviderKind Kind => VexProviderKind.Vendor;
|
||||
|
||||
public ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public async IAsyncEnumerable<VexRawDocument> FetchAsync(
|
||||
VexConnectorContext context,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var spec in _specs)
|
||||
{
|
||||
var document = spec.CreateDocument();
|
||||
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
yield return document;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class RecordingVexRawWriteGuard : IVexRawWriteGuard
|
||||
{
|
||||
private readonly List<RawVexDocumentModel> _invocations = new();
|
||||
|
||||
public IReadOnlyList<RawVexDocumentModel> Invocations => _invocations;
|
||||
|
||||
public string? FailDigest { get; set; }
|
||||
|
||||
public void EnsureValid(RawVexDocumentModel document)
|
||||
{
|
||||
_invocations.Add(document);
|
||||
if (FailDigest is not null && string.Equals(document.Upstream.ContentHash, FailDigest, StringComparison.Ordinal))
|
||||
{
|
||||
var violation = AocViolation.Create(
|
||||
AocViolationCode.SignatureInvalid,
|
||||
"/upstream/digest",
|
||||
"Synthetic guard failure.");
|
||||
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopClaimStore : IVexClaimStore
|
||||
{
|
||||
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(Array.Empty<VexClaim>());
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class DirectSessionProvider : IVexMongoSessionProvider
|
||||
{
|
||||
private readonly IMongoClient _client;
|
||||
|
||||
public DirectSessionProvider(IMongoClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async ValueTask<IClientSessionHandle> StartSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _client.StartSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan delta) => _utcNow += delta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Models;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using StellaOps.Excititor.Worker.Signature;
|
||||
using StellaOps.Aoc;
|
||||
using Xunit;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Tests;
|
||||
|
||||
public sealed class DefaultVexProviderRunnerTests
|
||||
{
|
||||
private static readonly VexConnectorSettings EmptySettings = VexConnectorSettings.Empty;
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Skips_WhenNextEligibleRunInFuture()
|
||||
{
|
||||
var time = new FixedTimeProvider(new DateTimeOffset(2025, 10, 21, 15, 0, 0, TimeSpan.Zero));
|
||||
var connector = TestConnector.Success("excititor:test");
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
stateRepository.Save(new VexConnectorState(
|
||||
"excititor:test",
|
||||
LastUpdated: null,
|
||||
DocumentDigests: ImmutableArray<string>.Empty,
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty,
|
||||
LastSuccessAt: null,
|
||||
FailureCount: 1,
|
||||
NextEligibleRun: time.GetUtcNow().AddHours(1),
|
||||
LastFailureReason: "previous failure"));
|
||||
|
||||
var services = CreateServiceProvider(connector, stateRepository);
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(5);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(30);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
|
||||
|
||||
connector.FetchInvoked.Should().BeFalse();
|
||||
var state = stateRepository.Get("excititor:test");
|
||||
state.Should().NotBeNull();
|
||||
state!.FailureCount.Should().Be(1);
|
||||
state.NextEligibleRun.Should().Be(time.GetUtcNow().AddHours(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Success_ResetsFailureCounters()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 21, 16, 0, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var connector = TestConnector.Success("excititor:test");
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
stateRepository.Save(new VexConnectorState(
|
||||
"excititor:test",
|
||||
LastUpdated: now.AddDays(-1),
|
||||
DocumentDigests: ImmutableArray<string>.Empty,
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty,
|
||||
LastSuccessAt: now.AddHours(-4),
|
||||
FailureCount: 2,
|
||||
NextEligibleRun: null,
|
||||
LastFailureReason: "failure"));
|
||||
|
||||
var services = CreateServiceProvider(connector, stateRepository);
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(2);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(30);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
|
||||
|
||||
connector.FetchInvoked.Should().BeTrue();
|
||||
var state = stateRepository.Get("excititor:test");
|
||||
state.Should().NotBeNull();
|
||||
state!.FailureCount.Should().Be(0);
|
||||
state.NextEligibleRun.Should().BeNull();
|
||||
state.LastFailureReason.Should().BeNull();
|
||||
state.LastSuccessAt.Should().Be(now);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesStoredResumeTokens()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 21, 18, 0, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var resumeTokens = ImmutableDictionary<string, string>.Empty
|
||||
.Add("cursor", "abc123");
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
stateRepository.Save(new VexConnectorState(
|
||||
"excititor:resume",
|
||||
LastUpdated: now.AddHours(-6),
|
||||
DocumentDigests: ImmutableArray<string>.Empty,
|
||||
ResumeTokens: resumeTokens,
|
||||
LastSuccessAt: now.AddHours(-7),
|
||||
FailureCount: 0,
|
||||
NextEligibleRun: null,
|
||||
LastFailureReason: null));
|
||||
|
||||
var connector = TestConnector.SuccessWithCapture("excititor:resume");
|
||||
var services = CreateServiceProvider(connector, stateRepository);
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(2);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(10);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
|
||||
|
||||
connector.LastContext.Should().NotBeNull();
|
||||
connector.LastContext!.Since.Should().Be(now.AddHours(-6));
|
||||
connector.LastContext.ResumeTokens.Should().BeEquivalentTo(resumeTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_SchedulesRefresh_ForUniqueClaims()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 21, 19, 0, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var rawDocument = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.org/vex.json"),
|
||||
now,
|
||||
"sha256:raw",
|
||||
ReadOnlyMemory<byte>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var claimDocument = new VexClaimDocument(
|
||||
VexDocumentFormat.Csaf,
|
||||
"sha256:claim",
|
||||
new Uri("https://example.org/vex.json"));
|
||||
|
||||
var primaryProduct = new VexProduct("pkg:test/app", "Test App", componentIdentifiers: new[] { "fingerprint:base" });
|
||||
var secondaryProduct = new VexProduct("pkg:test/other", "Other App", componentIdentifiers: new[] { "fingerprint:other" });
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new VexClaim("CVE-2025-0001", "provider-a", primaryProduct, VexClaimStatus.Affected, claimDocument, now.AddHours(-3), now.AddHours(-2)),
|
||||
new VexClaim("CVE-2025-0001", "provider-b", primaryProduct, VexClaimStatus.NotAffected, claimDocument, now.AddHours(-3), now.AddHours(-2)),
|
||||
new VexClaim("CVE-2025-0002", "provider-a", secondaryProduct, VexClaimStatus.Affected, claimDocument, now.AddHours(-2), now.AddHours(-1)),
|
||||
};
|
||||
|
||||
var connector = TestConnector.WithDocuments("excititor:test", rawDocument);
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
var normalizer = new StubNormalizerRouter(claims);
|
||||
var services = CreateServiceProvider(connector, stateRepository, normalizer);
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
|
||||
|
||||
normalizer.CallCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenSignatureVerifierFails_PropagatesException()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 21, 20, 0, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var content = Encoding.UTF8.GetBytes("{\"id\":\"sig\"}");
|
||||
var digest = ComputeDigest(content);
|
||||
var rawDocument = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.org/vex.json"),
|
||||
now,
|
||||
digest,
|
||||
content,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var connector = TestConnector.WithDocuments("excititor:test", rawDocument);
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
var failingVerifier = new ThrowingSignatureVerifier();
|
||||
var rawStore = new NoopRawStore();
|
||||
var services = CreateServiceProvider(
|
||||
connector,
|
||||
stateRepository,
|
||||
normalizerRouter: null,
|
||||
signatureVerifier: failingVerifier,
|
||||
rawStore: rawStore);
|
||||
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<ExcititorAocGuardException>(async () =>
|
||||
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None).AsTask());
|
||||
|
||||
failingVerifier.Invocations.Should().Be(1);
|
||||
rawStore.StoreCallCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_EnrichesMetadataWithSignatureResult()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 21, 21, 0, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var content = Encoding.UTF8.GetBytes("{\"id\":\"sig\"}");
|
||||
var digest = ComputeDigest(content);
|
||||
var document = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.OciAttestation,
|
||||
new Uri("https://example.org/attest.json"),
|
||||
now,
|
||||
digest,
|
||||
content,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var signatureMetadata = new VexSignatureMetadata(
|
||||
"cosign",
|
||||
subject: "subject",
|
||||
issuer: "issuer",
|
||||
keyId: "kid",
|
||||
verifiedAt: now,
|
||||
transparencyLogReference: "rekor://entry");
|
||||
|
||||
var signatureVerifier = new RecordingSignatureVerifier(signatureMetadata);
|
||||
var rawStore = new NoopRawStore();
|
||||
var connector = TestConnector.WithDocuments("excititor:test", document);
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
var services = CreateServiceProvider(
|
||||
connector,
|
||||
stateRepository,
|
||||
normalizerRouter: null,
|
||||
signatureVerifier: signatureVerifier,
|
||||
rawStore: rawStore);
|
||||
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
|
||||
|
||||
rawStore.StoreCallCount.Should().Be(1);
|
||||
rawStore.LastStoredDocument.Should().NotBeNull();
|
||||
rawStore.LastStoredDocument!.Metadata.Should().ContainKey("vex.signature.type");
|
||||
rawStore.LastStoredDocument.Metadata["vex.signature.type"].Should().Be("cosign");
|
||||
rawStore.LastStoredDocument.Metadata["signature.present"].Should().Be("true");
|
||||
rawStore.LastStoredDocument.Metadata["signature.verified"].Should().Be("true");
|
||||
signatureVerifier.Invocations.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Attestation_StoresVerifierMetadata()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 28, 7, 0, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var document = CreateAttestationRawDocument(now);
|
||||
|
||||
var diagnostics = ImmutableDictionary<string, string>.Empty
|
||||
.Add("verification.issuer", "issuer-from-verifier")
|
||||
.Add("verification.keyId", "key-from-verifier");
|
||||
|
||||
var attestationVerifier = new StubAttestationVerifier(true, diagnostics);
|
||||
var signatureVerifier = new WorkerSignatureVerifier(
|
||||
NullLogger<WorkerSignatureVerifier>.Instance,
|
||||
attestationVerifier,
|
||||
time);
|
||||
|
||||
var connector = TestConnector.WithDocuments("excititor:test", document);
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
var rawStore = new NoopRawStore();
|
||||
|
||||
var services = CreateServiceProvider(
|
||||
connector,
|
||||
stateRepository,
|
||||
normalizerRouter: null,
|
||||
signatureVerifier: signatureVerifier,
|
||||
rawStore: rawStore);
|
||||
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(5);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None);
|
||||
|
||||
rawStore.StoreCallCount.Should().Be(1);
|
||||
rawStore.LastStoredDocument.Should().NotBeNull();
|
||||
var metadata = rawStore.LastStoredDocument!.Metadata;
|
||||
metadata.Should().ContainKey("vex.signature.type");
|
||||
metadata["vex.signature.type"].Should().Be("cosign");
|
||||
metadata["vex.signature.issuer"].Should().Be("issuer-from-verifier");
|
||||
metadata["vex.signature.keyId"].Should().Be("key-from-verifier");
|
||||
metadata["signature.present"].Should().Be("true");
|
||||
metadata["signature.verified"].Should().Be("true");
|
||||
metadata.Should().ContainKey("vex.signature.verifiedAt");
|
||||
metadata["vex.signature.verifiedAt"].Should().Be(now.ToString("O"));
|
||||
attestationVerifier.Invocations.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Failure_AppliesBackoff()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 21, 17, 0, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var connector = TestConnector.Failure("excititor:test", new InvalidOperationException("boom"));
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
stateRepository.Save(new VexConnectorState(
|
||||
"excititor:test",
|
||||
LastUpdated: now.AddDays(-2),
|
||||
DocumentDigests: ImmutableArray<string>.Empty,
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty,
|
||||
LastSuccessAt: now.AddDays(-1),
|
||||
FailureCount: 1,
|
||||
NextEligibleRun: null,
|
||||
LastFailureReason: null));
|
||||
|
||||
var services = CreateServiceProvider(connector, stateRepository);
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(5);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(60);
|
||||
options.Retry.FailureThreshold = 3;
|
||||
options.Retry.QuarantineDuration = TimeSpan.FromHours(12);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await runner.RunAsync(new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings), CancellationToken.None).AsTask());
|
||||
|
||||
var state = stateRepository.Get("excititor:test");
|
||||
state.Should().NotBeNull();
|
||||
state!.FailureCount.Should().Be(2);
|
||||
state.LastFailureReason.Should().Be("boom");
|
||||
state.NextEligibleRun.Should().Be(now + TimeSpan.FromMinutes(10));
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateServiceProvider(
|
||||
IVexConnector connector,
|
||||
InMemoryStateRepository stateRepository,
|
||||
IVexNormalizerRouter? normalizerRouter = null,
|
||||
IVexSignatureVerifier? signatureVerifier = null,
|
||||
NoopRawStore? rawStore = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(connector);
|
||||
rawStore ??= new NoopRawStore();
|
||||
services.AddSingleton(rawStore);
|
||||
services.AddSingleton<IVexRawStore>(sp => rawStore);
|
||||
services.AddSingleton<IVexClaimStore>(new NoopClaimStore());
|
||||
services.AddSingleton<IVexProviderStore>(new NoopProviderStore());
|
||||
services.AddSingleton<IVexConnectorStateRepository>(stateRepository);
|
||||
services.AddSingleton<IVexNormalizerRouter>(normalizerRouter ?? new NoopNormalizerRouter());
|
||||
services.AddSingleton<IVexSignatureVerifier>(signatureVerifier ?? new NoopSignatureVerifier());
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static DefaultVexProviderRunner CreateRunner(
|
||||
IServiceProvider serviceProvider,
|
||||
TimeProvider timeProvider,
|
||||
Action<VexWorkerOptions> configure)
|
||||
{
|
||||
var options = new VexWorkerOptions();
|
||||
configure(options);
|
||||
return new DefaultVexProviderRunner(
|
||||
serviceProvider,
|
||||
new PluginCatalog(),
|
||||
NullLogger<DefaultVexProviderRunner>.Instance,
|
||||
timeProvider,
|
||||
Microsoft.Extensions.Options.Options.Create(options));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan delta) => _utcNow += delta;
|
||||
}
|
||||
|
||||
private sealed class NoopRawStore : IVexRawStore
|
||||
{
|
||||
public int StoreCallCount { get; private set; }
|
||||
public VexRawDocument? LastStoredDocument { get; private set; }
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
StoreCallCount++;
|
||||
LastStoredDocument = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken, IClientSessionHandle? session)
|
||||
{
|
||||
StoreCallCount++;
|
||||
LastStoredDocument = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<VexRawDocument?> FindByDigestAsync(string digest, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<VexRawDocument?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopClaimStore : IVexClaimStore
|
||||
{
|
||||
public ValueTask AppendAsync(IEnumerable<VexClaim> claims, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<IReadOnlyCollection<VexClaim>> FindAsync(string vulnerabilityId, string productKey, DateTimeOffset? since, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyCollection<VexClaim>>(Array.Empty<VexClaim>());
|
||||
}
|
||||
|
||||
private sealed class NoopProviderStore : IVexProviderStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, VexProvider> _providers = new(StringComparer.Ordinal);
|
||||
|
||||
public ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_providers.TryGetValue(id, out var provider);
|
||||
return ValueTask.FromResult<VexProvider?>(provider);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyCollection<VexProvider>>(_providers.Values.ToList());
|
||||
|
||||
public ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_providers[provider.Id] = provider;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class StubNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
private readonly ImmutableArray<VexClaim> _claims;
|
||||
|
||||
public StubNormalizerRouter(IEnumerable<VexClaim> claims)
|
||||
{
|
||||
_claims = claims.ToImmutableArray();
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
return ValueTask.FromResult(new VexClaimBatch(document, _claims, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class InMemoryStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, VexConnectorState> _states = new(StringComparer.Ordinal);
|
||||
|
||||
public VexConnectorState? Get(string connectorId)
|
||||
=> _states.TryGetValue(connectorId, out var state) ? state : null;
|
||||
|
||||
public void Save(VexConnectorState state)
|
||||
=> _states[state.ConnectorId] = state;
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(Get(connectorId));
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
Save(state);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestConnector : IVexConnector
|
||||
{
|
||||
private readonly Func<VexConnectorContext, CancellationToken, IAsyncEnumerable<VexRawDocument>> _fetch;
|
||||
private readonly Exception? _normalizeException;
|
||||
private readonly List<VexConnectorContext>? _capturedContexts;
|
||||
|
||||
private TestConnector(string id, Func<VexConnectorContext, CancellationToken, IAsyncEnumerable<VexRawDocument>> fetch, Exception? normalizeException = null, List<VexConnectorContext>? capturedContexts = null)
|
||||
{
|
||||
Id = id;
|
||||
_fetch = fetch;
|
||||
_normalizeException = normalizeException;
|
||||
_capturedContexts = capturedContexts;
|
||||
}
|
||||
|
||||
public static TestConnector Success(string id) => new(id, (_, _) => AsyncEnumerable.Empty<VexRawDocument>());
|
||||
|
||||
public static TestConnector SuccessWithCapture(string id)
|
||||
{
|
||||
var contexts = new List<VexConnectorContext>();
|
||||
return new TestConnector(id, (_, _) => AsyncEnumerable.Empty<VexRawDocument>(), capturedContexts: contexts);
|
||||
}
|
||||
|
||||
public static TestConnector WithDocuments(string id, params VexRawDocument[] documents)
|
||||
{
|
||||
return new TestConnector(id, (context, cancellationToken) => StreamAsync(context, documents, cancellationToken));
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<VexRawDocument> StreamAsync(
|
||||
VexConnectorContext context,
|
||||
IReadOnlyList<VexRawDocument> documents,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var document in documents)
|
||||
{
|
||||
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
yield return document;
|
||||
}
|
||||
}
|
||||
|
||||
public static TestConnector Failure(string id, Exception exception)
|
||||
{
|
||||
return new TestConnector(id, (_, _) => new ThrowingAsyncEnumerable(exception));
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public VexProviderKind Kind => VexProviderKind.Vendor;
|
||||
|
||||
public bool ValidateInvoked { get; private set; }
|
||||
|
||||
public bool FetchInvoked { get; private set; }
|
||||
|
||||
public VexConnectorContext? LastContext => _capturedContexts is { Count: > 0 } ? _capturedContexts[^1] : null;
|
||||
|
||||
public ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateInvoked = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
FetchInvoked = true;
|
||||
_capturedContexts?.Add(context);
|
||||
return _fetch(context, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_normalizeException is not null)
|
||||
{
|
||||
throw _normalizeException;
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingAsyncEnumerable : IAsyncEnumerable<VexRawDocument>, IAsyncEnumerator<VexRawDocument>
|
||||
{
|
||||
private readonly Exception _exception;
|
||||
|
||||
public ThrowingAsyncEnumerable(Exception exception) => _exception = exception;
|
||||
|
||||
public IAsyncEnumerator<VexRawDocument> GetAsyncEnumerator(CancellationToken cancellationToken = default) => this;
|
||||
|
||||
public ValueTask<bool> MoveNextAsync() => ValueTask.FromException<bool>(_exception);
|
||||
|
||||
public VexRawDocument Current => throw new InvalidOperationException();
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class ThrowingSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public int Invocations { get; private set; }
|
||||
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Invocations++;
|
||||
var violation = AocViolation.Create(
|
||||
AocViolationCode.SignatureInvalid,
|
||||
"/upstream/signature",
|
||||
"Synthetic verifier failure.");
|
||||
throw new ExcititorAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
private readonly VexSignatureMetadata? _result;
|
||||
|
||||
public RecordingSignatureVerifier(VexSignatureMetadata? result) => _result = result;
|
||||
|
||||
public int Invocations { get; private set; }
|
||||
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Invocations++;
|
||||
return ValueTask.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubAttestationVerifier : IVexAttestationVerifier
|
||||
{
|
||||
private readonly bool _isValid;
|
||||
private readonly ImmutableDictionary<string, string> _diagnostics;
|
||||
|
||||
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string> diagnostics)
|
||||
{
|
||||
_isValid = isValid;
|
||||
_diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
public int Invocations { get; private set; }
|
||||
|
||||
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Invocations++;
|
||||
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
private static VexRawDocument CreateAttestationRawDocument(DateTimeOffset observedAt)
|
||||
{
|
||||
var predicate = new VexAttestationPredicate(
|
||||
"export-id",
|
||||
"query-signature",
|
||||
"sha256",
|
||||
"abcd1234",
|
||||
VexExportFormat.Json,
|
||||
observedAt,
|
||||
new[] { "provider-a" },
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var statement = new VexInTotoStatement(
|
||||
VexInTotoStatement.InTotoType,
|
||||
"https://stella-ops.org/attestations/vex-export",
|
||||
new[] { new VexInTotoSubject("export-id", new Dictionary<string, string> { { "sha256", "abcd1234" } }) },
|
||||
predicate);
|
||||
|
||||
var serializerOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, serializerOptions);
|
||||
var envelope = new DsseEnvelope(
|
||||
Convert.ToBase64String(payloadBytes),
|
||||
"application/vnd.in-toto+json",
|
||||
new[] { new DsseSignature("deadbeef", "sig-key") });
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(
|
||||
envelope,
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
});
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes(envelopeJson);
|
||||
|
||||
return new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.OciAttestation,
|
||||
new Uri("https://example.org/vex-attestation.json"),
|
||||
observedAt,
|
||||
ComputeDigest(contentBytes),
|
||||
contentBytes,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
if (!SHA256.TryHashData(content, buffer, out _))
|
||||
{
|
||||
var hash = SHA256.HashData(content.ToArray());
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Models;
|
||||
using StellaOps.Excititor.Attestation.Verification;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Aoc;
|
||||
using StellaOps.Excititor.Worker.Signature;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Tests.Signature;
|
||||
|
||||
public sealed class WorkerSignatureVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsMetadata_WhenSignatureHintsPresent()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("{\"id\":\"1\"}");
|
||||
var digest = ComputeDigest(content);
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("tenant", "tenant-a")
|
||||
.Add("vex.signature.type", "cosign")
|
||||
.Add("vex.signature.subject", "subject")
|
||||
.Add("vex.signature.issuer", "issuer")
|
||||
.Add("vex.signature.keyId", "kid")
|
||||
.Add("vex.signature.verifiedAt", DateTimeOffset.UtcNow.ToString("O"))
|
||||
.Add("vex.signature.transparencyLogReference", "rekor://entry");
|
||||
|
||||
var document = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.org/vex.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
digest,
|
||||
content,
|
||||
metadata);
|
||||
|
||||
var verifier = new WorkerSignatureVerifier(NullLogger<WorkerSignatureVerifier>.Instance);
|
||||
|
||||
var result = await verifier.VerifyAsync(document, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Type.Should().Be("cosign");
|
||||
result.Subject.Should().Be("subject");
|
||||
result.Issuer.Should().Be("issuer");
|
||||
result.KeyId.Should().Be("kid");
|
||||
result.TransparencyLogReference.Should().Be("rekor://entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Throws_WhenChecksumMismatch()
|
||||
{
|
||||
var content = Encoding.UTF8.GetBytes("{\"id\":\"1\"}");
|
||||
var metadata = ImmutableDictionary<string, string>.Empty;
|
||||
var document = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.CycloneDx,
|
||||
new Uri("https://example.org/vex.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:deadbeef",
|
||||
content,
|
||||
metadata);
|
||||
|
||||
var verifier = new WorkerSignatureVerifier(NullLogger<WorkerSignatureVerifier>.Instance);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<ExcititorAocGuardException>(() => verifier.VerifyAsync(document, CancellationToken.None).AsTask());
|
||||
exception.PrimaryErrorCode.Should().Be("ERR_AOC_005");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Attestation_UsesVerifier()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var (document, metadata) = CreateAttestationDocument(now, subject: "export-1", includeRekor: true);
|
||||
|
||||
var attestationVerifier = new StubAttestationVerifier(true);
|
||||
var verifier = new WorkerSignatureVerifier(NullLogger<WorkerSignatureVerifier>.Instance, attestationVerifier, TimeProvider.System);
|
||||
|
||||
var result = await verifier.VerifyAsync(document with { Metadata = metadata }, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Type.Should().Be("cosign");
|
||||
result.Subject.Should().Be("export-1");
|
||||
attestationVerifier.Invocations.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AttestationThrows_WhenVerifierInvalid()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var (document, metadata) = CreateAttestationDocument(now, subject: "export-2", includeRekor: true);
|
||||
|
||||
var attestationVerifier = new StubAttestationVerifier(false);
|
||||
var verifier = new WorkerSignatureVerifier(NullLogger<WorkerSignatureVerifier>.Instance, attestationVerifier, TimeProvider.System);
|
||||
|
||||
await Assert.ThrowsAsync<ExcititorAocGuardException>(() => verifier.VerifyAsync(document with { Metadata = metadata }, CancellationToken.None).AsTask());
|
||||
attestationVerifier.Invocations.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Attestation_UsesDiagnosticsWhenMetadataMissing()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 28, 7, 0, 0, TimeSpan.Zero);
|
||||
var (document, _) = CreateAttestationDocument(now, subject: "export-3", includeRekor: false);
|
||||
|
||||
var diagnostics = ImmutableDictionary<string, string>.Empty
|
||||
.Add("verification.issuer", "issuer-from-attestation")
|
||||
.Add("verification.keyId", "kid-from-attestation");
|
||||
|
||||
var attestationVerifier = new StubAttestationVerifier(true, diagnostics);
|
||||
var verifier = new WorkerSignatureVerifier(
|
||||
NullLogger<WorkerSignatureVerifier>.Instance,
|
||||
attestationVerifier,
|
||||
new FixedTimeProvider(now));
|
||||
|
||||
var result = await verifier.VerifyAsync(document, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Issuer.Should().Be("issuer-from-attestation");
|
||||
result.KeyId.Should().Be("kid-from-attestation");
|
||||
result.TransparencyLogReference.Should().BeNull();
|
||||
result.VerifiedAt.Should().Be(now);
|
||||
attestationVerifier.Invocations.Should().Be(1);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
return SHA256.TryHashData(payload, buffer, out _)
|
||||
? "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant()
|
||||
: "sha256:" + Convert.ToHexString(SHA256.HashData(payload.ToArray())).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static (VexRawDocument Document, ImmutableDictionary<string, string> Metadata) CreateAttestationDocument(DateTimeOffset createdAt, string subject, bool includeRekor)
|
||||
{
|
||||
var predicate = new VexAttestationPredicate(
|
||||
subject,
|
||||
"query=signature",
|
||||
"sha256",
|
||||
"abcd1234",
|
||||
VexExportFormat.Json,
|
||||
createdAt,
|
||||
new[] { "provider-a" },
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var statement = new VexInTotoStatement(
|
||||
VexInTotoStatement.InTotoType,
|
||||
"https://stella-ops.org/attestations/vex-export",
|
||||
new[] { new VexInTotoSubject(subject, new Dictionary<string, string> { { "sha256", "abcd1234" } }) },
|
||||
predicate);
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
});
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
Convert.ToBase64String(payloadBytes),
|
||||
"application/vnd.in-toto+json",
|
||||
new[] { new DsseSignature("deadbeef", "key-1") });
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
});
|
||||
|
||||
var contentBytes = Encoding.UTF8.GetBytes(envelopeJson);
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
metadataBuilder["tenant"] = "tenant-a";
|
||||
metadataBuilder["vex.signature.type"] = "cosign";
|
||||
metadataBuilder["vex.signature.verifiedAt"] = createdAt.ToString("O");
|
||||
if (includeRekor)
|
||||
{
|
||||
metadataBuilder["vex.signature.transparencyLogReference"] = "rekor://entry/123";
|
||||
}
|
||||
|
||||
var document = new VexRawDocument(
|
||||
"provider-a",
|
||||
VexDocumentFormat.OciAttestation,
|
||||
new Uri("https://example.org/attestation.json"),
|
||||
createdAt,
|
||||
ComputeDigest(contentBytes),
|
||||
contentBytes,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
return (document, metadataBuilder.ToImmutable());
|
||||
}
|
||||
|
||||
private sealed class StubAttestationVerifier : IVexAttestationVerifier
|
||||
{
|
||||
private readonly bool _isValid;
|
||||
private readonly ImmutableDictionary<string, string> _diagnostics;
|
||||
|
||||
public StubAttestationVerifier(bool isValid, ImmutableDictionary<string, string>? diagnostics = null)
|
||||
{
|
||||
_isValid = isValid;
|
||||
_diagnostics = diagnostics ?? ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
public int Invocations { get; private set; }
|
||||
|
||||
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Invocations++;
|
||||
return ValueTask.FromResult(new VexAttestationVerification(_isValid, _diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Excititor.Worker/StellaOps.Excititor.Worker.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,106 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Worker.Options;
|
||||
using StellaOps.Excititor.Worker.Scheduling;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Worker.Tests;
|
||||
|
||||
public sealed class VexWorkerOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveSchedules_UsesDefaultIntervalWhenNotSpecified()
|
||||
{
|
||||
var options = new VexWorkerOptions
|
||||
{
|
||||
DefaultInterval = TimeSpan.FromMinutes(30),
|
||||
OfflineInterval = TimeSpan.FromHours(6),
|
||||
OfflineMode = false,
|
||||
};
|
||||
options.Providers.Add(new VexWorkerProviderOptions { ProviderId = "excititor:redhat" });
|
||||
|
||||
var schedules = options.ResolveSchedules();
|
||||
|
||||
schedules.Should().ContainSingle();
|
||||
schedules[0].Interval.Should().Be(TimeSpan.FromMinutes(30));
|
||||
schedules[0].Settings.Should().Be(VexConnectorSettings.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSchedules_HonorsOfflineInterval()
|
||||
{
|
||||
var options = new VexWorkerOptions
|
||||
{
|
||||
DefaultInterval = TimeSpan.FromMinutes(30),
|
||||
OfflineInterval = TimeSpan.FromHours(8),
|
||||
OfflineMode = true,
|
||||
};
|
||||
options.Providers.Add(new VexWorkerProviderOptions { ProviderId = "excititor:offline" });
|
||||
|
||||
var schedules = options.ResolveSchedules();
|
||||
|
||||
schedules.Should().ContainSingle();
|
||||
schedules[0].Interval.Should().Be(TimeSpan.FromHours(8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSchedules_SkipsDisabledProviders()
|
||||
{
|
||||
var options = new VexWorkerOptions();
|
||||
options.Providers.Add(new VexWorkerProviderOptions { ProviderId = "excititor:enabled" });
|
||||
options.Providers.Add(new VexWorkerProviderOptions { ProviderId = "excititor:disabled", Enabled = false });
|
||||
|
||||
var schedules = options.ResolveSchedules();
|
||||
|
||||
schedules.Should().HaveCount(1);
|
||||
schedules[0].ProviderId.Should().Be("excititor:enabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSchedules_UsesProviderIntervalOverride()
|
||||
{
|
||||
var options = new VexWorkerOptions
|
||||
{
|
||||
DefaultInterval = TimeSpan.FromMinutes(15),
|
||||
};
|
||||
options.Providers.Add(new VexWorkerProviderOptions
|
||||
{
|
||||
ProviderId = "excititor:custom",
|
||||
Interval = TimeSpan.FromMinutes(5),
|
||||
InitialDelay = TimeSpan.FromSeconds(10),
|
||||
});
|
||||
|
||||
var schedules = options.ResolveSchedules();
|
||||
|
||||
schedules.Should().ContainSingle();
|
||||
schedules[0].Interval.Should().Be(TimeSpan.FromMinutes(5));
|
||||
schedules[0].InitialDelay.Should().Be(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshOptions_DefaultsAlignWithExpectedValues()
|
||||
{
|
||||
var options = new VexWorkerRefreshOptions();
|
||||
|
||||
options.Enabled.Should().BeTrue();
|
||||
options.ConsensusTtl.Should().Be(TimeSpan.FromHours(2));
|
||||
options.Damper.ResolveDuration(0.95).Should().Be(TimeSpan.FromHours(24));
|
||||
options.Damper.ResolveDuration(0.6).Should().Be(TimeSpan.FromHours(36));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DamperOptions_ClampDurationWithinBounds()
|
||||
{
|
||||
var options = new VexStabilityDamperOptions
|
||||
{
|
||||
Minimum = TimeSpan.FromHours(10),
|
||||
Maximum = TimeSpan.FromHours(20),
|
||||
DefaultDuration = TimeSpan.FromHours(30),
|
||||
};
|
||||
options.Rules.Clear();
|
||||
options.Rules.Add(new VexStabilityDamperRule { MinWeight = 0.8, Duration = TimeSpan.FromHours(40) });
|
||||
|
||||
options.ClampDuration(TimeSpan.FromHours(5)).Should().Be(TimeSpan.FromHours(10));
|
||||
options.ResolveDuration(0.85).Should().Be(TimeSpan.FromHours(20));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user