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,23 @@
# Excititor S3 Artifact Store Tests Agent Charter
## Mission
Validate the Excititor S3 artifact store client behavior with deterministic unit tests.
## Responsibilities
- Cover S3 client success/failure paths with stable fixtures.
- Keep test execution offline-friendly and deterministic.
## Required Reading
- docs/modules/excititor/architecture.md
- docs/modules/platform/architecture-overview.md
## Definition of Done
- Tests cover basic CRUD and error paths for the S3 client.
- 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,42 @@
using Amazon.S3;
using Amazon.S3.Model;
using Moq;
using StellaOps.Excititor.ArtifactStores.S3;
using StellaOps.Excititor.Export;
using StellaOps.TestKit;
namespace StellaOps.Excititor.ArtifactStores.S3.Tests;
public sealed class S3ArtifactClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ObjectExistsAsync_ReturnsTrue_WhenMetadataSucceeds()
{
var mock = new Mock<IAmazonS3>();
mock.Setup(x => x.GetObjectMetadataAsync("bucket", "key", default)).ReturnsAsync(new GetObjectMetadataResponse
{
HttpStatusCode = System.Net.HttpStatusCode.OK,
});
var client = new S3ArtifactClient(mock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger<S3ArtifactClient>.Instance);
var exists = await client.ObjectExistsAsync("bucket", "key", default);
Assert.True(exists);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PutObjectAsync_MapsMetadata()
{
var mock = new Mock<IAmazonS3>();
mock.Setup(x => x.PutObjectAsync(It.IsAny<PutObjectRequest>(), default))
.ReturnsAsync(new PutObjectResponse());
var client = new S3ArtifactClient(mock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger<S3ArtifactClient>.Instance);
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
await client.PutObjectAsync("bucket", "key", stream, new Dictionary<string, string> { ["a"] = "b" }, default);
mock.Verify(x => x.PutObjectAsync(It.Is<PutObjectRequest>(r => r.Metadata["a"] == "b"), default), Times.Once);
}
}

View File

@@ -0,0 +1,17 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.ArtifactStores.S3/StellaOps.Excititor.ArtifactStores.S3.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
# Excititor S3 Artifact Store 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-0294-M | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
| AUDIT-0294-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
| AUDIT-0294-A | DONE | Waived (test project; revalidated 2026-01-07). |

View File

@@ -0,0 +1,23 @@
# Excititor Attestation Tests Agent Charter
## Mission
Validate Excititor attestation signing and verification with deterministic tests.
## Responsibilities
- Cover DSSE envelope creation and verification logic.
- Exercise transparency log and signature verification paths.
## Required Reading
- docs/modules/excititor/architecture.md
- docs/modules/platform/architecture-overview.md
## Definition of Done
- Tests cover core success/failure paths for attestation components.
- 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,29 @@
<?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>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Compile Remove="..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
<Compile Remove="..\..\StellaOps.Concelier.Tests.Shared\ConcelierFixtureCollection.cs" />
<Compile Remove="..\..\StellaOps.Concelier.Tests.Shared\**\*" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
<!-- Explicitly exclude xunit v2 to avoid conflicts -->
<PackageReference Remove="xunit" />
<PackageReference Remove="xunit.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
# Excititor Attestation 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-0296-M | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
| AUDIT-0296-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
| AUDIT-0296-A | DONE | Waived (test project; revalidated 2026-01-07). |

View File

@@ -0,0 +1,94 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core.Dsse;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
using StellaOps.TestKit;
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexAttestationClientTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignAsync_ReturnsEnvelopeDigestAndDiagnostics()
{
var signer = new FakeSigner();
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
var options = Options.Create(new VexAttestationClientOptions());
var verifier = new FakeVerifier();
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier);
var request = new VexAttestationRequest(
ExportId: "exports/456",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "deadbeef"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: ImmutableArray.Create("vendor"),
Metadata: ImmutableDictionary<string, string>.Empty);
var response = await client.SignAsync(request, CancellationToken.None);
Assert.NotNull(response.Attestation);
Assert.NotNull(response.Attestation.EnvelopeDigest);
Assert.True(response.Diagnostics.ContainsKey("envelope"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignAsync_SubmitsToTransparencyLog_WhenConfigured()
{
var signer = new FakeSigner();
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
var options = Options.Create(new VexAttestationClientOptions());
var transparency = new FakeTransparencyLogClient();
var verifier = new FakeVerifier();
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparencyLogClient: transparency);
var request = new VexAttestationRequest(
ExportId: "exports/789",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "deadbeef"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: ImmutableArray.Create("vendor"),
Metadata: ImmutableDictionary<string, string>.Empty);
var response = await client.SignAsync(request, CancellationToken.None);
Assert.NotNull(response.Attestation.Rekor);
Assert.True(response.Diagnostics.ContainsKey("rekorLocation"));
Assert.True(transparency.SubmitCalled);
}
private sealed class FakeSigner : IVexSigner
{
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
}
private sealed class FakeTransparencyLogClient : ITransparencyLogClient
{
public bool SubmitCalled { get; private set; }
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
{
SubmitCalled = true;
return ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "23", null));
}
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> ValueTask.FromResult(true);
}
private sealed class FakeVerifier : IVexAttestationVerifier
{
public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexAttestationVerification(true, VexAttestationDiagnostics.Empty));
}
}

View File

@@ -0,0 +1,344 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core.Dsse;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
using ICryptoProvider = StellaOps.Cryptography.ICryptoProvider;
using StellaOps.TestKit;
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexAttestationVerifierTests : IDisposable
{
private readonly VexAttestationMetrics _metrics = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics.Result);
Assert.Null(verification.Diagnostics.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var tamperedMetadata = new VexAttestationMetadata(
metadata.PredicateType,
metadata.Rekor,
"sha256:deadbeef",
metadata.SignedAt);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, tamperedMetadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("invalid", verification.Diagnostics.Result);
Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]);
Assert.Equal("envelope_digest_mismatch", verification.Diagnostics.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_AllowsOfflineTransparency_WhenConfigured()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
var transparency = new ThrowingTransparencyLogClient();
var verifier = CreateVerifier(options =>
{
options.AllowOfflineTransparency = true;
options.RequireTransparencyLog = true;
}, transparency);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("offline", verification.Diagnostics.RekorState);
Assert.Equal("degraded", verification.Diagnostics.Result);
Assert.Null(verification.Diagnostics.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyRequiredAndMissing()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
});
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("missing", verification.Diagnostics.RekorState);
Assert.Equal("invalid", verification.Diagnostics.Result);
Assert.Equal("missing", verification.Diagnostics.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyUnavailableAndOfflineDisallowed()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
var transparency = new ThrowingTransparencyLogClient();
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
}, transparency);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("unreachable", verification.Diagnostics.RekorState);
Assert.Equal("invalid", verification.Diagnostics.Result);
Assert.Equal("unreachable", verification.Diagnostics.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_HandlesDuplicateSourceProviders()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(
includeRekor: false,
sourceProviders: ImmutableArray.Create("provider-a", "provider-a"));
var normalizedRequest = request with { SourceProviders = ImmutableArray.Create("provider-a") };
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(normalizedRequest, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics.Result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenTrustedSignerConfigured()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var registry = new StubCryptoProviderRegistry(success: true);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = false;
options.RequireSignatureVerification = true;
options.TrustedSigners = ImmutableDictionary<string, VexAttestationVerificationOptions.TrustedSignerOptions>.Empty.Add(
"key",
new VexAttestationVerificationOptions.TrustedSignerOptions
{
Algorithm = StubCryptoProviderRegistry.Algorithm,
KeyReference = StubCryptoProviderRegistry.KeyReference
});
}, transparency: null, registry: registry);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("verified", verification.Diagnostics["signature.state"]);
Assert.Equal("valid", verification.Diagnostics.Result);
Assert.Null(verification.Diagnostics.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenSignatureFailsAndRequired()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var registry = new StubCryptoProviderRegistry(success: false);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = false;
options.RequireSignatureVerification = true;
options.TrustedSigners = ImmutableDictionary<string, VexAttestationVerificationOptions.TrustedSignerOptions>.Empty.Add(
"key",
new VexAttestationVerificationOptions.TrustedSignerOptions
{
Algorithm = StubCryptoProviderRegistry.Algorithm,
KeyReference = StubCryptoProviderRegistry.KeyReference
});
}, transparency: null, registry: registry);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("error", verification.Diagnostics["signature.state"]);
Assert.Equal("verification_failed", verification.Diagnostics["signature.reason"]);
Assert.Equal("verification_failed", verification.Diagnostics.FailureReason);
Assert.Equal("invalid", verification.Diagnostics.Result);
}
private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(
bool includeRekor = false,
ImmutableArray<string>? sourceProviders = null)
{
var signer = new FakeSigner();
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
var options = Options.Create(new VexAttestationClientOptions());
var transparency = includeRekor ? new FakeTransparencyLogClient() : null;
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, timeProvider: null, transparencyLogClient: transparency);
var providers = sourceProviders ?? ImmutableArray.Create("provider-a");
var request = new VexAttestationRequest(
ExportId: "exports/unit-test",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "cafebabe"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: providers,
Metadata: ImmutableDictionary<string, string>.Empty);
var response = await client.SignAsync(request, CancellationToken.None);
var envelope = response.Diagnostics["envelope"];
return (request, response.Attestation, envelope);
}
private VexAttestationVerifier CreateVerifier(
Action<VexAttestationVerificationOptions>? configureOptions = null,
ITransparencyLogClient? transparency = null,
ICryptoProviderRegistry? registry = null)
{
var options = new VexAttestationVerificationOptions();
configureOptions?.Invoke(options);
return new VexAttestationVerifier(
NullLogger<VexAttestationVerifier>.Instance,
transparency,
Options.Create(options),
_metrics,
registry);
}
public void Dispose()
{
_metrics.Dispose();
}
private sealed class FakeSigner : IVexSigner
{
internal static readonly string SignatureBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("signature"));
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload(SignatureBase64, "key"));
}
private sealed class FakeTransparencyLogClient : ITransparencyLogClient
{
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
=> ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "42", null));
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> ValueTask.FromResult(true);
}
private sealed class ThrowingTransparencyLogClient : ITransparencyLogClient
{
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> throw new HttpRequestException("rekor unavailable");
}
private sealed class StubCryptoProviderRegistry : ICryptoProviderRegistry
{
public const string Algorithm = "ed25519";
public const string KeyReference = "stub-key";
private readonly StubCryptoSigner _signer;
private readonly IReadOnlyCollection<ICryptoProvider> _providers = Array.Empty<ICryptoProvider>();
public StubCryptoProviderRegistry(bool success)
{
_signer = new StubCryptoSigner(KeyReference, Algorithm, success);
}
public IReadOnlyCollection<ICryptoProvider> Providers => _providers;
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
{
provider = null!;
return false;
}
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
=> throw new NotSupportedException();
public CryptoSignerResolution ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null)
{
if (!string.Equals(keyReference.KeyId, KeyReference, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Unknown key '{keyReference.KeyId}'.");
}
return new CryptoSignerResolution(_signer, "stub");
}
public CryptoHasherResolution ResolveHasher(string algorithmId, string? preferredProvider = null)
=> throw new NotSupportedException("Hasher not needed for these tests.");
}
private sealed class StubCryptoSigner : ICryptoSigner
{
private readonly bool _success;
private readonly byte[] _expectedSignature;
public StubCryptoSigner(string keyId, string algorithmId, bool success)
{
KeyId = keyId;
AlgorithmId = algorithmId;
_success = success;
_expectedSignature = Convert.FromBase64String(FakeSigner.SignatureBase64);
}
public string KeyId { get; }
public string AlgorithmId { get; }
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_success && signature.Span.SequenceEqual(_expectedSignature.AsSpan()));
public JsonWebKey ExportPublicJsonWebKey()
=> new JsonWebKey();
}
}

View File

@@ -0,0 +1,54 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Models;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core;
using StellaOps.TestKit;
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexDsseBuilderTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateEnvelopeAsync_ProducesDeterministicPayload()
{
var signer = new FakeSigner("signature-value", "key-1");
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
var request = new VexAttestationRequest(
ExportId: "exports/123",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "deadbeef"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: ImmutableArray.Create("vendor"),
Metadata: ImmutableDictionary<string, string>.Empty);
var envelope = await builder.CreateEnvelopeAsync(request, request.Metadata, CancellationToken.None);
Assert.Equal("application/vnd.in-toto+json", envelope.PayloadType);
Assert.Single(envelope.Signatures);
Assert.Equal("signature-value", envelope.Signatures[0].Signature);
Assert.Equal("key-1", envelope.Signatures[0].KeyId);
var digest = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
Assert.StartsWith("sha256:", digest);
}
private sealed class FakeSigner : IVexSigner
{
private readonly string _signature;
private readonly string _keyId;
public FakeSigner(string signature, string keyId)
{
_signature = signature;
_keyId = keyId;
}
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload(_signature, _keyId));
}
}

View File

@@ -0,0 +1,241 @@
// -----------------------------------------------------------------------------
// VexRekorAttestationFlowTests.cs
// Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage
// Task: VRL-010 - Integration tests for VEX-Rekor attestation flow
// Description: End-to-end tests for VEX observation attestation and verification
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Excititor.Attestation.Tests;
[Trait("Category", TestCategories.Integration)]
public sealed class VexRekorAttestationFlowTests
{
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider;
private readonly Mock<IVexObservationAttestationService> _mockAttestationService;
public VexRekorAttestationFlowTests()
{
_timeProvider = new FakeTimeProvider(FixedTimestamp);
_mockAttestationService = new Mock<IVexObservationAttestationService>();
}
[Fact]
public async Task AttestAndLinkAsync_WhenSuccessful_ReturnsSuccessResult()
{
// Arrange
var observation = CreateTestObservation("obs-001");
var expectedLinkage = new RekorLinkage
{
Uuid = "test-uuid-12345678",
LogIndex = 12345,
IntegratedTime = FixedTimestamp,
LogUrl = "https://rekor.sigstore.dev"
};
var expectedResult = VexObservationAttestationResult.Succeeded(
"obs-001",
expectedLinkage,
TimeSpan.FromMilliseconds(100));
_mockAttestationService
.Setup(s => s.AttestAndLinkAsync(
It.IsAny<VexObservation>(),
It.IsAny<VexAttestationOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await _mockAttestationService.Object.AttestAndLinkAsync(
observation,
new VexAttestationOptions { SubmitToRekor = true },
CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.RekorLinkage.Should().NotBeNull();
result.RekorLinkage!.Uuid.Should().Be("test-uuid-12345678");
result.RekorLinkage.LogIndex.Should().Be(12345);
}
[Fact]
public async Task AttestAndLinkAsync_WhenObservationNotFound_ReturnsFailure()
{
// Arrange
var observation = CreateTestObservation("nonexistent");
var expectedResult = VexObservationAttestationResult.Failed(
"nonexistent",
"Observation not found",
VexAttestationErrorCode.ObservationNotFound);
_mockAttestationService
.Setup(s => s.AttestAndLinkAsync(
It.IsAny<VexObservation>(),
It.IsAny<VexAttestationOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await _mockAttestationService.Object.AttestAndLinkAsync(
observation,
new VexAttestationOptions { SubmitToRekor = true },
CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ErrorCode.Should().Be(VexAttestationErrorCode.ObservationNotFound);
}
[Fact]
public async Task VerifyLinkageAsync_WhenValid_ReturnsSuccess()
{
// Arrange
var expectedLinkage = new RekorLinkage
{
Uuid = "valid-uuid-12345678",
LogIndex = 12345,
IntegratedTime = FixedTimestamp.AddMinutes(-5),
LogUrl = "https://rekor.sigstore.dev"
};
var expectedResult = new RekorLinkageVerificationResult
{
IsValid = true,
Status = RekorLinkageVerificationStatus.Valid,
Linkage = expectedLinkage,
Message = null
};
_mockAttestationService
.Setup(s => s.VerifyLinkageAsync(
It.Is<string>(id => id == "obs-003"),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResult);
// Act
var result = await _mockAttestationService.Object.VerifyLinkageAsync("obs-003", CancellationToken.None);
// Assert
result.IsValid.Should().BeTrue();
result.Linkage.Should().NotBeNull();
result.Linkage!.Uuid.Should().Be("valid-uuid-12345678");
}
[Fact]
public async Task VerifyLinkageAsync_WhenNoLinkage_ReturnsNotLinked()
{
// Arrange
_mockAttestationService
.Setup(s => s.VerifyLinkageAsync(
It.Is<string>(id => id == "obs-004"),
It.IsAny<CancellationToken>()))
.ReturnsAsync(RekorLinkageVerificationResult.NoLinkage);
// Act
var result = await _mockAttestationService.Object.VerifyLinkageAsync("obs-004", CancellationToken.None);
// Assert
result.IsValid.Should().BeFalse();
result.Status.Should().Be(RekorLinkageVerificationStatus.NoLinkage);
}
[Fact]
public async Task AttestBatchAsync_MultipleObservations_AttestsAll()
{
// Arrange
var ids = new List<string> { "batch-obs-001", "batch-obs-002", "batch-obs-003" };
var results = ids.Select(id => VexObservationAttestationResult.Succeeded(
id,
new RekorLinkage
{
Uuid = $"uuid-{id}",
LogIndex = 10000 + ids.IndexOf(id),
IntegratedTime = FixedTimestamp
})).ToList();
_mockAttestationService
.Setup(s => s.AttestBatchAsync(
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<VexAttestationOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(results);
// Act
var batchResults = await _mockAttestationService.Object.AttestBatchAsync(
ids,
new VexAttestationOptions { SubmitToRekor = true },
CancellationToken.None);
// Assert
batchResults.Should().HaveCount(3);
batchResults.All(r => r.Success).Should().BeTrue();
}
[Fact]
public async Task GetPendingAttestationsAsync_ReturnsUnlinkedObservationIds()
{
// Arrange
var pendingIds = new List<string> { "unlinked-001", "unlinked-002" };
_mockAttestationService
.Setup(s => s.GetPendingAttestationsAsync(
It.IsAny<int>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(pendingIds);
// Act
var pending = await _mockAttestationService.Object.GetPendingAttestationsAsync(10, CancellationToken.None);
// Assert
pending.Should().HaveCount(2);
pending.Should().Contain("unlinked-001");
pending.Should().Contain("unlinked-002");
}
// Helper methods
private VexObservation CreateTestObservation(string id)
{
return new VexObservation(
observationId: id,
tenant: "default",
providerId: "test-provider",
streamId: "test-stream",
upstream: new VexObservationUpstream(
upstreamId: "upstream-1",
documentVersion: "1.0",
fetchedAt: FixedTimestamp.AddDays(-1),
receivedAt: FixedTimestamp,
contentHash: "sha256:abc123",
signature: new VexObservationSignature(false, null, null, null)),
statements: ImmutableArray.Create(
new VexObservationStatement(
vulnerabilityId: "CVE-2026-0001",
productKey: "pkg:example/test@1.0",
status: VexClaimStatus.NotAffected,
lastObserved: FixedTimestamp.AddDays(-1))),
content: new VexObservationContent(
format: "csaf",
specVersion: "2.0",
raw: JsonNode.Parse("""{"test": "content"}""")!),
linkset: new VexObservationLinkset(
aliases: null,
purls: null,
cpes: null,
references: null),
createdAt: FixedTimestamp);
}
}

View File

@@ -0,0 +1,23 @@
# Excititor Cisco CSAF Connector Tests Agent Charter
## Mission
Validate Cisco CSAF connector and normalizer behavior with deterministic tests.
## Responsibilities
- Cover connector fetch, metadata discovery, and state handling.
- Cover CSAF normalizer fixtures and deterministic output.
## Required Reading
- docs/modules/excititor/architecture.md
- docs/modules/platform/architecture-overview.md
## Definition of Done
- Tests cover success and failure cases for connector and metadata loader.
- 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,149 @@
// -----------------------------------------------------------------------------
// CiscoCsafNormalizerTests.cs
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
// Task: CONN-FIX-010
// Description: Fixture-based parser/normalizer tests for Cisco CSAF connector
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests;
/// <summary>
/// Fixture-based normalizer tests for Cisco CSAF documents.
/// Implements Model C1 (Connector/External) test requirements.
/// </summary>
[Trait("Category", TestCategories.Unit)]
[Trait("Category", TestCategories.Snapshot)]
public sealed class CiscoCsafNormalizerTests
{
private readonly CsafNormalizer _normalizer;
private readonly VexProvider _provider;
private readonly string _fixturesDir;
private readonly string _expectedDir;
public CiscoCsafNormalizerTests()
{
_normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
_provider = new VexProvider("cisco-csaf", "Cisco PSIRT", VexProviderKind.Vendor);
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-cisco-sa.json", "typical-cisco-sa.canonical.json")]
[InlineData("edge-multi-product-status.json", "edge-multi-product-status.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));
}
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("error-malformed-dates.json", "error-malformed-dates.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
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-cisco-sa.json")]
[InlineData("edge-multi-product-status.json")]
public async Task Normalize_SameInput_ProducesDeterministicOutput(string fixtureFile)
{
// Arrange
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
// Act
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
results.Distinct().Should().HaveCount(1);
}
private static VexRawDocument CreateRawDocument(string json)
{
var content = System.Text.Encoding.UTF8.GetBytes(json);
return new VexRawDocument(
ProviderId: "cisco-csaf",
Format: VexDocumentFormat.Csaf,
SourceUri: new Uri("https://sec.cloudapps.cisco.com/security/center/test.json"),
RetrievedAt: DateTimeOffset.UtcNow,
Digest: "sha256:test-" + Guid.NewGuid().ToString("N")[..8],
Content: content,
Metadata: 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,354 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Cisco.CSAF;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using System.Collections.Immutable;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using System.Threading;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.Connectors;
public sealed class CiscoCsafConnectorTests
{
[Fact]
public async Task FetchAsync_NewAdvisory_StoresDocumentAndUpdatesState()
{
var responses = new Dictionary<Uri, Queue<HttpResponseMessage>>
{
[new Uri("https://api.cisco.test/.well-known/csaf/provider-metadata.json")] = QueueResponses("""
{
"metadata": {
"publisher": {
"name": "Cisco",
"category": "vendor",
"contact_details": { "id": "excititor:cisco" }
}
},
"distributions": {
"directories": [ "https://api.cisco.test/csaf/" ]
}
}
"""),
[new Uri("https://api.cisco.test/csaf/index.json")] = QueueResponses("""
{
"advisories": [
{
"id": "cisco-sa-2025",
"url": "https://api.cisco.test/csaf/cisco-sa-2025.json",
"published": "2025-10-01T00:00:00Z",
"lastModified": "2025-10-02T00:00:00Z",
"sha256": "cafebabe"
}
]
}
"""),
[new Uri("https://api.cisco.test/csaf/cisco-sa-2025.json")] = QueueResponses("{ \"document\": \"payload\" }")
};
var handler = new RoutingHttpMessageHandler(responses);
var httpClient = new HttpClient(handler);
var factory = new SingleHttpClientFactory(httpClient);
var metadataLoader = new CiscoProviderMetadataLoader(
factory,
new MemoryCache(new MemoryCacheOptions()),
Options.Create(new CiscoConnectorOptions
{
MetadataUri = "https://api.cisco.test/.well-known/csaf/provider-metadata.json",
PersistOfflineSnapshot = false,
}),
NullLogger<CiscoProviderMetadataLoader>.Instance,
new MockFileSystem());
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new CiscoCsafConnector(
metadataLoader,
factory,
stateRepository,
new[] { new CiscoConnectorOptionsValidator() },
NullLogger<CiscoCsafConnector>.Instance,
TimeProvider.System);
var settings = new VexConnectorSettings(ImmutableDictionary<string, string>.Empty);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider(), ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().HaveCount(1);
// second run should not refetch documents
sink.Documents.Clear();
documents.Clear();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
}
[Fact]
public async Task FetchAsync_EmitsTrustMetadataAndUpsertsProvider()
{
var metadataResponse = """
{
"metadata": {
"publisher": {
"name": "Cisco",
"category": "vendor",
"contact_details": { "id": "excititor:cisco" }
}
},
"trust": {
"weight": 0.75,
"cosign": {
"issuer": "https://issuer.example.com",
"identity_pattern": "https://sig.example.com/*"
},
"pgp_fingerprints": [
"0123456789ABCDEF",
"FEDCBA9876543210"
]
},
"distributions": {
"directories": [ "https://api.cisco.test/csaf/" ]
}
}
""";
var responses = new Dictionary<Uri, Queue<HttpResponseMessage>>
{
[new Uri("https://api.cisco.test/.well-known/csaf/provider-metadata.json")] = QueueResponses(metadataResponse),
[new Uri("https://api.cisco.test/csaf/index.json")] = QueueResponses("""
{
"advisories": [
{
"id": "cisco-sa-2025",
"url": "https://api.cisco.test/csaf/cisco-sa-2025.json",
"published": "2025-10-01T00:00:00Z",
"lastModified": "2025-10-02T00:00:00Z",
"sha256": "cafebabe"
}
]
}
"""),
[new Uri("https://api.cisco.test/csaf/cisco-sa-2025.json")] = QueueResponses("{ \"document\": \"payload\" }")
};
var handler = new RoutingHttpMessageHandler(responses);
var httpClient = new HttpClient(handler);
var factory = new SingleHttpClientFactory(httpClient);
var connectorOptions = new CiscoConnectorOptions
{
MetadataUri = "https://api.cisco.test/.well-known/csaf/provider-metadata.json",
PersistOfflineSnapshot = false,
};
var metadataLoader = new CiscoProviderMetadataLoader(
factory,
new MemoryCache(new MemoryCacheOptions()),
Options.Create(connectorOptions),
NullLogger<CiscoProviderMetadataLoader>.Instance,
new MockFileSystem());
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new CiscoCsafConnector(
metadataLoader,
factory,
stateRepository,
new[] { new CiscoConnectorOptionsValidator() },
NullLogger<CiscoCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var providerStore = new StubProviderStore();
var services = new ServiceCollection()
.AddSingleton<IVexProviderStore>(providerStore)
.BuildServiceProvider();
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);
var metadata = documents[0].Metadata;
metadata.Should().Contain("vex.provenance.provider", "excititor:cisco");
metadata.Should().Contain("vex.provenance.providerName", "Cisco");
metadata.Should().Contain("vex.provenance.trust.weight", "0.75");
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
metadata.Should().Contain("vex.provenance.cosign.identityPattern", "https://sig.example.com/*");
metadata.Should().Contain("vex.provenance.pgp.fingerprints", "0123456789ABCDEF,FEDCBA9876543210");
providerStore.SavedProviders.Should().HaveCount(1);
var savedProvider = providerStore.SavedProviders[0];
savedProvider.Id.Should().Be("excititor:cisco");
savedProvider.Trust.Weight.Should().Be(0.75);
savedProvider.Trust.Cosign.Should().NotBeNull();
savedProvider.Trust.Cosign!.Issuer.Should().Be("https://issuer.example.com");
savedProvider.Trust.Cosign.IdentityPattern.Should().Be("https://sig.example.com/*");
savedProvider.Trust.PgpFingerprints.Should().Contain(new[] { "0123456789ABCDEF", "FEDCBA9876543210" });
}
private static Queue<HttpResponseMessage> QueueResponses(string payload)
=> new(new[]
{
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
}
});
private sealed class RoutingHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, Queue<HttpResponseMessage>> _responses;
public RoutingHttpMessageHandler(Dictionary<Uri, Queue<HttpResponseMessage>> responses)
{
_responses = responses;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var queue) && queue.Count > 0)
{
var response = queue.Peek();
return Task.FromResult(response.Clone());
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}"),
});
}
}
private sealed class SingleHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? CurrentState { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
CurrentState = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
{
var list = CurrentState is null
? Array.Empty<VexConnectorState>()
: new[] { CurrentState };
return ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(list);
}
}
private sealed class StubProviderStore : IVexProviderStore
{
public List<VexProvider> SavedProviders { get; } = new();
public ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexProvider?>(null);
public ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyCollection<VexProvider>>(Array.Empty<VexProvider>());
public ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken)
{
SavedProviders.Add(provider);
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}
internal static class HttpResponseMessageExtensions
{
public static HttpResponseMessage Clone(this HttpResponseMessage response)
{
var clone = new HttpResponseMessage(response.StatusCode);
foreach (var header in response.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (response.Content is not null)
{
var payload = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
clone.Content = new StringContent(payload, Encoding.UTF8, response.Content.Headers.ContentType?.MediaType);
}
return clone;
}
}

View File

@@ -0,0 +1,12 @@
# Cisco 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,167 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-20100",
"product": {
"key": "asa-9.16",
"name": "Cisco ASA Software 9.16",
"purl": null,
"cpe": "cpe:/a:cisco:adaptive_security_appliance_software:9.16"
},
"status": "affected",
"justification": null,
"detail": "Cisco ASA and FTD Software WebVPN XSS Vulnerability",
"metadata": {
"csaf.product_status.raw": "known_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-20100",
"product": {
"key": "asa-9.18",
"name": "Cisco ASA Software 9.18",
"purl": null,
"cpe": "cpe:/a:cisco:adaptive_security_appliance_software:9.18"
},
"status": "fixed",
"justification": null,
"detail": "Cisco ASA and FTD Software WebVPN XSS Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-20100",
"product": {
"key": "ftd-7.2",
"name": "Cisco Firepower Threat Defense 7.2",
"purl": null,
"cpe": "cpe:/a:cisco:firepower_threat_defense:7.2"
},
"status": "affected",
"justification": null,
"detail": "Cisco ASA and FTD Software WebVPN XSS Vulnerability",
"metadata": {
"csaf.product_status.raw": "known_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-20100",
"product": {
"key": "ftd-7.4",
"name": "Cisco Firepower Threat Defense 7.4",
"purl": null,
"cpe": "cpe:/a:cisco:firepower_threat_defense:7.4"
},
"status": "fixed",
"justification": null,
"detail": "Cisco ASA and FTD Software WebVPN XSS Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-20101",
"product": {
"key": "asa-9.16",
"name": "Cisco ASA Software 9.16",
"purl": null,
"cpe": "cpe:/a:cisco:adaptive_security_appliance_software:9.16"
},
"status": "fixed",
"justification": null,
"detail": "Cisco ASA Software WebVPN CSRF Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-20101",
"product": {
"key": "asa-9.18",
"name": "Cisco ASA Software 9.18",
"purl": null,
"cpe": "cpe:/a:cisco:adaptive_security_appliance_software:9.18"
},
"status": "fixed",
"justification": null,
"detail": "Cisco ASA Software WebVPN CSRF Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-20101",
"product": {
"key": "ftd-7.2",
"name": "Cisco Firepower Threat Defense 7.2",
"purl": null,
"cpe": "cpe:/a:cisco:firepower_threat_defense:7.2"
},
"status": "NotAffected",
"justification": "component_not_present",
"detail": "Cisco ASA Software WebVPN CSRF Vulnerability",
"metadata": {
"csaf.justification.label": "component_not_present",
"csaf.product_status.raw": "known_not_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-20101",
"product": {
"key": "ftd-7.4",
"name": "Cisco Firepower Threat Defense 7.4",
"purl": null,
"cpe": "cpe:/a:cisco:firepower_threat_defense:7.4"
},
"status": "NotAffected",
"justification": "component_not_present",
"detail": "Cisco ASA Software WebVPN CSRF Vulnerability",
"metadata": {
"csaf.justification.label": "component_not_present",
"csaf.product_status.raw": "known_not_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,28 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-99999",
"product": {
"key": "test-product",
"name": "Test Product",
"purl": null,
"cpe": null
},
"status": "UnderInvestigation",
"justification": null,
"detail": null,
"metadata": {
"csaf.product_status.raw": "under_investigation",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-test-invalid",
"csaf.tracking.status": "interim",
"csaf.tracking.version": "0.1"
}
}
],
"diagnostics": {},
"errors": {
"invalid_dates": true
}
}

View File

@@ -0,0 +1,24 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-20001",
"product": {
"key": "ios-xe-17.9",
"name": "Cisco IOS XE Software 17.9",
"purl": null,
"cpe": "cpe:/o:cisco:ios_xe:17.9"
},
"status": "fixed",
"justification": null,
"detail": "Cisco IOS XE Software Web UI Privilege Escalation Vulnerability",
"metadata": {
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Cisco PSIRT",
"csaf.tracking.id": "cisco-sa-ios-xe-web-ui-priv-esc-j22SvAb",
"csaf.tracking.status": "final",
"csaf.tracking.version": "1.0"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,21 @@
# Cisco CSAF Connector Fixtures
This directory contains raw CSAF document fixtures captured from Cisco'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 Cisco's official PSIRT CSAF feed:
- https://sec.cloudapps.cisco.com/security/center/publicationListing.x
## Updating Fixtures
Run the FixtureUpdater tool to refresh fixtures from live sources:
```bash
dotnet run --project tools/FixtureUpdater -- --connector Cisco.CSAF
```

View File

@@ -0,0 +1,83 @@
{
"document": {
"publisher": {
"name": "Cisco PSIRT",
"category": "vendor",
"namespace": "https://www.cisco.com/security"
},
"tracking": {
"id": "cisco-sa-asa-ftd-webvpn-XzCyz3j",
"status": "final",
"version": "2.1",
"initial_release_date": "2025-03-01T16:00:00Z",
"current_release_date": "2025-03-15T20:00:00Z"
},
"title": "Cisco ASA and FTD Software WebVPN Multiple Vulnerabilities"
},
"product_tree": {
"full_product_names": [
{
"product_id": "asa-9.16",
"name": "Cisco ASA Software 9.16",
"product_identification_helper": {
"cpe": "cpe:/a:cisco:adaptive_security_appliance_software:9.16"
}
},
{
"product_id": "asa-9.18",
"name": "Cisco ASA Software 9.18",
"product_identification_helper": {
"cpe": "cpe:/a:cisco:adaptive_security_appliance_software:9.18"
}
},
{
"product_id": "ftd-7.2",
"name": "Cisco Firepower Threat Defense 7.2",
"product_identification_helper": {
"cpe": "cpe:/a:cisco:firepower_threat_defense:7.2"
}
},
{
"product_id": "ftd-7.4",
"name": "Cisco Firepower Threat Defense 7.4",
"product_identification_helper": {
"cpe": "cpe:/a:cisco:firepower_threat_defense:7.4"
}
}
],
"product_groups": [
{
"group_id": "asa-products",
"product_ids": ["asa-9.16", "asa-9.18"]
},
{
"group_id": "ftd-products",
"product_ids": ["ftd-7.2", "ftd-7.4"]
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-20100",
"title": "Cisco ASA and FTD Software WebVPN XSS Vulnerability",
"product_status": {
"fixed": ["asa-9.18", "ftd-7.4"],
"known_affected": ["asa-9.16", "ftd-7.2"]
}
},
{
"cve": "CVE-2025-20101",
"title": "Cisco ASA Software WebVPN CSRF Vulnerability",
"product_status": {
"fixed": ["asa-9.16", "asa-9.18"],
"known_not_affected": ["ftd-7.2", "ftd-7.4"]
},
"flags": [
{
"label": "component_not_present",
"group_ids": ["ftd-products"]
}
]
}
]
}

View File

@@ -0,0 +1,32 @@
{
"document": {
"publisher": {
"name": "Cisco PSIRT",
"category": "vendor"
},
"tracking": {
"id": "cisco-sa-test-invalid",
"status": "interim",
"version": "0.1",
"initial_release_date": "not-a-valid-date",
"current_release_date": "also-invalid"
},
"title": "Test Advisory with Invalid Dates"
},
"product_tree": {
"full_product_names": [
{
"product_id": "test-product",
"name": "Test Product"
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-99999",
"product_status": {
"under_investigation": ["test-product"]
}
}
]
}

View File

@@ -0,0 +1,43 @@
{
"document": {
"publisher": {
"name": "Cisco PSIRT",
"category": "vendor",
"namespace": "https://www.cisco.com/security"
},
"tracking": {
"id": "cisco-sa-ios-xe-web-ui-priv-esc-j22SvAb",
"status": "final",
"version": "1.0",
"initial_release_date": "2025-02-01T16:00:00Z",
"current_release_date": "2025-02-01T16:00:00Z"
},
"title": "Cisco IOS XE Software Web UI Privilege Escalation Vulnerability"
},
"product_tree": {
"full_product_names": [
{
"product_id": "ios-xe-17.9",
"name": "Cisco IOS XE Software 17.9",
"product_identification_helper": {
"cpe": "cpe:/o:cisco:ios_xe:17.9"
}
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-20001",
"title": "Cisco IOS XE Software Web UI Privilege Escalation Vulnerability",
"product_status": {
"fixed": ["ios-xe-17.9"]
},
"notes": [
{
"category": "description",
"text": "A vulnerability in the web UI of Cisco IOS XE Software could allow an authenticated, remote attacker to execute commands with elevated privileges."
}
]
}
]
}

View File

@@ -0,0 +1,149 @@
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
using StellaOps.Excititor.Core;
using System.IO.Abstractions.TestingHelpers;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.Metadata;
public sealed class CiscoProviderMetadataLoaderTests
{
[Fact]
public async Task LoadAsync_FetchesFromNetworkWithBearerToken()
{
var payload = """
{
"metadata": {
"publisher": {
"name": "Cisco CSAF",
"category": "vendor",
"contact_details": {
"id": "excititor:cisco"
}
}
},
"distributions": {
"directories": [
"https://api.security.cisco.com/csaf/v2/advisories/"
]
},
"discovery": {
"well_known": "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
"rolie": "https://api.security.cisco.com/csaf/rolie/feed"
},
"trust": {
"weight": 0.9,
"cosign": {
"issuer": "https://oidc.security.cisco.com",
"identity_pattern": "spiffe://cisco/*"
},
"pgp_fingerprints": [ "1234ABCD" ]
}
}
""";
HttpRequestMessage? capturedRequest = null;
var handler = new FakeHttpMessageHandler(request =>
{
capturedRequest = request;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"etag1\"") }
};
});
var httpClient = new HttpClient(handler);
var factory = new SingleHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new CiscoConnectorOptions
{
ApiToken = "token-123",
MetadataUri = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
});
var fileSystem = new MockFileSystem();
var loader = new CiscoProviderMetadataLoader(factory, cache, options, NullLogger<CiscoProviderMetadataLoader>.Instance, fileSystem);
var result = await loader.LoadAsync(CancellationToken.None);
result.Provider.Id.Should().Be("excititor:cisco");
result.Provider.BaseUris.Should().ContainSingle(uri => uri.ToString() == "https://api.security.cisco.com/csaf/v2/advisories/");
result.Provider.Discovery.RolIeService.Should().Be(new Uri("https://api.security.cisco.com/csaf/rolie/feed"));
result.ServedFromCache.Should().BeFalse();
capturedRequest.Should().NotBeNull();
capturedRequest!.Headers.Authorization.Should().NotBeNull();
capturedRequest.Headers.Authorization!.Parameter.Should().Be("token-123");
}
[Fact]
public async Task LoadAsync_FallsBackToOfflineSnapshot()
{
var payload = """
{
"metadata": {
"publisher": {
"name": "Cisco CSAF",
"category": "vendor",
"contact_details": {
"id": "excititor:cisco"
}
}
}
}
""";
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var httpClient = new HttpClient(handler);
var factory = new SingleHttpClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new CiscoConnectorOptions
{
MetadataUri = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
PreferOfflineSnapshot = true,
OfflineSnapshotPath = "/snapshots/cisco.json",
});
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/snapshots/cisco.json"] = new MockFileData(payload),
});
var loader = new CiscoProviderMetadataLoader(factory, cache, options, NullLogger<CiscoProviderMetadataLoader>.Instance, fileSystem);
var result = await loader.LoadAsync(CancellationToken.None);
result.FromOfflineSnapshot.Should().BeTrue();
result.Provider.Id.Should().Be("excititor:cisco");
}
private sealed class SingleHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
_responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_responder(request));
}
}
}

View File

@@ -0,0 +1,32 @@
<?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.Cisco.CSAF/StellaOps.Excititor.Connectors.Cisco.CSAF.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</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 Cisco 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-0299-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0299-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0299-A | DONE | Waived (test project; revalidated 2026-01-07). |

View File

@@ -0,0 +1,23 @@
# Excititor MSRC CSAF Connector Tests Agent Charter
## Mission
Validate MSRC CSAF connector and normalizer behavior with deterministic tests.
## Responsibilities
- Cover connector fetch, token acquisition, and state handling.
- Cover CSAF normalizer fixtures and deterministic output.
## Required Reading
- docs/modules/excititor/architecture.md
- docs/modules/platform/architecture-overview.md
## Definition of Done
- Tests cover success and failure cases for connector, token provider, and metadata handling.
- 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,176 @@
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Authentication;
public sealed class MsrcTokenProviderTests
{
[Fact]
public async Task GetAccessTokenAsync_CachesUntilExpiry()
{
var handler = new TestHttpMessageHandler(new[]
{
CreateTokenResponse("token-1"),
CreateTokenResponse("token-2"),
});
var client = new HttpClient(handler) { BaseAddress = new Uri("https://login.microsoftonline.com/") };
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = Options.Create(new MsrcConnectorOptions
{
TenantId = "contoso.onmicrosoft.com",
ClientId = "client-id",
ClientSecret = "secret",
});
var timeProvider = new AdjustableTimeProvider();
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance, timeProvider);
var first = await provider.GetAccessTokenAsync(CancellationToken.None);
first.Value.Should().Be("token-1");
handler.InvocationCount.Should().Be(1);
var second = await provider.GetAccessTokenAsync(CancellationToken.None);
second.Value.Should().Be("token-1");
handler.InvocationCount.Should().Be(1);
}
[Fact]
public async Task GetAccessTokenAsync_RefreshesWhenExpired()
{
var handler = new TestHttpMessageHandler(new[]
{
CreateTokenResponse("token-1", expiresIn: 120),
CreateTokenResponse("token-2", expiresIn: 3600),
});
var client = new HttpClient(handler) { BaseAddress = new Uri("https://login.microsoftonline.com/") };
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = Options.Create(new MsrcConnectorOptions
{
TenantId = "contoso.onmicrosoft.com",
ClientId = "client-id",
ClientSecret = "secret",
ExpiryLeewaySeconds = 60,
});
var timeProvider = new AdjustableTimeProvider();
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance, timeProvider);
var first = await provider.GetAccessTokenAsync(CancellationToken.None);
first.Value.Should().Be("token-1");
handler.InvocationCount.Should().Be(1);
timeProvider.Advance(TimeSpan.FromMinutes(2));
var second = await provider.GetAccessTokenAsync(CancellationToken.None);
second.Value.Should().Be("token-2");
handler.InvocationCount.Should().Be(2);
}
[Fact]
public async Task GetAccessTokenAsync_OfflineStaticToken()
{
var factory = Substitute.For<IHttpClientFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = Options.Create(new MsrcConnectorOptions
{
PreferOfflineToken = true,
StaticAccessToken = "offline-token",
});
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance);
var token = await provider.GetAccessTokenAsync(CancellationToken.None);
token.Value.Should().Be("offline-token");
token.ExpiresAt.Should().Be(DateTimeOffset.MaxValue);
}
[Fact]
public async Task GetAccessTokenAsync_OfflineFileToken()
{
var factory = Substitute.For<IHttpClientFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var offlinePath = fileSystem.Path.Combine("/tokens", "msrc.txt");
fileSystem.AddFile(offlinePath, new MockFileData("file-token"));
var options = Options.Create(new MsrcConnectorOptions
{
PreferOfflineToken = true,
OfflineTokenPath = offlinePath,
});
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance);
var token = await provider.GetAccessTokenAsync(CancellationToken.None);
token.Value.Should().Be("file-token");
token.ExpiresAt.Should().Be(DateTimeOffset.MaxValue);
}
private static HttpResponseMessage CreateTokenResponse(string token, int expiresIn = 3600)
{
var json = $"{{\"access_token\":\"{token}\",\"token_type\":\"Bearer\",\"expires_in\":{expiresIn}}}";
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
}
private sealed class AdjustableTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan span) => _now = _now.Add(span);
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses;
public TestHttpMessageHandler(IEnumerable<HttpResponseMessage> responses)
{
_responses = new Queue<HttpResponseMessage>(responses);
}
public int InvocationCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
if (_responses.Count == 0)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("no responses remaining"),
});
}
return Task.FromResult(_responses.Dequeue());
}
}
}

View File

@@ -0,0 +1,471 @@
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.Core.Storage;
using Xunit;
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)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
State = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
{
IReadOnlyCollection<VexConnectorState> result = State is not null
? new[] { State }
: Array.Empty<VexConnectorState>();
return ValueTask.FromResult(result);
}
}
[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);
}
}
}

View File

@@ -0,0 +1,12 @@
# MSRC 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,106 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-21010",
"product": {
"key": "windows-server-2019",
"name": "Windows Server 2019",
"purl": null,
"cpe": "cpe:/o:microsoft:windows_server_2019:-:*:*:*:*:*:*:*"
},
"status": "fixed",
"justification": null,
"detail": "Windows SMB Remote Code Execution Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Microsoft Security Response Center",
"csaf.tracking.id": "ADV250002",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-21010",
"product": {
"key": "windows-server-2022",
"name": "Windows Server 2022",
"purl": null,
"cpe": "cpe:/o:microsoft:windows_server_2022:-:*:*:*:*:*:*:*"
},
"status": "fixed",
"justification": null,
"detail": "Windows SMB Remote Code Execution Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Microsoft Security Response Center",
"csaf.tracking.id": "ADV250002",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-21011",
"product": {
"key": "windows-server-2019",
"name": "Windows Server 2019",
"purl": null,
"cpe": "cpe:/o:microsoft:windows_server_2019:-:*:*:*:*:*:*:*"
},
"status": "NotAffected",
"justification": "component_not_present",
"detail": "Windows Print Spooler Elevation of Privilege",
"metadata": {
"csaf.justification.label": "component_not_present",
"csaf.product_status.raw": "known_not_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Microsoft Security Response Center",
"csaf.tracking.id": "ADV250002",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-21011",
"product": {
"key": "windows-server-2022",
"name": "Windows Server 2022",
"purl": null,
"cpe": "cpe:/o:microsoft:windows_server_2022:-:*:*:*:*:*:*:*"
},
"status": "fixed",
"justification": null,
"detail": "Windows Print Spooler Elevation of Privilege",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Microsoft Security Response Center",
"csaf.tracking.id": "ADV250002",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
},
{
"vulnerabilityId": "CVE-2025-21012",
"product": {
"key": "office-365",
"name": "Microsoft 365 Apps for Enterprise",
"purl": null,
"cpe": "cpe:/a:microsoft:365_apps:-:*:*:*:enterprise:*:*:*"
},
"status": "fixed",
"justification": null,
"detail": "Microsoft Excel Remote Code Execution Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Microsoft Security Response Center",
"csaf.tracking.id": "ADV250002",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2.1"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,26 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-99999",
"product": {
"key": "CVE-2025-99999",
"name": "CVE-2025-99999",
"purl": null,
"cpe": null
},
"status": "UnderInvestigation",
"justification": null,
"detail": null,
"metadata": {
"csaf.publisher.name": "Microsoft Security Response Center",
"csaf.tracking.id": "ADV250099",
"csaf.tracking.status": "draft"
}
}
],
"diagnostics": {},
"errors": {
"missing_product_tree": true,
"missing_product_status": true
}
}

View File

@@ -0,0 +1,24 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-21001",
"product": {
"key": "windows-11-23h2",
"name": "Windows 11 Version 23H2 for x64-based Systems",
"purl": null,
"cpe": "cpe:/o:microsoft:windows_11:23h2:*:*:*:*:*:x64:*"
},
"status": "fixed",
"justification": null,
"detail": "Windows Kernel Elevation of Privilege Vulnerability",
"metadata": {
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Microsoft Security Response Center",
"csaf.tracking.id": "ADV250001",
"csaf.tracking.status": "final",
"csaf.tracking.version": "1.0"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,21 @@
# MSRC CSAF Connector Fixtures
This directory contains raw CSAF document fixtures captured from Microsoft Security Response Center.
## 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 MSRC's official CSAF feed:
- https://api.msrc.microsoft.com/cvrf/v3.0/
## Updating Fixtures
Run the FixtureUpdater tool to refresh fixtures from live sources:
```bash
dotnet run --project tools/FixtureUpdater -- --connector MSRC.CSAF
```

View File

@@ -0,0 +1,78 @@
{
"document": {
"publisher": {
"name": "Microsoft Security Response Center",
"category": "vendor",
"namespace": "https://msrc.microsoft.com"
},
"tracking": {
"id": "ADV250002",
"status": "final",
"version": "2.1",
"initial_release_date": "2025-02-11T08:00:00Z",
"current_release_date": "2025-02-18T12:00:00Z"
},
"title": "February 2025 Security Updates"
},
"product_tree": {
"full_product_names": [
{
"product_id": "windows-server-2022",
"name": "Windows Server 2022",
"product_identification_helper": {
"cpe": "cpe:/o:microsoft:windows_server_2022:-:*:*:*:*:*:*:*"
}
},
{
"product_id": "windows-server-2019",
"name": "Windows Server 2019",
"product_identification_helper": {
"cpe": "cpe:/o:microsoft:windows_server_2019:-:*:*:*:*:*:*:*"
}
},
{
"product_id": "office-365",
"name": "Microsoft 365 Apps for Enterprise",
"product_identification_helper": {
"cpe": "cpe:/a:microsoft:365_apps:-:*:*:*:enterprise:*:*:*"
}
}
],
"product_groups": [
{
"group_id": "windows-servers",
"product_ids": ["windows-server-2022", "windows-server-2019"]
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-21010",
"title": "Windows SMB Remote Code Execution Vulnerability",
"product_status": {
"fixed": ["windows-server-2022", "windows-server-2019"]
}
},
{
"cve": "CVE-2025-21011",
"title": "Windows Print Spooler Elevation of Privilege",
"product_status": {
"fixed": ["windows-server-2022"],
"known_not_affected": ["windows-server-2019"]
},
"flags": [
{
"label": "component_not_present",
"product_ids": ["windows-server-2019"]
}
]
},
{
"cve": "CVE-2025-21012",
"title": "Microsoft Excel Remote Code Execution Vulnerability",
"product_status": {
"fixed": ["office-365"]
}
}
]
}

View File

@@ -0,0 +1,16 @@
{
"document": {
"publisher": {
"name": "Microsoft Security Response Center"
},
"tracking": {
"id": "ADV250099",
"status": "draft"
}
},
"vulnerabilities": [
{
"cve": "CVE-2025-99999"
}
]
}

View File

@@ -0,0 +1,43 @@
{
"document": {
"publisher": {
"name": "Microsoft Security Response Center",
"category": "vendor",
"namespace": "https://msrc.microsoft.com"
},
"tracking": {
"id": "ADV250001",
"status": "final",
"version": "1.0",
"initial_release_date": "2025-01-14T08:00:00Z",
"current_release_date": "2025-01-14T08:00:00Z"
},
"title": "Microsoft Windows Security Update"
},
"product_tree": {
"full_product_names": [
{
"product_id": "windows-11-23h2",
"name": "Windows 11 Version 23H2 for x64-based Systems",
"product_identification_helper": {
"cpe": "cpe:/o:microsoft:windows_11:23h2:*:*:*:*:*:x64:*"
}
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-21001",
"title": "Windows Kernel Elevation of Privilege Vulnerability",
"product_status": {
"fixed": ["windows-11-23h2"]
},
"notes": [
{
"category": "description",
"text": "An elevation of privilege vulnerability exists in Windows Kernel."
}
]
}
]
}

View File

@@ -0,0 +1,128 @@
// -----------------------------------------------------------------------------
// MsrcCsafNormalizerTests.cs
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
// Task: CONN-FIX-010
// Description: Fixture-based parser/normalizer tests for MSRC 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.MSRC.CSAF.Tests;
/// <summary>
/// Fixture-based normalizer tests for MSRC CSAF documents.
/// Implements Model C1 (Connector/External) test requirements.
/// </summary>
[Trait("Category", TestCategories.Unit)]
[Trait("Category", TestCategories.Snapshot)]
public sealed class MsrcCsafNormalizerTests
{
private readonly CsafNormalizer _normalizer;
private readonly VexProvider _provider;
private readonly string _fixturesDir;
private readonly string _expectedDir;
public MsrcCsafNormalizerTests()
{
_normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
_provider = new VexProvider("msrc-csaf", "Microsoft Security Response Center", VexProviderKind.Vendor);
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-msrc.json", "typical-msrc.canonical.json")]
[InlineData("edge-multi-cve.json", "edge-multi-cve.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));
}
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-msrc.json")]
[InlineData("edge-multi-cve.json")]
public async Task Normalize_SameInput_ProducesDeterministicOutput(string fixtureFile)
{
// Arrange
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
// Act
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
results.Distinct().Should().HaveCount(1);
}
private static VexRawDocument CreateRawDocument(string json)
{
var content = System.Text.Encoding.UTF8.GetBytes(json);
return new VexRawDocument(
"msrc-csaf",
VexDocumentFormat.Csaf,
new Uri("https://api.msrc.microsoft.com/cvrf/v3.0/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,29 @@
<?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.MSRC.CSAF/StellaOps.Excititor.Connectors.MSRC.CSAF.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="NSubstitute" />
<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 MSRC 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-0301-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0301-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0301-A | DONE | Waived (test project; revalidated 2026-01-07). |

View File

@@ -0,0 +1,24 @@
# Excititor OCI OpenVEX Attest Tests Agent Charter
## Mission
Validate OCI OpenVEX attestation connector behavior with deterministic tests.
## Responsibilities
- Cover discovery and offline bundle handling.
- Cover connector fetch and provenance metadata enrichment.
- Cover OpenVEX attestation fixture parsing expectations.
## Required Reading
- docs/modules/excititor/architecture.md
- docs/modules/platform/architecture-overview.md
## Definition of Done
- Tests cover success and failure paths for discovery 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,76 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Core;
using System.Collections.Generic;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Configuration;
public sealed class OciOpenVexAttestationConnectorOptionsValidatorTests
{
[Fact]
public void Validate_WithValidConfiguration_Succeeds()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/offline/registry.example.com/repo/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
});
var validator = new OciOpenVexAttestationConnectorOptionsValidator(fileSystem);
var options = new OciOpenVexAttestationConnectorOptions
{
AllowHttpRegistries = true,
};
options.Images.Add(new OciImageSubscriptionOptions
{
Reference = "registry.example.com/repo/image:latest",
OfflineBundlePath = "/offline/registry.example.com/repo/latest/openvex-attestations.tgz",
});
options.Registry.Username = "user";
options.Registry.Password = "pass";
options.Cosign.Mode = CosignCredentialMode.None;
var errors = new List<string>();
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
errors.Should().BeEmpty();
}
[Fact]
public void Validate_WhenImagesMissing_AddsError()
{
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
var options = new OciOpenVexAttestationConnectorOptions();
var errors = new List<string>();
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
errors.Should().ContainSingle().Which.Should().Contain("At least one OCI image reference must be configured.");
}
[Fact]
public void Validate_WhenDigestMalformed_AddsError()
{
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
var options = new OciOpenVexAttestationConnectorOptions();
options.Images.Add(new OciImageSubscriptionOptions
{
Reference = "registry.test/repo/image@sha256:not-a-digest",
});
var errors = new List<string>();
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
errors.Should().ContainSingle();
}
}

View File

@@ -0,0 +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
});
}
}
[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 NoopVexSignatureVerifier(),
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 { }
}
}
}

View File

@@ -0,0 +1,83 @@
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using System.Collections.Generic;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Discovery;
public sealed class OciAttestationDiscoveryServiceTests
{
[Fact]
public async Task LoadAsync_ResolvesOfflinePaths()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var options = new OciOpenVexAttestationConnectorOptions
{
AllowHttpRegistries = true,
};
options.Images.Add(new OciImageSubscriptionOptions
{
Reference = "registry.example.com/repo/image:latest",
});
options.Offline.RootDirectory = "/bundles";
options.Cosign.Mode = CosignCredentialMode.None;
var result = await service.LoadAsync(options, CancellationToken.None);
result.Targets.Should().ContainSingle();
result.Targets[0].OfflineBundle.Should().NotBeNull();
var offline = result.Targets[0].OfflineBundle!;
offline.Exists.Should().BeTrue();
var expectedPath = fileSystem.Path.Combine(
fileSystem.Path.GetFullPath("/bundles"),
"registry.example.com",
"repo",
"image",
"latest",
"openvex-attestations.tgz");
offline.Path.Should().Be(expectedPath);
}
[Fact]
public async Task LoadAsync_CachesResults()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var options = new OciOpenVexAttestationConnectorOptions
{
AllowHttpRegistries = true,
};
options.Images.Add(new OciImageSubscriptionOptions
{
Reference = "registry.example.com/repo/image:latest",
});
options.Offline.RootDirectory = "/bundles";
options.Cosign.Mode = CosignCredentialMode.None;
var first = await service.LoadAsync(options, CancellationToken.None);
var second = await service.LoadAsync(options, CancellationToken.None);
ReferenceEquals(first, second).Should().BeTrue();
}
}

View File

@@ -0,0 +1,12 @@
# OCI OpenVEX Attestation 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,108 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-2001",
"product": {
"key": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab",
"name": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab",
"purl": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab",
"cpe": null
},
"status": "fixed",
"justification": null,
"detail": "Images rebuilt with patched base image.",
"metadata": {
"openvex.document.author": "Example Platform Security",
"openvex.document.version": "2",
"openvex.statement.status": "fixed"
}
},
{
"vulnerabilityId": "CVE-2025-2001",
"product": {
"key": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234",
"name": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234",
"purl": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234",
"cpe": null
},
"status": "fixed",
"justification": null,
"detail": "Images rebuilt with patched base image.",
"metadata": {
"openvex.document.author": "Example Platform Security",
"openvex.document.version": "2",
"openvex.statement.status": "fixed"
}
},
{
"vulnerabilityId": "CVE-2025-2001",
"product": {
"key": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12",
"name": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12",
"purl": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12",
"cpe": null
},
"status": "NotAffected",
"justification": "component_not_present",
"detail": null,
"metadata": {
"openvex.document.author": "Example Platform Security",
"openvex.document.version": "2",
"openvex.statement.justification": "component_not_present",
"openvex.statement.status": "not_affected"
}
},
{
"vulnerabilityId": "CVE-2025-2002",
"product": {
"key": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234",
"name": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234",
"purl": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234",
"cpe": null
},
"status": "affected",
"justification": null,
"detail": null,
"metadata": {
"openvex.document.author": "Example Platform Security",
"openvex.document.version": "2",
"openvex.statement.status": "affected"
}
},
{
"vulnerabilityId": "CVE-2025-2003",
"product": {
"key": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab",
"name": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab",
"purl": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab",
"cpe": null
},
"status": "UnderInvestigation",
"justification": null,
"detail": null,
"metadata": {
"openvex.document.author": "Example Platform Security",
"openvex.document.version": "2",
"openvex.statement.status": "under_investigation"
}
},
{
"vulnerabilityId": "CVE-2025-2003",
"product": {
"key": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12",
"name": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12",
"purl": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12",
"cpe": null
},
"status": "UnderInvestigation",
"justification": null,
"detail": null,
"metadata": {
"openvex.document.author": "Example Platform Security",
"openvex.document.version": "2",
"openvex.statement.status": "under_investigation"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,8 @@
{
"claims": [],
"diagnostics": {},
"errors": {
"invalid_predicate": true,
"missing_statements": true
}
}

View File

@@ -0,0 +1,24 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-0001",
"product": {
"key": "pkg:oci/myapp@sha256:a1b2c3d4",
"name": "pkg:oci/myapp@sha256:a1b2c3d4",
"purl": "pkg:oci/example/myapp@sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456",
"cpe": null
},
"status": "NotAffected",
"justification": "vulnerable_code_not_in_execute_path",
"detail": "The vulnerable function is not called in production code paths.",
"metadata": {
"openvex.document.author": "Example Security Team",
"openvex.document.version": "1",
"openvex.product.source": "pkg:oci/myapp@sha256:a1b2c3d4",
"openvex.statement.justification": "vulnerable_code_not_in_execute_path",
"openvex.statement.status": "not_affected"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,22 @@
# OCI OpenVEX Attestation Connector Fixtures
This directory contains raw OpenVEX attestation fixtures in OCI format.
## Fixture Categories
- `typical-*.json` - Standard OpenVEX attestations with common patterns
- `edge-*.json` - Edge cases (multiple statements, complex justifications)
- `error-*.json` - Malformed or missing required fields
## Fixture Sources
Fixtures are captured from OCI registry attestations following the OpenVEX in-toto format:
- in-toto attestation bundles with OpenVEX predicates
- OCI artifact manifests with VEX annotations
## Updating Fixtures
Run the FixtureUpdater tool to refresh fixtures from live sources:
```bash
dotnet run --project tools/FixtureUpdater -- --connector OCI.OpenVEX.Attest
```

View File

@@ -0,0 +1,108 @@
{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": "https://openvex.dev/ns/v0.2.0",
"subject": [
{
"name": "ghcr.io/example/frontend",
"digest": {
"sha256": "frontend123456789012345678901234567890abcdef1234567890abcdef1234"
}
},
{
"name": "ghcr.io/example/backend",
"digest": {
"sha256": "backend1234567890123456789012345678901234567890abcdef1234567890ab"
}
},
{
"name": "ghcr.io/example/worker",
"digest": {
"sha256": "worker12345678901234567890123456789012345678901234567890abcdef12"
}
}
],
"predicate": {
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example.com/vex/platform-2.0.0",
"author": "Example Platform Security",
"role": "vendor",
"timestamp": "2025-06-15T14:30:00Z",
"version": 2,
"statements": [
{
"vulnerability": {
"@id": "https://nvd.nist.gov/vuln/detail/CVE-2025-2001",
"name": "CVE-2025-2001"
},
"products": [
{
"@id": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234",
"identifiers": {
"purl": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234"
}
},
{
"@id": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab",
"identifiers": {
"purl": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab"
}
}
],
"status": "fixed",
"action_statement": "Images rebuilt with patched base image."
},
{
"vulnerability": {
"@id": "https://nvd.nist.gov/vuln/detail/CVE-2025-2001",
"name": "CVE-2025-2001"
},
"products": [
{
"@id": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12",
"identifiers": {
"purl": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12"
}
}
],
"status": "not_affected",
"justification": "component_not_present"
},
{
"vulnerability": {
"@id": "https://nvd.nist.gov/vuln/detail/CVE-2025-2002",
"name": "CVE-2025-2002"
},
"products": [
{
"@id": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234",
"identifiers": {
"purl": "pkg:oci/example/frontend@sha256:frontend123456789012345678901234567890abcdef1234567890abcdef1234"
}
}
],
"status": "affected"
},
{
"vulnerability": {
"@id": "https://nvd.nist.gov/vuln/detail/CVE-2025-2003",
"name": "CVE-2025-2003"
},
"products": [
{
"@id": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab",
"identifiers": {
"purl": "pkg:oci/example/backend@sha256:backend1234567890123456789012345678901234567890abcdef1234567890ab"
}
},
{
"@id": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12",
"identifiers": {
"purl": "pkg:oci/example/worker@sha256:worker12345678901234567890123456789012345678901234567890abcdef12"
}
}
],
"status": "under_investigation"
}
]
}
}

View File

@@ -0,0 +1,15 @@
{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": "https://openvex.dev/ns/v0.2.0",
"subject": [
{
"name": "ghcr.io/example/invalid",
"digest": {
"sha256": "invalid123456789012345678901234567890abcdef1234567890abcdef12345"
}
}
],
"predicate": {
"@context": "https://openvex.dev/ns/v0.2.0"
}
}

View File

@@ -0,0 +1,39 @@
{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": "https://openvex.dev/ns/v0.2.0",
"subject": [
{
"name": "ghcr.io/example/myapp",
"digest": {
"sha256": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
}
}
],
"predicate": {
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example.com/vex/myapp-1.0.0",
"author": "Example Security Team",
"role": "vendor",
"timestamp": "2025-05-01T10:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"@id": "https://nvd.nist.gov/vuln/detail/CVE-2025-0001",
"name": "CVE-2025-0001"
},
"products": [
{
"@id": "pkg:oci/myapp@sha256:a1b2c3d4",
"identifiers": {
"purl": "pkg:oci/example/myapp@sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
}
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "The vulnerable function is not called in production code paths."
}
]
}
}

View File

@@ -0,0 +1,235 @@
// -----------------------------------------------------------------------------
// OciOpenVexAttestNormalizerTests.cs
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
// Task: CONN-FIX-010
// Description: Fixture-based parser tests for OCI OpenVEX attestation connector
// Note: Full normalizer tests pending EXCITITOR-CONN-OCI-01-002 (OciAttestation normalizer)
// -----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests;
/// <summary>
/// Fixture-based parser tests for OCI OpenVEX attestation documents.
/// Implements Model C1 (Connector/External) test requirements.
///
/// NOTE: Full normalizer snapshot tests are pending the implementation of
/// a dedicated OciAttestation normalizer (EXCITITOR-CONN-OCI-01-002).
/// These tests validate fixture structure and in-toto statement parsing.
/// </summary>
[Trait("Category", TestCategories.Unit)]
[Trait("Category", TestCategories.Snapshot)]
public sealed class OciOpenVexAttestNormalizerTests
{
private readonly string _fixturesDir;
private readonly string _expectedDir;
public OciOpenVexAttestNormalizerTests()
{
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-oci-vex.json")]
[InlineData("edge-multi-subject.json")]
public async Task Fixture_IsValidInTotoStatement(string fixtureFile)
{
// Arrange
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
// Act
var statement = JsonSerializer.Deserialize<InTotoStatement>(rawJson, JsonOptions);
// Assert
statement.Should().NotBeNull();
statement!.Type.Should().Be("https://in-toto.io/Statement/v0.1");
statement.PredicateType.Should().Be("https://openvex.dev/ns/v0.2.0");
statement.Subject.Should().NotBeEmpty();
statement.Predicate.Should().NotBeNull();
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-oci-vex.json")]
[InlineData("edge-multi-subject.json")]
public async Task Fixture_PredicateContainsOpenVexStatements(string fixtureFile)
{
// Arrange
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
// Act
var statement = JsonSerializer.Deserialize<InTotoStatement>(rawJson, JsonOptions);
// Assert
statement.Should().NotBeNull();
statement!.Predicate.Should().NotBeNull();
statement.Predicate!.Statements.Should().NotBeNullOrEmpty();
foreach (var vexStatement in statement.Predicate.Statements!)
{
vexStatement.Vulnerability.Should().NotBeNull();
vexStatement.Vulnerability!.Name.Should().NotBeNullOrEmpty();
vexStatement.Status.Should().NotBeNullOrEmpty();
}
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-oci-vex.json", "typical-oci-vex.canonical.json")]
[InlineData("edge-multi-subject.json", "edge-multi-subject.canonical.json")]
public async Task Expected_MatchesFixtureVulnerabilities(string fixtureFile, string expectedFile)
{
// Arrange
var fixtureJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
var expectedJson = await File.ReadAllTextAsync(Path.Combine(_expectedDir, expectedFile));
// Act
var statement = JsonSerializer.Deserialize<InTotoStatement>(fixtureJson, JsonOptions);
var expected = JsonSerializer.Deserialize<ExpectedClaimBatch>(expectedJson, ExpectedJsonOptions);
// Assert
statement.Should().NotBeNull();
expected.Should().NotBeNull();
expected!.Claims.Should().NotBeEmpty();
// Verify that expected claims match vulnerabilities in the predicate
var fixtureVulns = statement!.Predicate?.Statements?
.Select(s => s.Vulnerability?.Name)
.Where(v => !string.IsNullOrEmpty(v))
.ToHashSet() ?? new HashSet<string?>();
foreach (var claim in expected.Claims)
{
fixtureVulns.Should().Contain(claim.VulnerabilityId,
$"Expected vulnerability {claim.VulnerabilityId} should exist in fixture");
}
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("error-invalid-predicate.json")]
public async Task ErrorFixture_HasInvalidOrMissingPredicate(string fixtureFile)
{
// Arrange
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
// Act
var statement = JsonSerializer.Deserialize<InTotoStatement>(rawJson, JsonOptions);
// Assert - error fixtures should have invalid or empty predicate statements
statement.Should().NotBeNull();
var hasValidStatements = statement!.Predicate?.Statements?.Any(s =>
!string.IsNullOrEmpty(s.Vulnerability?.Name) &&
!string.IsNullOrEmpty(s.Status)) ?? false;
hasValidStatements.Should().BeFalse(
"Error fixture should not contain valid VEX statements");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-oci-vex.json")]
[InlineData("edge-multi-subject.json")]
public async Task Fixture_SameInput_ProducesDeterministicParsing(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 statement = JsonSerializer.Deserialize<InTotoStatement>(rawJson, JsonOptions);
var serialized = SerializeStatement(statement!);
results.Add(serialized);
}
// Assert
results.Distinct().Should().HaveCount(1);
}
private static string SerializeStatement(InTotoStatement statement)
{
var simplified = new
{
statement.Type,
statement.PredicateType,
Subjects = statement.Subject?.Select(s => new { s.Name, s.Digest }),
Statements = statement.Predicate?.Statements?.Select(s => new
{
VulnerabilityName = s.Vulnerability?.Name,
s.Status,
s.Justification
})
};
return JsonSerializer.Serialize(simplified, JsonOptions);
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
private static readonly JsonSerializerOptions ExpectedJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
// Models for parsing in-toto statement with OpenVEX predicate
private sealed record InTotoStatement(
[property: System.Text.Json.Serialization.JsonPropertyName("_type")] string Type,
[property: System.Text.Json.Serialization.JsonPropertyName("predicateType")] string PredicateType,
List<InTotoSubject>? Subject,
OpenVexPredicate? Predicate);
private sealed record InTotoSubject(
string Name,
Dictionary<string, string>? Digest);
private sealed record OpenVexPredicate(
[property: System.Text.Json.Serialization.JsonPropertyName("@context")] string? Context,
[property: System.Text.Json.Serialization.JsonPropertyName("@id")] string? Id,
string? Author,
string? Role,
string? Timestamp,
int? Version,
List<OpenVexStatement>? Statements);
private sealed record OpenVexStatement(
OpenVexVulnerability? Vulnerability,
List<OpenVexProduct>? Products,
string? Status,
string? Justification,
[property: System.Text.Json.Serialization.JsonPropertyName("impact_statement")] string? ImpactStatement);
private sealed record OpenVexVulnerability(
[property: System.Text.Json.Serialization.JsonPropertyName("@id")] string? Id,
string? Name);
private sealed record OpenVexProduct(
[property: System.Text.Json.Serialization.JsonPropertyName("@id")] string? Id,
OpenVexIdentifiers? Identifiers);
private sealed record OpenVexIdentifiers(string? Purl);
// Expected claim records for snapshot verification
private sealed record ExpectedClaimBatch(List<ExpectedClaim> Claims, Dictionary<string, string>? Diagnostics);
private sealed record ExpectedClaim(
[property: System.Text.Json.Serialization.JsonPropertyName("vulnerabilityId")] 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,30 @@
<?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.OCI.OpenVEX.Attest/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.OpenVEX/StellaOps.Excititor.Formats.OpenVEX.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<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 OCI OpenVEX Attest 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-0303-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0303-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0303-A | DONE | Waived (test project; revalidated 2026-01-07). |

View File

@@ -0,0 +1,23 @@
# Excititor Oracle CSAF Connector Tests Agent Charter
## Mission
Validate Oracle CSAF connector and normalizer behavior with deterministic tests.
## Responsibilities
- Cover catalog loading, offline snapshots, and connector fetch behavior.
- Cover CSAF normalizer fixture parsing and deterministic output.
## Required Reading
- docs/modules/excititor/architecture.md
- docs/modules/platform/architecture-overview.md
## Definition of Done
- Tests cover success and failure cases for catalog 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,317 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Oracle.CSAF;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Connectors;
public sealed class OracleCsafConnectorTests
{
[Fact]
public async Task FetchAsync_NewEntry_PersistsDocumentAndUpdatesState()
{
var documentUri = new Uri("https://oracle.example/security/csaf/cpu2025oct.json");
var payload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var payloadDigest = ComputeDigest(payload);
var snapshotPath = "/snapshots/oracle-catalog.json";
var fileSystem = new MockFileSystem();
fileSystem.AddFile(snapshotPath, new MockFileData(BuildOfflineSnapshot(documentUri, payloadDigest, "2025-10-15T00:00:00Z")));
var handler = new StubHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
{
[documentUri] = CreateResponse(payload),
});
var httpClient = new HttpClient(handler);
var httpFactory = new SingleHttpClientFactory(httpClient);
var loader = new OracleCatalogLoader(
httpFactory,
new MemoryCache(new MemoryCacheOptions()),
fileSystem,
NullLogger<OracleCatalogLoader>.Instance,
TimeProvider.System);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new OracleCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { new OracleConnectorOptionsValidator(fileSystem) },
NullLogger<OracleCsafConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add(nameof(OracleConnectorOptions.PreferOfflineSnapshot), "true")
.Add(nameof(OracleConnectorOptions.OfflineSnapshotPath), snapshotPath)
.Add(nameof(OracleConnectorOptions.PersistOfflineSnapshot), "false");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(
Since: null,
Settings: settings,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
documents[0].Digest.Should().Be(payloadDigest);
documents[0].Metadata["oracle.csaf.entryId"].Should().Be("CPU2025Oct");
documents[0].Metadata["oracle.csaf.sha256"].Should().Be(payloadDigest);
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().ContainSingle().Which.Should().Be(payloadDigest);
handler.GetCallCount(documentUri).Should().Be(1);
// second run should short-circuit without downloading again
sink.Documents.Clear();
documents.Clear();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
handler.GetCallCount(documentUri).Should().Be(1);
}
[Fact]
public async Task FetchAsync_ChecksumMismatch_SkipsDocument()
{
var documentUri = new Uri("https://oracle.example/security/csaf/cpu2025oct.json");
var payload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var snapshotPath = "/snapshots/oracle-catalog.json";
var fileSystem = new MockFileSystem();
fileSystem.AddFile(snapshotPath, new MockFileData(BuildOfflineSnapshot(documentUri, "deadbeef", "2025-10-15T00:00:00Z")));
var handler = new StubHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
{
[documentUri] = CreateResponse(payload),
});
var httpClient = new HttpClient(handler);
var httpFactory = new SingleHttpClientFactory(httpClient);
var loader = new OracleCatalogLoader(
httpFactory,
new MemoryCache(new MemoryCacheOptions()),
fileSystem,
NullLogger<OracleCatalogLoader>.Instance,
TimeProvider.System);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new OracleCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { new OracleConnectorOptionsValidator(fileSystem) },
NullLogger<OracleCsafConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add(nameof(OracleConnectorOptions.PreferOfflineSnapshot), "true")
.Add(nameof(OracleConnectorOptions.OfflineSnapshotPath), snapshotPath)
.Add(nameof(OracleConnectorOptions.PersistOfflineSnapshot), "false");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(
Since: null,
Settings: settings,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.State.Should().BeNull();
handler.GetCallCount(documentUri).Should().Be(1);
}
private static HttpResponseMessage CreateResponse(byte[] payload)
=> new(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload)
{
Headers =
{
ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"),
}
}
};
private static string ComputeDigest(byte[] payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
}
private static string BuildOfflineSnapshot(Uri documentUri, string sha256, string publishedAt)
{
var snapshot = new
{
metadata = new
{
generatedAt = "2025-10-14T12:00:00Z",
entries = new[]
{
new
{
id = "CPU2025Oct",
title = "Oracle Critical Patch Update Advisory - October 2025",
documentUri = documentUri.ToString(),
publishedAt,
revision = publishedAt,
sha256,
size = 1024,
products = new[] { "Oracle Database" }
}
},
cpuSchedule = Array.Empty<object>()
},
fetchedAt = "2025-10-14T12:00:00Z"
};
return JsonSerializer.Serialize(snapshot, new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
private readonly Dictionary<Uri, int> _callCounts = new();
public StubHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
{
_responses = responses;
}
public int GetCallCount(Uri uri) => _callCounts.TryGetValue(uri, out var count) ? count : 0;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is null || !_responses.TryGetValue(request.RequestUri, out var response))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
_callCounts.TryGetValue(request.RequestUri, out var count);
_callCounts[request.RequestUri] = count + 1;
return Task.FromResult(response.Clone());
}
}
private sealed class SingleHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
=> ValueTask.FromResult(State);
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>());
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}
internal static class HttpResponseMessageExtensions
{
public static HttpResponseMessage Clone(this HttpResponseMessage response)
{
var clone = new HttpResponseMessage(response.StatusCode);
foreach (var header in response.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (response.Content is not null)
{
var payload = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
var mediaType = response.Content.Headers.ContentType?.MediaType ?? "application/json";
clone.Content = new ByteArrayContent(payload);
clone.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mediaType);
}
return clone;
}
}

View File

@@ -0,0 +1,12 @@
# Oracle 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,168 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-20100",
"product": {
"key": "java-se-11",
"name": "Oracle Java SE 11",
"purl": null,
"cpe": "cpe:/a:oracle:jdk:11"
},
"status": "affected",
"justification": null,
"detail": "Oracle Java SE Networking Vulnerability",
"metadata": {
"csaf.product_status.raw": "known_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-APR-2025-JAVA",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2"
}
},
{
"vulnerabilityId": "CVE-2025-20100",
"product": {
"key": "java-se-17",
"name": "Oracle Java SE 17",
"purl": "pkg:maven/oracle/jdk@17.0.11",
"cpe": "cpe:/a:oracle:jdk:17"
},
"status": "fixed",
"justification": null,
"detail": "Oracle Java SE Networking Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-APR-2025-JAVA",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2"
}
},
{
"vulnerabilityId": "CVE-2025-20100",
"product": {
"key": "java-se-21",
"name": "Oracle Java SE 21",
"purl": "pkg:maven/oracle/jdk@21.0.3",
"cpe": "cpe:/a:oracle:jdk:21"
},
"status": "fixed",
"justification": null,
"detail": "Oracle Java SE Networking Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-APR-2025-JAVA",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2"
}
},
{
"vulnerabilityId": "CVE-2025-20100",
"product": {
"key": "java-se-8",
"name": "Oracle Java SE 8",
"purl": null,
"cpe": "cpe:/a:oracle:jdk:1.8.0"
},
"status": "affected",
"justification": null,
"detail": "Oracle Java SE Networking Vulnerability",
"metadata": {
"csaf.product_status.raw": "known_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-APR-2025-JAVA",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2"
}
},
{
"vulnerabilityId": "CVE-2025-20101",
"product": {
"key": "java-se-11",
"name": "Oracle Java SE 11",
"purl": null,
"cpe": "cpe:/a:oracle:jdk:11"
},
"status": "NotAffected",
"justification": "vulnerable_code_not_present",
"detail": "Oracle Java SE Hotspot JIT Compiler Vulnerability",
"metadata": {
"csaf.justification.label": "vulnerable_code_not_present",
"csaf.product_status.raw": "known_not_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-APR-2025-JAVA",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2"
}
},
{
"vulnerabilityId": "CVE-2025-20101",
"product": {
"key": "java-se-17",
"name": "Oracle Java SE 17",
"purl": "pkg:maven/oracle/jdk@17.0.11",
"cpe": "cpe:/a:oracle:jdk:17"
},
"status": "NotAffected",
"justification": "vulnerable_code_not_present",
"detail": "Oracle Java SE Hotspot JIT Compiler Vulnerability",
"metadata": {
"csaf.justification.label": "vulnerable_code_not_present",
"csaf.product_status.raw": "known_not_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-APR-2025-JAVA",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2"
}
},
{
"vulnerabilityId": "CVE-2025-20101",
"product": {
"key": "java-se-21",
"name": "Oracle Java SE 21",
"purl": "pkg:maven/oracle/jdk@21.0.3",
"cpe": "cpe:/a:oracle:jdk:21"
},
"status": "fixed",
"justification": null,
"detail": "Oracle Java SE Hotspot JIT Compiler Vulnerability",
"metadata": {
"csaf.product_status.raw": "fixed",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-APR-2025-JAVA",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2"
}
},
{
"vulnerabilityId": "CVE-2025-20101",
"product": {
"key": "java-se-8",
"name": "Oracle Java SE 8",
"purl": null,
"cpe": "cpe:/a:oracle:jdk:1.8.0"
},
"status": "NotAffected",
"justification": "vulnerable_code_not_present",
"detail": "Oracle Java SE Hotspot JIT Compiler Vulnerability",
"metadata": {
"csaf.justification.label": "vulnerable_code_not_present",
"csaf.product_status.raw": "known_not_affected",
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-APR-2025-JAVA",
"csaf.tracking.status": "final",
"csaf.tracking.version": "2"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,7 @@
{
"claims": [],
"diagnostics": {},
"errors": {
"missing_vulnerabilities": true
}
}

View File

@@ -0,0 +1,24 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-20001",
"product": {
"key": "oracle-db-19c",
"name": "Oracle Database Server 19c",
"purl": null,
"cpe": "cpe:/a:oracle:database_server:19c"
},
"status": "fixed",
"justification": null,
"detail": "Oracle Database Server SQL Injection Vulnerability",
"metadata": {
"csaf.publisher.category": "vendor",
"csaf.publisher.name": "Oracle",
"csaf.tracking.id": "CPU-JAN-2025-001",
"csaf.tracking.status": "final",
"csaf.tracking.version": "1"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,21 @@
# Oracle CSAF Connector Fixtures
This directory contains raw CSAF document fixtures captured from Oracle'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 Oracle's official Critical Patch Update (CPU) security advisories:
- https://www.oracle.com/security-alerts/
## Updating Fixtures
Run the FixtureUpdater tool to refresh fixtures from live sources:
```bash
dotnet run --project tools/FixtureUpdater -- --connector Oracle.CSAF
```

View File

@@ -0,0 +1,75 @@
{
"document": {
"publisher": {
"name": "Oracle",
"category": "vendor",
"namespace": "https://www.oracle.com"
},
"tracking": {
"id": "CPU-APR-2025-JAVA",
"status": "final",
"version": "2",
"initial_release_date": "2025-04-15T00:00:00Z",
"current_release_date": "2025-04-20T08:00:00Z"
},
"title": "Oracle Java SE Critical Patch Update Advisory - April 2025"
},
"product_tree": {
"full_product_names": [
{
"product_id": "java-se-21",
"name": "Oracle Java SE 21",
"product_identification_helper": {
"cpe": "cpe:/a:oracle:jdk:21",
"purl": "pkg:maven/oracle/jdk@21.0.3"
}
},
{
"product_id": "java-se-17",
"name": "Oracle Java SE 17",
"product_identification_helper": {
"cpe": "cpe:/a:oracle:jdk:17",
"purl": "pkg:maven/oracle/jdk@17.0.11"
}
},
{
"product_id": "java-se-11",
"name": "Oracle Java SE 11",
"product_identification_helper": {
"cpe": "cpe:/a:oracle:jdk:11"
}
},
{
"product_id": "java-se-8",
"name": "Oracle Java SE 8",
"product_identification_helper": {
"cpe": "cpe:/a:oracle:jdk:1.8.0"
}
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-20100",
"title": "Oracle Java SE Networking Vulnerability",
"product_status": {
"fixed": ["java-se-21", "java-se-17"],
"known_affected": ["java-se-11", "java-se-8"]
}
},
{
"cve": "CVE-2025-20101",
"title": "Oracle Java SE Hotspot JIT Compiler Vulnerability",
"product_status": {
"fixed": ["java-se-21"],
"known_not_affected": ["java-se-17", "java-se-11", "java-se-8"]
},
"flags": [
{
"label": "vulnerable_code_not_present",
"product_ids": ["java-se-17", "java-se-11", "java-se-8"]
}
]
}
]
}

View File

@@ -0,0 +1,24 @@
{
"document": {
"publisher": {
"name": "Oracle",
"category": "vendor"
},
"tracking": {
"id": "CPU-ERR-2025",
"status": "draft",
"version": "1",
"initial_release_date": "2025-01-01T00:00:00Z",
"current_release_date": "2025-01-01T00:00:00Z"
},
"title": "Incomplete Advisory"
},
"product_tree": {
"full_product_names": [
{
"product_id": "test-product",
"name": "Test Product"
}
]
}
}

View File

@@ -0,0 +1,43 @@
{
"document": {
"publisher": {
"name": "Oracle",
"category": "vendor",
"namespace": "https://www.oracle.com"
},
"tracking": {
"id": "CPU-JAN-2025-001",
"status": "final",
"version": "1",
"initial_release_date": "2025-01-21T00:00:00Z",
"current_release_date": "2025-01-21T00:00:00Z"
},
"title": "Oracle Critical Patch Update Advisory - January 2025"
},
"product_tree": {
"full_product_names": [
{
"product_id": "oracle-db-19c",
"name": "Oracle Database Server 19c",
"product_identification_helper": {
"cpe": "cpe:/a:oracle:database_server:19c"
}
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-20001",
"title": "Oracle Database Server SQL Injection Vulnerability",
"product_status": {
"fixed": ["oracle-db-19c"]
},
"notes": [
{
"category": "description",
"text": "Vulnerability in the Oracle Database Server component. Easily exploitable vulnerability allows low privileged attacker with network access via Oracle Net to compromise Oracle Database Server."
}
]
}
]
}

View File

@@ -0,0 +1,205 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using System.Threading;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Metadata;
public sealed class OracleCatalogLoaderTests
{
private const string SampleCatalog = """
{
"generated": "2025-09-30T18:00:00Z",
"catalog": [
{
"id": "CPU2025Oct",
"title": "Oracle Critical Patch Update Advisory - October 2025",
"published": "2025-10-15T00:00:00Z",
"revision": "2025-10-15T00:00:00Z",
"document": {
"url": "https://updates.oracle.com/cpu/2025-10/cpu2025oct.json",
"sha256": "abc123",
"size": 1024
},
"products": ["Oracle Database", "Java SE"]
}
],
"schedule": [
{ "window": "2025-Oct", "releaseDate": "2025-10-15T00:00:00Z" }
]
}
""";
private const string SampleCalendar = """
{
"cpuWindows": [
{ "name": "2026-Jan", "releaseDate": "2026-01-21T00:00:00Z" }
]
}
""";
[Fact]
public async Task LoadAsync_FetchesAndCachesCatalog()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
{
[new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json")] = CreateResponse(SampleCatalog),
[new Uri("https://www.oracle.com/security-alerts/cpu/cal.json")] = CreateResponse(SampleCalendar),
});
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance, new AdjustableTimeProvider());
var options = new OracleConnectorOptions
{
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
CpuCalendarUri = new Uri("https://www.oracle.com/security-alerts/cpu/cal.json"),
OfflineSnapshotPath = "/snapshots/oracle-catalog.json",
};
var result = await loader.LoadAsync(options, CancellationToken.None);
result.Metadata.Entries.Should().HaveCount(1);
result.Metadata.CpuSchedule.Should().Contain(r => r.Window == "2025-Oct");
result.Metadata.CpuSchedule.Should().Contain(r => r.Window == "2026-Jan");
result.FromCache.Should().BeFalse();
fileSystem.FileExists(options.OfflineSnapshotPath!).Should().BeTrue();
handler.ResetInvocationCount();
var cached = await loader.LoadAsync(options, CancellationToken.None);
cached.FromCache.Should().BeTrue();
handler.InvocationCount.Should().Be(0);
}
[Fact]
public async Task LoadAsync_UsesOfflineSnapshotWhenNetworkFails()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var offlineJson = """
{
"metadata": {
"generatedAt": "2025-09-30T18:00:00Z",
"entries": [
{
"id": "CPU2025Oct",
"title": "Oracle Critical Patch Update Advisory - October 2025",
"documentUri": "https://updates.oracle.com/cpu/2025-10/cpu2025oct.json",
"publishedAt": "2025-10-15T00:00:00Z",
"revision": "2025-10-15T00:00:00Z",
"sha256": "abc123",
"size": 1024,
"products": [ "Oracle Database" ]
}
],
"cpuSchedule": [
{ "window": "2025-Oct", "releaseDate": "2025-10-15T00:00:00Z" }
]
},
"fetchedAt": "2025-10-01T00:00:00Z"
}
""";
fileSystem.AddFile("/snapshots/oracle-catalog.json", new MockFileData(offlineJson));
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance, new AdjustableTimeProvider());
var options = new OracleConnectorOptions
{
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
OfflineSnapshotPath = "/snapshots/oracle-catalog.json",
PreferOfflineSnapshot = true,
};
var result = await loader.LoadAsync(options, CancellationToken.None);
result.FromOfflineSnapshot.Should().BeTrue();
result.Metadata.Entries.Should().NotBeEmpty();
}
[Fact]
public async Task LoadAsync_ThrowsWhenOfflinePreferredButMissing()
{
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
var client = new HttpClient(handler);
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance);
var options = new OracleConnectorOptions
{
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
PreferOfflineSnapshot = true,
OfflineSnapshotPath = "/missing/oracle-catalog.json",
};
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
}
private static HttpResponseMessage CreateResponse(string payload)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class AdjustableTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
public TestHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
{
_responses = responses;
}
public int InvocationCount { get; private set; }
public void ResetInvocationCount() => InvocationCount = 0;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var response))
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return new HttpResponseMessage(response.StatusCode)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
}
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("unexpected request"),
};
}
}
}

View File

@@ -0,0 +1,147 @@
// -----------------------------------------------------------------------------
// OracleCsafNormalizerTests.cs
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
// Task: CONN-FIX-010
// Description: Fixture-based parser/normalizer tests for Oracle 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.Oracle.CSAF.Tests;
/// <summary>
/// Fixture-based normalizer tests for Oracle CSAF documents.
/// Implements Model C1 (Connector/External) test requirements.
/// </summary>
[Trait("Category", TestCategories.Unit)]
[Trait("Category", TestCategories.Snapshot)]
public sealed class OracleCsafNormalizerTests
{
private readonly CsafNormalizer _normalizer;
private readonly VexProvider _provider;
private readonly string _fixturesDir;
private readonly string _expectedDir;
public OracleCsafNormalizerTests()
{
_normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
_provider = new VexProvider("oracle-csaf", "Oracle", VexProviderKind.Vendor);
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-cpu.json", "typical-cpu.canonical.json")]
[InlineData("edge-multi-version.json", "edge-multi-version.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));
}
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("error-missing-vulnerabilities.json", "error-missing-vulnerabilities.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
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-cpu.json")]
[InlineData("edge-multi-version.json")]
public async Task Normalize_SameInput_ProducesDeterministicOutput(string fixtureFile)
{
// Arrange
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
// Act
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
results.Distinct().Should().HaveCount(1);
}
private static VexRawDocument CreateRawDocument(string json)
{
var content = System.Text.Encoding.UTF8.GetBytes(json);
return new VexRawDocument(
"oracle-csaf",
VexDocumentFormat.Csaf,
new Uri("https://www.oracle.com/security-alerts/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.Oracle.CSAF/StellaOps.Excititor.Connectors.Oracle.CSAF.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<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 Oracle 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-0305-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0305-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0305-A | DONE | Waived (test project; revalidated 2026-01-07). |

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

View File

@@ -0,0 +1,24 @@
# Excititor SUSE Rancher VEX Hub Connector Tests Agent Charter
## Mission
Validate SUSE Rancher VEX Hub connector and normalizer behavior with deterministic tests.
## Responsibilities
- Cover connector fetch, event batch parsing, and checkpoint handling.
- Cover discovery metadata and token acquisition behavior (offline/unauthenticated).
- Cover OpenVEX normalizer fixtures and deterministic output.
## Required Reading
- docs/modules/excititor/architecture.md
- docs/modules/platform/architecture-overview.md
## Definition of Done
- Tests cover success and failure cases for connector, metadata loader, and event client.
- 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,138 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using Xunit;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Authentication;
public sealed class RancherHubTokenProviderTests
{
private const string TokenResponse = "{\"access_token\":\"abc123\",\"token_type\":\"Bearer\",\"expires_in\":3600}";
[Fact]
public async Task GetAccessTokenAsync_RequestsAndCachesToken()
{
var handler = TestHttpMessageHandler.RespondWith(request =>
{
request.Headers.Authorization.Should().NotBeNull();
request.Content.Should().NotBeNull();
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TokenResponse, Encoding.UTF8, "application/json"),
};
});
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://identity.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var options = new RancherHubConnectorOptions
{
ClientId = "client",
ClientSecret = "secret",
TokenEndpoint = new Uri("https://identity.suse.com/oauth/token"),
Audience = "https://vexhub.suse.com",
};
options.Scopes.Clear();
options.Scopes.Add("hub.read");
options.Scopes.Add("hub.events");
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
token.Should().NotBeNull();
token!.Value.Should().Be("abc123");
var cached = await provider.GetAccessTokenAsync(options, CancellationToken.None);
cached.Should().NotBeNull();
handler.InvocationCount.Should().Be(1);
}
[Fact]
public async Task GetAccessTokenAsync_ReturnsNullWhenOfflinePreferred()
{
var handler = TestHttpMessageHandler.RespondWith(_ => new HttpResponseMessage(HttpStatusCode.OK));
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://identity.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var options = new RancherHubConnectorOptions
{
PreferOfflineSnapshot = true,
ClientId = "client",
ClientSecret = "secret",
TokenEndpoint = new Uri("https://identity.suse.com/oauth/token"),
};
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
token.Should().BeNull();
handler.InvocationCount.Should().Be(0);
}
[Fact]
public async Task GetAccessTokenAsync_ReturnsNullWithoutCredentials()
{
var handler = TestHttpMessageHandler.RespondWith(_ => new HttpResponseMessage(HttpStatusCode.OK));
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://identity.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var options = new RancherHubConnectorOptions();
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
token.Should().BeNull();
handler.InvocationCount.Should().Be(0);
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
private TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
public int InvocationCount { get; private set; }
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
=> new(responseFactory);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
return Task.FromResult(_responseFactory(request));
}
}
}

View File

@@ -0,0 +1,12 @@
# SUSE Rancher VEX Hub 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,78 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-1001",
"product": {
"key": "pkg:oci/rancher@sha256:v2.8.4",
"name": "pkg:oci/rancher@sha256:v2.8.4",
"purl": "pkg:oci/suse/rancher@2.8.4",
"cpe": null
},
"status": "fixed",
"justification": null,
"detail": "Update to Rancher 2.8.4 or later",
"metadata": {
"openvex.document.author": "SUSE Rancher Security Team",
"openvex.document.version": "3",
"openvex.product.source": "pkg:oci/rancher@sha256:v2.8.4",
"openvex.statement.status": "fixed"
}
},
{
"vulnerabilityId": "CVE-2025-1002",
"product": {
"key": "pkg:oci/rancher@sha256:v2.7.12",
"name": "pkg:oci/rancher@sha256:v2.7.12",
"purl": "pkg:oci/suse/rancher@2.7.12",
"cpe": null
},
"status": "UnderInvestigation",
"justification": null,
"detail": null,
"metadata": {
"openvex.document.author": "SUSE Rancher Security Team",
"openvex.document.version": "3",
"openvex.product.source": "pkg:oci/rancher@sha256:v2.7.12",
"openvex.statement.status": "under_investigation"
}
},
{
"vulnerabilityId": "CVE-2025-1002",
"product": {
"key": "pkg:oci/rancher@sha256:v2.8.4",
"name": "pkg:oci/rancher@sha256:v2.8.4",
"purl": "pkg:oci/suse/rancher@2.8.4",
"cpe": null
},
"status": "UnderInvestigation",
"justification": null,
"detail": null,
"metadata": {
"openvex.document.author": "SUSE Rancher Security Team",
"openvex.document.version": "3",
"openvex.product.source": "pkg:oci/rancher@sha256:v2.8.4",
"openvex.statement.status": "under_investigation"
}
},
{
"vulnerabilityId": "CVE-2025-1003",
"product": {
"key": "pkg:oci/rancher-agent@sha256:v2.8.4",
"name": "pkg:oci/rancher-agent@sha256:v2.8.4",
"purl": "pkg:oci/suse/rancher-agent@2.8.4",
"cpe": null
},
"status": "NotAffected",
"justification": "component_not_present",
"detail": "The rancher-agent image does not include the affected library.",
"metadata": {
"openvex.document.author": "SUSE Rancher Security Team",
"openvex.document.version": "3",
"openvex.product.source": "pkg:oci/rancher-agent@sha256:v2.8.4",
"openvex.statement.justification": "component_not_present",
"openvex.statement.status": "not_affected"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,7 @@
{
"claims": [],
"diagnostics": {},
"errors": {
"missing_statements": true
}
}

View File

@@ -0,0 +1,24 @@
{
"claims": [
{
"vulnerabilityId": "CVE-2025-0001",
"product": {
"key": "pkg:oci/rancher@sha256:abc123",
"name": "pkg:oci/rancher@sha256:abc123",
"purl": "pkg:oci/rancher@sha256:abc123def456",
"cpe": null
},
"status": "NotAffected",
"justification": "vulnerable_code_not_present",
"detail": "Rancher uses a patched version of containerd that is not vulnerable.",
"metadata": {
"openvex.document.author": "SUSE Rancher Security Team",
"openvex.document.version": "1",
"openvex.product.source": "pkg:oci/rancher@sha256:abc123",
"openvex.statement.justification": "vulnerable_code_not_present",
"openvex.statement.status": "not_affected"
}
}
],
"diagnostics": {}
}

View File

@@ -0,0 +1,21 @@
# SUSE Rancher VEX Hub Connector Fixtures
This directory contains raw VEX document fixtures captured from SUSE's Rancher VEX Hub.
## Fixture Categories
- `typical-*.json` - Standard OpenVEX documents with common patterns
- `edge-*.json` - Edge cases (multiple products, status transitions, justifications)
- `error-*.json` - Malformed or missing required fields
## Fixture Sources
Fixtures are captured from SUSE's official Rancher VEX Hub:
- https://github.com/rancher/vexhub
## Updating Fixtures
Run the FixtureUpdater tool to refresh fixtures from live sources:
```bash
dotnet run --project tools/FixtureUpdater -- --connector SUSE.RancherVEXHub
```

View File

@@ -0,0 +1,47 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://rancher.com/security/vex/rancher-2.8.4-1",
"author": "SUSE Rancher Security Team",
"role": "vendor",
"timestamp": "2025-04-15T08:00:00Z",
"version": 3,
"statements": [
{
"vulnerability": "CVE-2025-1001",
"products": [
{
"id": "pkg:oci/rancher@sha256:v2.8.4",
"purl": "pkg:oci/suse/rancher@2.8.4"
}
],
"status": "fixed",
"statement": "Update to Rancher 2.8.4 or later"
},
{
"vulnerability": "CVE-2025-1002",
"products": [
{
"id": "pkg:oci/rancher@sha256:v2.8.4",
"purl": "pkg:oci/suse/rancher@2.8.4"
},
{
"id": "pkg:oci/rancher@sha256:v2.7.12",
"purl": "pkg:oci/suse/rancher@2.7.12"
}
],
"status": "under_investigation"
},
{
"vulnerability": "CVE-2025-1003",
"products": [
{
"id": "pkg:oci/rancher-agent@sha256:v2.8.4",
"purl": "pkg:oci/suse/rancher-agent@2.8.4"
}
],
"status": "not_affected",
"justification": "component_not_present",
"statement": "The rancher-agent image does not include the affected library."
}
]
}

View File

@@ -0,0 +1,7 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://rancher.com/security/vex/invalid-1",
"author": "SUSE Rancher Security Team",
"timestamp": "2025-01-01T00:00:00Z",
"version": 1
}

View File

@@ -0,0 +1,22 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://rancher.com/security/vex/rancher-2.8.3-1",
"author": "SUSE Rancher Security Team",
"role": "vendor",
"timestamp": "2025-03-01T12:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": "CVE-2025-0001",
"products": [
{
"id": "pkg:oci/rancher@sha256:abc123",
"purl": "pkg:oci/rancher@sha256:abc123def456"
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_present",
"statement": "Rancher uses a patched version of containerd that is not vulnerable."
}
]
}

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using System.IO.Abstractions.TestingHelpers;
using System.Threading;
using Xunit;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Metadata;
public sealed class RancherHubMetadataLoaderTests
{
private const string SampleDiscovery = """
{
"hubId": "excititor:suse.rancher",
"title": "SUSE Rancher VEX Hub",
"subscription": {
"eventsUri": "https://vexhub.suse.com/api/v1/events",
"checkpointUri": "https://vexhub.suse.com/api/v1/checkpoints",
"requiresAuthentication": true,
"channels": ["rke2", "k3s"],
"scopes": ["hub.read", "hub.events"]
},
"authentication": {
"tokenUri": "https://identity.suse.com/oauth2/token",
"audience": "https://vexhub.suse.com"
},
"offline": {
"snapshotUri": "https://downloads.suse.com/vexhub/snapshot.json",
"sha256": "deadbeef",
"updated": "2025-10-10T12:00:00Z"
}
}
""";
[Fact]
public async Task LoadAsync_FetchesAndCachesMetadata()
{
var handler = TestHttpMessageHandler.RespondWith(_ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(SampleDiscovery, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
return response;
});
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://vexhub.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var offlinePath = fileSystem.Path.Combine(@"C:\offline", "rancher-hub.json");
var options = new RancherHubConnectorOptions
{
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
OfflineSnapshotPath = offlinePath,
};
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
var result = await loader.LoadAsync(options, CancellationToken.None);
result.FromCache.Should().BeFalse();
result.FromOfflineSnapshot.Should().BeFalse();
result.Metadata.Provider.DisplayName.Should().Be("SUSE Rancher VEX Hub");
result.Metadata.Subscription.EventsUri.Should().Be(new Uri("https://vexhub.suse.com/api/v1/events"));
result.Metadata.Authentication.TokenEndpoint.Should().Be(new Uri("https://identity.suse.com/oauth2/token"));
// Second call should be served from cache (no additional HTTP invocation).
handler.ResetInvocationCount();
await loader.LoadAsync(options, CancellationToken.None);
handler.InvocationCount.Should().Be(0);
}
[Fact]
public async Task LoadAsync_UsesOfflineSnapshotWhenNetworkFails()
{
var handler = TestHttpMessageHandler.RespondWith(_ => throw new HttpRequestException("network down"));
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://vexhub.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var offlinePath = fileSystem.Path.Combine(@"C:\offline", "rancher-hub.json");
fileSystem.AddFile(offlinePath, new MockFileData(SampleDiscovery));
var options = new RancherHubConnectorOptions
{
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
OfflineSnapshotPath = offlinePath,
};
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
var result = await loader.LoadAsync(options, CancellationToken.None);
result.FromOfflineSnapshot.Should().BeTrue();
result.Metadata.Subscription.RequiresAuthentication.Should().BeTrue();
result.Metadata.OfflineSnapshot.Should().NotBeNull();
}
[Fact]
public async Task LoadAsync_ThrowsWhenOfflinePreferredButMissing()
{
var handler = TestHttpMessageHandler.RespondWith(_ => throw new HttpRequestException("network down"));
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://vexhub.suse.com"),
};
var factory = new SingleClientHttpClientFactory(client);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var options = new RancherHubConnectorOptions
{
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
OfflineSnapshotPath = "/offline/missing.json",
PreferOfflineSnapshot = true,
};
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
private TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
public int InvocationCount { get; private set; }
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
=> new(responseFactory);
public void ResetInvocationCount() => InvocationCount = 0;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
return Task.FromResult(_responseFactory(request));
}
}
}

View File

@@ -0,0 +1,170 @@
// -----------------------------------------------------------------------------
// RancherVexHubNormalizerTests.cs
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
// Task: CONN-FIX-010
// Description: Fixture-based parser/normalizer tests for SUSE Rancher VEX Hub connector
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests;
/// <summary>
/// Fixture-based normalizer tests for SUSE Rancher VEX Hub OpenVEX documents.
/// Implements Model C1 (Connector/External) test requirements.
/// </summary>
[Trait("Category", TestCategories.Unit)]
[Trait("Category", TestCategories.Snapshot)]
public sealed class RancherVexHubNormalizerTests
{
private readonly OpenVexNormalizer _normalizer;
private readonly VexProvider _provider;
private readonly string _fixturesDir;
private readonly string _expectedDir;
public RancherVexHubNormalizerTests()
{
_normalizer = new OpenVexNormalizer(NullLogger<OpenVexNormalizer>.Instance);
_provider = new VexProvider("suse-rancher-vexhub", "SUSE Rancher VEX Hub", VexProviderKind.Vendor);
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("typical-rancher.json", "typical-rancher.canonical.json")]
[InlineData("edge-status-transitions.json", "edge-status-transitions.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));
}
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("error-missing-statements.json", "error-missing-statements.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 with missing statements produce empty claims
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-rancher.json")]
[InlineData("edge-status-transitions.json")]
public async Task Normalize_SameInput_ProducesDeterministicOutput(string fixtureFile)
{
// Arrange
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
// Act
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
results.Distinct().Should().HaveCount(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanHandle_OpenVexDocument_ReturnsTrue()
{
// Arrange
var document = new VexRawDocument(
ProviderId: "suse-rancher-vexhub",
Format: VexDocumentFormat.OpenVex,
SourceUri: new Uri("https://example.com/openvex.json"),
RetrievedAt: DateTimeOffset.UtcNow,
Digest: "sha256:test",
Content: ReadOnlyMemory<byte>.Empty,
Metadata: ImmutableDictionary<string, string>.Empty);
// Act
var canHandle = _normalizer.CanHandle(document);
// Assert
canHandle.Should().BeTrue();
}
private static VexRawDocument CreateRawDocument(string json)
{
var content = System.Text.Encoding.UTF8.GetBytes(json);
return new VexRawDocument(
ProviderId: "suse-rancher-vexhub",
Format: VexDocumentFormat.OpenVex,
SourceUri: new Uri("https://github.com/rancher/vexhub/test.json"),
RetrievedAt: DateTimeOffset.UtcNow,
Digest: "sha256:test-" + Guid.NewGuid().ToString("N")[..8],
Content: content,
Metadata: 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,33 @@
<?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.SUSE.RancherVEXHub/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.OpenVEX/StellaOps.Excititor.Formats.OpenVEX.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</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 SUSE Rancher VEX Hub 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-0309-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0309-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0309-A | DONE | Waived (test project; revalidated 2026-01-07). |

View File

@@ -0,0 +1,24 @@
# Excititor Ubuntu CSAF Connector Tests Agent Charter
## Mission
Validate Ubuntu CSAF connector and normalizer behavior with deterministic tests.
## Responsibilities
- Cover connector fetch, checksum/ETag handling, and state updates.
- Cover index/catalog loader offline fallback and channel filtering.
- Cover CSAF normalizer fixtures and deterministic output.
## Required Reading
- docs/modules/excititor/architecture.md
- docs/modules/platform/architecture-overview.md
## Definition of Done
- Tests cover success and failure cases for connector and catalog loader.
- 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,443 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
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.Core.Storage;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
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[]
{
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
});
await connector.ValidateAsync(settings, CancellationToken.None);
var providerStore = new InMemoryProviderStore();
var services = new ServiceCollection()
.AddSingleton<IVexProviderStore>(providerStore)
.BuildServiceProvider();
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 stored = sink.Documents.Single();
stored.Digest.Should().Be($"sha256:{documentSha}");
stored.Metadata.Should().Contain("ubuntu.etag", "etag-123");
stored.Metadata.Should().Contain("vex.provenance.provider", "excititor:ubuntu");
stored.Metadata.Should().Contain("vex.provenance.providerName", "Ubuntu CSAF");
stored.Metadata.Should().Contain("vex.provenance.providerKind", "distro");
stored.Metadata.Should().Contain("vex.provenance.trust.weight", "0.63");
stored.Metadata.Should().Contain("vex.provenance.trust.tier", "distro-trusted");
stored.Metadata.Should().Contain("vex.provenance.trust.note", "tier=distro-trusted;weight=0.63");
stored.Metadata.Should().Contain(
"vex.provenance.pgp.fingerprints",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
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();
// Entry is skipped based on timestamp cursor (entryTimestamp <= since),
// so no additional HTTP request is made on the second pass.
handler.DocumentRequestCount.Should().Be(1);
providerStore.SavedProviders.Should().ContainSingle();
var savedProvider = providerStore.SavedProviders.Single();
savedProvider.Trust.Weight.Should().Be(0.63);
savedProvider.Trust.PgpFingerprints.Should().Contain(new[]
{
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
});
}
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);
var providerStore = new InMemoryProviderStore();
var services = new ServiceCollection()
.AddSingleton<IVexProviderStore>(providerStore)
.BuildServiceProvider();
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();
handler.DocumentRequestCount.Should().Be(1);
providerStore.SavedProviders.Should().ContainSingle();
}
private static VexConnectorSettings BuildConnectorSettings(Uri indexUri, double trustWeight = 0.75, string trustTier = "distro", string[]? fingerprints = null)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
builder["IndexUri"] = indexUri.ToString();
builder["Channels:0"] = "stable";
builder["TrustWeight"] = trustWeight.ToString(CultureInfo.InvariantCulture);
builder["TrustTier"] = trustTier;
if (fingerprints is not null)
{
for (var i = 0; i < fingerprints.Length; i++)
{
builder[$"PgpFingerprints:{i}"] = fingerprints[i];
}
}
return new VexConnectorSettings(builder.ToImmutable());
}
private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp)
{
var catalogUrl = advisoryUri.GetLeftPart(UriPartial.Authority) + "/security/csaf/stable/catalog.json";
var indexJson = $$$"""
{
"generated": "2025-10-18T00:00:00Z",
"channels": [
{
"name": "stable",
"catalogUrl": "{{{catalogUrl}}}",
"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)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
CurrentState = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(
CurrentState is not null ? new[] { CurrentState } : Array.Empty<VexConnectorState>());
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryProviderStore : IVexProviderStore
{
public List<VexProvider> SavedProviders { get; } = new();
public ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken)
=> ValueTask.FromResult(SavedProviders.LastOrDefault(provider => provider.Id == id));
public ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyCollection<VexProvider>>(SavedProviders.ToList());
public ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken)
{
var existingIndex = SavedProviders.FindIndex(p => p.Id == provider.Id);
if (existingIndex >= 0)
{
SavedProviders[existingIndex] = provider;
}
else
{
SavedProviders.Add(provider);
}
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));
}
}

Some files were not shown because too many files have changed in this diff Show More