Add new features and tests for AirGap and Time modules
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Introduced `SbomService` tasks documentation.
- Updated `StellaOps.sln` to include new projects: `StellaOps.AirGap.Time` and `StellaOps.AirGap.Importer`.
- Added unit tests for `BundleImportPlanner`, `DsseVerifier`, `ImportValidator`, and other components in the `StellaOps.AirGap.Importer.Tests` namespace.
- Implemented `InMemoryBundleRepositories` for testing bundle catalog and item repositories.
- Created `MerkleRootCalculator`, `RootRotationPolicy`, and `TufMetadataValidator` tests.
- Developed `StalenessCalculator` and `TimeAnchorLoader` tests in the `StellaOps.AirGap.Time.Tests` namespace.
- Added `fetch-sbomservice-deps.sh` script for offline dependency fetching.
This commit is contained in:
master
2025-11-20 23:29:54 +02:00
parent 65b1599229
commit 79b8e53441
182 changed files with 6660 additions and 1242 deletions

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Excititor.WebService.Contracts;
public sealed record AttestationVerifyRequest
{
public string ExportId { get; init; } = string.Empty;
public string QuerySignature { get; init; } = string.Empty;
public string ArtifactDigest { get; init; } = string.Empty;
public string Format { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
= DateTimeOffset.UnixEpoch;
public IReadOnlyList<string> SourceProviders { get; init; }
= Array.Empty<string>();
public IReadOnlyDictionary<string, string> Metadata { get; init; }
= new Dictionary<string, string>(StringComparer.Ordinal);
public AttestationVerifyMetadata Attestation { get; init; }
= new();
public string Envelope { get; init; } = string.Empty;
public bool IsReverify { get; init; }
= false;
}
public sealed record AttestationVerifyMetadata
{
public string PredicateType { get; init; } = string.Empty;
public string EnvelopeDigest { get; init; } = string.Empty;
public DateTimeOffset SignedAt { get; init; } = DateTimeOffset.UnixEpoch;
public AttestationRekorReference? Rekor { get; init; }
= null;
}
public sealed record AttestationRekorReference
{
public string? ApiVersion { get; init; }
= null;
public string? Location { get; init; }
= null;
public long? LogIndex { get; init; }
= null;
public Uri? InclusionProofUrl { get; init; }
= null;
}
public sealed record AttestationVerifyResponse(bool Valid, IDictionary<string, string> Diagnostics);

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Collections.Immutable;
using System.Globalization;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
@@ -13,16 +14,16 @@ using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Attestation.Extensions;
using StellaOps.Excititor.Attestation;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.ArtifactStores.S3.Extensions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.Excititor.Formats.CycloneDX;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Attestation.Extensions;
using StellaOps.Excititor.Attestation;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.ArtifactStores.S3.Extensions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.Excititor.Formats.CycloneDX;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Endpoints;
@@ -34,14 +35,14 @@ using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Telemetry;
using MongoDB.Driver;
using MongoDB.Bson;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
var services = builder.Services;
services.AddOptions<VexMongoStorageOptions>()
.Bind(configuration.GetSection("Excititor:Storage:Mongo"))
.ValidateOnStart();
services.AddOptions<VexMongoStorageOptions>()
.Bind(configuration.GetSection("Excititor:Storage:Mongo"))
.ValidateOnStart();
services.AddExcititorMongoStorage();
services.AddCsafNormalizer();
services.AddCycloneDxNormalizer();
@@ -60,6 +61,8 @@ services.Configure<VexAttestationClientOptions>(configuration.GetSection("Exciti
services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("Excititor:Attestation:Verification"));
services.AddVexPolicy();
services.AddSingleton<IVexEvidenceChunkService, VexEvidenceChunkService>();
services.AddSingleton<ChunkTelemetry>();
services.AddSingleton<ChunkTelemetry>();
services.AddRedHatCsafConnector();
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
services.AddSingleton<MirrorRateLimiter>();
@@ -67,47 +70,47 @@ services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IVexObservationProjectionService, VexObservationProjectionService>();
services.AddScoped<IVexObservationLookup, MongoVexObservationLookup>();
services.AddScoped<IVexObservationLookup, MongoVexObservationLookup>();
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
if (rekorSection.Exists())
{
services.AddVexRekorClient(opts => rekorSection.Bind(opts));
}
var fileSystemSection = configuration.GetSection("Excititor:Artifacts:FileSystem");
if (fileSystemSection.Exists())
{
services.AddVexFileSystemArtifactStore(opts => fileSystemSection.Bind(opts));
}
else
{
services.AddVexFileSystemArtifactStore(_ => { });
}
var s3Section = configuration.GetSection("Excititor:Artifacts:S3");
if (s3Section.Exists())
{
services.AddVexS3ArtifactClient(opts => s3Section.GetSection("Client").Bind(opts));
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>(provider =>
{
var options = new S3ArtifactStoreOptions();
s3Section.GetSection("Store").Bind(options);
return new S3ArtifactStore(
provider.GetRequiredService<IS3ArtifactClient>(),
Microsoft.Extensions.Options.Options.Create(options),
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<S3ArtifactStore>>());
});
}
var offlineSection = configuration.GetSection("Excititor:Artifacts:OfflineBundle");
if (offlineSection.Exists())
{
services.AddVexOfflineBundleArtifactStore(opts => offlineSection.Bind(opts));
}
services.AddEndpointsApiExplorer();
services.AddHealthChecks();
services.AddSingleton(TimeProvider.System);
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
if (rekorSection.Exists())
{
services.AddVexRekorClient(opts => rekorSection.Bind(opts));
}
var fileSystemSection = configuration.GetSection("Excititor:Artifacts:FileSystem");
if (fileSystemSection.Exists())
{
services.AddVexFileSystemArtifactStore(opts => fileSystemSection.Bind(opts));
}
else
{
services.AddVexFileSystemArtifactStore(_ => { });
}
var s3Section = configuration.GetSection("Excititor:Artifacts:S3");
if (s3Section.Exists())
{
services.AddVexS3ArtifactClient(opts => s3Section.GetSection("Client").Bind(opts));
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>(provider =>
{
var options = new S3ArtifactStoreOptions();
s3Section.GetSection("Store").Bind(options);
return new S3ArtifactStore(
provider.GetRequiredService<IS3ArtifactClient>(),
Microsoft.Extensions.Options.Options.Create(options),
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<S3ArtifactStore>>());
});
}
var offlineSection = configuration.GetSection("Excititor:Artifacts:OfflineBundle");
if (offlineSection.Exists())
{
services.AddVexOfflineBundleArtifactStore(opts => offlineSection.Bind(opts));
}
services.AddEndpointsApiExplorer();
services.AddHealthChecks();
services.AddSingleton(TimeProvider.System);
services.AddMemoryCache();
services.AddAuthentication();
services.AddAuthorization();
@@ -115,70 +118,134 @@ services.AddAuthorization();
builder.ConfigureExcititorTelemetry();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseObservabilityHeaders();
app.MapGet("/excititor/status", async (HttpContext context,
IEnumerable<IVexArtifactStore> artifactStores,
IOptions<VexMongoStorageOptions> mongoOptions,
TimeProvider timeProvider) =>
{
var payload = new StatusResponse(
timeProvider.GetUtcNow(),
mongoOptions.Value.RawBucketName,
mongoOptions.Value.GridFsInlineThresholdBytes,
artifactStores.Select(store => store.GetType().Name).ToArray());
context.Response.ContentType = "application/json";
await System.Text.Json.JsonSerializer.SerializeAsync(context.Response.Body, payload);
});
app.MapHealthChecks("/excititor/health");
app.MapPost("/excititor/statements", async (
VexStatementIngestRequest request,
IVexClaimStore claimStore,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request?.Statements is null || request.Statements.Count == 0)
{
return Results.BadRequest("At least one statement must be provided.");
}
var claims = request.Statements.Select(statement => statement.ToDomainClaim());
await claimStore.AppendAsync(claims, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
return Results.Accepted();
});
app.MapGet("/excititor/statements/{vulnerabilityId}/{productKey}", async (
string vulnerabilityId,
string productKey,
DateTimeOffset? since,
IVexClaimStore claimStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return Results.BadRequest("vulnerabilityId and productKey are required.");
}
var claims = await claimStore.FindAsync(vulnerabilityId.Trim(), productKey.Trim(), since, cancellationToken).ConfigureAwait(false);
return Results.Ok(claims);
});
app.MapGet("/excititor/status", async (HttpContext context,
IEnumerable<IVexArtifactStore> artifactStores,
IOptions<VexMongoStorageOptions> mongoOptions,
TimeProvider timeProvider) =>
{
var payload = new StatusResponse(
timeProvider.GetUtcNow(),
mongoOptions.Value.RawBucketName,
mongoOptions.Value.GridFsInlineThresholdBytes,
artifactStores.Select(store => store.GetType().Name).ToArray());
context.Response.ContentType = "application/json";
await System.Text.Json.JsonSerializer.SerializeAsync(context.Response.Body, payload);
});
app.MapHealthChecks("/excititor/health");
app.MapPost("/v1/attestations/verify", async (
[FromServices] IVexAttestationClient attestationClient,
[FromBody] AttestationVerifyRequest request,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest("Request body is required.");
}
if (string.IsNullOrWhiteSpace(request.ExportId) ||
string.IsNullOrWhiteSpace(request.QuerySignature) ||
string.IsNullOrWhiteSpace(request.ArtifactDigest) ||
string.IsNullOrWhiteSpace(request.Format) ||
string.IsNullOrWhiteSpace(request.Envelope) ||
string.IsNullOrWhiteSpace(request.Attestation?.EnvelopeDigest))
{
return Results.BadRequest("Missing required fields.");
}
if (!Enum.TryParse<VexExportFormat>(request.Format, ignoreCase: true, out var format))
{
return Results.BadRequest("Unknown export format.");
}
var attestationRequest = new VexAttestationRequest(
request.ExportId.Trim(),
new VexQuerySignature(request.QuerySignature.Trim()),
new VexContentAddress(request.ArtifactDigest.Trim()),
format,
request.CreatedAt,
request.SourceProviders?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
request.Metadata?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary<string, string>.Empty);
var rekor = request.Attestation?.Rekor is null
? null
: new VexRekorReference(
request.Attestation.Rekor.ApiVersion ?? "0.2",
request.Attestation.Rekor.Location,
request.Attestation.Rekor.LogIndex,
request.Attestation.Rekor.InclusionProofUrl);
var attestationMetadata = new VexAttestationMetadata(
request.Attestation?.PredicateType ?? string.Empty,
rekor,
request.Attestation!.EnvelopeDigest,
request.Attestation.SignedAt);
var verificationRequest = new VexAttestationVerificationRequest(
attestationRequest,
attestationMetadata,
request.Envelope,
request.IsReverify);
var verification = await attestationClient.VerifyAsync(verificationRequest, cancellationToken).ConfigureAwait(false);
var response = new AttestationVerifyResponse(
verification.IsValid,
new Dictionary<string, string>(verification.Diagnostics, StringComparer.Ordinal));
return Results.Ok(response);
});
app.MapPost("/excititor/statements"
, async (
VexStatementIngestRequest request,
IVexClaimStore claimStore,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request?.Statements is null || request.Statements.Count == 0)
{
return Results.BadRequest("At least one statement must be provided.");
}
var claims = request.Statements.Select(statement => statement.ToDomainClaim());
await claimStore.AppendAsync(claims, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
return Results.Accepted();
});
app.MapGet("/excititor/statements/{vulnerabilityId}/{productKey}", async (
string vulnerabilityId,
string productKey,
DateTimeOffset? since,
IVexClaimStore claimStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return Results.BadRequest("vulnerabilityId and productKey are required.");
}
var claims = await claimStore.FindAsync(vulnerabilityId.Trim(), productKey.Trim(), since, cancellationToken).ConfigureAwait(false);
return Results.Ok(claims);
});
app.MapPost("/excititor/admin/backfill-statements", async (
VexStatementBackfillRequest? request,
VexStatementBackfillService backfillService,
CancellationToken cancellationToken) =>
{
request ??= new VexStatementBackfillRequest();
var result = await backfillService.RunAsync(request, cancellationToken).ConfigureAwait(false);
var message = FormattableString.Invariant(
$"Backfill completed: evaluated {result.DocumentsEvaluated}, backfilled {result.DocumentsBackfilled}, claims written {result.ClaimsWritten}, skipped {result.SkippedExisting}, failures {result.NormalizationFailures}.");
request ??= new VexStatementBackfillRequest();
var result = await backfillService.RunAsync(request, cancellationToken).ConfigureAwait(false);
var message = FormattableString.Invariant(
$"Backfill completed: evaluated {result.DocumentsEvaluated}, backfilled {result.DocumentsBackfilled}, claims written {result.ClaimsWritten}, skipped {result.SkippedExisting}, failures {result.NormalizationFailures}.");
return Results.Ok(new
{
message,
@@ -742,17 +809,23 @@ app.MapGet("/v1/vex/evidence/chunks", async (
HttpContext context,
[FromServices] IVexEvidenceChunkService chunkService,
[FromServices] IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] ChunkTelemetry chunkTelemetry,
[FromServices] ILogger<VexEvidenceChunkRequest> logger,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var start = Stopwatch.GetTimestamp();
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
chunkTelemetry.RecordIngested(null, null, "unauthorized", "missing-scope", 0, 0, 0);
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
{
chunkTelemetry.RecordIngested(tenant?.TenantId, null, "rejected", "tenant-invalid", 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return tenantError;
}
@@ -785,11 +858,13 @@ app.MapGet("/v1/vex/evidence/chunks", async (
catch (OperationCanceledException)
{
EvidenceTelemetry.RecordChunkOutcome(tenant, "cancelled");
chunkTelemetry.RecordIngested(tenant?.TenantId, request.ProviderFilter.Count > 0 ? string.Join(',', request.ProviderFilter) : null, "cancelled", null, 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
catch
{
EvidenceTelemetry.RecordChunkOutcome(tenant, "error");
chunkTelemetry.RecordIngested(tenant?.TenantId, request.ProviderFilter.Count > 0 ? string.Join(',', request.ProviderFilter) : null, "error", null, 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
throw;
}
@@ -814,13 +889,25 @@ app.MapGet("/v1/vex/evidence/chunks", async (
context.Response.ContentType = "application/x-ndjson";
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
long payloadBytes = 0;
foreach (var chunk in result.Chunks)
{
var line = JsonSerializer.Serialize(chunk, options);
payloadBytes += Encoding.UTF8.GetByteCount(line) + 1;
await context.Response.WriteAsync(line, cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync("\n", cancellationToken).ConfigureAwait(false);
}
var elapsedMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds;
chunkTelemetry.RecordIngested(
tenant?.TenantId,
request.ProviderFilter.Count > 0 ? string.Join(',', request.ProviderFilter) : null,
"success",
null,
result.TotalCount,
payloadBytes,
elapsedMs);
return Results.Empty;
});
@@ -969,107 +1056,107 @@ app.MapGet("/obs/excititor/health", async (
IngestEndpoints.MapIngestEndpoints(app);
ResolveEndpoint.MapResolveEndpoint(app);
MirrorEndpoints.MapMirrorEndpoints(app);
app.Run();
public partial class Program;
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
internal sealed record VexStatementIngestRequest(IReadOnlyList<VexStatementEntry> Statements);
internal sealed record VexStatementEntry(
string VulnerabilityId,
string ProviderId,
string ProductKey,
string? ProductName,
string? ProductVersion,
string? ProductPurl,
string? ProductCpe,
IReadOnlyList<string>? ComponentIdentifiers,
VexClaimStatus Status,
VexJustification? Justification,
string? Detail,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen,
VexDocumentFormat DocumentFormat,
string DocumentDigest,
string DocumentUri,
string? DocumentRevision,
VexSignatureMetadataRequest? Signature,
VexConfidenceRequest? Confidence,
VexSignalRequest? Signals,
IReadOnlyDictionary<string, string>? Metadata)
{
public VexClaim ToDomainClaim()
{
var product = new VexProduct(
ProductKey,
ProductName,
ProductVersion,
ProductPurl,
ProductCpe,
ComponentIdentifiers ?? Array.Empty<string>());
if (!Uri.TryCreate(DocumentUri, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException($"DocumentUri '{DocumentUri}' is not a valid absolute URI.");
}
var document = new VexClaimDocument(
DocumentFormat,
DocumentDigest,
uri,
DocumentRevision,
Signature?.ToDomain());
var additionalMetadata = Metadata is null
? ImmutableDictionary<string, string>.Empty
: Metadata.ToImmutableDictionary(StringComparer.Ordinal);
return new VexClaim(
VulnerabilityId,
ProviderId,
product,
Status,
document,
FirstSeen,
LastSeen,
Justification,
Detail,
Confidence?.ToDomain(),
Signals?.ToDomain(),
additionalMetadata);
}
}
internal sealed record VexSignatureMetadataRequest(
string Type,
string? Subject,
string? Issuer,
string? KeyId,
DateTimeOffset? VerifiedAt,
string? TransparencyLogReference)
{
public VexSignatureMetadata ToDomain()
=> new(Type, Subject, Issuer, KeyId, VerifiedAt, TransparencyLogReference);
}
internal sealed record VexConfidenceRequest(string Level, double? Score, string? Method)
{
public VexConfidence ToDomain() => new(Level, Score, Method);
}
internal sealed record VexSignalRequest(VexSeveritySignalRequest? Severity, bool? Kev, double? Epss)
{
public VexSignalSnapshot ToDomain()
=> new(Severity?.ToDomain(), Kev, Epss);
}
internal sealed record VexSeveritySignalRequest(string Scheme, double? Score, string? Label, string? Vector)
{
public VexSeveritySignal ToDomain() => new(Scheme, Score, Label, Vector);
}
app.Run();
public partial class Program;
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
internal sealed record VexStatementIngestRequest(IReadOnlyList<VexStatementEntry> Statements);
internal sealed record VexStatementEntry(
string VulnerabilityId,
string ProviderId,
string ProductKey,
string? ProductName,
string? ProductVersion,
string? ProductPurl,
string? ProductCpe,
IReadOnlyList<string>? ComponentIdentifiers,
VexClaimStatus Status,
VexJustification? Justification,
string? Detail,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen,
VexDocumentFormat DocumentFormat,
string DocumentDigest,
string DocumentUri,
string? DocumentRevision,
VexSignatureMetadataRequest? Signature,
VexConfidenceRequest? Confidence,
VexSignalRequest? Signals,
IReadOnlyDictionary<string, string>? Metadata)
{
public VexClaim ToDomainClaim()
{
var product = new VexProduct(
ProductKey,
ProductName,
ProductVersion,
ProductPurl,
ProductCpe,
ComponentIdentifiers ?? Array.Empty<string>());
if (!Uri.TryCreate(DocumentUri, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException($"DocumentUri '{DocumentUri}' is not a valid absolute URI.");
}
var document = new VexClaimDocument(
DocumentFormat,
DocumentDigest,
uri,
DocumentRevision,
Signature?.ToDomain());
var additionalMetadata = Metadata is null
? ImmutableDictionary<string, string>.Empty
: Metadata.ToImmutableDictionary(StringComparer.Ordinal);
return new VexClaim(
VulnerabilityId,
ProviderId,
product,
Status,
document,
FirstSeen,
LastSeen,
Justification,
Detail,
Confidence?.ToDomain(),
Signals?.ToDomain(),
additionalMetadata);
}
}
internal sealed record VexSignatureMetadataRequest(
string Type,
string? Subject,
string? Issuer,
string? KeyId,
DateTimeOffset? VerifiedAt,
string? TransparencyLogReference)
{
public VexSignatureMetadata ToDomain()
=> new(Type, Subject, Issuer, KeyId, VerifiedAt, TransparencyLogReference);
}
internal sealed record VexConfidenceRequest(string Level, double? Score, string? Method)
{
public VexConfidence ToDomain() => new(Level, Score, Method);
}
internal sealed record VexSignalRequest(VexSeveritySignalRequest? Severity, bool? Kev, double? Epss)
{
public VexSignalSnapshot ToDomain()
=> new(Severity?.ToDomain(), Kev, Epss);
}
internal sealed record VexSeveritySignalRequest(string Scheme, double? Score, string? Label, string? Vector)
{
public VexSeveritySignal ToDomain() => new(Scheme, Score, Label, Vector);
}
app.MapGet(
"/v1/vex/observations",
async (

View File

@@ -0,0 +1,51 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Excititor.WebService.Telemetry;
internal sealed class ChunkTelemetry
{
private readonly Counter<long> _ingestedTotal;
private readonly Histogram<long> _itemCount;
private readonly Histogram<long> _payloadBytes;
private readonly Histogram<double> _latencyMs;
public ChunkTelemetry(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("StellaOps.Excititor.Chunks");
_ingestedTotal = meter.CreateCounter<long>(
name: "vex_chunks_ingested_total",
unit: "chunks",
description: "Chunks submitted to Excititor VEX ingestion.");
_itemCount = meter.CreateHistogram<long>(
name: "vex_chunks_item_count",
unit: "items",
description: "Item count per submitted chunk.");
_payloadBytes = meter.CreateHistogram<long>(
name: "vex_chunks_payload_bytes",
unit: "bytes",
description: "Payload size per submitted chunk.");
_latencyMs = meter.CreateHistogram<double>(
name: "vex_chunks_latency_ms",
unit: "ms",
description: "End-to-end processing latency per chunk request.");
}
public void RecordIngested(string? tenant, string? source, string status, string? reason, long itemCount, long payloadBytes, double latencyMs)
{
var tags = new TagList
{
{ "tenant", tenant ?? "" },
{ "source", source ?? "" },
{ "status", status },
};
if (!string.IsNullOrWhiteSpace(reason))
{
tags.Add("reason", reason);
}
_ingestedTotal.Add(1, tags);
_itemCount.Record(itemCount, tags);
_payloadBytes.Record(payloadBytes, tags);
_latencyMs.Record(latencyMs, tags);
}
}

View File

@@ -0,0 +1,204 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Excititor.Connectors.Abstractions.Trust;
public sealed record ConnectorSignerMetadataSet(
string SchemaVersion,
DateTimeOffset GeneratedAt,
ImmutableArray<ConnectorSignerMetadata> Connectors)
{
private readonly ImmutableDictionary<string, ConnectorSignerMetadata> _byId =
Connectors.ToImmutableDictionary(x => x.ConnectorId, StringComparer.OrdinalIgnoreCase);
public bool TryGet(string connectorId, [NotNullWhen(true)] out ConnectorSignerMetadata? metadata)
=> _byId.TryGetValue(connectorId, out metadata);
}
public sealed record ConnectorSignerMetadata(
string ConnectorId,
string ProviderName,
string ProviderSlug,
string IssuerTier,
ImmutableArray<ConnectorSignerSigner> Signers,
ConnectorSignerBundleRef? Bundle,
string? ValidFrom,
string? ValidTo,
bool Revoked,
string? Notes);
public sealed record ConnectorSignerSigner(
string Usage,
ImmutableArray<ConnectorSignerFingerprint> Fingerprints,
string? KeyLocator,
ImmutableArray<string> CertificateChain);
public sealed record ConnectorSignerFingerprint(
string Alg,
string Format,
string Value);
public sealed record ConnectorSignerBundleRef(
string Kind,
string Uri,
string? Digest,
DateTimeOffset? PublishedAt);
public static class ConnectorSignerMetadataLoader
{
public static ConnectorSignerMetadataSet? TryLoad(string? path, Stream? overrideStream = null)
{
if (string.IsNullOrWhiteSpace(path) && overrideStream is null)
{
return null;
}
try
{
using var stream = overrideStream ?? File.OpenRead(path!);
var root = JsonNode.Parse(stream, new JsonNodeOptions { PropertyNameCaseInsensitive = true });
if (root is null)
{
return null;
}
var version = root["schemaVersion"]?.GetValue<string?>() ?? "0.0.0";
var generatedAt = root["generatedAt"]?.GetValue<DateTimeOffset?>() ?? DateTimeOffset.MinValue;
var connectorsNode = root["connectors"] as JsonArray;
if (connectorsNode is null || connectorsNode.Count == 0)
{
return null;
}
var connectors = connectorsNode
.Select(ParseConnector)
.Where(c => c is not null)
.Select(c => c!)
.OrderBy(c => c.ConnectorId, StringComparer.Ordinal)
.ToImmutableArray();
return new ConnectorSignerMetadataSet(version, generatedAt, connectors);
}
catch
{
return null;
}
}
private static ConnectorSignerMetadata? ParseConnector(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
var id = obj["connectorId"]?.GetValue<string?>();
var providerName = obj["provider"]?["name"]?.GetValue<string?>();
var providerSlug = obj["provider"]?["slug"]?.GetValue<string?>();
var issuerTier = obj["issuerTier"]?.GetValue<string?>();
var signers = (obj["signers"] as JsonArray)?.Select(ParseSigner)
.Where(x => x is not null)
.Select(x => x!)
.ToImmutableArray() ?? ImmutableArray<ConnectorSignerSigner>.Empty;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(providerName) || signers.Length == 0)
{
return null;
}
return new ConnectorSignerMetadata(
id!,
providerName!,
providerSlug ?? providerName!,
issuerTier ?? "untrusted",
signers,
ParseBundle(obj["bundle"]),
obj["validFrom"]?.GetValue<string?>(),
obj["validTo"]?.GetValue<string?>(),
obj["revoked"]?.GetValue<bool?>() ?? false,
obj["notes"]?.GetValue<string?>());
}
private static ConnectorSignerSigner? ParseSigner(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
var usage = obj["usage"]?.GetValue<string?>();
var fps = (obj["fingerprints"] as JsonArray)?.Select(ParseFingerprint)
.Where(x => x is not null)
.Select(x => x!)
.ToImmutableArray() ?? ImmutableArray<ConnectorSignerFingerprint>.Empty;
if (string.IsNullOrWhiteSpace(usage) || fps.IsDefaultOrEmpty)
{
return null;
}
var chain = (obj["certificateChain"] as JsonArray)?.Select(x => x?.GetValue<string?>())
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v!)
.ToImmutableArray() ?? ImmutableArray<string>.Empty;
return new ConnectorSignerSigner(
usage!,
fps,
obj["keyLocator"]?.GetValue<string?>(),
chain);
}
private static ConnectorSignerFingerprint? ParseFingerprint(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
var alg = obj["alg"]?.GetValue<string?>();
var format = obj["format"]?.GetValue<string?>();
var value = obj["value"]?.GetValue<string?>();
if (string.IsNullOrWhiteSpace(alg) || string.IsNullOrWhiteSpace(format) || string.IsNullOrWhiteSpace(value))
{
return null;
}
return new ConnectorSignerFingerprint(alg!, format!, value!);
}
private static ConnectorSignerBundleRef? ParseBundle(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
var kind = obj["kind"]?.GetValue<string?>();
var uri = obj["uri"]?.GetValue<string?>();
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(uri))
{
return null;
}
DateTimeOffset? published = null;
if (obj["publishedAt"] is JsonNode publishedNode && publishedNode.GetValue<string?>() is { } publishedString)
{
if (DateTimeOffset.TryParse(publishedString, out var parsed))
{
published = parsed;
}
}
return new ConnectorSignerBundleRef(
kind!,
uri!,
obj["digest"]?.GetValue<string?>(),
published);
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging;
namespace StellaOps.Excititor.Connectors.Abstractions.Trust;
public static class ConnectorSignerMetadataEnricher
{
private static readonly object Sync = new();
private static ConnectorSignerMetadataSet? _cached;
private static string? _cachedPath;
private const string EnvVar = "STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH";
public static void Enrich(
VexConnectorMetadataBuilder builder,
string connectorId,
ILogger? logger = null,
string? metadataPath = null)
{
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(connectorId)) return;
var path = metadataPath ?? Environment.GetEnvironmentVariable(EnvVar);
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var metadata = LoadCached(path, logger);
if (metadata is null || !metadata.TryGet(connectorId, out var connector))
{
return;
}
builder
.Add("vex.provenance.trust.issuerTier", connector.IssuerTier)
.Add("vex.provenance.trust.signers", string.Join(';', connector.Signers.SelectMany(s => s.Fingerprints.Select(fp => fp.Value))))
.Add("vex.provenance.trust.provider", connector.ProviderSlug);
if (connector.Bundle is { } bundle)
{
builder
.Add("vex.provenance.bundle.kind", bundle.Kind)
.Add("vex.provenance.bundle.uri", bundle.Uri)
.Add("vex.provenance.bundle.digest", bundle.Digest)
.Add("vex.provenance.bundle.publishedAt", bundle.PublishedAt?.ToUniversalTime().ToString("O"));
}
}
private static ConnectorSignerMetadataSet? LoadCached(string path, ILogger? logger)
{
if (!string.Equals(path, _cachedPath, StringComparison.OrdinalIgnoreCase) || _cached is null)
{
lock (Sync)
{
if (!string.Equals(path, _cachedPath, StringComparison.OrdinalIgnoreCase) || _cached is null)
{
if (!File.Exists(path))
{
logger?.LogDebug("Connector signer metadata file not found at {Path}; skipping enrichment.", path);
_cached = null;
}
else
{
_cached = ConnectorSignerMetadataLoader.TryLoad(path);
_cachedPath = path;
if (_cached is null)
{
logger?.LogWarning("Failed to load connector signer metadata from {Path}.", path);
}
}
}
}
}
return _cached;
}
}

View File

@@ -12,6 +12,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
@@ -276,6 +277,8 @@ public sealed class MsrcCsafConnector : VexConnectorBase
{
builder.Add("http.lastModified", lastModified.ToString("O"));
}
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
});
return CreateRawDocument(VexDocumentFormat.Csaf, documentUri, payload, metadata);

View File

@@ -1,11 +1,13 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
@@ -187,11 +189,11 @@ public sealed class OciOpenVexAttestationConnector : VexConnectorBase
}
}
if (signature is not null)
{
builder["vex.signature.type"] = signature.Type;
if (!string.IsNullOrWhiteSpace(signature.Subject))
{
if (signature is not null)
{
builder["vex.signature.type"] = signature.Type;
if (!string.IsNullOrWhiteSpace(signature.Subject))
{
builder["vex.signature.subject"] = signature.Subject!;
}
@@ -211,11 +213,19 @@ public sealed class OciOpenVexAttestationConnector : VexConnectorBase
}
if (!string.IsNullOrWhiteSpace(signature.TransparencyLogReference))
{
builder["vex.signature.transparencyLogReference"] = signature.TransparencyLogReference!;
}
}
return builder.ToImmutable();
}
}
{
builder["vex.signature.transparencyLogReference"] = signature.TransparencyLogReference!;
}
}
var metadataBuilder = new VexConnectorMetadataBuilder();
metadataBuilder.AddRange(builder.Select(kv => new KeyValuePair<string, string?>(kv.Key, kv.Value)));
ConnectorSignerMetadataEnricher.Enrich(metadataBuilder, Descriptor.Id, Logger);
foreach (var kv in metadataBuilder.Build())
{
builder[kv.Key] = kv.Value;
}
return builder.ToImmutable();
}
}

View File

@@ -8,10 +8,11 @@ using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
@@ -260,11 +261,13 @@ public sealed class OracleCsafConnector : VexConnectorBase
builder.Add("oracle.csaf.sha256", NormalizeDigest(entry.Sha256));
builder.Add("oracle.csaf.size", entry.Size?.ToString(CultureInfo.InvariantCulture));
if (!entry.Products.IsDefaultOrEmpty)
{
builder.Add("oracle.csaf.products", string.Join(",", entry.Products));
}
});
if (!entry.Products.IsDefaultOrEmpty)
{
builder.Add("oracle.csaf.products", string.Join(",", entry.Products));
}
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
});
return CreateRawDocument(VexDocumentFormat.Csaf, entry.DocumentUri, payload.AsMemory(), metadata);
}

View File

@@ -10,6 +10,7 @@ using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
@@ -459,6 +460,8 @@ public sealed class UbuntuCsafConnector : VexConnectorBase
builder
.Add("vex.provenance.trust.tier", tier)
.Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
}
private static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)

View File

@@ -1,367 +1,464 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.MSRC.CSAF;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors;
public sealed class MsrcCsafConnectorTests
{
private static readonly VexConnectorDescriptor Descriptor = new("excititor:msrc", VexProviderKind.Vendor, "MSRC CSAF");
[Fact]
public async Task FetchAsync_EmitsDocumentAndPersistsState()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"severity": "Critical",
"releaseDate": "2025-10-17T00:00:00Z",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var emitted = documents[0];
emitted.SourceUri.Should().Be(new Uri("https://example.com/csaf/ADV-0001.json"));
emitted.Metadata["msrc.vulnerabilityId"].Should().Be("ADV-0001");
emitted.Metadata["msrc.csaf.format"].Should().Be("json");
emitted.Metadata.Should().NotContainKey("excititor.quarantine.reason");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 18, 0, 0, 0, TimeSpan.Zero));
stateRepository.State.DocumentDigests.Should().HaveCount(1);
}
[Fact]
public async Task FetchAsync_SkipsDocumentsWithExistingDigest()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var firstPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
firstPass.Add(document);
}
firstPass.Should().HaveCount(1);
stateRepository.State.Should().NotBeNull();
var persistedState = stateRepository.State!;
handler.Reset(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
sink.Documents.Clear();
var secondPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
secondPass.Add(document);
}
secondPass.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().Equal(persistedState.DocumentDigests);
}
[Fact]
public async Task FetchAsync_QuarantinesInvalidCsafPayload()
{
var summary = """
{
"value": [
{
"id": "ADV-0002",
"vulnerabilityId": "ADV-0002",
"lastModifiedDate": "2025-10-19T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0002.zip"
}
]
}
""";
var csafZip = CreateZip("document.json", "{ invalid json ");
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csafZip, "application/zip"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 17, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().HaveCount(1);
sink.Documents[0].Metadata["excititor.quarantine.reason"].Should().Contain("JSON parse failed");
sink.Documents[0].Metadata["msrc.csaf.format"].Should().Be("zip");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().HaveCount(1);
}
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
=> new(statusCode)
{
Content = new StringContent(content, Encoding.UTF8, contentType),
};
private static HttpResponseMessage Response(HttpStatusCode statusCode, byte[] content, string contentType)
{
var response = new HttpResponseMessage(statusCode);
response.Content = new ByteArrayContent(content);
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
return response;
}
private static MsrcConnectorOptions CreateOptions()
=> new()
{
BaseUri = new Uri("https://example.com/", UriKind.Absolute),
TenantId = Guid.NewGuid().ToString(),
ClientId = "client-id",
ClientSecret = "secret",
Scope = MsrcConnectorOptions.DefaultScope,
PageSize = 5,
MaxAdvisoriesPerFetch = 5,
RequestDelay = TimeSpan.Zero,
RetryBaseDelay = TimeSpan.FromMilliseconds(10),
MaxRetryAttempts = 2,
};
private static byte[] CreateZip(string entryName, string content)
{
using var buffer = new MemoryStream();
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
return buffer.ToArray();
}
private sealed class StubTokenProvider : IMsrcTokenProvider
{
public ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(new MsrcAccessToken("token", "Bearer", DateTimeOffset.MaxValue));
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
{
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
}
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
=> new(responders);
public void Reset(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
{
_responders.Clear();
foreach (var responder in responders)
{
_responders.Enqueue(responder);
}
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_responders.Count == 0)
{
throw new InvalidOperationException("No responder configured for MSRC connector test request.");
}
var responder = _responders.Count > 1 ? _responders.Dequeue() : _responders.Peek();
var response = responder(request);
response.RequestMessage = request;
return Task.FromResult(response);
}
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.MSRC.CSAF;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors;
public sealed class MsrcCsafConnectorTests
{
private static readonly VexConnectorDescriptor Descriptor = new("excititor:msrc", VexProviderKind.Vendor, "MSRC CSAF");
[Fact]
public async Task FetchAsync_EmitsDocumentAndPersistsState()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"severity": "Critical",
"releaseDate": "2025-10-17T00:00:00Z",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var emitted = documents[0];
emitted.SourceUri.Should().Be(new Uri("https://example.com/csaf/ADV-0001.json"));
emitted.Metadata["msrc.vulnerabilityId"].Should().Be("ADV-0001");
emitted.Metadata["msrc.csaf.format"].Should().Be("json");
emitted.Metadata.Should().NotContainKey("excititor.quarantine.reason");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 18, 0, 0, 0, TimeSpan.Zero));
stateRepository.State.DocumentDigests.Should().HaveCount(1);
}
[Fact]
public async Task FetchAsync_SkipsDocumentsWithExistingDigest()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var firstPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
firstPass.Add(document);
}
firstPass.Should().HaveCount(1);
stateRepository.State.Should().NotBeNull();
var persistedState = stateRepository.State!;
handler.Reset(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
sink.Documents.Clear();
var secondPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
secondPass.Add(document);
}
secondPass.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().Equal(persistedState.DocumentDigests);
}
[Fact]
public async Task FetchAsync_QuarantinesInvalidCsafPayload()
{
var summary = """
{
"value": [
{
"id": "ADV-0002",
"vulnerabilityId": "ADV-0002",
"lastModifiedDate": "2025-10-19T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0002.zip"
}
]
}
""";
var csafZip = CreateZip("document.json", "{ invalid json ");
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csafZip, "application/zip"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 17, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().HaveCount(1);
sink.Documents[0].Metadata["excititor.quarantine.reason"].Should().Contain("JSON parse failed");
sink.Documents[0].Metadata["msrc.csaf.format"].Should().Be("zip");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().HaveCount(1);
}
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
=> new(statusCode)
{
Content = new StringContent(content, Encoding.UTF8, contentType),
};
private static HttpResponseMessage Response(HttpStatusCode statusCode, byte[] content, string contentType)
{
var response = new HttpResponseMessage(statusCode);
response.Content = new ByteArrayContent(content);
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
return response;
}
private static MsrcConnectorOptions CreateOptions()
=> new()
{
BaseUri = new Uri("https://example.com/", UriKind.Absolute),
TenantId = Guid.NewGuid().ToString(),
ClientId = "client-id",
ClientSecret = "secret",
Scope = MsrcConnectorOptions.DefaultScope,
PageSize = 5,
MaxAdvisoriesPerFetch = 5,
RequestDelay = TimeSpan.Zero,
RetryBaseDelay = TimeSpan.FromMilliseconds(10),
MaxRetryAttempts = 2,
};
private static byte[] CreateZip(string entryName, string content)
{
using var buffer = new MemoryStream();
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
return buffer.ToArray();
}
private sealed class StubTokenProvider : IMsrcTokenProvider
{
public ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(new MsrcAccessToken("token", "Bearer", DateTimeOffset.MaxValue));
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
[Fact]
public async Task FetchAsync_EnrichesSignerMetadataWhenConfigured()
{
using var tempMetadata = CreateTempSignerMetadata("excititor:msrc", "tier-1", "abc123");
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
try
{
var summary = """
{
"value": [
{ "id": "ADV-0002", "vulnerabilityId": "ADV-0002", "cvrfUrl": "https://example.com/csaf/ADV-0002.json" }
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/"), };
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
Options.Create(CreateOptions()),
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
await foreach (var _ in connector.FetchAsync(context, CancellationToken.None)) { }
var emitted = sink.Documents.Single();
emitted.Metadata.Should().Contain("vex.provenance.trust.issuerTier", "tier-1");
emitted.Metadata.Should().ContainKey("vex.provenance.trust.signers");
}
finally
{
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
}
}
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
{
var pathTemp = Path.GetTempFileName();
var json = $"""
{{
"schemaVersion": "1.0.0",
"generatedAt": "2025-11-20T00:00:00Z",
"connectors": [
{{
"connectorId": "{connectorId}",
"provider": {{ "name": "{connectorId}", "slug": "{connectorId}" }},
"issuerTier": "{tier}",
"signers": [
{{
"usage": "csaf",
"fingerprints": [
{{ "alg": "sha256", "format": "pgp", "value": "{fingerprint}" }}
]
}}
]
}}
]
}}
""";
File.WriteAllText(pathTemp, json);
return new TempMetadataFile(pathTemp);
}
private sealed record TempMetadataFile(string Path) : IDisposable
{
public void Dispose()
{
try { File.Delete(Path); } catch { }
}
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
{
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
}
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
=> new(responders);
public void Reset(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
{
_responders.Clear();
foreach (var responder in responders)
{
_responders.Enqueue(responder);
}
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_responders.Count == 0)
{
throw new InvalidOperationException("No responder configured for MSRC connector test request.");
}
var responder = _responders.Count > 1 ? _responders.Dequeue() : _responders.Peek();
var response = responder(request);
response.RequestMessage = request;
return Task.FromResult(response);
}
}
}

View File

@@ -1,215 +1,313 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Core;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Connector;
public sealed class OciOpenVexAttestationConnectorTests
{
[Fact]
public async Task FetchAsync_WithOfflineBundle_EmitsRawDocument()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "None");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
documents[0].Format.Should().Be(VexDocumentFormat.OciAttestation);
documents[0].Metadata.Should().ContainKey("oci.attestation.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.registryAuthMode").WhoseValue.Should().Be("Anonymous");
verifier.VerifyCalls.Should().Be(1);
}
[Fact]
public async Task FetchAsync_WithSignatureMetadata_EnrichesProvenance()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "Keyless")
.Add("Cosign:Keyless:Issuer", "https://issuer.example.com")
.Add("Cosign:Keyless:Subject", "subject@example.com");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier
{
Result = new VexSignatureMetadata(
type: "cosign",
subject: "sig-subject",
issuer: "sig-issuer",
keyId: "key-id",
verifiedAt: DateTimeOffset.UtcNow,
transparencyLogReference: "rekor://entry/123")
};
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
var metadata = documents[0].Metadata;
metadata.Should().Contain("vex.signature.type", "cosign");
metadata.Should().Contain("vex.signature.subject", "sig-subject");
metadata.Should().Contain("vex.signature.issuer", "sig-issuer");
metadata.Should().Contain("vex.signature.keyId", "key-id");
metadata.Should().ContainKey("vex.signature.verifiedAt");
metadata.Should().Contain("vex.signature.transparencyLogReference", "rekor://entry/123");
metadata.Should().Contain("vex.provenance.cosign.mode", "Keyless");
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
metadata.Should().Contain("vex.provenance.cosign.subject", "subject@example.com");
verifier.VerifyCalls.Should().Be(1);
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class CapturingSignatureVerifier : IVexSignatureVerifier
{
public int VerifyCalls { get; private set; }
public VexSignatureMetadata? Result { get; set; }
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
VerifyCalls++;
return ValueTask.FromResult(Result);
}
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
RequestMessage = request
});
}
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Core;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Connector;
public sealed class OciOpenVexAttestationConnectorTests
{
[Fact]
public async Task FetchAsync_WithOfflineBundle_EmitsRawDocument()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "None");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
documents[0].Format.Should().Be(VexDocumentFormat.OciAttestation);
documents[0].Metadata.Should().ContainKey("oci.attestation.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.registryAuthMode").WhoseValue.Should().Be("Anonymous");
verifier.VerifyCalls.Should().Be(1);
}
[Fact]
public async Task FetchAsync_WithSignatureMetadata_EnrichesProvenance()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "Keyless")
.Add("Cosign:Keyless:Issuer", "https://issuer.example.com")
.Add("Cosign:Keyless:Subject", "subject@example.com");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier
{
Result = new VexSignatureMetadata(
type: "cosign",
subject: "sig-subject",
issuer: "sig-issuer",
keyId: "key-id",
verifiedAt: DateTimeOffset.UtcNow,
transparencyLogReference: "rekor://entry/123")
};
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
var metadata = documents[0].Metadata;
metadata.Should().Contain("vex.signature.type", "cosign");
metadata.Should().Contain("vex.signature.subject", "sig-subject");
metadata.Should().Contain("vex.signature.issuer", "sig-issuer");
metadata.Should().Contain("vex.signature.keyId", "key-id");
metadata.Should().ContainKey("vex.signature.verifiedAt");
metadata.Should().Contain("vex.signature.transparencyLogReference", "rekor://entry/123");
metadata.Should().Contain("vex.provenance.cosign.mode", "Keyless");
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
metadata.Should().Contain("vex.provenance.cosign.subject", "subject@example.com");
verifier.VerifyCalls.Should().Be(1);
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class CapturingSignatureVerifier : IVexSignatureVerifier
{
public int VerifyCalls { get; private set; }
public VexSignatureMetadata? Result { get; set; }
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
VerifyCalls++;
return ValueTask.FromResult(Result);
}
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
RequestMessage = request
});
}
}
[Fact]
public async Task FetchAsync_EnrichesSignerTrustMetadataWhenConfigured()
{
using var tempMetadata = CreateTempSignerMetadata("excititor:oci.openvex.attest", "tier-0", "feed-fp");
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
try
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{"payload":"","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":""}]}")
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "None");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
await foreach (var _ in connector.FetchAsync(context, CancellationToken.None)) { }
var emitted = sink.Documents.Single();
emitted.Metadata.Should().Contain("vex.provenance.trust.issuerTier", "tier-0");
emitted.Metadata.Should().ContainKey("vex.provenance.trust.signers");
}
finally
{
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
}
}
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
{
var pathTemp = System.IO.Path.GetTempFileName();
var json = $"""
{{
"schemaVersion": "1.0.0",
"generatedAt": "2025-11-20T00:00:00Z",
"connectors": [
{{
"connectorId": "{connectorId}",
"provider": {{ "name": "{connectorId}", "slug": "{connectorId}" }},
"issuerTier": "{tier}",
"signers": [
{{
"usage": "attestation",
"fingerprints": [
{{ "alg": "sha256", "format": "cosign", "value": "{fingerprint}" }}
]
}}
]
}}
]
}}
""";
System.IO.File.WriteAllText(pathTemp, json);
return new TempMetadataFile(pathTemp);
}
private sealed record TempMetadataFile(string Path) : IDisposable
{
public void Dispose()
{
try { System.IO.File.Delete(Path); } catch { }
}
}
}

View File

@@ -1,62 +1,67 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors;
public sealed class UbuntuCsafConnectorTests
{
[Fact]
public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag()
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z");
var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var documentSha = ComputeSha256(documentPayload);
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors;
public sealed class UbuntuCsafConnectorTests
{
[Fact]
public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag()
{
using var tempMetadata = CreateTempSignerMetadata("excititor:ubuntu", "tier-2", "deadbeef");
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
try
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z");
var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var documentSha = ComputeSha256(documentPayload);
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
var settings = BuildConnectorSettings(indexUri, trustWeight: 0.63, trustTier: "distro-trusted",
fingerprints: new[]
{
@@ -72,15 +77,15 @@ public sealed class UbuntuCsafConnectorTests
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), services, ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var stored = sink.Documents.Single();
stored.Digest.Should().Be($"sha256:{documentSha}");
stored.Metadata.Should().Contain("ubuntu.etag", "etag-123");
@@ -93,25 +98,25 @@ public sealed class UbuntuCsafConnectorTests
stored.Metadata.Should().Contain(
"vex.provenance.pgp.fingerprints",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}");
stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123");
stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
handler.DocumentRequestCount.Should().Be(1);
// Second run: Expect connector to send If-None-Match and skip download via 304.
sink.Documents.Clear();
documents.Clear();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}");
stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123");
stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
handler.DocumentRequestCount.Should().Be(1);
// Second run: Expect connector to send If-None-Match and skip download via 304.
sink.Documents.Clear();
documents.Clear();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
handler.DocumentRequestCount.Should().Be(2);
handler.SeenIfNoneMatch.Should().Contain("\"etag-123\"");
@@ -123,37 +128,41 @@ public sealed class UbuntuCsafConnectorTests
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
});
}
[Fact]
public async Task FetchAsync_SkipsWhenChecksumMismatch()
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z");
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
}
finally
{
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
}
[Fact]
public async Task FetchAsync_SkipsWhenChecksumMismatch()
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z");
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
var settings = BuildConnectorSettings(indexUri);
await connector.ValidateAsync(settings, CancellationToken.None);
@@ -164,17 +173,17 @@ public sealed class UbuntuCsafConnectorTests
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), services, ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty();
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty();
handler.DocumentRequestCount.Should().Be(1);
providerStore.SavedProviders.Should().ContainSingle();
}
@@ -198,146 +207,183 @@ public sealed class UbuntuCsafConnectorTests
return new VexConnectorSettings(builder.ToImmutable());
}
private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp)
{
var indexJson = """
{
"generated": "2025-10-18T00:00:00Z",
"channels": [
{
"name": "stable",
"catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json",
"sha256": "ignore"
}
]
}
""";
var catalogJson = """
{
"resources": [
{
"id": "{{advisoryId}}",
"type": "csaf",
"url": "{{advisoryUri}}",
"last_modified": "{{timestamp}}",
"hashes": {
"sha256": "{{SHA256}}"
},
"etag": "\"etag-123\"",
"title": "{{advisoryId}}"
}
]
}
""";
return (indexJson, catalogJson);
}
private static string ComputeSha256(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class UbuntuTestHttpHandler : HttpMessageHandler
{
private readonly Uri _indexUri;
private readonly string _indexPayload;
private readonly Uri _catalogUri;
private readonly string _catalogPayload;
private readonly Uri _documentUri;
private readonly byte[] _documentPayload;
private readonly string _expectedEtag;
public int DocumentRequestCount { get; private set; }
public List<string> SeenIfNoneMatch { get; } = new();
public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag)
{
_indexUri = indexUri;
_indexPayload = indexPayload;
_catalogUri = catalogUri;
_catalogPayload = catalogPayload;
_documentUri = documentUri;
_documentPayload = documentPayload;
_expectedEtag = expectedEtag;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri == _indexUri)
{
return Task.FromResult(CreateJsonResponse(_indexPayload));
}
if (request.RequestUri == _catalogUri)
{
return Task.FromResult(CreateJsonResponse(_catalogPayload));
}
if (request.RequestUri == _documentUri)
{
DocumentRequestCount++;
if (request.Headers.IfNoneMatch is { Count: > 0 })
{
var header = request.Headers.IfNoneMatch.First().ToString();
SeenIfNoneMatch.Add(header);
if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"")
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified));
}
}
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_documentPayload),
};
response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\"");
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}"),
});
}
private static HttpResponseMessage CreateJsonResponse(string payload)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? CurrentState { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
CurrentState = state;
return ValueTask.CompletedTask;
}
}
private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp)
{
var indexJson = """
{
"generated": "2025-10-18T00:00:00Z",
"channels": [
{
"name": "stable",
"catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json",
"sha256": "ignore"
}
]
}
""";
var catalogJson = """
{
"resources": [
{
"id": "{{advisoryId}}",
"type": "csaf",
"url": "{{advisoryUri}}",
"last_modified": "{{timestamp}}",
"hashes": {
"sha256": "{{SHA256}}"
},
"etag": "\"etag-123\"",
"title": "{{advisoryId}}"
}
]
}
""";
return (indexJson, catalogJson);
}
private static string ComputeSha256(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
{
var pathTemp = System.IO.Path.GetTempFileName();
var json = $"""
{{
\"schemaVersion\": \"1.0.0\",
\"generatedAt\": \"2025-11-20T00:00:00Z\",
\"connectors\": [
{{
\"connectorId\": \"{connectorId}\",
\"provider\": {{ \"name\": \"{connectorId}\", \"slug\": \"{connectorId}\" }},
\"issuerTier\": \"{tier}\",
\"signers\": [
{{
\"usage\": \"csaf\",
\"fingerprints\": [
{{ \"alg\": \"sha256\", \"format\": \"pgp\", \"value\": \"{fingerprint}\" }}
]
}}
]
}}
]
}}
""";
System.IO.File.WriteAllText(pathTemp, json);
return new TempMetadataFile(pathTemp);
}
private sealed record TempMetadataFile(string Path) : IDisposable
{
public void Dispose()
{
try { System.IO.File.Delete(Path); } catch { }
}
}
private sealed class UbuntuTestHttpHandler : HttpMessageHandler
{
private readonly Uri _indexUri;
private readonly string _indexPayload;
private readonly Uri _catalogUri;
private readonly string _catalogPayload;
private readonly Uri _documentUri;
private readonly byte[] _documentPayload;
private readonly string _expectedEtag;
public int DocumentRequestCount { get; private set; }
public List<string> SeenIfNoneMatch { get; } = new();
public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag)
{
_indexUri = indexUri;
_indexPayload = indexPayload;
_catalogUri = catalogUri;
_catalogPayload = catalogPayload;
_documentUri = documentUri;
_documentPayload = documentPayload;
_expectedEtag = expectedEtag;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri == _indexUri)
{
return Task.FromResult(CreateJsonResponse(_indexPayload));
}
if (request.RequestUri == _catalogUri)
{
return Task.FromResult(CreateJsonResponse(_catalogPayload));
}
if (request.RequestUri == _documentUri)
{
DocumentRequestCount++;
if (request.Headers.IfNoneMatch is { Count: > 0 })
{
var header = request.Headers.IfNoneMatch.First().ToString();
SeenIfNoneMatch.Add(header);
if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"")
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified));
}
}
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_documentPayload),
};
response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\"");
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}"),
});
}
private static HttpResponseMessage CreateJsonResponse(string payload)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? CurrentState { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
CurrentState = state;
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
@@ -374,16 +420,16 @@ public sealed class UbuntuCsafConnectorTests
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Json;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Excititor.WebService.Contracts;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class AttestationVerifyEndpointTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public AttestationVerifyEndpointTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Verify_ReturnsOk_WhenPayloadValid()
{
var client = _factory.CreateClient();
var request = new AttestationVerifyRequest
{
ExportId = "export-123",
QuerySignature = "purl=foo",
ArtifactDigest = "sha256:deadbeef",
Format = "VexJson",
CreatedAt = DateTimeOffset.Parse("2025-11-20T00:00:00Z"),
SourceProviders = new[] { "ghsa" },
Metadata = new Dictionary<string, string> { { "foo", "bar" } },
Attestation = new AttestationVerifyMetadata
{
PredicateType = "https://stella-ops.org/attestations/vex-export",
EnvelopeDigest = "sha256:abcd",
SignedAt = DateTimeOffset.Parse("2025-11-20T00:00:00Z"),
Rekor = new AttestationRekorReference
{
ApiVersion = "0.2",
Location = "https://rekor.example/log/123",
LogIndex = 1,
InclusionProofUrl = new Uri("https://rekor.example/log/123/proof")
}
},
Envelope = "{}"
};
var response = await client.PostAsJsonAsync("/v1/attestations/verify", request);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<AttestationVerifyResponse>();
body.Should().NotBeNull();
body!.Valid.Should().BeTrue();
}
[Fact]
public async Task Verify_ReturnsBadRequest_WhenFieldsMissing()
{
var client = _factory.CreateClient();
var request = new AttestationVerifyRequest
{
ExportId = "", // missing
QuerySignature = "",
ArtifactDigest = "",
Format = "",
Envelope = ""
};
var response = await client.PostAsJsonAsync("/v1/attestations/verify", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}