consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Excititor RedHat CSAF Connector Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Validate Red Hat CSAF connector and normalizer behavior with deterministic tests.
|
||||
|
||||
## Responsibilities
|
||||
- Cover provider metadata loading and connector fetch behavior.
|
||||
- Cover CSAF normalizer fixture parsing and deterministic output.
|
||||
- Maintain opt-in live schema drift tests for external advisories.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/excititor/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- Tests cover success and failure cases for metadata loading and connector fetch.
|
||||
- Fixtures avoid nondeterministic inputs.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for negative/error paths.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,283 @@
|
||||
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.Core.Storage;
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
State = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(
|
||||
State is not null ? new[] { State } : Array.Empty<VexConnectorState>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# RedHat CSAF Expected Outputs
|
||||
|
||||
This directory contains expected normalized VEX claim snapshots for each fixture.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
- `{fixture-name}.canonical.json` - Expected normalized output for successful parsing
|
||||
- `{fixture-name}.error.json` - Expected error classification for malformed inputs
|
||||
|
||||
## Snapshot Format
|
||||
|
||||
Expected outputs use the internal normalized VEX claim model in canonical JSON format.
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5678",
|
||||
"product": {
|
||||
"key": "rhel-7-openssl-legacy",
|
||||
"name": "Red Hat Enterprise Linux 7 openssl (legacy)",
|
||||
"purl": null,
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:7::openssl"
|
||||
},
|
||||
"status": "NotAffected",
|
||||
"justification": "VulnerableCodeNotPresent",
|
||||
"detail": "OpenSSL buffer overflow in X.509 certificate verification",
|
||||
"metadata": {
|
||||
"csaf.justification.label": "vulnerable_code_not_present",
|
||||
"csaf.product_status.raw": "known_not_affected",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5678",
|
||||
"product": {
|
||||
"key": "rhel-8-openssl",
|
||||
"name": "Red Hat Enterprise Linux 8 openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@1.1.1k-12.el8_9",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:8::openssl"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL buffer overflow in X.509 certificate verification",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5678",
|
||||
"product": {
|
||||
"key": "rhel-9-openssl",
|
||||
"name": "Red Hat Enterprise Linux 9 openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@3.0.7-25.el9_3",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::openssl"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL buffer overflow in X.509 certificate verification",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5679",
|
||||
"product": {
|
||||
"key": "rhel-7-openssl-legacy",
|
||||
"name": "Red Hat Enterprise Linux 7 openssl (legacy)",
|
||||
"purl": null,
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:7::openssl"
|
||||
},
|
||||
"status": "affected",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL timing side-channel in RSA decryption",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "known_affected",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5679",
|
||||
"product": {
|
||||
"key": "rhel-8-openssl",
|
||||
"name": "Red Hat Enterprise Linux 8 openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@1.1.1k-12.el8_9",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:8::openssl"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL timing side-channel in RSA decryption",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5679",
|
||||
"product": {
|
||||
"key": "rhel-9-openssl",
|
||||
"name": "Red Hat Enterprise Linux 9 openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@3.0.7-25.el9_3",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::openssl"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL timing side-channel in RSA decryption",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-9999",
|
||||
"product": {
|
||||
"key": "rhel-9-test",
|
||||
"name": "Test Product",
|
||||
"purl": null,
|
||||
"cpe": null
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": null,
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security"
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-1234",
|
||||
"product": {
|
||||
"key": "rhel-9-kernel",
|
||||
"name": "Red Hat Enterprise Linux 9 kernel",
|
||||
"purl": "pkg:rpm/redhat/kernel@5.14.0-427.13.1.el9_4",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::kernel"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "Kernel privilege escalation vulnerability",
|
||||
"metadata": {
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:1001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# RedHat CSAF Connector Fixtures
|
||||
|
||||
This directory contains raw CSAF document fixtures captured from Red Hat's security feed.
|
||||
|
||||
## Fixture Categories
|
||||
|
||||
- `typical-*.json` - Standard CSAF documents with common patterns
|
||||
- `edge-*.json` - Edge cases (multiple products, complex remediations)
|
||||
- `error-*.json` - Malformed or missing required fields
|
||||
|
||||
## Fixture Sources
|
||||
|
||||
Fixtures are captured from Red Hat's official CSAF feed:
|
||||
- https://access.redhat.com/security/data/csaf/v2/advisories/
|
||||
|
||||
## Updating Fixtures
|
||||
|
||||
Run the FixtureUpdater tool to refresh fixtures from live sources:
|
||||
```bash
|
||||
dotnet run --project tools/FixtureUpdater -- --connector RedHat.CSAF
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"document": {
|
||||
"publisher": {
|
||||
"name": "Red Hat Product Security",
|
||||
"category": "vendor",
|
||||
"namespace": "https://www.redhat.com"
|
||||
},
|
||||
"tracking": {
|
||||
"id": "RHSA-2025:2001",
|
||||
"status": "final",
|
||||
"version": "5",
|
||||
"initial_release_date": "2025-09-15T08:00:00Z",
|
||||
"current_release_date": "2025-11-20T14:30:00Z"
|
||||
},
|
||||
"title": "Critical: openssl security update"
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "rhel-8-openssl",
|
||||
"name": "Red Hat Enterprise Linux 8 openssl",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:8::openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@1.1.1k-12.el8_9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"product_id": "rhel-9-openssl",
|
||||
"name": "Red Hat Enterprise Linux 9 openssl",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@3.0.7-25.el9_3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"product_id": "rhel-7-openssl-legacy",
|
||||
"name": "Red Hat Enterprise Linux 7 openssl (legacy)",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:7::openssl"
|
||||
}
|
||||
}
|
||||
],
|
||||
"product_groups": [
|
||||
{
|
||||
"group_id": "affected-openssl-group",
|
||||
"product_ids": ["rhel-8-openssl", "rhel-9-openssl"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-5678",
|
||||
"title": "OpenSSL buffer overflow in X.509 certificate verification",
|
||||
"product_status": {
|
||||
"fixed": ["rhel-8-openssl", "rhel-9-openssl"],
|
||||
"known_not_affected": ["rhel-7-openssl-legacy"]
|
||||
},
|
||||
"flags": [
|
||||
{
|
||||
"label": "vulnerable_code_not_present",
|
||||
"product_ids": ["rhel-7-openssl-legacy"]
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"category": "description",
|
||||
"text": "A buffer overflow vulnerability was found in OpenSSL X.509 certificate verification."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-5679",
|
||||
"title": "OpenSSL timing side-channel in RSA decryption",
|
||||
"product_status": {
|
||||
"known_affected": ["rhel-7-openssl-legacy"],
|
||||
"fixed": ["rhel-8-openssl", "rhel-9-openssl"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"document": {
|
||||
"publisher": {
|
||||
"name": "Red Hat Product Security",
|
||||
"category": "vendor"
|
||||
}
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "rhel-9-test",
|
||||
"name": "Test Product"
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-9999",
|
||||
"product_status": {
|
||||
"fixed": ["rhel-9-test"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"document": {
|
||||
"publisher": {
|
||||
"name": "Red Hat Product Security",
|
||||
"category": "vendor",
|
||||
"namespace": "https://www.redhat.com"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"title": "Important: kernel security update"
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "rhel-9-kernel",
|
||||
"name": "Red Hat Enterprise Linux 9 kernel",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::kernel",
|
||||
"purl": "pkg:rpm/redhat/kernel@5.14.0-427.13.1.el9_4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"branches": [
|
||||
{
|
||||
"name": "Red Hat Enterprise Linux",
|
||||
"category": "product_family",
|
||||
"branches": [
|
||||
{
|
||||
"name": "9",
|
||||
"category": "product_version",
|
||||
"product": {
|
||||
"product_id": "rhel-9-kernel",
|
||||
"name": "Red Hat Enterprise Linux 9 kernel"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-1234",
|
||||
"title": "Kernel privilege escalation vulnerability",
|
||||
"product_status": {
|
||||
"fixed": ["rhel-9-kernel"]
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"category": "description",
|
||||
"text": "A flaw was found in the kernel that allows local privilege escalation."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,58 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RedHatCsafLiveSchemaTests.cs
|
||||
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
|
||||
// Task: CONN-FIX-015
|
||||
// Description: Live schema drift detection tests for RedHat CSAF connector
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Connectors;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live schema drift detection tests for Red Hat CSAF documents.
|
||||
/// These tests verify that live Red Hat security advisories match our fixture schema.
|
||||
///
|
||||
/// IMPORTANT: These tests are opt-in and disabled by default.
|
||||
/// To run: set STELLAOPS_LIVE_TESTS=true
|
||||
/// To auto-update: set STELLAOPS_UPDATE_FIXTURES=true
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Live)]
|
||||
public sealed class RedHatCsafLiveSchemaTests : ConnectorLiveSchemaTestBase
|
||||
{
|
||||
protected override string FixturesDirectory =>
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures");
|
||||
|
||||
protected override string ConnectorName => "RedHat-CSAF";
|
||||
|
||||
protected override IEnumerable<LiveSchemaTestCase> GetTestCases()
|
||||
{
|
||||
// Red Hat CSAF advisories are available at:
|
||||
// https://access.redhat.com/security/data/csaf/v2/advisories/
|
||||
|
||||
yield return new(
|
||||
"typical-rhsa.json",
|
||||
"https://access.redhat.com/security/data/csaf/v2/advisories/2025/rhsa-2025_0001.json",
|
||||
"Typical RHSA advisory with product branches and fixed status");
|
||||
|
||||
yield return new(
|
||||
"edge-multi-product.json",
|
||||
"https://access.redhat.com/security/data/csaf/v2/advisories/2025/rhsa-2025_0002.json",
|
||||
"Edge case: multiple products and CVEs in single advisory");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects schema drift between live Red Hat CSAF API and stored fixtures.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Run with: dotnet test --filter "Category=Live"
|
||||
/// Or: STELLAOPS_LIVE_TESTS=true dotnet test --filter "FullyQualifiedName~RedHatCsafLiveSchemaTests"
|
||||
/// </remarks>
|
||||
[LiveTest]
|
||||
public async Task DetectSchemaDrift()
|
||||
{
|
||||
await RunSchemaDriftTestsAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RedHatCsafNormalizerTests.cs
|
||||
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
|
||||
// Task: CONN-FIX-010
|
||||
// Description: Fixture-based parser/normalizer tests for RedHat CSAF connector
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Formats.CSAF;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fixture-based normalizer tests for RedHat CSAF documents.
|
||||
/// Implements Model C1 (Connector/External) test requirements:
|
||||
/// - raw upstream payload fixture → normalized internal model snapshot
|
||||
/// - deterministic parsing (same input → same output)
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Snapshot)]
|
||||
public sealed class RedHatCsafNormalizerTests
|
||||
{
|
||||
private readonly CsafNormalizer _normalizer;
|
||||
private readonly VexProvider _provider;
|
||||
private readonly string _fixturesDir;
|
||||
private readonly string _expectedDir;
|
||||
|
||||
public RedHatCsafNormalizerTests()
|
||||
{
|
||||
_normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
|
||||
_provider = new VexProvider("redhat-csaf", "Red Hat CSAF", VexProviderKind.Vendor);
|
||||
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
|
||||
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("typical-rhsa.json", "typical-rhsa.canonical.json")]
|
||||
[InlineData("edge-multi-product.json", "edge-multi-product.canonical.json")]
|
||||
public async Task Normalize_Fixture_ProducesExpectedClaims(string fixtureFile, string expectedFile)
|
||||
{
|
||||
// Arrange
|
||||
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
|
||||
var rawDocument = CreateRawDocument(rawJson);
|
||||
|
||||
// Act
|
||||
var batch = await _normalizer.NormalizeAsync(rawDocument, _provider, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
batch.Claims.Should().NotBeEmpty();
|
||||
|
||||
var expectedJson = await File.ReadAllTextAsync(Path.Combine(_expectedDir, expectedFile));
|
||||
var expected = JsonSerializer.Deserialize<ExpectedClaimBatch>(expectedJson, JsonOptions);
|
||||
|
||||
batch.Claims.Length.Should().Be(expected!.Claims.Count);
|
||||
|
||||
for (int i = 0; i < batch.Claims.Length; i++)
|
||||
{
|
||||
var actual = batch.Claims[i];
|
||||
var expectedClaim = expected.Claims[i];
|
||||
|
||||
actual.VulnerabilityId.Should().Be(expectedClaim.VulnerabilityId);
|
||||
actual.Product.Key.Should().Be(expectedClaim.Product.Key);
|
||||
actual.Status.Should().Be(Enum.Parse<VexClaimStatus>(expectedClaim.Status, ignoreCase: true));
|
||||
|
||||
if (expectedClaim.Justification is not null)
|
||||
{
|
||||
actual.Justification.Should().Be(Enum.Parse<VexJustification>(expectedClaim.Justification, ignoreCase: true));
|
||||
}
|
||||
else
|
||||
{
|
||||
actual.Justification.Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("error-missing-tracking.json", "error-missing-tracking.error.json")]
|
||||
public async Task Normalize_ErrorFixture_ProducesExpectedOutput(string fixtureFile, string expectedFile)
|
||||
{
|
||||
// Arrange
|
||||
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
|
||||
var rawDocument = CreateRawDocument(rawJson);
|
||||
|
||||
// Act
|
||||
var batch = await _normalizer.NormalizeAsync(rawDocument, _provider, CancellationToken.None);
|
||||
|
||||
// Assert - error fixtures may still produce claims but with limited metadata
|
||||
var expectedJson = await File.ReadAllTextAsync(Path.Combine(_expectedDir, expectedFile));
|
||||
var expected = JsonSerializer.Deserialize<ExpectedClaimBatch>(expectedJson, JsonOptions);
|
||||
|
||||
batch.Claims.Length.Should().Be(expected!.Claims.Count);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("typical-rhsa.json")]
|
||||
[InlineData("edge-multi-product.json")]
|
||||
[InlineData("error-missing-tracking.json")]
|
||||
public async Task Normalize_SameInput_ProducesDeterministicOutput(string fixtureFile)
|
||||
{
|
||||
// Arrange
|
||||
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
|
||||
|
||||
// Act - parse multiple times
|
||||
var results = new List<string>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var rawDocument = CreateRawDocument(rawJson);
|
||||
var batch = await _normalizer.NormalizeAsync(rawDocument, _provider, CancellationToken.None);
|
||||
var serialized = SerializeClaims(batch.Claims);
|
||||
results.Add(serialized);
|
||||
}
|
||||
|
||||
// Assert - all results should be identical
|
||||
results.Distinct().Should().HaveCount(1,
|
||||
$"parsing '{fixtureFile}' multiple times should produce identical output");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanHandle_CsafDocument_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var document = new VexRawDocument(
|
||||
"redhat-csaf",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.com/csaf.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:test",
|
||||
ReadOnlyMemory<byte>.Empty,
|
||||
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
// Act
|
||||
var canHandle = _normalizer.CanHandle(document);
|
||||
|
||||
// Assert
|
||||
canHandle.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanHandle_NonCsafDocument_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var document = new VexRawDocument(
|
||||
"some-provider",
|
||||
VexDocumentFormat.OpenVex,
|
||||
new Uri("https://example.com/openvex.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:test",
|
||||
ReadOnlyMemory<byte>.Empty,
|
||||
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
// Act
|
||||
var canHandle = _normalizer.CanHandle(document);
|
||||
|
||||
// Assert
|
||||
canHandle.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static VexRawDocument CreateRawDocument(string json)
|
||||
{
|
||||
var content = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
return new VexRawDocument(
|
||||
"redhat-csaf",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://access.redhat.com/security/data/csaf/v2/advisories/test.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:test-" + Guid.NewGuid().ToString("N")[..8],
|
||||
content,
|
||||
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
private static string SerializeClaims(IReadOnlyList<VexClaim> claims)
|
||||
{
|
||||
var simplified = claims.Select(c => new
|
||||
{
|
||||
c.VulnerabilityId,
|
||||
ProductKey = c.Product.Key,
|
||||
Status = c.Status.ToString(),
|
||||
Justification = c.Justification?.ToString()
|
||||
});
|
||||
return JsonSerializer.Serialize(simplified, JsonOptions);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private sealed record ExpectedClaimBatch(List<ExpectedClaim> Claims, Dictionary<string, string>? Diagnostics);
|
||||
|
||||
private sealed record ExpectedClaim(
|
||||
string VulnerabilityId,
|
||||
ExpectedProduct Product,
|
||||
string Status,
|
||||
string? Justification,
|
||||
string? Detail,
|
||||
Dictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record ExpectedProduct(
|
||||
string Key,
|
||||
string? Name,
|
||||
string? Purl,
|
||||
string? Cpe);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.RedHat.CSAF/StellaOps.Excititor.Connectors.RedHat.CSAF.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Expected\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor RedHat CSAF Connector Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0307-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0307-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0307-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
Reference in New Issue
Block a user