consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -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.

View File

@@ -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>());
}
}

View File

@@ -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.

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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
```

View File

@@ -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"]
}
}
]
}

View File

@@ -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"]
}
}
]
}

View File

@@ -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."
}
]
}
]
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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). |