consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.Metadata;
|
||||
|
||||
public sealed class CiscoProviderMetadataLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesFromNetworkWithBearerToken()
|
||||
{
|
||||
var payload = """
|
||||
{
|
||||
"metadata": {
|
||||
"publisher": {
|
||||
"name": "Cisco CSAF",
|
||||
"category": "vendor",
|
||||
"contact_details": {
|
||||
"id": "excititor:cisco"
|
||||
}
|
||||
}
|
||||
},
|
||||
"distributions": {
|
||||
"directories": [
|
||||
"https://api.security.cisco.com/csaf/v2/advisories/"
|
||||
]
|
||||
},
|
||||
"discovery": {
|
||||
"well_known": "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
|
||||
"rolie": "https://api.security.cisco.com/csaf/rolie/feed"
|
||||
},
|
||||
"trust": {
|
||||
"weight": 0.9,
|
||||
"cosign": {
|
||||
"issuer": "https://oidc.security.cisco.com",
|
||||
"identity_pattern": "spiffe://cisco/*"
|
||||
},
|
||||
"pgp_fingerprints": [ "1234ABCD" ]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
HttpRequestMessage? capturedRequest = null;
|
||||
var handler = new FakeHttpMessageHandler(request =>
|
||||
{
|
||||
capturedRequest = request;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"etag1\"") }
|
||||
};
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = Options.Create(new CiscoConnectorOptions
|
||||
{
|
||||
ApiToken = "token-123",
|
||||
MetadataUri = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
|
||||
});
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new CiscoProviderMetadataLoader(factory, cache, options, NullLogger<CiscoProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var result = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
result.Provider.Id.Should().Be("excititor:cisco");
|
||||
result.Provider.BaseUris.Should().ContainSingle(uri => uri.ToString() == "https://api.security.cisco.com/csaf/v2/advisories/");
|
||||
result.Provider.Discovery.RolIeService.Should().Be(new Uri("https://api.security.cisco.com/csaf/rolie/feed"));
|
||||
result.ServedFromCache.Should().BeFalse();
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest!.Headers.Authorization.Should().NotBeNull();
|
||||
capturedRequest.Headers.Authorization!.Parameter.Should().Be("token-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FallsBackToOfflineSnapshot()
|
||||
{
|
||||
var payload = """
|
||||
{
|
||||
"metadata": {
|
||||
"publisher": {
|
||||
"name": "Cisco CSAF",
|
||||
"category": "vendor",
|
||||
"contact_details": {
|
||||
"id": "excititor:cisco"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = Options.Create(new CiscoConnectorOptions
|
||||
{
|
||||
MetadataUri = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
OfflineSnapshotPath = "/snapshots/cisco.json",
|
||||
});
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/snapshots/cisco.json"] = new MockFileData(payload),
|
||||
});
|
||||
var loader = new CiscoProviderMetadataLoader(factory, cache, options, NullLogger<CiscoProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var result = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
result.FromOfflineSnapshot.Should().BeTrue();
|
||||
result.Provider.Id.Should().Be("excititor:cisco");
|
||||
}
|
||||
|
||||
private sealed class SingleHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class FakeHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
|
||||
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
_responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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). |
|
||||
@@ -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.
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Authentication;
|
||||
|
||||
public sealed class MsrcTokenProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_CachesUntilExpiry()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new[]
|
||||
{
|
||||
CreateTokenResponse("token-1"),
|
||||
CreateTokenResponse("token-2"),
|
||||
});
|
||||
var client = new HttpClient(handler) { BaseAddress = new Uri("https://login.microsoftonline.com/") };
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = Options.Create(new MsrcConnectorOptions
|
||||
{
|
||||
TenantId = "contoso.onmicrosoft.com",
|
||||
ClientId = "client-id",
|
||||
ClientSecret = "secret",
|
||||
});
|
||||
|
||||
var timeProvider = new AdjustableTimeProvider();
|
||||
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance, timeProvider);
|
||||
|
||||
var first = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
first.Value.Should().Be("token-1");
|
||||
handler.InvocationCount.Should().Be(1);
|
||||
|
||||
var second = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
second.Value.Should().Be("token-1");
|
||||
handler.InvocationCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_RefreshesWhenExpired()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new[]
|
||||
{
|
||||
CreateTokenResponse("token-1", expiresIn: 120),
|
||||
CreateTokenResponse("token-2", expiresIn: 3600),
|
||||
});
|
||||
var client = new HttpClient(handler) { BaseAddress = new Uri("https://login.microsoftonline.com/") };
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = Options.Create(new MsrcConnectorOptions
|
||||
{
|
||||
TenantId = "contoso.onmicrosoft.com",
|
||||
ClientId = "client-id",
|
||||
ClientSecret = "secret",
|
||||
ExpiryLeewaySeconds = 60,
|
||||
});
|
||||
|
||||
var timeProvider = new AdjustableTimeProvider();
|
||||
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance, timeProvider);
|
||||
|
||||
var first = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
first.Value.Should().Be("token-1");
|
||||
handler.InvocationCount.Should().Be(1);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
var second = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
second.Value.Should().Be("token-2");
|
||||
handler.InvocationCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_OfflineStaticToken()
|
||||
{
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = Options.Create(new MsrcConnectorOptions
|
||||
{
|
||||
PreferOfflineToken = true,
|
||||
StaticAccessToken = "offline-token",
|
||||
});
|
||||
|
||||
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance);
|
||||
var token = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
token.Value.Should().Be("offline-token");
|
||||
token.ExpiresAt.Should().Be(DateTimeOffset.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_OfflineFileToken()
|
||||
{
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var offlinePath = fileSystem.Path.Combine("/tokens", "msrc.txt");
|
||||
fileSystem.AddFile(offlinePath, new MockFileData("file-token"));
|
||||
|
||||
var options = Options.Create(new MsrcConnectorOptions
|
||||
{
|
||||
PreferOfflineToken = true,
|
||||
OfflineTokenPath = offlinePath,
|
||||
});
|
||||
|
||||
var provider = new MsrcTokenProvider(factory, cache, fileSystem, options, NullLogger<MsrcTokenProvider>.Instance);
|
||||
var token = await provider.GetAccessTokenAsync(CancellationToken.None);
|
||||
token.Value.Should().Be("file-token");
|
||||
token.ExpiresAt.Should().Be(DateTimeOffset.MaxValue);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateTokenResponse(string token, int expiresIn = 3600)
|
||||
{
|
||||
var json = $"{{\"access_token\":\"{token}\",\"token_type\":\"Bearer\",\"expires_in\":{expiresIn}}}";
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class AdjustableTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan span) => _now = _now.Add(span);
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses;
|
||||
|
||||
public TestHttpMessageHandler(IEnumerable<HttpResponseMessage> responses)
|
||||
{
|
||||
_responses = new Queue<HttpResponseMessage>(responses);
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
if (_responses.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("no responses remaining"),
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(_responses.Dequeue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"document": {
|
||||
"publisher": {
|
||||
"name": "Microsoft Security Response Center"
|
||||
},
|
||||
"tracking": {
|
||||
"id": "ADV250099",
|
||||
"status": "draft"
|
||||
}
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-99999"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
@@ -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.
|
||||
@@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Configuration;
|
||||
|
||||
public sealed class OciOpenVexAttestationConnectorOptionsValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WithValidConfiguration_Succeeds()
|
||||
{
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/offline/registry.example.com/repo/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||
});
|
||||
|
||||
var validator = new OciOpenVexAttestationConnectorOptionsValidator(fileSystem);
|
||||
var options = new OciOpenVexAttestationConnectorOptions
|
||||
{
|
||||
AllowHttpRegistries = true,
|
||||
};
|
||||
|
||||
options.Images.Add(new OciImageSubscriptionOptions
|
||||
{
|
||||
Reference = "registry.example.com/repo/image:latest",
|
||||
OfflineBundlePath = "/offline/registry.example.com/repo/latest/openvex-attestations.tgz",
|
||||
});
|
||||
|
||||
options.Registry.Username = "user";
|
||||
options.Registry.Password = "pass";
|
||||
|
||||
options.Cosign.Mode = CosignCredentialMode.None;
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenImagesMissing_AddsError()
|
||||
{
|
||||
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
|
||||
var options = new OciOpenVexAttestationConnectorOptions();
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||
|
||||
errors.Should().ContainSingle().Which.Should().Contain("At least one OCI image reference must be configured.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhenDigestMalformed_AddsError()
|
||||
{
|
||||
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
|
||||
var options = new OciOpenVexAttestationConnectorOptions();
|
||||
options.Images.Add(new OciImageSubscriptionOptions
|
||||
{
|
||||
Reference = "registry.test/repo/image@sha256:not-a-digest",
|
||||
});
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||
|
||||
errors.Should().ContainSingle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 { }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Discovery;
|
||||
|
||||
public sealed class OciAttestationDiscoveryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadAsync_ResolvesOfflinePaths()
|
||||
{
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||
});
|
||||
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||
|
||||
var options = new OciOpenVexAttestationConnectorOptions
|
||||
{
|
||||
AllowHttpRegistries = true,
|
||||
};
|
||||
|
||||
options.Images.Add(new OciImageSubscriptionOptions
|
||||
{
|
||||
Reference = "registry.example.com/repo/image:latest",
|
||||
});
|
||||
|
||||
options.Offline.RootDirectory = "/bundles";
|
||||
options.Cosign.Mode = CosignCredentialMode.None;
|
||||
|
||||
var result = await service.LoadAsync(options, CancellationToken.None);
|
||||
|
||||
result.Targets.Should().ContainSingle();
|
||||
result.Targets[0].OfflineBundle.Should().NotBeNull();
|
||||
var offline = result.Targets[0].OfflineBundle!;
|
||||
offline.Exists.Should().BeTrue();
|
||||
var expectedPath = fileSystem.Path.Combine(
|
||||
fileSystem.Path.GetFullPath("/bundles"),
|
||||
"registry.example.com",
|
||||
"repo",
|
||||
"image",
|
||||
"latest",
|
||||
"openvex-attestations.tgz");
|
||||
offline.Path.Should().Be(expectedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_CachesResults()
|
||||
{
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||
});
|
||||
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||
|
||||
var options = new OciOpenVexAttestationConnectorOptions
|
||||
{
|
||||
AllowHttpRegistries = true,
|
||||
};
|
||||
|
||||
options.Images.Add(new OciImageSubscriptionOptions
|
||||
{
|
||||
Reference = "registry.example.com/repo/image:latest",
|
||||
});
|
||||
|
||||
options.Offline.RootDirectory = "/bundles";
|
||||
options.Cosign.Mode = CosignCredentialMode.None;
|
||||
|
||||
var first = await service.LoadAsync(options, CancellationToken.None);
|
||||
var second = await service.LoadAsync(options, CancellationToken.None);
|
||||
|
||||
ReferenceEquals(first, second).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"claims": [],
|
||||
"diagnostics": {},
|
||||
"errors": {
|
||||
"invalid_predicate": true,
|
||||
"missing_statements": true
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"claims": [],
|
||||
"diagnostics": {},
|
||||
"errors": {
|
||||
"missing_vulnerabilities": true
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Metadata;
|
||||
|
||||
public sealed class OracleCatalogLoaderTests
|
||||
{
|
||||
private const string SampleCatalog = """
|
||||
{
|
||||
"generated": "2025-09-30T18:00:00Z",
|
||||
"catalog": [
|
||||
{
|
||||
"id": "CPU2025Oct",
|
||||
"title": "Oracle Critical Patch Update Advisory - October 2025",
|
||||
"published": "2025-10-15T00:00:00Z",
|
||||
"revision": "2025-10-15T00:00:00Z",
|
||||
"document": {
|
||||
"url": "https://updates.oracle.com/cpu/2025-10/cpu2025oct.json",
|
||||
"sha256": "abc123",
|
||||
"size": 1024
|
||||
},
|
||||
"products": ["Oracle Database", "Java SE"]
|
||||
}
|
||||
],
|
||||
"schedule": [
|
||||
{ "window": "2025-Oct", "releaseDate": "2025-10-15T00:00:00Z" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string SampleCalendar = """
|
||||
{
|
||||
"cpuWindows": [
|
||||
{ "name": "2026-Jan", "releaseDate": "2026-01-21T00:00:00Z" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesAndCachesCatalog()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
|
||||
{
|
||||
[new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json")] = CreateResponse(SampleCatalog),
|
||||
[new Uri("https://www.oracle.com/security-alerts/cpu/cal.json")] = CreateResponse(SampleCalendar),
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new OracleConnectorOptions
|
||||
{
|
||||
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
|
||||
CpuCalendarUri = new Uri("https://www.oracle.com/security-alerts/cpu/cal.json"),
|
||||
OfflineSnapshotPath = "/snapshots/oracle-catalog.json",
|
||||
};
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.Metadata.Entries.Should().HaveCount(1);
|
||||
result.Metadata.CpuSchedule.Should().Contain(r => r.Window == "2025-Oct");
|
||||
result.Metadata.CpuSchedule.Should().Contain(r => r.Window == "2026-Jan");
|
||||
result.FromCache.Should().BeFalse();
|
||||
fileSystem.FileExists(options.OfflineSnapshotPath!).Should().BeTrue();
|
||||
|
||||
handler.ResetInvocationCount();
|
||||
var cached = await loader.LoadAsync(options, CancellationToken.None);
|
||||
cached.FromCache.Should().BeTrue();
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesOfflineSnapshotWhenNetworkFails()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var offlineJson = """
|
||||
{
|
||||
"metadata": {
|
||||
"generatedAt": "2025-09-30T18:00:00Z",
|
||||
"entries": [
|
||||
{
|
||||
"id": "CPU2025Oct",
|
||||
"title": "Oracle Critical Patch Update Advisory - October 2025",
|
||||
"documentUri": "https://updates.oracle.com/cpu/2025-10/cpu2025oct.json",
|
||||
"publishedAt": "2025-10-15T00:00:00Z",
|
||||
"revision": "2025-10-15T00:00:00Z",
|
||||
"sha256": "abc123",
|
||||
"size": 1024,
|
||||
"products": [ "Oracle Database" ]
|
||||
}
|
||||
],
|
||||
"cpuSchedule": [
|
||||
{ "window": "2025-Oct", "releaseDate": "2025-10-15T00:00:00Z" }
|
||||
]
|
||||
},
|
||||
"fetchedAt": "2025-10-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
fileSystem.AddFile("/snapshots/oracle-catalog.json", new MockFileData(offlineJson));
|
||||
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new OracleConnectorOptions
|
||||
{
|
||||
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
|
||||
OfflineSnapshotPath = "/snapshots/oracle-catalog.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
};
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.FromOfflineSnapshot.Should().BeTrue();
|
||||
result.Metadata.Entries.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ThrowsWhenOfflinePreferredButMissing()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new OracleCatalogLoader(factory, cache, fileSystem, NullLogger<OracleCatalogLoader>.Instance);
|
||||
|
||||
var options = new OracleConnectorOptions
|
||||
{
|
||||
CatalogUri = new Uri("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json"),
|
||||
PreferOfflineSnapshot = true,
|
||||
OfflineSnapshotPath = "/missing/oracle-catalog.json",
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(string payload)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class AdjustableTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
|
||||
|
||||
public TestHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public void ResetInvocationCount() => InvocationCount = 0;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var response))
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new HttpResponseMessage(response.StatusCode)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("unexpected request"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
@@ -0,0 +1,24 @@
|
||||
# Excititor RedHat CSAF Connector Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Validate Red Hat CSAF connector and normalizer behavior with deterministic tests.
|
||||
|
||||
## Responsibilities
|
||||
- Cover provider metadata loading and connector fetch behavior.
|
||||
- Cover CSAF normalizer fixture parsing and deterministic output.
|
||||
- Maintain opt-in live schema drift tests for external advisories.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/excititor/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- Tests cover success and failure cases for metadata loading and connector fetch.
|
||||
- Fixtures avoid nondeterministic inputs.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for negative/error paths.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests.Connectors;
|
||||
|
||||
public sealed class RedHatCsafConnectorTests
|
||||
{
|
||||
private static readonly VexConnectorDescriptor Descriptor = new("excititor:redhat", VexProviderKind.Distro, "Red Hat CSAF");
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_EmitsDocumentsAfterSince()
|
||||
{
|
||||
var metadata = """
|
||||
{
|
||||
"metadata": {
|
||||
"provider": { "name": "Red Hat Product Security" }
|
||||
},
|
||||
"distributions": [
|
||||
{ "directory": "https://example.com/security/data/csaf/v2/advisories/" }
|
||||
],
|
||||
"rolie": {
|
||||
"feeds": [
|
||||
{ "url": "https://example.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var feed = """
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<id>urn:redhat:1</id>
|
||||
<updated>2025-10-16T10:00:00Z</updated>
|
||||
<link href="https://example.com/doc1.json" rel="enclosure" />
|
||||
</entry>
|
||||
<entry>
|
||||
<id>urn:redhat:2</id>
|
||||
<updated>2025-10-17T10:00:00Z</updated>
|
||||
<link href="https://example.com/doc2.json" rel="enclosure" />
|
||||
</entry>
|
||||
</feed>
|
||||
""";
|
||||
|
||||
var handler = TestHttpMessageHandler.Create(
|
||||
request => Response(HttpStatusCode.OK, metadata, "application/json"),
|
||||
request => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
|
||||
request => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://example.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = Options.Create(new RedHatConnectorOptions());
|
||||
var metadataLoader = new RedHatProviderMetadataLoader(factory, cache, options, NullLogger<RedHatProviderMetadataLoader>.Instance);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var connector = new RedHatCsafConnector(Descriptor, metadataLoader, factory, stateRepository, NullLogger<RedHatCsafConnector>.Instance, TimeProvider.System);
|
||||
|
||||
var rawSink = new CapturingRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
new DateTimeOffset(2025, 10, 16, 12, 0, 0, TimeSpan.Zero),
|
||||
VexConnectorSettings.Empty,
|
||||
rawSink,
|
||||
new NoopSignatureVerifier(),
|
||||
new NoopNormalizerRouter(),
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var results = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
results.Add(document);
|
||||
}
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Single(rawSink.Documents);
|
||||
Assert.Equal("https://example.com/doc2.json", results[0].SourceUri.ToString());
|
||||
Assert.Equal("https://example.com/doc2.json", rawSink.Documents[0].SourceUri.ToString());
|
||||
Assert.Equal(3, handler.CallCount);
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 17, 10, 0, 0, TimeSpan.Zero));
|
||||
stateRepository.State.DocumentDigests.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_UsesStateToSkipDuplicateDocuments()
|
||||
{
|
||||
var metadata = """
|
||||
{
|
||||
"metadata": {
|
||||
"provider": { "name": "Red Hat Product Security" }
|
||||
},
|
||||
"distributions": [
|
||||
{ "directory": "https://example.com/security/data/csaf/v2/advisories/" }
|
||||
],
|
||||
"rolie": {
|
||||
"feeds": [
|
||||
{ "url": "https://example.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var feed = """
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<id>urn:redhat:1</id>
|
||||
<updated>2025-10-17T10:00:00Z</updated>
|
||||
<link href="https://example.com/doc1.json" rel="enclosure" />
|
||||
</entry>
|
||||
</feed>
|
||||
""";
|
||||
|
||||
var handler1 = TestHttpMessageHandler.Create(
|
||||
_ => Response(HttpStatusCode.OK, metadata, "application/json"),
|
||||
_ => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
|
||||
_ => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
|
||||
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
await ExecuteFetchAsync(handler1, stateRepository);
|
||||
|
||||
stateRepository.State.Should().NotBeNull();
|
||||
var previousState = stateRepository.State!;
|
||||
|
||||
var handler2 = TestHttpMessageHandler.Create(
|
||||
_ => Response(HttpStatusCode.OK, metadata, "application/json"),
|
||||
_ => Response(HttpStatusCode.OK, feed, "application/atom+xml"),
|
||||
_ => Response(HttpStatusCode.OK, "{ \"csaf\": 1 }", "application/json"));
|
||||
|
||||
var (results, rawSink) = await ExecuteFetchAsync(handler2, stateRepository);
|
||||
|
||||
results.Should().BeEmpty();
|
||||
rawSink.Documents.Should().BeEmpty();
|
||||
stateRepository.State!.DocumentDigests.Should().Equal(previousState.DocumentDigests);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
|
||||
=> new(statusCode)
|
||||
{
|
||||
Content = new StringContent(content, Encoding.UTF8, contentType),
|
||||
};
|
||||
|
||||
private sealed class CapturingRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
|
||||
|
||||
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
|
||||
{
|
||||
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
|
||||
=> new(responders);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
if (_responders.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No responder configured for request.");
|
||||
}
|
||||
|
||||
var responder = _responders.Count > 1
|
||||
? _responders.Dequeue()
|
||||
: _responders.Peek();
|
||||
|
||||
var response = responder(request);
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(List<VexRawDocument> Documents, CapturingRawSink Sink)> ExecuteFetchAsync(
|
||||
TestHttpMessageHandler handler,
|
||||
InMemoryConnectorStateRepository stateRepository)
|
||||
{
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://example.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var options = Options.Create(new RedHatConnectorOptions());
|
||||
var metadataLoader = new RedHatProviderMetadataLoader(factory, cache, options, NullLogger<RedHatProviderMetadataLoader>.Instance);
|
||||
var connector = new RedHatCsafConnector(Descriptor, metadataLoader, factory, stateRepository, NullLogger<RedHatCsafConnector>.Instance, TimeProvider.System);
|
||||
|
||||
var rawSink = new CapturingRawSink();
|
||||
var context = new VexConnectorContext(
|
||||
null,
|
||||
VexConnectorSettings.Empty,
|
||||
rawSink,
|
||||
new NoopSignatureVerifier(),
|
||||
new NoopNormalizerRouter(),
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
return (documents, rawSink);
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? State { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (State is not null && string.Equals(State.ConnectorId, connectorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult<VexConnectorState?>(State);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<VexConnectorState?>(null);
|
||||
}
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
|
||||
{
|
||||
State = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IReadOnlyCollection<VexConnectorState>>(
|
||||
State is not null ? new[] { State } : Array.Empty<VexConnectorState>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# RedHat CSAF Expected Outputs
|
||||
|
||||
This directory contains expected normalized VEX claim snapshots for each fixture.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
- `{fixture-name}.canonical.json` - Expected normalized output for successful parsing
|
||||
- `{fixture-name}.error.json` - Expected error classification for malformed inputs
|
||||
|
||||
## Snapshot Format
|
||||
|
||||
Expected outputs use the internal normalized VEX claim model in canonical JSON format.
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5678",
|
||||
"product": {
|
||||
"key": "rhel-7-openssl-legacy",
|
||||
"name": "Red Hat Enterprise Linux 7 openssl (legacy)",
|
||||
"purl": null,
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:7::openssl"
|
||||
},
|
||||
"status": "NotAffected",
|
||||
"justification": "VulnerableCodeNotPresent",
|
||||
"detail": "OpenSSL buffer overflow in X.509 certificate verification",
|
||||
"metadata": {
|
||||
"csaf.justification.label": "vulnerable_code_not_present",
|
||||
"csaf.product_status.raw": "known_not_affected",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5678",
|
||||
"product": {
|
||||
"key": "rhel-8-openssl",
|
||||
"name": "Red Hat Enterprise Linux 8 openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@1.1.1k-12.el8_9",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:8::openssl"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL buffer overflow in X.509 certificate verification",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5678",
|
||||
"product": {
|
||||
"key": "rhel-9-openssl",
|
||||
"name": "Red Hat Enterprise Linux 9 openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@3.0.7-25.el9_3",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::openssl"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL buffer overflow in X.509 certificate verification",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5679",
|
||||
"product": {
|
||||
"key": "rhel-7-openssl-legacy",
|
||||
"name": "Red Hat Enterprise Linux 7 openssl (legacy)",
|
||||
"purl": null,
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:7::openssl"
|
||||
},
|
||||
"status": "affected",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL timing side-channel in RSA decryption",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "known_affected",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5679",
|
||||
"product": {
|
||||
"key": "rhel-8-openssl",
|
||||
"name": "Red Hat Enterprise Linux 8 openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@1.1.1k-12.el8_9",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:8::openssl"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL timing side-channel in RSA decryption",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-5679",
|
||||
"product": {
|
||||
"key": "rhel-9-openssl",
|
||||
"name": "Red Hat Enterprise Linux 9 openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@3.0.7-25.el9_3",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::openssl"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "OpenSSL timing side-channel in RSA decryption",
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:2001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "5"
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-9999",
|
||||
"product": {
|
||||
"key": "rhel-9-test",
|
||||
"name": "Test Product",
|
||||
"purl": null,
|
||||
"cpe": null
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": null,
|
||||
"metadata": {
|
||||
"csaf.product_status.raw": "fixed",
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security"
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-1234",
|
||||
"product": {
|
||||
"key": "rhel-9-kernel",
|
||||
"name": "Red Hat Enterprise Linux 9 kernel",
|
||||
"purl": "pkg:rpm/redhat/kernel@5.14.0-427.13.1.el9_4",
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::kernel"
|
||||
},
|
||||
"status": "fixed",
|
||||
"justification": null,
|
||||
"detail": "Kernel privilege escalation vulnerability",
|
||||
"metadata": {
|
||||
"csaf.publisher.category": "vendor",
|
||||
"csaf.publisher.name": "Red Hat Product Security",
|
||||
"csaf.tracking.id": "RHSA-2025:1001",
|
||||
"csaf.tracking.status": "final",
|
||||
"csaf.tracking.version": "3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# RedHat CSAF Connector Fixtures
|
||||
|
||||
This directory contains raw CSAF document fixtures captured from Red Hat's security feed.
|
||||
|
||||
## Fixture Categories
|
||||
|
||||
- `typical-*.json` - Standard CSAF documents with common patterns
|
||||
- `edge-*.json` - Edge cases (multiple products, complex remediations)
|
||||
- `error-*.json` - Malformed or missing required fields
|
||||
|
||||
## Fixture Sources
|
||||
|
||||
Fixtures are captured from Red Hat's official CSAF feed:
|
||||
- https://access.redhat.com/security/data/csaf/v2/advisories/
|
||||
|
||||
## Updating Fixtures
|
||||
|
||||
Run the FixtureUpdater tool to refresh fixtures from live sources:
|
||||
```bash
|
||||
dotnet run --project tools/FixtureUpdater -- --connector RedHat.CSAF
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"document": {
|
||||
"publisher": {
|
||||
"name": "Red Hat Product Security",
|
||||
"category": "vendor",
|
||||
"namespace": "https://www.redhat.com"
|
||||
},
|
||||
"tracking": {
|
||||
"id": "RHSA-2025:2001",
|
||||
"status": "final",
|
||||
"version": "5",
|
||||
"initial_release_date": "2025-09-15T08:00:00Z",
|
||||
"current_release_date": "2025-11-20T14:30:00Z"
|
||||
},
|
||||
"title": "Critical: openssl security update"
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "rhel-8-openssl",
|
||||
"name": "Red Hat Enterprise Linux 8 openssl",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:8::openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@1.1.1k-12.el8_9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"product_id": "rhel-9-openssl",
|
||||
"name": "Red Hat Enterprise Linux 9 openssl",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::openssl",
|
||||
"purl": "pkg:rpm/redhat/openssl@3.0.7-25.el9_3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"product_id": "rhel-7-openssl-legacy",
|
||||
"name": "Red Hat Enterprise Linux 7 openssl (legacy)",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:7::openssl"
|
||||
}
|
||||
}
|
||||
],
|
||||
"product_groups": [
|
||||
{
|
||||
"group_id": "affected-openssl-group",
|
||||
"product_ids": ["rhel-8-openssl", "rhel-9-openssl"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-5678",
|
||||
"title": "OpenSSL buffer overflow in X.509 certificate verification",
|
||||
"product_status": {
|
||||
"fixed": ["rhel-8-openssl", "rhel-9-openssl"],
|
||||
"known_not_affected": ["rhel-7-openssl-legacy"]
|
||||
},
|
||||
"flags": [
|
||||
{
|
||||
"label": "vulnerable_code_not_present",
|
||||
"product_ids": ["rhel-7-openssl-legacy"]
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"category": "description",
|
||||
"text": "A buffer overflow vulnerability was found in OpenSSL X.509 certificate verification."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cve": "CVE-2025-5679",
|
||||
"title": "OpenSSL timing side-channel in RSA decryption",
|
||||
"product_status": {
|
||||
"known_affected": ["rhel-7-openssl-legacy"],
|
||||
"fixed": ["rhel-8-openssl", "rhel-9-openssl"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"document": {
|
||||
"publisher": {
|
||||
"name": "Red Hat Product Security",
|
||||
"category": "vendor"
|
||||
}
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "rhel-9-test",
|
||||
"name": "Test Product"
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-9999",
|
||||
"product_status": {
|
||||
"fixed": ["rhel-9-test"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"document": {
|
||||
"publisher": {
|
||||
"name": "Red Hat Product Security",
|
||||
"category": "vendor",
|
||||
"namespace": "https://www.redhat.com"
|
||||
},
|
||||
"tracking": {
|
||||
"id": "RHSA-2025:1001",
|
||||
"status": "final",
|
||||
"version": "3",
|
||||
"initial_release_date": "2025-10-01T12:00:00Z",
|
||||
"current_release_date": "2025-10-05T10:00:00Z"
|
||||
},
|
||||
"title": "Important: kernel security update"
|
||||
},
|
||||
"product_tree": {
|
||||
"full_product_names": [
|
||||
{
|
||||
"product_id": "rhel-9-kernel",
|
||||
"name": "Red Hat Enterprise Linux 9 kernel",
|
||||
"product_identification_helper": {
|
||||
"cpe": "cpe:/a:redhat:enterprise_linux:9::kernel",
|
||||
"purl": "pkg:rpm/redhat/kernel@5.14.0-427.13.1.el9_4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"branches": [
|
||||
{
|
||||
"name": "Red Hat Enterprise Linux",
|
||||
"category": "product_family",
|
||||
"branches": [
|
||||
{
|
||||
"name": "9",
|
||||
"category": "product_version",
|
||||
"product": {
|
||||
"product_id": "rhel-9-kernel",
|
||||
"name": "Red Hat Enterprise Linux 9 kernel"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"cve": "CVE-2025-1234",
|
||||
"title": "Kernel privilege escalation vulnerability",
|
||||
"product_status": {
|
||||
"fixed": ["rhel-9-kernel"]
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"category": "description",
|
||||
"text": "A flaw was found in the kernel that allows local privilege escalation."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests.Metadata;
|
||||
|
||||
public sealed class RedHatProviderMetadataLoaderTests
|
||||
{
|
||||
private const string SampleJson = """
|
||||
{
|
||||
"metadata": {
|
||||
"provider": {
|
||||
"name": "Red Hat Product Security"
|
||||
}
|
||||
},
|
||||
"distributions": [
|
||||
{ "directory": "https://access.redhat.com/security/data/csaf/v2/advisories/" }
|
||||
],
|
||||
"rolie": {
|
||||
"feeds": [
|
||||
{ "url": "https://access.redhat.com/security/data/csaf/v2/advisories/rolie/feed.atom" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesMetadataAndCaches()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(SampleJson, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://access.redhat.com/"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = new RedHatConnectorOptions
|
||||
{
|
||||
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
|
||||
OfflineSnapshotPath = fileSystem.Path.Combine("/offline", "redhat-provider.json"),
|
||||
CosignIssuer = "https://sigstore.dev/redhat",
|
||||
CosignIdentityPattern = "^spiffe://redhat/.+$",
|
||||
};
|
||||
options.PgpFingerprints.Add("A1B2C3D4E5F6");
|
||||
options.Validate(fileSystem);
|
||||
|
||||
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var result = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Red Hat Product Security", result.Provider.DisplayName);
|
||||
Assert.False(result.FromCache);
|
||||
Assert.False(result.FromOfflineSnapshot);
|
||||
Assert.Single(result.Provider.BaseUris);
|
||||
Assert.Equal("https://access.redhat.com/security/data/csaf/v2/advisories/", result.Provider.BaseUris[0].ToString());
|
||||
Assert.Equal("https://access.redhat.com/.well-known/csaf/provider-metadata.json", result.Provider.Discovery.WellKnownMetadata?.ToString());
|
||||
Assert.Equal("https://access.redhat.com/security/data/csaf/v2/advisories/rolie/feed.atom", result.Provider.Discovery.RolIeService?.ToString());
|
||||
Assert.Equal(1.0, result.Provider.Trust.Weight);
|
||||
Assert.NotNull(result.Provider.Trust.Cosign);
|
||||
Assert.Equal("https://sigstore.dev/redhat", result.Provider.Trust.Cosign!.Issuer);
|
||||
Assert.Equal("^spiffe://redhat/.+$", result.Provider.Trust.Cosign.IdentityPattern);
|
||||
Assert.Contains("A1B2C3D4E5F6", result.Provider.Trust.PgpFingerprints);
|
||||
Assert.True(fileSystem.FileExists(options.OfflineSnapshotPath));
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
|
||||
var second = await loader.LoadAsync(CancellationToken.None);
|
||||
Assert.True(second.FromCache);
|
||||
Assert.False(second.FromOfflineSnapshot);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesOfflineSnapshotWhenPreferred()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => throw new InvalidOperationException("HTTP should not be called"));
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||
{
|
||||
["/snapshots/redhat.json"] = new MockFileData(SampleJson),
|
||||
});
|
||||
|
||||
var options = new RedHatConnectorOptions
|
||||
{
|
||||
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
|
||||
OfflineSnapshotPath = "/snapshots/redhat.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
PersistOfflineSnapshot = false,
|
||||
};
|
||||
options.Validate(fileSystem);
|
||||
|
||||
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var result = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Red Hat Product Security", result.Provider.DisplayName);
|
||||
Assert.False(result.FromCache);
|
||||
Assert.True(result.FromOfflineSnapshot);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
|
||||
var second = await loader.LoadAsync(CancellationToken.None);
|
||||
Assert.True(second.FromCache);
|
||||
Assert.True(second.FromOfflineSnapshot);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesETagForConditionalRequest()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.Create(
|
||||
_ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(SampleJson, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
|
||||
return response;
|
||||
},
|
||||
request =>
|
||||
{
|
||||
Assert.Contains(request.Headers.IfNoneMatch, etag => etag.Tag == "\"abc\"");
|
||||
return new HttpResponseMessage(HttpStatusCode.NotModified);
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = new RedHatConnectorOptions
|
||||
{
|
||||
MetadataUri = new Uri("https://access.redhat.com/.well-known/csaf/provider-metadata.json"),
|
||||
OfflineSnapshotPath = "/offline/redhat.json",
|
||||
MetadataCacheDuration = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
options.Validate(fileSystem);
|
||||
|
||||
var loader = new RedHatProviderMetadataLoader(factory, cache, Options.Create(options), NullLogger<RedHatProviderMetadataLoader>.Instance, fileSystem);
|
||||
|
||||
var first = await loader.LoadAsync(CancellationToken.None);
|
||||
Assert.False(first.FromCache);
|
||||
Assert.False(first.FromOfflineSnapshot);
|
||||
|
||||
Assert.True(cache.TryGetValue(RedHatProviderMetadataLoader.CacheKey, out var entryObj));
|
||||
Assert.NotNull(entryObj);
|
||||
|
||||
var entryType = entryObj!.GetType();
|
||||
var provider = entryType.GetProperty("Provider")!.GetValue(entryObj);
|
||||
var fetchedAt = entryType.GetProperty("FetchedAt")!.GetValue(entryObj);
|
||||
var etag = entryType.GetProperty("ETag")!.GetValue(entryObj);
|
||||
var fromOffline = entryType.GetProperty("FromOffline")!.GetValue(entryObj);
|
||||
|
||||
var expiredEntry = Activator.CreateInstance(entryType, provider, fetchedAt, DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1), etag, fromOffline);
|
||||
cache.Set(RedHatProviderMetadataLoader.CacheKey, expiredEntry!, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1),
|
||||
});
|
||||
|
||||
var second = await loader.LoadAsync(CancellationToken.None);
|
||||
|
||||
var third = await loader.LoadAsync(CancellationToken.None);
|
||||
Assert.True(third.FromCache);
|
||||
|
||||
Assert.Equal(2, handler.CallCount);
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
|
||||
|
||||
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
|
||||
{
|
||||
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
=> new(new[] { responder });
|
||||
|
||||
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
|
||||
=> new(responders);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
if (_responders.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No responder configured for request.");
|
||||
}
|
||||
|
||||
var responder = _responders.Count > 1
|
||||
? _responders.Dequeue()
|
||||
: _responders.Peek();
|
||||
|
||||
var response = responder(request);
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RedHatCsafLiveSchemaTests.cs
|
||||
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
|
||||
// Task: CONN-FIX-015
|
||||
// Description: Live schema drift detection tests for RedHat CSAF connector
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Connectors;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live schema drift detection tests for Red Hat CSAF documents.
|
||||
/// These tests verify that live Red Hat security advisories match our fixture schema.
|
||||
///
|
||||
/// IMPORTANT: These tests are opt-in and disabled by default.
|
||||
/// To run: set STELLAOPS_LIVE_TESTS=true
|
||||
/// To auto-update: set STELLAOPS_UPDATE_FIXTURES=true
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Live)]
|
||||
public sealed class RedHatCsafLiveSchemaTests : ConnectorLiveSchemaTestBase
|
||||
{
|
||||
protected override string FixturesDirectory =>
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures");
|
||||
|
||||
protected override string ConnectorName => "RedHat-CSAF";
|
||||
|
||||
protected override IEnumerable<LiveSchemaTestCase> GetTestCases()
|
||||
{
|
||||
// Red Hat CSAF advisories are available at:
|
||||
// https://access.redhat.com/security/data/csaf/v2/advisories/
|
||||
|
||||
yield return new(
|
||||
"typical-rhsa.json",
|
||||
"https://access.redhat.com/security/data/csaf/v2/advisories/2025/rhsa-2025_0001.json",
|
||||
"Typical RHSA advisory with product branches and fixed status");
|
||||
|
||||
yield return new(
|
||||
"edge-multi-product.json",
|
||||
"https://access.redhat.com/security/data/csaf/v2/advisories/2025/rhsa-2025_0002.json",
|
||||
"Edge case: multiple products and CVEs in single advisory");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects schema drift between live Red Hat CSAF API and stored fixtures.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Run with: dotnet test --filter "Category=Live"
|
||||
/// Or: STELLAOPS_LIVE_TESTS=true dotnet test --filter "FullyQualifiedName~RedHatCsafLiveSchemaTests"
|
||||
/// </remarks>
|
||||
[LiveTest]
|
||||
public async Task DetectSchemaDrift()
|
||||
{
|
||||
await RunSchemaDriftTestsAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RedHatCsafNormalizerTests.cs
|
||||
// Sprint: SPRINT_5100_0007_0005_connector_fixtures
|
||||
// Task: CONN-FIX-010
|
||||
// Description: Fixture-based parser/normalizer tests for RedHat CSAF connector
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Formats.CSAF;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fixture-based normalizer tests for RedHat CSAF documents.
|
||||
/// Implements Model C1 (Connector/External) test requirements:
|
||||
/// - raw upstream payload fixture → normalized internal model snapshot
|
||||
/// - deterministic parsing (same input → same output)
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Snapshot)]
|
||||
public sealed class RedHatCsafNormalizerTests
|
||||
{
|
||||
private readonly CsafNormalizer _normalizer;
|
||||
private readonly VexProvider _provider;
|
||||
private readonly string _fixturesDir;
|
||||
private readonly string _expectedDir;
|
||||
|
||||
public RedHatCsafNormalizerTests()
|
||||
{
|
||||
_normalizer = new CsafNormalizer(NullLogger<CsafNormalizer>.Instance);
|
||||
_provider = new VexProvider("redhat-csaf", "Red Hat CSAF", VexProviderKind.Vendor);
|
||||
_fixturesDir = Path.Combine(AppContext.BaseDirectory, "Fixtures");
|
||||
_expectedDir = Path.Combine(AppContext.BaseDirectory, "Expected");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("typical-rhsa.json", "typical-rhsa.canonical.json")]
|
||||
[InlineData("edge-multi-product.json", "edge-multi-product.canonical.json")]
|
||||
public async Task Normalize_Fixture_ProducesExpectedClaims(string fixtureFile, string expectedFile)
|
||||
{
|
||||
// Arrange
|
||||
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
|
||||
var rawDocument = CreateRawDocument(rawJson);
|
||||
|
||||
// Act
|
||||
var batch = await _normalizer.NormalizeAsync(rawDocument, _provider, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
batch.Claims.Should().NotBeEmpty();
|
||||
|
||||
var expectedJson = await File.ReadAllTextAsync(Path.Combine(_expectedDir, expectedFile));
|
||||
var expected = JsonSerializer.Deserialize<ExpectedClaimBatch>(expectedJson, JsonOptions);
|
||||
|
||||
batch.Claims.Length.Should().Be(expected!.Claims.Count);
|
||||
|
||||
for (int i = 0; i < batch.Claims.Length; i++)
|
||||
{
|
||||
var actual = batch.Claims[i];
|
||||
var expectedClaim = expected.Claims[i];
|
||||
|
||||
actual.VulnerabilityId.Should().Be(expectedClaim.VulnerabilityId);
|
||||
actual.Product.Key.Should().Be(expectedClaim.Product.Key);
|
||||
actual.Status.Should().Be(Enum.Parse<VexClaimStatus>(expectedClaim.Status, ignoreCase: true));
|
||||
|
||||
if (expectedClaim.Justification is not null)
|
||||
{
|
||||
actual.Justification.Should().Be(Enum.Parse<VexJustification>(expectedClaim.Justification, ignoreCase: true));
|
||||
}
|
||||
else
|
||||
{
|
||||
actual.Justification.Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("error-missing-tracking.json", "error-missing-tracking.error.json")]
|
||||
public async Task Normalize_ErrorFixture_ProducesExpectedOutput(string fixtureFile, string expectedFile)
|
||||
{
|
||||
// Arrange
|
||||
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
|
||||
var rawDocument = CreateRawDocument(rawJson);
|
||||
|
||||
// Act
|
||||
var batch = await _normalizer.NormalizeAsync(rawDocument, _provider, CancellationToken.None);
|
||||
|
||||
// Assert - error fixtures may still produce claims but with limited metadata
|
||||
var expectedJson = await File.ReadAllTextAsync(Path.Combine(_expectedDir, expectedFile));
|
||||
var expected = JsonSerializer.Deserialize<ExpectedClaimBatch>(expectedJson, JsonOptions);
|
||||
|
||||
batch.Claims.Length.Should().Be(expected!.Claims.Count);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("typical-rhsa.json")]
|
||||
[InlineData("edge-multi-product.json")]
|
||||
[InlineData("error-missing-tracking.json")]
|
||||
public async Task Normalize_SameInput_ProducesDeterministicOutput(string fixtureFile)
|
||||
{
|
||||
// Arrange
|
||||
var rawJson = await File.ReadAllTextAsync(Path.Combine(_fixturesDir, fixtureFile));
|
||||
|
||||
// Act - parse multiple times
|
||||
var results = new List<string>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var rawDocument = CreateRawDocument(rawJson);
|
||||
var batch = await _normalizer.NormalizeAsync(rawDocument, _provider, CancellationToken.None);
|
||||
var serialized = SerializeClaims(batch.Claims);
|
||||
results.Add(serialized);
|
||||
}
|
||||
|
||||
// Assert - all results should be identical
|
||||
results.Distinct().Should().HaveCount(1,
|
||||
$"parsing '{fixtureFile}' multiple times should produce identical output");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanHandle_CsafDocument_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var document = new VexRawDocument(
|
||||
"redhat-csaf",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://example.com/csaf.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:test",
|
||||
ReadOnlyMemory<byte>.Empty,
|
||||
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
// Act
|
||||
var canHandle = _normalizer.CanHandle(document);
|
||||
|
||||
// Assert
|
||||
canHandle.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanHandle_NonCsafDocument_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var document = new VexRawDocument(
|
||||
"some-provider",
|
||||
VexDocumentFormat.OpenVex,
|
||||
new Uri("https://example.com/openvex.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:test",
|
||||
ReadOnlyMemory<byte>.Empty,
|
||||
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
// Act
|
||||
var canHandle = _normalizer.CanHandle(document);
|
||||
|
||||
// Assert
|
||||
canHandle.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static VexRawDocument CreateRawDocument(string json)
|
||||
{
|
||||
var content = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
return new VexRawDocument(
|
||||
"redhat-csaf",
|
||||
VexDocumentFormat.Csaf,
|
||||
new Uri("https://access.redhat.com/security/data/csaf/v2/advisories/test.json"),
|
||||
DateTimeOffset.UtcNow,
|
||||
"sha256:test-" + Guid.NewGuid().ToString("N")[..8],
|
||||
content,
|
||||
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
private static string SerializeClaims(IReadOnlyList<VexClaim> claims)
|
||||
{
|
||||
var simplified = claims.Select(c => new
|
||||
{
|
||||
c.VulnerabilityId,
|
||||
ProductKey = c.Product.Key,
|
||||
Status = c.Status.ToString(),
|
||||
Justification = c.Justification?.ToString()
|
||||
});
|
||||
return JsonSerializer.Serialize(simplified, JsonOptions);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private sealed record ExpectedClaimBatch(List<ExpectedClaim> Claims, Dictionary<string, string>? Diagnostics);
|
||||
|
||||
private sealed record ExpectedClaim(
|
||||
string VulnerabilityId,
|
||||
ExpectedProduct Product,
|
||||
string Status,
|
||||
string? Justification,
|
||||
string? Detail,
|
||||
Dictionary<string, string>? Metadata);
|
||||
|
||||
private sealed record ExpectedProduct(
|
||||
string Key,
|
||||
string? Name,
|
||||
string? Purl,
|
||||
string? Cpe);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.RedHat.CSAF/StellaOps.Excititor.Connectors.RedHat.CSAF.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CSAF/StellaOps.Excititor.Formats.CSAF.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Expected\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor RedHat CSAF Connector Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0307-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0307-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0307-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
@@ -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.
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Authentication;
|
||||
|
||||
public sealed class RancherHubTokenProviderTests
|
||||
{
|
||||
private const string TokenResponse = "{\"access_token\":\"abc123\",\"token_type\":\"Bearer\",\"expires_in\":3600}";
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_RequestsAndCachesToken()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(request =>
|
||||
{
|
||||
request.Headers.Authorization.Should().NotBeNull();
|
||||
request.Content.Should().NotBeNull();
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TokenResponse, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://identity.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
ClientId = "client",
|
||||
ClientSecret = "secret",
|
||||
TokenEndpoint = new Uri("https://identity.suse.com/oauth/token"),
|
||||
Audience = "https://vexhub.suse.com",
|
||||
};
|
||||
options.Scopes.Clear();
|
||||
options.Scopes.Add("hub.read");
|
||||
options.Scopes.Add("hub.events");
|
||||
|
||||
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
|
||||
token.Should().NotBeNull();
|
||||
token!.Value.Should().Be("abc123");
|
||||
|
||||
var cached = await provider.GetAccessTokenAsync(options, CancellationToken.None);
|
||||
cached.Should().NotBeNull();
|
||||
handler.InvocationCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_ReturnsNullWhenOfflinePreferred()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://identity.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
PreferOfflineSnapshot = true,
|
||||
ClientId = "client",
|
||||
ClientSecret = "secret",
|
||||
TokenEndpoint = new Uri("https://identity.suse.com/oauth/token"),
|
||||
};
|
||||
|
||||
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
|
||||
token.Should().BeNull();
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenAsync_ReturnsNullWithoutCredentials()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://identity.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var provider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var options = new RancherHubConnectorOptions();
|
||||
|
||||
var token = await provider.GetAccessTokenAsync(options, CancellationToken.None);
|
||||
token.Should().BeNull();
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
private TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
=> new(responseFactory);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"claims": [],
|
||||
"diagnostics": {},
|
||||
"errors": {
|
||||
"missing_statements": true
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Metadata;
|
||||
|
||||
public sealed class RancherHubMetadataLoaderTests
|
||||
{
|
||||
private const string SampleDiscovery = """
|
||||
{
|
||||
"hubId": "excititor:suse.rancher",
|
||||
"title": "SUSE Rancher VEX Hub",
|
||||
"subscription": {
|
||||
"eventsUri": "https://vexhub.suse.com/api/v1/events",
|
||||
"checkpointUri": "https://vexhub.suse.com/api/v1/checkpoints",
|
||||
"requiresAuthentication": true,
|
||||
"channels": ["rke2", "k3s"],
|
||||
"scopes": ["hub.read", "hub.events"]
|
||||
},
|
||||
"authentication": {
|
||||
"tokenUri": "https://identity.suse.com/oauth2/token",
|
||||
"audience": "https://vexhub.suse.com"
|
||||
},
|
||||
"offline": {
|
||||
"snapshotUri": "https://downloads.suse.com/vexhub/snapshot.json",
|
||||
"sha256": "deadbeef",
|
||||
"updated": "2025-10-10T12:00:00Z"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesAndCachesMetadata()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ =>
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(SampleDiscovery, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue("\"abc\"");
|
||||
return response;
|
||||
});
|
||||
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://vexhub.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var offlinePath = fileSystem.Path.Combine(@"C:\offline", "rancher-hub.json");
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
|
||||
OfflineSnapshotPath = offlinePath,
|
||||
};
|
||||
|
||||
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
|
||||
result.FromCache.Should().BeFalse();
|
||||
result.FromOfflineSnapshot.Should().BeFalse();
|
||||
result.Metadata.Provider.DisplayName.Should().Be("SUSE Rancher VEX Hub");
|
||||
result.Metadata.Subscription.EventsUri.Should().Be(new Uri("https://vexhub.suse.com/api/v1/events"));
|
||||
result.Metadata.Authentication.TokenEndpoint.Should().Be(new Uri("https://identity.suse.com/oauth2/token"));
|
||||
|
||||
// Second call should be served from cache (no additional HTTP invocation).
|
||||
handler.ResetInvocationCount();
|
||||
await loader.LoadAsync(options, CancellationToken.None);
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesOfflineSnapshotWhenNetworkFails()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => throw new HttpRequestException("network down"));
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://vexhub.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var offlinePath = fileSystem.Path.Combine(@"C:\offline", "rancher-hub.json");
|
||||
fileSystem.AddFile(offlinePath, new MockFileData(SampleDiscovery));
|
||||
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
|
||||
OfflineSnapshotPath = offlinePath,
|
||||
};
|
||||
|
||||
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.FromOfflineSnapshot.Should().BeTrue();
|
||||
result.Metadata.Subscription.RequiresAuthentication.Should().BeTrue();
|
||||
result.Metadata.OfflineSnapshot.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ThrowsWhenOfflinePreferredButMissing()
|
||||
{
|
||||
var handler = TestHttpMessageHandler.RespondWith(_ => throw new HttpRequestException("network down"));
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://vexhub.suse.com"),
|
||||
};
|
||||
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var options = new RancherHubConnectorOptions
|
||||
{
|
||||
DiscoveryUri = new Uri("https://vexhub.suse.com/.well-known/vex/rancher-hub.json"),
|
||||
OfflineSnapshotPath = "/offline/missing.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
};
|
||||
|
||||
var tokenProvider = new RancherHubTokenProvider(factory, cache, NullLogger<RancherHubTokenProvider>.Instance);
|
||||
var loader = new RancherHubMetadataLoader(factory, cache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
private TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public static TestHttpMessageHandler RespondWith(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
=> new(responseFactory);
|
||||
|
||||
public void ResetInvocationCount() => InvocationCount = 0;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user