Add new features and tests for AirGap and Time modules
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Introduced `SbomService` tasks documentation. - Updated `StellaOps.sln` to include new projects: `StellaOps.AirGap.Time` and `StellaOps.AirGap.Importer`. - Added unit tests for `BundleImportPlanner`, `DsseVerifier`, `ImportValidator`, and other components in the `StellaOps.AirGap.Importer.Tests` namespace. - Implemented `InMemoryBundleRepositories` for testing bundle catalog and item repositories. - Created `MerkleRootCalculator`, `RootRotationPolicy`, and `TufMetadataValidator` tests. - Developed `StalenessCalculator` and `TimeAnchorLoader` tests in the `StellaOps.AirGap.Time.Tests` namespace. - Added `fetch-sbomservice-deps.sh` script for offline dependency fetching.
This commit is contained in:
@@ -1,367 +1,464 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_EnrichesSignerMetadataWhenConfigured()
|
||||
{
|
||||
using var tempMetadata = CreateTempSignerMetadata("excititor:msrc", "tier-1", "abc123");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
|
||||
|
||||
try
|
||||
{
|
||||
var summary = """
|
||||
{
|
||||
"value": [
|
||||
{ "id": "ADV-0002", "vulnerabilityId": "ADV-0002", "cvrfUrl": "https://example.com/csaf/ADV-0002.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 connector = new MsrcCsafConnector(
|
||||
factory,
|
||||
new StubTokenProvider(),
|
||||
stateRepository,
|
||||
Options.Create(CreateOptions()),
|
||||
NullLogger<MsrcCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
|
||||
|
||||
var sink = new CapturingRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
Since: null,
|
||||
Settings: VexConnectorSettings.Empty,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: new NoopSignatureVerifier(),
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
await foreach (var _ in connector.FetchAsync(context, CancellationToken.None)) { }
|
||||
|
||||
var emitted = sink.Documents.Single();
|
||||
emitted.Metadata.Should().Contain("vex.provenance.trust.issuerTier", "tier-1");
|
||||
emitted.Metadata.Should().ContainKey("vex.provenance.trust.signers");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
|
||||
{
|
||||
var pathTemp = Path.GetTempFileName();
|
||||
var json = $"""
|
||||
{{
|
||||
"schemaVersion": "1.0.0",
|
||||
"generatedAt": "2025-11-20T00:00:00Z",
|
||||
"connectors": [
|
||||
{{
|
||||
"connectorId": "{connectorId}",
|
||||
"provider": {{ "name": "{connectorId}", "slug": "{connectorId}" }},
|
||||
"issuerTier": "{tier}",
|
||||
"signers": [
|
||||
{{
|
||||
"usage": "csaf",
|
||||
"fingerprints": [
|
||||
{{ "alg": "sha256", "format": "pgp", "value": "{fingerprint}" }}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
""";
|
||||
File.WriteAllText(pathTemp, json);
|
||||
return new TempMetadataFile(pathTemp);
|
||||
}
|
||||
|
||||
private sealed record TempMetadataFile(string Path) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(Path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +1,313 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_EnrichesSignerTrustMetadataWhenConfigured()
|
||||
{
|
||||
using var tempMetadata = CreateTempSignerMetadata("excititor:oci.openvex.attest", "tier-0", "feed-fp");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
|
||||
try
|
||||
{
|
||||
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 context = new VexConnectorContext(
|
||||
Since: null,
|
||||
Settings: VexConnectorSettings.Empty,
|
||||
RawSink: sink,
|
||||
SignatureVerifier: new NoopSignatureVerifier(),
|
||||
Normalizers: new NoopNormalizerRouter(),
|
||||
Services: new ServiceCollection().BuildServiceProvider(),
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
await foreach (var _ in connector.FetchAsync(context, CancellationToken.None)) { }
|
||||
|
||||
var emitted = sink.Documents.Single();
|
||||
emitted.Metadata.Should().Contain("vex.provenance.trust.issuerTier", "tier-0");
|
||||
emitted.Metadata.Should().ContainKey("vex.provenance.trust.signers");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
|
||||
{
|
||||
var pathTemp = System.IO.Path.GetTempFileName();
|
||||
var json = $"""
|
||||
{{
|
||||
"schemaVersion": "1.0.0",
|
||||
"generatedAt": "2025-11-20T00:00:00Z",
|
||||
"connectors": [
|
||||
{{
|
||||
"connectorId": "{connectorId}",
|
||||
"provider": {{ "name": "{connectorId}", "slug": "{connectorId}" }},
|
||||
"issuerTier": "{tier}",
|
||||
"signers": [
|
||||
{{
|
||||
"usage": "attestation",
|
||||
"fingerprints": [
|
||||
{{ "alg": "sha256", "format": "cosign", "value": "{fingerprint}" }}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
""";
|
||||
System.IO.File.WriteAllText(pathTemp, json);
|
||||
return new TempMetadataFile(pathTemp);
|
||||
}
|
||||
|
||||
private sealed record TempMetadataFile(string Path) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
try { System.IO.File.Delete(Path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,62 +1,67 @@
|
||||
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);
|
||||
|
||||
using System;
|
||||
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()
|
||||
{
|
||||
using var tempMetadata = CreateTempSignerMetadata("excititor:ubuntu", "tier-2", "deadbeef");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
|
||||
try
|
||||
{
|
||||
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 = BuildConnectorSettings(indexUri, trustWeight: 0.63, trustTier: "distro-trusted",
|
||||
fingerprints: new[]
|
||||
{
|
||||
@@ -72,15 +77,15 @@ public sealed class UbuntuCsafConnectorTests
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), services, 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 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.Should().Contain("ubuntu.etag", "etag-123");
|
||||
@@ -93,25 +98,25 @@ public sealed class UbuntuCsafConnectorTests
|
||||
stored.Metadata.Should().Contain(
|
||||
"vex.provenance.pgp.fingerprints",
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
|
||||
|
||||
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();
|
||||
|
||||
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\"");
|
||||
|
||||
@@ -123,37 +128,41 @@ public sealed class UbuntuCsafConnectorTests
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
|
||||
});
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
var settings = BuildConnectorSettings(indexUri);
|
||||
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||
|
||||
@@ -164,17 +173,17 @@ public sealed class UbuntuCsafConnectorTests
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), services, 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();
|
||||
|
||||
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);
|
||||
providerStore.SavedProviders.Should().ContainSingle();
|
||||
}
|
||||
@@ -198,146 +207,183 @@ public sealed class UbuntuCsafConnectorTests
|
||||
return new VexConnectorSettings(builder.ToImmutable());
|
||||
}
|
||||
|
||||
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 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 static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
|
||||
{
|
||||
var pathTemp = System.IO.Path.GetTempFileName();
|
||||
var json = $"""
|
||||
{{
|
||||
\"schemaVersion\": \"1.0.0\",
|
||||
\"generatedAt\": \"2025-11-20T00:00:00Z\",
|
||||
\"connectors\": [
|
||||
{{
|
||||
\"connectorId\": \"{connectorId}\",
|
||||
\"provider\": {{ \"name\": \"{connectorId}\", \"slug\": \"{connectorId}\" }},
|
||||
\"issuerTier\": \"{tier}\",
|
||||
\"signers\": [
|
||||
{{
|
||||
\"usage\": \"csaf\",
|
||||
\"fingerprints\": [
|
||||
{{ \"alg\": \"sha256\", \"format\": \"pgp\", \"value\": \"{fingerprint}\" }}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
""";
|
||||
System.IO.File.WriteAllText(pathTemp, json);
|
||||
return new TempMetadataFile(pathTemp);
|
||||
}
|
||||
|
||||
private sealed record TempMetadataFile(string Path) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
try { System.IO.File.Delete(Path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -374,16 +420,16 @@ public sealed class UbuntuCsafConnectorTests
|
||||
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 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,77 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class AttestationVerifyEndpointTests : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
|
||||
public AttestationVerifyEndpointTests(TestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_ReturnsOk_WhenPayloadValid()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var request = new AttestationVerifyRequest
|
||||
{
|
||||
ExportId = "export-123",
|
||||
QuerySignature = "purl=foo",
|
||||
ArtifactDigest = "sha256:deadbeef",
|
||||
Format = "VexJson",
|
||||
CreatedAt = DateTimeOffset.Parse("2025-11-20T00:00:00Z"),
|
||||
SourceProviders = new[] { "ghsa" },
|
||||
Metadata = new Dictionary<string, string> { { "foo", "bar" } },
|
||||
Attestation = new AttestationVerifyMetadata
|
||||
{
|
||||
PredicateType = "https://stella-ops.org/attestations/vex-export",
|
||||
EnvelopeDigest = "sha256:abcd",
|
||||
SignedAt = DateTimeOffset.Parse("2025-11-20T00:00:00Z"),
|
||||
Rekor = new AttestationRekorReference
|
||||
{
|
||||
ApiVersion = "0.2",
|
||||
Location = "https://rekor.example/log/123",
|
||||
LogIndex = 1,
|
||||
InclusionProofUrl = new Uri("https://rekor.example/log/123/proof")
|
||||
}
|
||||
},
|
||||
Envelope = "{}"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/attestations/verify", request);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadFromJsonAsync<AttestationVerifyResponse>();
|
||||
body.Should().NotBeNull();
|
||||
body!.Valid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_ReturnsBadRequest_WhenFieldsMissing()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var request = new AttestationVerifyRequest
|
||||
{
|
||||
ExportId = "", // missing
|
||||
QuerySignature = "",
|
||||
ArtifactDigest = "",
|
||||
Format = "",
|
||||
Envelope = ""
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/attestations/verify", request);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user