up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,38 +1,38 @@
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Export;
namespace StellaOps.Excititor.ArtifactStores.S3.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddVexS3ArtifactClient(this IServiceCollection services, Action<S3ArtifactClientOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
services.Configure(configure);
services.AddSingleton(CreateS3Client);
services.AddSingleton<IS3ArtifactClient, S3ArtifactClient>();
return services;
}
private static IAmazonS3 CreateS3Client(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptions<S3ArtifactClientOptions>>().Value;
var config = new AmazonS3Config
{
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
ForcePathStyle = options.ForcePathStyle,
};
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
{
config.ServiceURL = options.ServiceUrl;
}
return new AmazonS3Client(config);
}
}
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Export;
namespace StellaOps.Excititor.ArtifactStores.S3.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddVexS3ArtifactClient(this IServiceCollection services, Action<S3ArtifactClientOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
services.Configure(configure);
services.AddSingleton(CreateS3Client);
services.AddSingleton<IS3ArtifactClient, S3ArtifactClient>();
return services;
}
private static IAmazonS3 CreateS3Client(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptions<S3ArtifactClientOptions>>().Value;
var config = new AmazonS3Config
{
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
ForcePathStyle = options.ForcePathStyle,
};
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
{
config.ServiceURL = options.ServiceUrl;
}
return new AmazonS3Client(config);
}
}

View File

@@ -1,85 +1,85 @@
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Export;
namespace StellaOps.Excititor.ArtifactStores.S3;
public sealed class S3ArtifactClientOptions
{
public string Region { get; set; } = "us-east-1";
public string? ServiceUrl { get; set; }
= null;
public bool ForcePathStyle { get; set; }
= true;
}
public sealed class S3ArtifactClient : IS3ArtifactClient
{
private readonly IAmazonS3 _s3;
private readonly ILogger<S3ArtifactClient> _logger;
public S3ArtifactClient(IAmazonS3 s3, ILogger<S3ArtifactClient> logger)
{
_s3 = s3 ?? throw new ArgumentNullException(nameof(s3));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken)
{
try
{
var metadata = await _s3.GetObjectMetadataAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
return metadata.HttpStatusCode == System.Net.HttpStatusCode.OK;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return false;
}
}
public async Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken)
{
var request = new PutObjectRequest
{
BucketName = bucketName,
Key = key,
InputStream = content,
AutoCloseStream = false,
};
foreach (var kvp in metadata)
{
request.Metadata[kvp.Key] = kvp.Value;
}
await _s3.PutObjectAsync(request, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Uploaded object {Bucket}/{Key}", bucketName, key);
}
public async Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
{
try
{
var response = await _s3.GetObjectAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
var buffer = new MemoryStream();
await response.ResponseStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
buffer.Position = 0;
return buffer;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("Object {Bucket}/{Key} not found", bucketName, key);
return null;
}
}
public async Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
{
await _s3.DeleteObjectAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Deleted object {Bucket}/{Key}", bucketName, key);
}
}
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Export;
namespace StellaOps.Excititor.ArtifactStores.S3;
public sealed class S3ArtifactClientOptions
{
public string Region { get; set; } = "us-east-1";
public string? ServiceUrl { get; set; }
= null;
public bool ForcePathStyle { get; set; }
= true;
}
public sealed class S3ArtifactClient : IS3ArtifactClient
{
private readonly IAmazonS3 _s3;
private readonly ILogger<S3ArtifactClient> _logger;
public S3ArtifactClient(IAmazonS3 s3, ILogger<S3ArtifactClient> logger)
{
_s3 = s3 ?? throw new ArgumentNullException(nameof(s3));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken)
{
try
{
var metadata = await _s3.GetObjectMetadataAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
return metadata.HttpStatusCode == System.Net.HttpStatusCode.OK;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return false;
}
}
public async Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken)
{
var request = new PutObjectRequest
{
BucketName = bucketName,
Key = key,
InputStream = content,
AutoCloseStream = false,
};
foreach (var kvp in metadata)
{
request.Metadata[kvp.Key] = kvp.Value;
}
await _s3.PutObjectAsync(request, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Uploaded object {Bucket}/{Key}", bucketName, key);
}
public async Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
{
try
{
var response = await _s3.GetObjectAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
var buffer = new MemoryStream();
await response.ResponseStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
buffer.Position = 0;
return buffer;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("Object {Bucket}/{Key} not found", bucketName, key);
return null;
}
}
public async Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken)
{
await _s3.DeleteObjectAsync(bucketName, key, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Deleted object {Bucket}/{Key}", bucketName, key);
}
}

View File

@@ -1,13 +1,13 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.Attestation.Dsse;
public sealed record DsseEnvelope(
[property: JsonPropertyName("payload")] string Payload,
[property: JsonPropertyName("payloadType")] string PayloadType,
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignature> Signatures);
public sealed record DsseSignature(
[property: JsonPropertyName("sig")] string Signature,
[property: JsonPropertyName("keyid")] string? KeyId);
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.Attestation.Dsse;
public sealed record DsseEnvelope(
[property: JsonPropertyName("payload")] string Payload,
[property: JsonPropertyName("payloadType")] string PayloadType,
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignature> Signatures);
public sealed record DsseSignature(
[property: JsonPropertyName("sig")] string Signature,
[property: JsonPropertyName("keyid")] string? KeyId);

View File

@@ -1,83 +1,83 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Attestation.Models;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation.Dsse;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Attestation.Models;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation.Dsse;
public sealed class VexDsseBuilder
{
internal const string PayloadType = "application/vnd.in-toto+json";
private readonly IVexSigner _signer;
private readonly ILogger<VexDsseBuilder> _logger;
private readonly JsonSerializerOptions _serializerOptions;
public VexDsseBuilder(IVexSigner signer, ILogger<VexDsseBuilder> logger)
{
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_serializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
WriteIndented = false,
};
_serializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
}
public async ValueTask<DsseEnvelope> CreateEnvelopeAsync(
VexAttestationRequest request,
IReadOnlyDictionary<string, string>? metadata,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var predicate = VexAttestationPredicate.FromRequest(request, metadata);
var subject = new VexInTotoSubject(
request.ExportId,
new Dictionary<string, string>(StringComparer.Ordinal)
{
{ request.Artifact.Algorithm.ToLowerInvariant(), request.Artifact.Digest }
});
var statement = new VexInTotoStatement(
VexInTotoStatement.InTotoType,
"https://stella-ops.org/attestations/vex-export",
new[] { subject },
predicate);
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, _serializerOptions);
var signatureResult = await _signer.SignAsync(payloadBytes, cancellationToken).ConfigureAwait(false);
var envelope = new DsseEnvelope(
Convert.ToBase64String(payloadBytes),
PayloadType,
new[] { new DsseSignature(signatureResult.Signature, signatureResult.KeyId) });
_logger.LogDebug("DSSE envelope created for export {ExportId}", request.ExportId);
return envelope;
}
public static string ComputeEnvelopeDigest(DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
});
var bytes = Encoding.UTF8.GetBytes(envelopeJson);
var hash = SHA256.HashData(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
}
private readonly IVexSigner _signer;
private readonly ILogger<VexDsseBuilder> _logger;
private readonly JsonSerializerOptions _serializerOptions;
public VexDsseBuilder(IVexSigner signer, ILogger<VexDsseBuilder> logger)
{
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_serializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
WriteIndented = false,
};
_serializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
}
public async ValueTask<DsseEnvelope> CreateEnvelopeAsync(
VexAttestationRequest request,
IReadOnlyDictionary<string, string>? metadata,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var predicate = VexAttestationPredicate.FromRequest(request, metadata);
var subject = new VexInTotoSubject(
request.ExportId,
new Dictionary<string, string>(StringComparer.Ordinal)
{
{ request.Artifact.Algorithm.ToLowerInvariant(), request.Artifact.Digest }
});
var statement = new VexInTotoStatement(
VexInTotoStatement.InTotoType,
"https://stella-ops.org/attestations/vex-export",
new[] { subject },
predicate);
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, _serializerOptions);
var signatureResult = await _signer.SignAsync(payloadBytes, cancellationToken).ConfigureAwait(false);
var envelope = new DsseEnvelope(
Convert.ToBase64String(payloadBytes),
PayloadType,
new[] { new DsseSignature(signatureResult.Signature, signatureResult.KeyId) });
_logger.LogDebug("DSSE envelope created for export {ExportId}", request.ExportId);
return envelope;
}
public static string ComputeEnvelopeDigest(DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
});
var bytes = Encoding.UTF8.GetBytes(envelopeJson);
var hash = SHA256.HashData(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -1,44 +1,44 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation.Models;
public sealed record VexAttestationPredicate(
string ExportId,
string QuerySignature,
string ArtifactAlgorithm,
string ArtifactDigest,
VexExportFormat Format,
DateTimeOffset CreatedAt,
IReadOnlyList<string> SourceProviders,
IReadOnlyDictionary<string, string> Metadata)
{
public static VexAttestationPredicate FromRequest(
VexAttestationRequest request,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(
request.ExportId,
request.QuerySignature.Value,
request.Artifact.Algorithm,
request.Artifact.Digest,
request.Format,
request.CreatedAt,
request.SourceProviders,
metadata is null ? ImmutableDictionary<string, string>.Empty : metadata.ToImmutableDictionary(StringComparer.Ordinal));
}
public sealed record VexInTotoSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);
public sealed record VexInTotoStatement(
[property: JsonPropertyName("_type")] string Type,
string PredicateType,
IReadOnlyList<VexInTotoSubject> Subject,
VexAttestationPredicate Predicate)
{
public static readonly string InTotoType = "https://in-toto.io/Statement/v0.1";
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation.Models;
public sealed record VexAttestationPredicate(
string ExportId,
string QuerySignature,
string ArtifactAlgorithm,
string ArtifactDigest,
VexExportFormat Format,
DateTimeOffset CreatedAt,
IReadOnlyList<string> SourceProviders,
IReadOnlyDictionary<string, string> Metadata)
{
public static VexAttestationPredicate FromRequest(
VexAttestationRequest request,
IReadOnlyDictionary<string, string>? metadata = null)
=> new(
request.ExportId,
request.QuerySignature.Value,
request.Artifact.Algorithm,
request.Artifact.Digest,
request.Format,
request.CreatedAt,
request.SourceProviders,
metadata is null ? ImmutableDictionary<string, string>.Empty : metadata.ToImmutableDictionary(StringComparer.Ordinal));
}
public sealed record VexInTotoSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);
public sealed record VexInTotoStatement(
[property: JsonPropertyName("_type")] string Type,
string PredicateType,
IReadOnlyList<VexInTotoSubject> Subject,
VexAttestationPredicate Predicate)
{
public static readonly string InTotoType = "https://in-toto.io/Statement/v0.1";
}

View File

@@ -1,12 +1,12 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Attestation.Signing;
public sealed record VexSignedPayload(string Signature, string? KeyId);
public interface IVexSigner
{
ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken);
}
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Attestation.Signing;
public sealed record VexSignedPayload(string Signature, string? KeyId);
public interface IVexSigner
{
ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken);
}

View File

@@ -1,14 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Attestation.Dsse;
namespace StellaOps.Excititor.Attestation.Transparency;
public sealed record TransparencyLogEntry(string Id, string Location, string? LogIndex, string? InclusionProofUrl);
public interface ITransparencyLogClient
{
ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken);
ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken);
}
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Attestation.Dsse;
namespace StellaOps.Excititor.Attestation.Transparency;
public sealed record TransparencyLogEntry(string Id, string Location, string? LogIndex, string? InclusionProofUrl);
public interface ITransparencyLogClient
{
ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken);
ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken);
}

View File

@@ -1,91 +1,91 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Dsse;
namespace StellaOps.Excititor.Attestation.Transparency;
internal sealed class RekorHttpClient : ITransparencyLogClient
{
private readonly HttpClient _httpClient;
private readonly RekorHttpClientOptions _options;
private readonly ILogger<RekorHttpClient> _logger;
public RekorHttpClient(HttpClient httpClient, IOptions<RekorHttpClientOptions> options, ILogger<RekorHttpClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (!string.IsNullOrWhiteSpace(_options.BaseAddress))
{
_httpClient.BaseAddress = new Uri(_options.BaseAddress, UriKind.Absolute);
}
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
{
_httpClient.DefaultRequestHeaders.Add("Authorization", _options.ApiKey);
}
}
public async ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelope);
var payload = JsonSerializer.Serialize(envelope);
using var content = new StringContent(payload);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage? response = null;
for (var attempt = 0; attempt < _options.RetryCount; attempt++)
{
response = await _httpClient.PostAsync("/api/v2/log/entries", content, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
break;
}
_logger.LogWarning("Rekor submission failed with status {Status}; attempt {Attempt}", response.StatusCode, attempt + 1);
if (attempt + 1 < _options.RetryCount)
{
await Task.Delay(_options.RetryDelay, cancellationToken).ConfigureAwait(false);
}
}
if (response is null || !response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Failed to submit attestation to Rekor ({response?.StatusCode}).");
}
var entryLocation = response.Headers.Location?.ToString() ?? string.Empty;
var body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken).ConfigureAwait(false);
var entry = ParseEntryLocation(entryLocation, body);
_logger.LogInformation("Rekor entry recorded at {Location}", entry.Location);
return entry;
}
public async ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(entryLocation))
{
return false;
}
var response = await _httpClient.GetAsync(entryLocation, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
private static TransparencyLogEntry ParseEntryLocation(string location, JsonElement body)
{
var id = body.TryGetProperty("uuid", out var uuid) ? uuid.GetString() ?? string.Empty : Guid.NewGuid().ToString();
var logIndex = body.TryGetProperty("logIndex", out var logIndexElement) ? logIndexElement.GetString() : null;
string? inclusionProof = null;
if (body.TryGetProperty("verification", out var verification) && verification.TryGetProperty("inclusionProof", out var inclusion))
{
inclusionProof = inclusion.GetProperty("logIndex").GetRawText();
}
return new TransparencyLogEntry(id, location, logIndex, inclusionProof);
}
}
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Dsse;
namespace StellaOps.Excititor.Attestation.Transparency;
internal sealed class RekorHttpClient : ITransparencyLogClient
{
private readonly HttpClient _httpClient;
private readonly RekorHttpClientOptions _options;
private readonly ILogger<RekorHttpClient> _logger;
public RekorHttpClient(HttpClient httpClient, IOptions<RekorHttpClientOptions> options, ILogger<RekorHttpClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (!string.IsNullOrWhiteSpace(_options.BaseAddress))
{
_httpClient.BaseAddress = new Uri(_options.BaseAddress, UriKind.Absolute);
}
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
{
_httpClient.DefaultRequestHeaders.Add("Authorization", _options.ApiKey);
}
}
public async ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelope);
var payload = JsonSerializer.Serialize(envelope);
using var content = new StringContent(payload);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage? response = null;
for (var attempt = 0; attempt < _options.RetryCount; attempt++)
{
response = await _httpClient.PostAsync("/api/v2/log/entries", content, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
break;
}
_logger.LogWarning("Rekor submission failed with status {Status}; attempt {Attempt}", response.StatusCode, attempt + 1);
if (attempt + 1 < _options.RetryCount)
{
await Task.Delay(_options.RetryDelay, cancellationToken).ConfigureAwait(false);
}
}
if (response is null || !response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Failed to submit attestation to Rekor ({response?.StatusCode}).");
}
var entryLocation = response.Headers.Location?.ToString() ?? string.Empty;
var body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken).ConfigureAwait(false);
var entry = ParseEntryLocation(entryLocation, body);
_logger.LogInformation("Rekor entry recorded at {Location}", entry.Location);
return entry;
}
public async ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(entryLocation))
{
return false;
}
var response = await _httpClient.GetAsync(entryLocation, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
private static TransparencyLogEntry ParseEntryLocation(string location, JsonElement body)
{
var id = body.TryGetProperty("uuid", out var uuid) ? uuid.GetString() ?? string.Empty : Guid.NewGuid().ToString();
var logIndex = body.TryGetProperty("logIndex", out var logIndexElement) ? logIndexElement.GetString() : null;
string? inclusionProof = null;
if (body.TryGetProperty("verification", out var verification) && verification.TryGetProperty("inclusionProof", out var inclusion))
{
inclusionProof = inclusion.GetProperty("logIndex").GetRawText();
}
return new TransparencyLogEntry(id, location, logIndex, inclusionProof);
}
}

View File

@@ -1,13 +1,13 @@
namespace StellaOps.Excititor.Attestation.Transparency;
public sealed class RekorHttpClientOptions
{
public string BaseAddress { get; set; } = "https://rekor.sigstore.dev";
public string? ApiKey { get; set; }
= null;
public int RetryCount { get; set; } = 3;
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(2);
}
namespace StellaOps.Excititor.Attestation.Transparency;
public sealed class RekorHttpClientOptions
{
public string BaseAddress { get; set; } = "https://rekor.sigstore.dev";
public string? ApiKey { get; set; }
= null;
public int RetryCount { get; set; } = 3;
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(2);
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
@@ -15,24 +15,24 @@ using StellaOps.Excititor.Attestation.Models;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Core;
using StellaOps.Cryptography;
namespace StellaOps.Excititor.Attestation.Verification;
internal sealed class VexAttestationVerifier : IVexAttestationVerifier
{
private static readonly JsonSerializerOptions EnvelopeSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly JsonSerializerOptions StatementSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
namespace StellaOps.Excititor.Attestation.Verification;
internal sealed class VexAttestationVerifier : IVexAttestationVerifier
{
private static readonly JsonSerializerOptions EnvelopeSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly JsonSerializerOptions StatementSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private readonly ILogger<VexAttestationVerifier> _logger;
private readonly ITransparencyLogClient? _transparencyLogClient;
private readonly VexAttestationVerificationOptions _options;
@@ -55,10 +55,10 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
_cryptoRegistry = cryptoRegistry;
_trustedSigners = _options.TrustedSigners;
}
public async ValueTask<VexAttestationVerification> VerifyAsync(
VexAttestationVerificationRequest request,
CancellationToken cancellationToken)
public async ValueTask<VexAttestationVerification> VerifyAsync(
VexAttestationVerificationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
@@ -212,16 +212,16 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
finally
{
stopwatch.Stop();
var tags = new KeyValuePair<string, object?>[]
{
new("result", resultLabel),
new("component", component),
new("rekor", rekorState),
};
_metrics.VerifyTotal.Add(1, tags);
_metrics.VerifyDuration.Record(stopwatch.Elapsed.TotalSeconds, tags);
}
var tags = new KeyValuePair<string, object?>[]
{
new("result", resultLabel),
new("component", component),
new("rekor", rekorState),
};
_metrics.VerifyTotal.Add(1, tags);
_metrics.VerifyDuration.Record(stopwatch.Elapsed.TotalSeconds, tags);
}
VexAttestationVerification BuildResult(bool isValid)
{
diagnostics["result"] = resultLabel;
@@ -353,266 +353,266 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
out DsseEnvelope envelope,
ImmutableDictionary<string, string>.Builder diagnostics)
{
try
{
envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, EnvelopeSerializerOptions)
?? throw new InvalidOperationException("Envelope deserialized to null.");
return true;
}
catch (Exception ex)
{
diagnostics["envelope.error"] = ex.GetType().Name;
envelope = default!;
return false;
}
}
private static bool TryDecodePayload(
string payloadBase64,
out byte[] payloadBytes,
ImmutableDictionary<string, string>.Builder diagnostics)
{
try
{
payloadBytes = Convert.FromBase64String(payloadBase64);
return true;
}
catch (FormatException)
{
diagnostics["payload.base64"] = "invalid";
payloadBytes = Array.Empty<byte>();
return false;
}
}
private static bool TryDeserializeStatement(
byte[] payload,
out VexInTotoStatement statement,
ImmutableDictionary<string, string>.Builder diagnostics)
{
try
{
statement = JsonSerializer.Deserialize<VexInTotoStatement>(payload, StatementSerializerOptions)
?? throw new InvalidOperationException("Statement deserialized to null.");
return true;
}
catch (Exception ex)
{
diagnostics["payload.error"] = ex.GetType().Name;
statement = default!;
return false;
}
}
private static bool ValidatePredicateType(
VexInTotoStatement statement,
VexAttestationVerificationRequest request,
ImmutableDictionary<string, string>.Builder diagnostics)
{
var predicateType = statement.PredicateType ?? string.Empty;
if (!string.Equals(predicateType, request.Metadata.PredicateType, StringComparison.Ordinal))
{
diagnostics["predicate.type"] = predicateType;
return false;
}
return true;
}
private static bool ValidateSubject(
VexInTotoStatement statement,
VexAttestationVerificationRequest request,
ImmutableDictionary<string, string>.Builder diagnostics)
{
if (statement.Subject is null || statement.Subject.Count != 1)
{
diagnostics["subject.count"] = (statement.Subject?.Count ?? 0).ToString();
return false;
}
var subject = statement.Subject[0];
if (!string.Equals(subject.Name, request.Attestation.ExportId, StringComparison.Ordinal))
{
diagnostics["subject.name"] = subject.Name ?? string.Empty;
return false;
}
if (subject.Digest is null)
{
diagnostics["subject.digest"] = "missing";
return false;
}
var algorithmKey = request.Attestation.Artifact.Algorithm.ToLowerInvariant();
if (!subject.Digest.TryGetValue(algorithmKey, out var digest)
|| !string.Equals(digest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase))
{
diagnostics["subject.digest"] = digest ?? string.Empty;
return false;
}
return true;
}
private bool ValidatePredicate(
VexInTotoStatement statement,
VexAttestationVerificationRequest request,
ImmutableDictionary<string, string>.Builder diagnostics)
{
var predicate = statement.Predicate;
if (predicate is null)
{
diagnostics["predicate.state"] = "missing";
return false;
}
if (!string.Equals(predicate.ExportId, request.Attestation.ExportId, StringComparison.Ordinal))
{
diagnostics["predicate.exportId"] = predicate.ExportId ?? string.Empty;
return false;
}
if (!string.Equals(predicate.QuerySignature, request.Attestation.QuerySignature.Value, StringComparison.Ordinal))
{
diagnostics["predicate.querySignature"] = predicate.QuerySignature ?? string.Empty;
return false;
}
if (!string.Equals(predicate.ArtifactAlgorithm, request.Attestation.Artifact.Algorithm, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(predicate.ArtifactDigest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase))
{
diagnostics["predicate.artifact"] = $"{predicate.ArtifactAlgorithm}:{predicate.ArtifactDigest}";
return false;
}
if (predicate.Format != request.Attestation.Format)
{
diagnostics["predicate.format"] = predicate.Format.ToString();
return false;
}
var createdDelta = (predicate.CreatedAt - request.Attestation.CreatedAt).Duration();
if (createdDelta > _options.MaxClockSkew)
{
diagnostics["predicate.createdAtDelta"] = createdDelta.ToString();
return false;
}
if (!SetEquals(predicate.SourceProviders, request.Attestation.SourceProviders))
{
diagnostics["predicate.sourceProviders"] = string.Join(",", predicate.SourceProviders ?? Array.Empty<string>());
return false;
}
if (request.Attestation.Metadata.Count > 0)
{
if (predicate.Metadata is null)
{
diagnostics["predicate.metadata"] = "missing";
return false;
}
foreach (var kvp in request.Attestation.Metadata)
{
if (!predicate.Metadata.TryGetValue(kvp.Key, out var actual)
|| !string.Equals(actual, kvp.Value, StringComparison.Ordinal))
{
diagnostics[$"predicate.metadata.{kvp.Key}"] = actual ?? string.Empty;
return false;
}
}
}
return true;
}
private bool ValidateMetadataDigest(
DsseEnvelope envelope,
VexAttestationMetadata metadata,
ImmutableDictionary<string, string>.Builder diagnostics)
{
if (string.IsNullOrWhiteSpace(metadata.EnvelopeDigest))
{
diagnostics["metadata.envelopeDigest"] = "missing";
return false;
}
var computed = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
if (!string.Equals(computed, metadata.EnvelopeDigest, StringComparison.OrdinalIgnoreCase))
{
diagnostics["metadata.envelopeDigest"] = metadata.EnvelopeDigest;
diagnostics["metadata.envelopeDigest.computed"] = computed;
return false;
}
diagnostics["metadata.envelopeDigest"] = "match";
return true;
}
private bool ValidateSignedAt(
VexAttestationMetadata metadata,
DateTimeOffset createdAt,
ImmutableDictionary<string, string>.Builder diagnostics)
{
if (metadata.SignedAt is null)
{
diagnostics["metadata.signedAt"] = "missing";
return false;
}
var delta = (metadata.SignedAt.Value - createdAt).Duration();
if (delta > _options.MaxClockSkew)
{
diagnostics["metadata.signedAtDelta"] = delta.ToString();
return false;
}
return true;
}
private async ValueTask<string> VerifyTransparencyAsync(
VexAttestationMetadata metadata,
ImmutableDictionary<string, string>.Builder diagnostics,
CancellationToken cancellationToken)
{
if (metadata.Rekor is null)
{
if (_options.RequireTransparencyLog)
{
diagnostics["rekor.state"] = "missing";
return "missing";
}
diagnostics["rekor.state"] = "disabled";
return "disabled";
}
if (_transparencyLogClient is null)
{
diagnostics["rekor.state"] = "client_unavailable";
return _options.RequireTransparencyLog ? "client_unavailable" : "disabled";
}
try
{
var verified = await _transparencyLogClient.VerifyAsync(metadata.Rekor.Location, cancellationToken).ConfigureAwait(false);
diagnostics["rekor.state"] = verified ? "verified" : "unverified";
return verified ? "verified" : "unverified";
}
catch (Exception ex)
{
diagnostics["rekor.error"] = ex.GetType().Name;
if (_options.AllowOfflineTransparency)
{
diagnostics["rekor.state"] = "offline";
return "offline";
}
diagnostics["rekor.state"] = "unreachable";
return "unreachable";
}
}
try
{
envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, EnvelopeSerializerOptions)
?? throw new InvalidOperationException("Envelope deserialized to null.");
return true;
}
catch (Exception ex)
{
diagnostics["envelope.error"] = ex.GetType().Name;
envelope = default!;
return false;
}
}
private static bool TryDecodePayload(
string payloadBase64,
out byte[] payloadBytes,
ImmutableDictionary<string, string>.Builder diagnostics)
{
try
{
payloadBytes = Convert.FromBase64String(payloadBase64);
return true;
}
catch (FormatException)
{
diagnostics["payload.base64"] = "invalid";
payloadBytes = Array.Empty<byte>();
return false;
}
}
private static bool TryDeserializeStatement(
byte[] payload,
out VexInTotoStatement statement,
ImmutableDictionary<string, string>.Builder diagnostics)
{
try
{
statement = JsonSerializer.Deserialize<VexInTotoStatement>(payload, StatementSerializerOptions)
?? throw new InvalidOperationException("Statement deserialized to null.");
return true;
}
catch (Exception ex)
{
diagnostics["payload.error"] = ex.GetType().Name;
statement = default!;
return false;
}
}
private static bool ValidatePredicateType(
VexInTotoStatement statement,
VexAttestationVerificationRequest request,
ImmutableDictionary<string, string>.Builder diagnostics)
{
var predicateType = statement.PredicateType ?? string.Empty;
if (!string.Equals(predicateType, request.Metadata.PredicateType, StringComparison.Ordinal))
{
diagnostics["predicate.type"] = predicateType;
return false;
}
return true;
}
private static bool ValidateSubject(
VexInTotoStatement statement,
VexAttestationVerificationRequest request,
ImmutableDictionary<string, string>.Builder diagnostics)
{
if (statement.Subject is null || statement.Subject.Count != 1)
{
diagnostics["subject.count"] = (statement.Subject?.Count ?? 0).ToString();
return false;
}
var subject = statement.Subject[0];
if (!string.Equals(subject.Name, request.Attestation.ExportId, StringComparison.Ordinal))
{
diagnostics["subject.name"] = subject.Name ?? string.Empty;
return false;
}
if (subject.Digest is null)
{
diagnostics["subject.digest"] = "missing";
return false;
}
var algorithmKey = request.Attestation.Artifact.Algorithm.ToLowerInvariant();
if (!subject.Digest.TryGetValue(algorithmKey, out var digest)
|| !string.Equals(digest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase))
{
diagnostics["subject.digest"] = digest ?? string.Empty;
return false;
}
return true;
}
private bool ValidatePredicate(
VexInTotoStatement statement,
VexAttestationVerificationRequest request,
ImmutableDictionary<string, string>.Builder diagnostics)
{
var predicate = statement.Predicate;
if (predicate is null)
{
diagnostics["predicate.state"] = "missing";
return false;
}
if (!string.Equals(predicate.ExportId, request.Attestation.ExportId, StringComparison.Ordinal))
{
diagnostics["predicate.exportId"] = predicate.ExportId ?? string.Empty;
return false;
}
if (!string.Equals(predicate.QuerySignature, request.Attestation.QuerySignature.Value, StringComparison.Ordinal))
{
diagnostics["predicate.querySignature"] = predicate.QuerySignature ?? string.Empty;
return false;
}
if (!string.Equals(predicate.ArtifactAlgorithm, request.Attestation.Artifact.Algorithm, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(predicate.ArtifactDigest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase))
{
diagnostics["predicate.artifact"] = $"{predicate.ArtifactAlgorithm}:{predicate.ArtifactDigest}";
return false;
}
if (predicate.Format != request.Attestation.Format)
{
diagnostics["predicate.format"] = predicate.Format.ToString();
return false;
}
var createdDelta = (predicate.CreatedAt - request.Attestation.CreatedAt).Duration();
if (createdDelta > _options.MaxClockSkew)
{
diagnostics["predicate.createdAtDelta"] = createdDelta.ToString();
return false;
}
if (!SetEquals(predicate.SourceProviders, request.Attestation.SourceProviders))
{
diagnostics["predicate.sourceProviders"] = string.Join(",", predicate.SourceProviders ?? Array.Empty<string>());
return false;
}
if (request.Attestation.Metadata.Count > 0)
{
if (predicate.Metadata is null)
{
diagnostics["predicate.metadata"] = "missing";
return false;
}
foreach (var kvp in request.Attestation.Metadata)
{
if (!predicate.Metadata.TryGetValue(kvp.Key, out var actual)
|| !string.Equals(actual, kvp.Value, StringComparison.Ordinal))
{
diagnostics[$"predicate.metadata.{kvp.Key}"] = actual ?? string.Empty;
return false;
}
}
}
return true;
}
private bool ValidateMetadataDigest(
DsseEnvelope envelope,
VexAttestationMetadata metadata,
ImmutableDictionary<string, string>.Builder diagnostics)
{
if (string.IsNullOrWhiteSpace(metadata.EnvelopeDigest))
{
diagnostics["metadata.envelopeDigest"] = "missing";
return false;
}
var computed = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
if (!string.Equals(computed, metadata.EnvelopeDigest, StringComparison.OrdinalIgnoreCase))
{
diagnostics["metadata.envelopeDigest"] = metadata.EnvelopeDigest;
diagnostics["metadata.envelopeDigest.computed"] = computed;
return false;
}
diagnostics["metadata.envelopeDigest"] = "match";
return true;
}
private bool ValidateSignedAt(
VexAttestationMetadata metadata,
DateTimeOffset createdAt,
ImmutableDictionary<string, string>.Builder diagnostics)
{
if (metadata.SignedAt is null)
{
diagnostics["metadata.signedAt"] = "missing";
return false;
}
var delta = (metadata.SignedAt.Value - createdAt).Duration();
if (delta > _options.MaxClockSkew)
{
diagnostics["metadata.signedAtDelta"] = delta.ToString();
return false;
}
return true;
}
private async ValueTask<string> VerifyTransparencyAsync(
VexAttestationMetadata metadata,
ImmutableDictionary<string, string>.Builder diagnostics,
CancellationToken cancellationToken)
{
if (metadata.Rekor is null)
{
if (_options.RequireTransparencyLog)
{
diagnostics["rekor.state"] = "missing";
return "missing";
}
diagnostics["rekor.state"] = "disabled";
return "disabled";
}
if (_transparencyLogClient is null)
{
diagnostics["rekor.state"] = "client_unavailable";
return _options.RequireTransparencyLog ? "client_unavailable" : "disabled";
}
try
{
var verified = await _transparencyLogClient.VerifyAsync(metadata.Rekor.Location, cancellationToken).ConfigureAwait(false);
diagnostics["rekor.state"] = verified ? "verified" : "unverified";
return verified ? "verified" : "unverified";
}
catch (Exception ex)
{
diagnostics["rekor.error"] = ex.GetType().Name;
if (_options.AllowOfflineTransparency)
{
diagnostics["rekor.state"] = "offline";
return "offline";
}
diagnostics["rekor.state"] = "unreachable";
return "unreachable";
}
}
private static bool SetEquals(IReadOnlyCollection<string>? left, ImmutableArray<string> right)
{
if (left is null || left.Count == 0)

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Attestation.Dsse;
@@ -12,14 +12,14 @@ using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation;
public sealed class VexAttestationClientOptions
{
public IReadOnlyDictionary<string, string> DefaultMetadata { get; set; } = ImmutableDictionary<string, string>.Empty;
}
namespace StellaOps.Excititor.Attestation;
public sealed class VexAttestationClientOptions
{
public IReadOnlyDictionary<string, string> DefaultMetadata { get; set; } = ImmutableDictionary<string, string>.Empty;
}
public sealed class VexAttestationClient : IVexAttestationClient
{
private readonly VexDsseBuilder _builder;
@@ -45,67 +45,67 @@ public sealed class VexAttestationClient : IVexAttestationClient
_defaultMetadata = options.Value.DefaultMetadata;
_transparencyLogClient = transparencyLogClient;
}
public async ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var mergedMetadata = MergeMetadata(request.Metadata, _defaultMetadata);
var envelope = await _builder.CreateEnvelopeAsync(request, mergedMetadata, cancellationToken).ConfigureAwait(false);
var envelopeDigest = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
var signedAt = _timeProvider.GetUtcNow();
var diagnosticsBuilder = ImmutableDictionary<string, string>.Empty
.Add("envelope", JsonSerializer.Serialize(envelope))
.Add("predicateType", "https://stella-ops.org/attestations/vex-export");
VexRekorReference? rekorReference = null;
if (_transparencyLogClient is not null)
{
try
{
var entry = await _transparencyLogClient.SubmitAsync(envelope, cancellationToken).ConfigureAwait(false);
rekorReference = new VexRekorReference("0.2", entry.Location, entry.LogIndex, entry.InclusionProofUrl is not null ? new Uri(entry.InclusionProofUrl) : null);
diagnosticsBuilder = diagnosticsBuilder.Add("rekorLocation", entry.Location);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to submit attestation to Rekor transparency log");
throw;
}
}
var metadata = new VexAttestationMetadata(
predicateType: "https://stella-ops.org/attestations/vex-export",
rekor: rekorReference,
envelopeDigest: envelopeDigest,
signedAt: signedAt);
_logger.LogInformation("Generated DSSE envelope for export {ExportId} ({Digest})", request.ExportId, envelopeDigest);
return new VexAttestationResponse(metadata, diagnosticsBuilder);
}
public async ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var mergedMetadata = MergeMetadata(request.Metadata, _defaultMetadata);
var envelope = await _builder.CreateEnvelopeAsync(request, mergedMetadata, cancellationToken).ConfigureAwait(false);
var envelopeDigest = VexDsseBuilder.ComputeEnvelopeDigest(envelope);
var signedAt = _timeProvider.GetUtcNow();
var diagnosticsBuilder = ImmutableDictionary<string, string>.Empty
.Add("envelope", JsonSerializer.Serialize(envelope))
.Add("predicateType", "https://stella-ops.org/attestations/vex-export");
VexRekorReference? rekorReference = null;
if (_transparencyLogClient is not null)
{
try
{
var entry = await _transparencyLogClient.SubmitAsync(envelope, cancellationToken).ConfigureAwait(false);
rekorReference = new VexRekorReference("0.2", entry.Location, entry.LogIndex, entry.InclusionProofUrl is not null ? new Uri(entry.InclusionProofUrl) : null);
diagnosticsBuilder = diagnosticsBuilder.Add("rekorLocation", entry.Location);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to submit attestation to Rekor transparency log");
throw;
}
}
var metadata = new VexAttestationMetadata(
predicateType: "https://stella-ops.org/attestations/vex-export",
rekor: rekorReference,
envelopeDigest: envelopeDigest,
signedAt: signedAt);
_logger.LogInformation("Generated DSSE envelope for export {ExportId} ({Digest})", request.ExportId, envelopeDigest);
return new VexAttestationResponse(metadata, diagnosticsBuilder);
}
public ValueTask<VexAttestationVerification> VerifyAsync(
VexAttestationVerificationRequest request,
CancellationToken cancellationToken)
=> _verifier.VerifyAsync(request, cancellationToken);
private static IReadOnlyDictionary<string, string> MergeMetadata(
IReadOnlyDictionary<string, string> requestMetadata,
IReadOnlyDictionary<string, string> defaults)
{
if (defaults.Count == 0)
{
return requestMetadata;
}
var merged = new Dictionary<string, string>(defaults, StringComparer.Ordinal);
foreach (var kvp in requestMetadata)
{
merged[kvp.Key] = kvp.Value;
}
return merged.ToImmutableDictionary(StringComparer.Ordinal);
}
}
private static IReadOnlyDictionary<string, string> MergeMetadata(
IReadOnlyDictionary<string, string> requestMetadata,
IReadOnlyDictionary<string, string> defaults)
{
if (defaults.Count == 0)
{
return requestMetadata;
}
var merged = new Dictionary<string, string>(defaults, StringComparer.Ordinal);
foreach (var kvp in requestMetadata)
{
merged[kvp.Key] = kvp.Value;
}
return merged.ToImmutableDictionary(StringComparer.Ordinal);
}
}

View File

@@ -1,12 +1,12 @@
using System.Collections.Generic;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Custom validator hook executed after connector options are bound.
/// </summary>
/// <typeparam name="TOptions">Connector-specific options type.</typeparam>
public interface IVexConnectorOptionsValidator<in TOptions>
{
void Validate(VexConnectorDescriptor descriptor, TOptions options, IList<string> errors);
}
using System.Collections.Generic;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Custom validator hook executed after connector options are bound.
/// </summary>
/// <typeparam name="TOptions">Connector-specific options type.</typeparam>
public interface IVexConnectorOptionsValidator<in TOptions>
{
void Validate(VexConnectorDescriptor descriptor, TOptions options, IList<string> errors);
}

View File

@@ -1,99 +1,99 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Convenience base class for implementing <see cref="IVexConnector" />.
/// </summary>
public abstract class VexConnectorBase : IVexConnector
{
protected VexConnectorBase(VexConnectorDescriptor descriptor, ILogger logger, TimeProvider? timeProvider = null)
{
Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
TimeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string Id => Descriptor.Id;
/// <inheritdoc />
public VexProviderKind Kind => Descriptor.Kind;
public VexConnectorDescriptor Descriptor { get; }
protected ILogger Logger { get; }
protected TimeProvider TimeProvider { get; }
protected DateTimeOffset UtcNow() => TimeProvider.GetUtcNow();
protected VexRawDocument CreateRawDocument(
VexDocumentFormat format,
Uri sourceUri,
ReadOnlyMemory<byte> content,
ImmutableDictionary<string, string>? metadata = null)
{
if (sourceUri is null)
{
throw new ArgumentNullException(nameof(sourceUri));
}
var digest = ComputeSha256(content.Span);
var captured = TimeProvider.GetUtcNow();
return new VexRawDocument(
Descriptor.Id,
format,
sourceUri,
captured,
digest,
content,
metadata ?? ImmutableDictionary<string, string>.Empty);
}
protected IDisposable BeginConnectorScope(string operation, IReadOnlyDictionary<string, object?>? metadata = null)
=> VexConnectorLogScope.Begin(Logger, Descriptor, operation, metadata);
protected void LogConnectorEvent(LogLevel level, string eventName, string message, IReadOnlyDictionary<string, object?>? metadata = null, Exception? exception = null)
{
using var scope = BeginConnectorScope(eventName, metadata);
if (exception is null)
{
Logger.Log(level, "{Message}", message);
}
else
{
Logger.Log(level, exception, "{Message}", message);
}
}
protected ImmutableDictionary<string, string> BuildMetadata(Action<VexConnectorMetadataBuilder> configure)
{
ArgumentNullException.ThrowIfNull(configure);
var builder = new VexConnectorMetadataBuilder();
configure(builder);
return builder.Build();
}
private static string ComputeSha256(ReadOnlySpan<byte> content)
{
Span<byte> buffer = stackalloc byte[32];
if (SHA256.TryHashData(content, buffer, out _))
{
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
}
using var sha = SHA256.Create();
var hash = sha.ComputeHash(content.ToArray());
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
public abstract ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken);
public abstract IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken);
public abstract ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
}
using System.Collections.Immutable;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Convenience base class for implementing <see cref="IVexConnector" />.
/// </summary>
public abstract class VexConnectorBase : IVexConnector
{
protected VexConnectorBase(VexConnectorDescriptor descriptor, ILogger logger, TimeProvider? timeProvider = null)
{
Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
TimeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public string Id => Descriptor.Id;
/// <inheritdoc />
public VexProviderKind Kind => Descriptor.Kind;
public VexConnectorDescriptor Descriptor { get; }
protected ILogger Logger { get; }
protected TimeProvider TimeProvider { get; }
protected DateTimeOffset UtcNow() => TimeProvider.GetUtcNow();
protected VexRawDocument CreateRawDocument(
VexDocumentFormat format,
Uri sourceUri,
ReadOnlyMemory<byte> content,
ImmutableDictionary<string, string>? metadata = null)
{
if (sourceUri is null)
{
throw new ArgumentNullException(nameof(sourceUri));
}
var digest = ComputeSha256(content.Span);
var captured = TimeProvider.GetUtcNow();
return new VexRawDocument(
Descriptor.Id,
format,
sourceUri,
captured,
digest,
content,
metadata ?? ImmutableDictionary<string, string>.Empty);
}
protected IDisposable BeginConnectorScope(string operation, IReadOnlyDictionary<string, object?>? metadata = null)
=> VexConnectorLogScope.Begin(Logger, Descriptor, operation, metadata);
protected void LogConnectorEvent(LogLevel level, string eventName, string message, IReadOnlyDictionary<string, object?>? metadata = null, Exception? exception = null)
{
using var scope = BeginConnectorScope(eventName, metadata);
if (exception is null)
{
Logger.Log(level, "{Message}", message);
}
else
{
Logger.Log(level, exception, "{Message}", message);
}
}
protected ImmutableDictionary<string, string> BuildMetadata(Action<VexConnectorMetadataBuilder> configure)
{
ArgumentNullException.ThrowIfNull(configure);
var builder = new VexConnectorMetadataBuilder();
configure(builder);
return builder.Build();
}
private static string ComputeSha256(ReadOnlySpan<byte> content)
{
Span<byte> buffer = stackalloc byte[32];
if (SHA256.TryHashData(content, buffer, out _))
{
return "sha256:" + Convert.ToHexString(buffer).ToLowerInvariant();
}
using var sha = SHA256.Create();
var hash = sha.ComputeHash(content.ToArray());
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
public abstract ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken);
public abstract IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken);
public abstract ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
}

View File

@@ -1,54 +1,54 @@
using System.Collections.Immutable;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Static descriptor for a Excititor connector plug-in.
/// </summary>
public sealed record VexConnectorDescriptor
{
public VexConnectorDescriptor(string id, VexProviderKind kind, string displayName)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Connector id must be provided.", nameof(id));
}
Id = id;
Kind = kind;
DisplayName = string.IsNullOrWhiteSpace(displayName) ? id : displayName;
}
/// <summary>
/// Stable connector identifier (matches provider id).
/// </summary>
public string Id { get; }
/// <summary>
/// Provider kind served by the connector.
/// </summary>
public VexProviderKind Kind { get; }
/// <summary>
/// Human friendly name used in logs/diagnostics.
/// </summary>
public string DisplayName { get; }
/// <summary>
/// Optional friendly description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Document formats the connector is expected to emit.
/// </summary>
public ImmutableArray<VexDocumentFormat> SupportedFormats { get; init; } = ImmutableArray<VexDocumentFormat>.Empty;
/// <summary>
/// Optional tags surfaced in diagnostics (e.g. "beta", "offline").
/// </summary>
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
public override string ToString() => $"{Id} ({Kind})";
}
using System.Collections.Immutable;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Static descriptor for a Excititor connector plug-in.
/// </summary>
public sealed record VexConnectorDescriptor
{
public VexConnectorDescriptor(string id, VexProviderKind kind, string displayName)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Connector id must be provided.", nameof(id));
}
Id = id;
Kind = kind;
DisplayName = string.IsNullOrWhiteSpace(displayName) ? id : displayName;
}
/// <summary>
/// Stable connector identifier (matches provider id).
/// </summary>
public string Id { get; }
/// <summary>
/// Provider kind served by the connector.
/// </summary>
public VexProviderKind Kind { get; }
/// <summary>
/// Human friendly name used in logs/diagnostics.
/// </summary>
public string DisplayName { get; }
/// <summary>
/// Optional friendly description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Document formats the connector is expected to emit.
/// </summary>
public ImmutableArray<VexDocumentFormat> SupportedFormats { get; init; } = ImmutableArray<VexDocumentFormat>.Empty;
/// <summary>
/// Optional tags surfaced in diagnostics (e.g. "beta", "offline").
/// </summary>
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
public override string ToString() => $"{Id} ({Kind})";
}

View File

@@ -1,50 +1,50 @@
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Helper to establish deterministic logging scopes for connector operations.
/// </summary>
public static class VexConnectorLogScope
{
public static IDisposable Begin(ILogger logger, VexConnectorDescriptor descriptor, string operation, IReadOnlyDictionary<string, object?>? metadata = null)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentException.ThrowIfNullOrEmpty(operation);
var scopeValues = new List<KeyValuePair<string, object?>>
{
new("vex.connector.id", descriptor.Id),
new("vex.connector.kind", descriptor.Kind.ToString()),
new("vex.connector.operation", operation),
};
if (!string.Equals(descriptor.DisplayName, descriptor.Id, StringComparison.Ordinal))
{
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.displayName", descriptor.DisplayName));
}
if (!string.IsNullOrWhiteSpace(descriptor.Description))
{
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.description", descriptor.Description));
}
if (!descriptor.Tags.IsDefaultOrEmpty)
{
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.tags", string.Join(",", descriptor.Tags)));
}
if (metadata is not null)
{
foreach (var kvp in metadata.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
scopeValues.Add(new KeyValuePair<string, object?>($"vex.{kvp.Key}", kvp.Value));
}
}
return logger.BeginScope(scopeValues)!;
}
}
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Helper to establish deterministic logging scopes for connector operations.
/// </summary>
public static class VexConnectorLogScope
{
public static IDisposable Begin(ILogger logger, VexConnectorDescriptor descriptor, string operation, IReadOnlyDictionary<string, object?>? metadata = null)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentException.ThrowIfNullOrEmpty(operation);
var scopeValues = new List<KeyValuePair<string, object?>>
{
new("vex.connector.id", descriptor.Id),
new("vex.connector.kind", descriptor.Kind.ToString()),
new("vex.connector.operation", operation),
};
if (!string.Equals(descriptor.DisplayName, descriptor.Id, StringComparison.Ordinal))
{
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.displayName", descriptor.DisplayName));
}
if (!string.IsNullOrWhiteSpace(descriptor.Description))
{
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.description", descriptor.Description));
}
if (!descriptor.Tags.IsDefaultOrEmpty)
{
scopeValues.Add(new KeyValuePair<string, object?>("vex.connector.tags", string.Join(",", descriptor.Tags)));
}
if (metadata is not null)
{
foreach (var kvp in metadata.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
scopeValues.Add(new KeyValuePair<string, object?>($"vex.{kvp.Key}", kvp.Value));
}
}
return logger.BeginScope(scopeValues)!;
}
}

View File

@@ -1,37 +1,37 @@
using System.Collections.Immutable;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Builds deterministic metadata dictionaries for raw documents and logging scopes.
/// </summary>
public sealed class VexConnectorMetadataBuilder
{
private readonly SortedDictionary<string, string> _values = new(StringComparer.Ordinal);
public VexConnectorMetadataBuilder Add(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value))
{
_values[key] = value!;
}
return this;
}
public VexConnectorMetadataBuilder Add(string key, DateTimeOffset value)
=> Add(key, value.ToUniversalTime().ToString("O"));
public VexConnectorMetadataBuilder AddRange(IEnumerable<KeyValuePair<string, string?>> items)
{
foreach (var item in items)
{
Add(item.Key, item.Value);
}
return this;
}
public ImmutableDictionary<string, string> Build()
=> _values.ToImmutableDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
}
using System.Collections.Immutable;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Builds deterministic metadata dictionaries for raw documents and logging scopes.
/// </summary>
public sealed class VexConnectorMetadataBuilder
{
private readonly SortedDictionary<string, string> _values = new(StringComparer.Ordinal);
public VexConnectorMetadataBuilder Add(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value))
{
_values[key] = value!;
}
return this;
}
public VexConnectorMetadataBuilder Add(string key, DateTimeOffset value)
=> Add(key, value.ToUniversalTime().ToString("O"));
public VexConnectorMetadataBuilder AddRange(IEnumerable<KeyValuePair<string, string?>> items)
{
foreach (var item in items)
{
Add(item.Key, item.Value);
}
return this;
}
public ImmutableDictionary<string, string> Build()
=> _values.ToImmutableDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
}

View File

@@ -1,157 +1,157 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.Extensions.Configuration;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Provides strongly typed binding and validation for connector options.
/// </summary>
public static class VexConnectorOptionsBinder
{
public static TOptions Bind<TOptions>(
VexConnectorDescriptor descriptor,
VexConnectorSettings settings,
VexConnectorOptionsBinderOptions? options = null,
IEnumerable<IVexConnectorOptionsValidator<TOptions>>? validators = null)
where TOptions : class, new()
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(settings);
var binderSettings = options ?? new VexConnectorOptionsBinderOptions();
var transformed = TransformValues(settings, binderSettings);
var configuration = BuildConfiguration(transformed);
var result = new TOptions();
var errors = new List<string>();
try
{
configuration.Bind(
result,
binderOptions => binderOptions.ErrorOnUnknownConfiguration = !binderSettings.AllowUnknownKeys);
}
catch (InvalidOperationException ex) when (!binderSettings.AllowUnknownKeys)
{
errors.Add(ex.Message);
}
binderSettings.PostConfigure?.Invoke(result);
if (binderSettings.ValidateDataAnnotations)
{
ValidateDataAnnotations(result, errors);
}
if (validators is not null)
{
foreach (var validator in validators)
{
validator?.Validate(descriptor, result, errors);
}
}
if (errors.Count > 0)
{
throw new VexConnectorOptionsValidationException(descriptor.Id, errors);
}
return result;
}
private static ImmutableDictionary<string, string?> TransformValues(
VexConnectorSettings settings,
VexConnectorOptionsBinderOptions binderOptions)
{
var builder = ImmutableDictionary.CreateBuilder<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in settings.Values)
{
var value = kvp.Value;
if (binderOptions.TrimWhitespace && value is not null)
{
value = value.Trim();
}
if (binderOptions.TreatEmptyAsNull && string.IsNullOrEmpty(value))
{
value = null;
}
if (value is not null && binderOptions.ExpandEnvironmentVariables)
{
value = Environment.ExpandEnvironmentVariables(value);
}
if (binderOptions.ValueTransformer is not null)
{
value = binderOptions.ValueTransformer.Invoke(kvp.Key, value);
}
builder[kvp.Key] = value;
}
return builder.ToImmutable();
}
private static IConfiguration BuildConfiguration(ImmutableDictionary<string, string?> values)
{
var sources = new List<KeyValuePair<string, string?>>();
foreach (var kvp in values)
{
if (kvp.Value is not null)
{
sources.Add(new KeyValuePair<string, string?>(kvp.Key, kvp.Value));
}
}
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.Add(new DictionaryConfigurationSource(sources));
return configurationBuilder.Build();
}
private static void ValidateDataAnnotations<TOptions>(TOptions options, IList<string> errors)
{
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(options!);
if (!Validator.TryValidateObject(options!, validationContext, validationResults, validateAllProperties: true))
{
foreach (var validationResult in validationResults)
{
if (!string.IsNullOrWhiteSpace(validationResult.ErrorMessage))
{
errors.Add(validationResult.ErrorMessage);
}
}
}
}
private sealed class DictionaryConfigurationSource : IConfigurationSource
{
private readonly IReadOnlyList<KeyValuePair<string, string?>> _data;
public DictionaryConfigurationSource(IEnumerable<KeyValuePair<string, string?>> data)
{
_data = data?.ToList() ?? new List<KeyValuePair<string, string?>>();
}
public IConfigurationProvider Build(IConfigurationBuilder builder) => new DictionaryConfigurationProvider(_data);
}
private sealed class DictionaryConfigurationProvider : ConfigurationProvider
{
public DictionaryConfigurationProvider(IEnumerable<KeyValuePair<string, string?>> data)
{
foreach (var pair in data)
{
if (pair.Value is not null)
{
Data[pair.Key] = pair.Value;
}
}
}
}
}
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.Extensions.Configuration;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Provides strongly typed binding and validation for connector options.
/// </summary>
public static class VexConnectorOptionsBinder
{
public static TOptions Bind<TOptions>(
VexConnectorDescriptor descriptor,
VexConnectorSettings settings,
VexConnectorOptionsBinderOptions? options = null,
IEnumerable<IVexConnectorOptionsValidator<TOptions>>? validators = null)
where TOptions : class, new()
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(settings);
var binderSettings = options ?? new VexConnectorOptionsBinderOptions();
var transformed = TransformValues(settings, binderSettings);
var configuration = BuildConfiguration(transformed);
var result = new TOptions();
var errors = new List<string>();
try
{
configuration.Bind(
result,
binderOptions => binderOptions.ErrorOnUnknownConfiguration = !binderSettings.AllowUnknownKeys);
}
catch (InvalidOperationException ex) when (!binderSettings.AllowUnknownKeys)
{
errors.Add(ex.Message);
}
binderSettings.PostConfigure?.Invoke(result);
if (binderSettings.ValidateDataAnnotations)
{
ValidateDataAnnotations(result, errors);
}
if (validators is not null)
{
foreach (var validator in validators)
{
validator?.Validate(descriptor, result, errors);
}
}
if (errors.Count > 0)
{
throw new VexConnectorOptionsValidationException(descriptor.Id, errors);
}
return result;
}
private static ImmutableDictionary<string, string?> TransformValues(
VexConnectorSettings settings,
VexConnectorOptionsBinderOptions binderOptions)
{
var builder = ImmutableDictionary.CreateBuilder<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in settings.Values)
{
var value = kvp.Value;
if (binderOptions.TrimWhitespace && value is not null)
{
value = value.Trim();
}
if (binderOptions.TreatEmptyAsNull && string.IsNullOrEmpty(value))
{
value = null;
}
if (value is not null && binderOptions.ExpandEnvironmentVariables)
{
value = Environment.ExpandEnvironmentVariables(value);
}
if (binderOptions.ValueTransformer is not null)
{
value = binderOptions.ValueTransformer.Invoke(kvp.Key, value);
}
builder[kvp.Key] = value;
}
return builder.ToImmutable();
}
private static IConfiguration BuildConfiguration(ImmutableDictionary<string, string?> values)
{
var sources = new List<KeyValuePair<string, string?>>();
foreach (var kvp in values)
{
if (kvp.Value is not null)
{
sources.Add(new KeyValuePair<string, string?>(kvp.Key, kvp.Value));
}
}
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.Add(new DictionaryConfigurationSource(sources));
return configurationBuilder.Build();
}
private static void ValidateDataAnnotations<TOptions>(TOptions options, IList<string> errors)
{
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(options!);
if (!Validator.TryValidateObject(options!, validationContext, validationResults, validateAllProperties: true))
{
foreach (var validationResult in validationResults)
{
if (!string.IsNullOrWhiteSpace(validationResult.ErrorMessage))
{
errors.Add(validationResult.ErrorMessage);
}
}
}
}
private sealed class DictionaryConfigurationSource : IConfigurationSource
{
private readonly IReadOnlyList<KeyValuePair<string, string?>> _data;
public DictionaryConfigurationSource(IEnumerable<KeyValuePair<string, string?>> data)
{
_data = data?.ToList() ?? new List<KeyValuePair<string, string?>>();
}
public IConfigurationProvider Build(IConfigurationBuilder builder) => new DictionaryConfigurationProvider(_data);
}
private sealed class DictionaryConfigurationProvider : ConfigurationProvider
{
public DictionaryConfigurationProvider(IEnumerable<KeyValuePair<string, string?>> data)
{
foreach (var pair in data)
{
if (pair.Value is not null)
{
Data[pair.Key] = pair.Value;
}
}
}
}
}

View File

@@ -1,45 +1,45 @@
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Customisation options for connector options binding.
/// </summary>
public sealed class VexConnectorOptionsBinderOptions
{
/// <summary>
/// Indicates whether environment variables should be expanded in option values.
/// Defaults to <c>true</c>.
/// </summary>
public bool ExpandEnvironmentVariables { get; set; } = true;
/// <summary>
/// When <c>true</c> the binder trims whitespace around option values.
/// </summary>
public bool TrimWhitespace { get; set; } = true;
/// <summary>
/// Converts empty strings to <c>null</c> before binding. Default: <c>true</c>.
/// </summary>
public bool TreatEmptyAsNull { get; set; } = true;
/// <summary>
/// When <c>false</c>, binding fails if unknown configuration keys are provided.
/// Default: <c>true</c> (permitting unknown keys).
/// </summary>
public bool AllowUnknownKeys { get; set; } = true;
/// <summary>
/// Enables <see cref="System.ComponentModel.DataAnnotations"/> validation after binding.
/// Default: <c>true</c>.
/// </summary>
public bool ValidateDataAnnotations { get; set; } = true;
/// <summary>
/// Optional post-configuration callback executed after binding.
/// </summary>
public Action<object>? PostConfigure { get; set; }
/// <summary>
/// Optional hook to transform raw configuration values before binding.
/// </summary>
public Func<string, string?, string?>? ValueTransformer { get; set; }
}
namespace StellaOps.Excititor.Connectors.Abstractions;
/// <summary>
/// Customisation options for connector options binding.
/// </summary>
public sealed class VexConnectorOptionsBinderOptions
{
/// <summary>
/// Indicates whether environment variables should be expanded in option values.
/// Defaults to <c>true</c>.
/// </summary>
public bool ExpandEnvironmentVariables { get; set; } = true;
/// <summary>
/// When <c>true</c> the binder trims whitespace around option values.
/// </summary>
public bool TrimWhitespace { get; set; } = true;
/// <summary>
/// Converts empty strings to <c>null</c> before binding. Default: <c>true</c>.
/// </summary>
public bool TreatEmptyAsNull { get; set; } = true;
/// <summary>
/// When <c>false</c>, binding fails if unknown configuration keys are provided.
/// Default: <c>true</c> (permitting unknown keys).
/// </summary>
public bool AllowUnknownKeys { get; set; } = true;
/// <summary>
/// Enables <see cref="System.ComponentModel.DataAnnotations"/> validation after binding.
/// Default: <c>true</c>.
/// </summary>
public bool ValidateDataAnnotations { get; set; } = true;
/// <summary>
/// Optional post-configuration callback executed after binding.
/// </summary>
public Action<object>? PostConfigure { get; set; }
/// <summary>
/// Optional hook to transform raw configuration values before binding.
/// </summary>
public Func<string, string?, string?>? ValueTransformer { get; set; }
}

View File

@@ -1,36 +1,36 @@
using System.Collections.Immutable;
namespace StellaOps.Excititor.Connectors.Abstractions;
public sealed class VexConnectorOptionsValidationException : Exception
{
public VexConnectorOptionsValidationException(
string connectorId,
IEnumerable<string> errors)
: base(BuildMessage(connectorId, errors))
{
ConnectorId = connectorId;
Errors = errors?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
}
public string ConnectorId { get; }
public ImmutableArray<string> Errors { get; }
private static string BuildMessage(string connectorId, IEnumerable<string> errors)
{
var builder = new System.Text.StringBuilder();
builder.Append("Connector options validation failed for '");
builder.Append(connectorId);
builder.Append("'.");
var list = errors?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
if (!list.IsDefaultOrEmpty)
{
builder.Append(" Errors: ");
builder.Append(string.Join("; ", list));
}
return builder.ToString();
}
}
using System.Collections.Immutable;
namespace StellaOps.Excititor.Connectors.Abstractions;
public sealed class VexConnectorOptionsValidationException : Exception
{
public VexConnectorOptionsValidationException(
string connectorId,
IEnumerable<string> errors)
: base(BuildMessage(connectorId, errors))
{
ConnectorId = connectorId;
Errors = errors?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
}
public string ConnectorId { get; }
public ImmutableArray<string> Errors { get; }
private static string BuildMessage(string connectorId, IEnumerable<string> errors)
{
var builder = new System.Text.StringBuilder();
builder.Append("Connector options validation failed for '");
builder.Append(connectorId);
builder.Append("'.");
var list = errors?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
if (!list.IsDefaultOrEmpty)
{
builder.Append(" Errors: ");
builder.Append(string.Join("; ", list));
}
return builder.ToString();
}
}

View File

@@ -1,58 +1,58 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
public sealed class CiscoConnectorOptions : IValidatableObject
{
public const string HttpClientName = "cisco-csaf";
/// <summary>
/// Endpoint for Cisco CSAF provider metadata discovery.
/// </summary>
[Required]
public string MetadataUri { get; set; } = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json";
/// <summary>
/// Optional bearer token used when Cisco endpoints require authentication.
/// </summary>
public string? ApiToken { get; set; }
/// <summary>
/// How long provider metadata remains cached.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(6);
/// <summary>
/// Whether to prefer offline snapshots when fetching metadata.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// When set, provider metadata will be persisted to the given file path.
/// </summary>
public bool PersistOfflineSnapshot { get; set; }
public string? OfflineSnapshotPath { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(MetadataUri))
{
yield return new ValidationResult("MetadataUri must be provided.", new[] { nameof(MetadataUri) });
}
else if (!Uri.TryCreate(MetadataUri, UriKind.Absolute, out _))
{
yield return new ValidationResult("MetadataUri must be an absolute URI.", new[] { nameof(MetadataUri) });
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
yield return new ValidationResult("MetadataCacheDuration must be greater than zero.", new[] { nameof(MetadataCacheDuration) });
}
if (PersistOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
yield return new ValidationResult("OfflineSnapshotPath must be provided when PersistOfflineSnapshot is enabled.", new[] { nameof(OfflineSnapshotPath) });
}
}
}
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
public sealed class CiscoConnectorOptions : IValidatableObject
{
public const string HttpClientName = "cisco-csaf";
/// <summary>
/// Endpoint for Cisco CSAF provider metadata discovery.
/// </summary>
[Required]
public string MetadataUri { get; set; } = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json";
/// <summary>
/// Optional bearer token used when Cisco endpoints require authentication.
/// </summary>
public string? ApiToken { get; set; }
/// <summary>
/// How long provider metadata remains cached.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(6);
/// <summary>
/// Whether to prefer offline snapshots when fetching metadata.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// When set, provider metadata will be persisted to the given file path.
/// </summary>
public bool PersistOfflineSnapshot { get; set; }
public string? OfflineSnapshotPath { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(MetadataUri))
{
yield return new ValidationResult("MetadataUri must be provided.", new[] { nameof(MetadataUri) });
}
else if (!Uri.TryCreate(MetadataUri, UriKind.Absolute, out _))
{
yield return new ValidationResult("MetadataUri must be an absolute URI.", new[] { nameof(MetadataUri) });
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
yield return new ValidationResult("MetadataCacheDuration must be greater than zero.", new[] { nameof(MetadataCacheDuration) });
}
if (PersistOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
yield return new ValidationResult("OfflineSnapshotPath must be provided when PersistOfflineSnapshot is enabled.", new[] { nameof(OfflineSnapshotPath) });
}
}
}

View File

@@ -1,25 +1,25 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
public sealed class CiscoConnectorOptionsValidator : IVexConnectorOptionsValidator<CiscoConnectorOptions>
{
public void Validate(VexConnectorDescriptor descriptor, CiscoConnectorOptions options, IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
var validationResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(options, new ValidationContext(options), validationResults, validateAllProperties: true))
{
foreach (var result in validationResults)
{
errors.Add(result.ErrorMessage ?? "Cisco connector options validation failed.");
}
}
}
}
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
public sealed class CiscoConnectorOptionsValidator : IVexConnectorOptionsValidator<CiscoConnectorOptions>
{
public void Validate(VexConnectorDescriptor descriptor, CiscoConnectorOptions options, IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
var validationResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(options, new ValidationContext(options), validationResults, validateAllProperties: true))
{
foreach (var result in validationResults)
{
errors.Add(result.ErrorMessage ?? "Cisco connector options validation failed.");
}
}
}
}

View File

@@ -1,52 +1,52 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.DependencyInjection;
public static class CiscoConnectorServiceCollectionExtensions
{
public static IServiceCollection AddCiscoCsafConnector(this IServiceCollection services, Action<CiscoConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<CiscoConnectorOptions>()
.Configure(options =>
{
configure?.Invoke(options);
})
.PostConfigure(options =>
{
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
});
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IVexConnectorOptionsValidator<CiscoConnectorOptions>, CiscoConnectorOptionsValidator>());
services.AddHttpClient(CiscoConnectorOptions.HttpClientName)
.ConfigureHttpClient((provider, client) =>
{
var options = provider.GetRequiredService<IOptions<CiscoConnectorOptions>>().Value;
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
if (!string.IsNullOrWhiteSpace(options.ApiToken))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiToken);
}
});
services.AddSingleton<CiscoProviderMetadataLoader>();
services.AddSingleton<IVexConnector, CiscoCsafConnector>();
return services;
}
}
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.DependencyInjection;
public static class CiscoConnectorServiceCollectionExtensions
{
public static IServiceCollection AddCiscoCsafConnector(this IServiceCollection services, Action<CiscoConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<CiscoConnectorOptions>()
.Configure(options =>
{
configure?.Invoke(options);
})
.PostConfigure(options =>
{
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
});
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IVexConnectorOptionsValidator<CiscoConnectorOptions>, CiscoConnectorOptionsValidator>());
services.AddHttpClient(CiscoConnectorOptions.HttpClientName)
.ConfigureHttpClient((provider, client) =>
{
var options = provider.GetRequiredService<IOptions<CiscoConnectorOptions>>().Value;
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
if (!string.IsNullOrWhiteSpace(options.ApiToken))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiToken);
}
});
services.AddSingleton<CiscoProviderMetadataLoader>();
services.AddSingleton<IVexConnector, CiscoCsafConnector>();
return services;
}
}

View File

@@ -1,332 +1,332 @@
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
public sealed class CiscoProviderMetadataLoader
{
public const string CacheKey = "StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly ILogger<CiscoProviderMetadataLoader> _logger;
private readonly CiscoConnectorOptions _options;
private readonly IFileSystem _fileSystem;
private readonly JsonSerializerOptions _serializerOptions;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public CiscoProviderMetadataLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IOptions<CiscoConnectorOptions> options,
ILogger<CiscoProviderMetadataLoader> logger,
IFileSystem? fileSystem = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
_fileSystem = fileSystem ?? new FileSystem();
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
}
public async Task<CiscoProviderMetadataResult> LoadAsync(CancellationToken cancellationToken)
{
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out var cached) && cached is not null && !cached.IsExpired())
{
_logger.LogDebug("Returning cached Cisco provider metadata (expires {Expires}).", cached.ExpiresAt);
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out cached) && cached is not null && !cached.IsExpired())
{
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
}
CacheEntry? previous = cached;
if (!_options.PreferOfflineSnapshot)
{
var network = await TryFetchFromNetworkAsync(previous, cancellationToken).ConfigureAwait(false);
if (network is not null)
{
StoreCache(network);
return new CiscoProviderMetadataResult(network.Provider, network.FetchedAt, false, false);
}
}
var offline = TryLoadFromOffline();
if (offline is not null)
{
var entry = offline with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
FromOffline = true,
};
StoreCache(entry);
return new CiscoProviderMetadataResult(entry.Provider, entry.FetchedAt, true, false);
}
throw new InvalidOperationException("Unable to load Cisco CSAF provider metadata from network or offline snapshot.");
}
finally
{
_semaphore.Release();
}
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(CacheEntry? previous, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(CiscoConnectorOptions.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, _options.MetadataUri);
if (!string.IsNullOrWhiteSpace(_options.ApiToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiToken);
}
if (!string.IsNullOrWhiteSpace(previous?.ETag) && EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
{
request.Headers.IfNoneMatch.Add(etag);
}
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
{
_logger.LogDebug("Cisco provider metadata not modified (etag {ETag}).", previous.ETag);
return previous with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
};
}
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var provider = ParseProvider(payload);
var etagHeader = response.Headers.ETag?.ToString();
if (_options.PersistOfflineSnapshot && !string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
{
try
{
_fileSystem.File.WriteAllText(_options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
}
}
return new CacheEntry(
provider,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
etagHeader,
FromOffline: false);
}
catch (Exception ex) when (ex is not OperationCanceledException && !_options.PreferOfflineSnapshot)
{
_logger.LogWarning(ex, "Failed to fetch Cisco provider metadata from {Uri}; falling back to offline snapshot when available.", _options.MetadataUri);
return null;
}
}
private CacheEntry? TryLoadFromOffline()
{
if (string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(_options.OfflineSnapshotPath))
{
_logger.LogWarning("Cisco offline snapshot path {Path} does not exist.", _options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(_options.OfflineSnapshotPath);
var provider = ParseProvider(payload);
return new CacheEntry(provider, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow + _options.MetadataCacheDuration, null, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Cisco provider metadata from offline snapshot {Path}.", _options.OfflineSnapshotPath);
return null;
}
}
private VexProvider ParseProvider(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("Cisco provider metadata payload was empty.");
}
ProviderMetadataDocument? document;
try
{
document = JsonSerializer.Deserialize<ProviderMetadataDocument>(payload, _serializerOptions);
}
catch (JsonException ex)
{
throw new InvalidOperationException("Failed to parse Cisco provider metadata.", ex);
}
if (document?.Metadata?.Publisher?.ContactDetails is null || string.IsNullOrWhiteSpace(document.Metadata.Publisher.ContactDetails.Id))
{
throw new InvalidOperationException("Cisco provider metadata did not include a publisher identifier.");
}
var discovery = new VexProviderDiscovery(document.Discovery?.WellKnown, document.Discovery?.RolIe);
var trust = document.Trust is null
? VexProviderTrust.Default
: new VexProviderTrust(
document.Trust.Weight ?? 1.0,
document.Trust.Cosign is null ? null : new VexCosignTrust(document.Trust.Cosign.Issuer ?? string.Empty, document.Trust.Cosign.IdentityPattern ?? string.Empty),
document.Trust.PgpFingerprints ?? Enumerable.Empty<string>());
var directories = document.Distributions?.Directories is null
? Enumerable.Empty<Uri>()
: document.Distributions.Directories
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => Uri.TryCreate(s, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)!
.Select(static uri => uri!);
return new VexProvider(
id: document.Metadata.Publisher.ContactDetails.Id,
displayName: document.Metadata.Publisher.Name ?? document.Metadata.Publisher.ContactDetails.Id,
kind: document.Metadata.Publisher.Category?.Equals("vendor", StringComparison.OrdinalIgnoreCase) == true ? VexProviderKind.Vendor : VexProviderKind.Hub,
baseUris: directories,
discovery: discovery,
trust: trust,
enabled: true);
}
private void StoreCache(CacheEntry entry)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpiration = entry.ExpiresAt,
};
_memoryCache.Set(CacheKey, entry, options);
}
private sealed record CacheEntry(
VexProvider Provider,
DateTimeOffset FetchedAt,
DateTimeOffset ExpiresAt,
string? ETag,
bool FromOffline)
{
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
}
}
public sealed record CiscoProviderMetadataResult(
VexProvider Provider,
DateTimeOffset FetchedAt,
bool FromOfflineSnapshot,
bool ServedFromCache);
#region document models
internal sealed class ProviderMetadataDocument
{
[System.Text.Json.Serialization.JsonPropertyName("metadata")]
public ProviderMetadataMetadata Metadata { get; set; } = new();
[System.Text.Json.Serialization.JsonPropertyName("discovery")]
public ProviderMetadataDiscovery? Discovery { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("trust")]
public ProviderMetadataTrust? Trust { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("distributions")]
public ProviderMetadataDistributions? Distributions { get; set; }
}
internal sealed class ProviderMetadataMetadata
{
[System.Text.Json.Serialization.JsonPropertyName("publisher")]
public ProviderMetadataPublisher Publisher { get; set; } = new();
}
internal sealed class ProviderMetadataPublisher
{
[System.Text.Json.Serialization.JsonPropertyName("name")]
public string? Name { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("category")]
public string? Category { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("contact_details")]
public ProviderMetadataPublisherContact ContactDetails { get; set; } = new();
}
internal sealed class ProviderMetadataPublisherContact
{
[System.Text.Json.Serialization.JsonPropertyName("id")]
public string? Id { get; set; }
}
internal sealed class ProviderMetadataDiscovery
{
[System.Text.Json.Serialization.JsonPropertyName("well_known")]
public Uri? WellKnown { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("rolie")]
public Uri? RolIe { get; set; }
}
internal sealed class ProviderMetadataTrust
{
[System.Text.Json.Serialization.JsonPropertyName("weight")]
public double? Weight { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("cosign")]
public ProviderMetadataTrustCosign? Cosign { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("pgp_fingerprints")]
public string[]? PgpFingerprints { get; set; }
}
internal sealed class ProviderMetadataTrustCosign
{
[System.Text.Json.Serialization.JsonPropertyName("issuer")]
public string? Issuer { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("identity_pattern")]
public string? IdentityPattern { get; set; }
}
internal sealed class ProviderMetadataDistributions
{
[System.Text.Json.Serialization.JsonPropertyName("directories")]
public string[]? Directories { get; set; }
}
#endregion
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
public sealed class CiscoProviderMetadataLoader
{
public const string CacheKey = "StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly ILogger<CiscoProviderMetadataLoader> _logger;
private readonly CiscoConnectorOptions _options;
private readonly IFileSystem _fileSystem;
private readonly JsonSerializerOptions _serializerOptions;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public CiscoProviderMetadataLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IOptions<CiscoConnectorOptions> options,
ILogger<CiscoProviderMetadataLoader> logger,
IFileSystem? fileSystem = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
_fileSystem = fileSystem ?? new FileSystem();
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
}
public async Task<CiscoProviderMetadataResult> LoadAsync(CancellationToken cancellationToken)
{
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out var cached) && cached is not null && !cached.IsExpired())
{
_logger.LogDebug("Returning cached Cisco provider metadata (expires {Expires}).", cached.ExpiresAt);
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out cached) && cached is not null && !cached.IsExpired())
{
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
}
CacheEntry? previous = cached;
if (!_options.PreferOfflineSnapshot)
{
var network = await TryFetchFromNetworkAsync(previous, cancellationToken).ConfigureAwait(false);
if (network is not null)
{
StoreCache(network);
return new CiscoProviderMetadataResult(network.Provider, network.FetchedAt, false, false);
}
}
var offline = TryLoadFromOffline();
if (offline is not null)
{
var entry = offline with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
FromOffline = true,
};
StoreCache(entry);
return new CiscoProviderMetadataResult(entry.Provider, entry.FetchedAt, true, false);
}
throw new InvalidOperationException("Unable to load Cisco CSAF provider metadata from network or offline snapshot.");
}
finally
{
_semaphore.Release();
}
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(CacheEntry? previous, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(CiscoConnectorOptions.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, _options.MetadataUri);
if (!string.IsNullOrWhiteSpace(_options.ApiToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiToken);
}
if (!string.IsNullOrWhiteSpace(previous?.ETag) && EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
{
request.Headers.IfNoneMatch.Add(etag);
}
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
{
_logger.LogDebug("Cisco provider metadata not modified (etag {ETag}).", previous.ETag);
return previous with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
};
}
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var provider = ParseProvider(payload);
var etagHeader = response.Headers.ETag?.ToString();
if (_options.PersistOfflineSnapshot && !string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
{
try
{
_fileSystem.File.WriteAllText(_options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
}
}
return new CacheEntry(
provider,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
etagHeader,
FromOffline: false);
}
catch (Exception ex) when (ex is not OperationCanceledException && !_options.PreferOfflineSnapshot)
{
_logger.LogWarning(ex, "Failed to fetch Cisco provider metadata from {Uri}; falling back to offline snapshot when available.", _options.MetadataUri);
return null;
}
}
private CacheEntry? TryLoadFromOffline()
{
if (string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(_options.OfflineSnapshotPath))
{
_logger.LogWarning("Cisco offline snapshot path {Path} does not exist.", _options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(_options.OfflineSnapshotPath);
var provider = ParseProvider(payload);
return new CacheEntry(provider, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow + _options.MetadataCacheDuration, null, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Cisco provider metadata from offline snapshot {Path}.", _options.OfflineSnapshotPath);
return null;
}
}
private VexProvider ParseProvider(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("Cisco provider metadata payload was empty.");
}
ProviderMetadataDocument? document;
try
{
document = JsonSerializer.Deserialize<ProviderMetadataDocument>(payload, _serializerOptions);
}
catch (JsonException ex)
{
throw new InvalidOperationException("Failed to parse Cisco provider metadata.", ex);
}
if (document?.Metadata?.Publisher?.ContactDetails is null || string.IsNullOrWhiteSpace(document.Metadata.Publisher.ContactDetails.Id))
{
throw new InvalidOperationException("Cisco provider metadata did not include a publisher identifier.");
}
var discovery = new VexProviderDiscovery(document.Discovery?.WellKnown, document.Discovery?.RolIe);
var trust = document.Trust is null
? VexProviderTrust.Default
: new VexProviderTrust(
document.Trust.Weight ?? 1.0,
document.Trust.Cosign is null ? null : new VexCosignTrust(document.Trust.Cosign.Issuer ?? string.Empty, document.Trust.Cosign.IdentityPattern ?? string.Empty),
document.Trust.PgpFingerprints ?? Enumerable.Empty<string>());
var directories = document.Distributions?.Directories is null
? Enumerable.Empty<Uri>()
: document.Distributions.Directories
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => Uri.TryCreate(s, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)!
.Select(static uri => uri!);
return new VexProvider(
id: document.Metadata.Publisher.ContactDetails.Id,
displayName: document.Metadata.Publisher.Name ?? document.Metadata.Publisher.ContactDetails.Id,
kind: document.Metadata.Publisher.Category?.Equals("vendor", StringComparison.OrdinalIgnoreCase) == true ? VexProviderKind.Vendor : VexProviderKind.Hub,
baseUris: directories,
discovery: discovery,
trust: trust,
enabled: true);
}
private void StoreCache(CacheEntry entry)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpiration = entry.ExpiresAt,
};
_memoryCache.Set(CacheKey, entry, options);
}
private sealed record CacheEntry(
VexProvider Provider,
DateTimeOffset FetchedAt,
DateTimeOffset ExpiresAt,
string? ETag,
bool FromOffline)
{
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
}
}
public sealed record CiscoProviderMetadataResult(
VexProvider Provider,
DateTimeOffset FetchedAt,
bool FromOfflineSnapshot,
bool ServedFromCache);
#region document models
internal sealed class ProviderMetadataDocument
{
[System.Text.Json.Serialization.JsonPropertyName("metadata")]
public ProviderMetadataMetadata Metadata { get; set; } = new();
[System.Text.Json.Serialization.JsonPropertyName("discovery")]
public ProviderMetadataDiscovery? Discovery { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("trust")]
public ProviderMetadataTrust? Trust { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("distributions")]
public ProviderMetadataDistributions? Distributions { get; set; }
}
internal sealed class ProviderMetadataMetadata
{
[System.Text.Json.Serialization.JsonPropertyName("publisher")]
public ProviderMetadataPublisher Publisher { get; set; } = new();
}
internal sealed class ProviderMetadataPublisher
{
[System.Text.Json.Serialization.JsonPropertyName("name")]
public string? Name { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("category")]
public string? Category { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("contact_details")]
public ProviderMetadataPublisherContact ContactDetails { get; set; } = new();
}
internal sealed class ProviderMetadataPublisherContact
{
[System.Text.Json.Serialization.JsonPropertyName("id")]
public string? Id { get; set; }
}
internal sealed class ProviderMetadataDiscovery
{
[System.Text.Json.Serialization.JsonPropertyName("well_known")]
public Uri? WellKnown { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("rolie")]
public Uri? RolIe { get; set; }
}
internal sealed class ProviderMetadataTrust
{
[System.Text.Json.Serialization.JsonPropertyName("weight")]
public double? Weight { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("cosign")]
public ProviderMetadataTrustCosign? Cosign { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("pgp_fingerprints")]
public string[]? PgpFingerprints { get; set; }
}
internal sealed class ProviderMetadataTrustCosign
{
[System.Text.Json.Serialization.JsonPropertyName("issuer")]
public string? Issuer { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("identity_pattern")]
public string? IdentityPattern { get; set; }
}
internal sealed class ProviderMetadataDistributions
{
[System.Text.Json.Serialization.JsonPropertyName("directories")]
public string[]? Directories { get; set; }
}
#endregion

View File

@@ -1,185 +1,185 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
public interface IMsrcTokenProvider
{
ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken);
}
public sealed class MsrcTokenProvider : IMsrcTokenProvider, IDisposable
{
private const string CachePrefix = "StellaOps.Excititor.Connectors.MSRC.CSAF.Token";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly IFileSystem _fileSystem;
private readonly ILogger<MsrcTokenProvider> _logger;
private readonly TimeProvider _timeProvider;
private readonly MsrcConnectorOptions _options;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
public MsrcTokenProvider(
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
IFileSystem fileSystem,
IOptions<MsrcConnectorOptions> options,
ILogger<MsrcTokenProvider> logger,
TimeProvider? timeProvider = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
_options.Validate(_fileSystem);
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (_options.PreferOfflineToken)
{
return LoadOfflineToken();
}
var cacheKey = CreateCacheKey();
if (_cache.TryGetValue<MsrcAccessToken>(cacheKey, out var cachedToken) &&
cachedToken is not null &&
!cachedToken.IsExpired(_timeProvider.GetUtcNow()))
{
return cachedToken;
}
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cache.TryGetValue<MsrcAccessToken>(cacheKey, out cachedToken) &&
cachedToken is not null &&
!cachedToken.IsExpired(_timeProvider.GetUtcNow()))
{
return cachedToken;
}
var token = await RequestTokenAsync(cancellationToken).ConfigureAwait(false);
var absoluteExpiration = token.ExpiresAt == DateTimeOffset.MaxValue
? (DateTimeOffset?)null
: token.ExpiresAt;
var options = new MemoryCacheEntryOptions();
if (absoluteExpiration.HasValue)
{
options.AbsoluteExpiration = absoluteExpiration.Value;
}
_cache.Set(cacheKey, token, options);
return token;
}
finally
{
_refreshLock.Release();
}
}
private MsrcAccessToken LoadOfflineToken()
{
if (!string.IsNullOrWhiteSpace(_options.StaticAccessToken))
{
return new MsrcAccessToken(_options.StaticAccessToken!, "Bearer", DateTimeOffset.MaxValue);
}
if (string.IsNullOrWhiteSpace(_options.OfflineTokenPath))
{
throw new InvalidOperationException("Offline token mode is enabled but no token was provided.");
}
if (!_fileSystem.File.Exists(_options.OfflineTokenPath))
{
throw new InvalidOperationException($"Offline token path '{_options.OfflineTokenPath}' does not exist.");
}
var token = _fileSystem.File.ReadAllText(_options.OfflineTokenPath).Trim();
if (string.IsNullOrEmpty(token))
{
throw new InvalidOperationException("Offline token file was empty.");
}
return new MsrcAccessToken(token, "Bearer", DateTimeOffset.MaxValue);
}
private async Task<MsrcAccessToken> RequestTokenAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Fetching MSRC AAD access token for tenant {TenantId}.", _options.TenantId);
var client = _httpClientFactory.CreateClient(MsrcConnectorOptions.TokenClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, BuildTokenUri())
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = _options.ClientId,
["client_secret"] = _options.ClientSecret!,
["grant_type"] = "client_credentials",
["scope"] = string.IsNullOrWhiteSpace(_options.Scope) ? MsrcConnectorOptions.DefaultScope : _options.Scope,
}),
};
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to acquire MSRC access token ({(int)response.StatusCode}). Response: {payload}");
}
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Token endpoint returned an empty payload.");
if (string.IsNullOrWhiteSpace(tokenResponse.AccessToken))
{
throw new InvalidOperationException("Token endpoint response did not include an access_token.");
}
var now = _timeProvider.GetUtcNow();
var expiresAt = tokenResponse.ExpiresIn > _options.ExpiryLeewaySeconds
? now.AddSeconds(tokenResponse.ExpiresIn - _options.ExpiryLeewaySeconds)
: now.AddMinutes(5);
return new MsrcAccessToken(tokenResponse.AccessToken!, tokenResponse.TokenType ?? "Bearer", expiresAt);
}
private string CreateCacheKey()
=> $"{CachePrefix}:{_options.TenantId}:{_options.ClientId}:{_options.Scope}";
private Uri BuildTokenUri()
=> new($"https://login.microsoftonline.com/{_options.TenantId}/oauth2/v2.0/token");
public void Dispose() => _refreshLock.Dispose();
private sealed record TokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
[JsonPropertyName("token_type")]
public string? TokenType { get; init; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
}
}
public sealed record MsrcAccessToken(string Value, string Type, DateTimeOffset ExpiresAt)
{
public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt;
}
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
public interface IMsrcTokenProvider
{
ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken);
}
public sealed class MsrcTokenProvider : IMsrcTokenProvider, IDisposable
{
private const string CachePrefix = "StellaOps.Excititor.Connectors.MSRC.CSAF.Token";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly IFileSystem _fileSystem;
private readonly ILogger<MsrcTokenProvider> _logger;
private readonly TimeProvider _timeProvider;
private readonly MsrcConnectorOptions _options;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
public MsrcTokenProvider(
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
IFileSystem fileSystem,
IOptions<MsrcConnectorOptions> options,
ILogger<MsrcTokenProvider> logger,
TimeProvider? timeProvider = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
_options.Validate(_fileSystem);
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (_options.PreferOfflineToken)
{
return LoadOfflineToken();
}
var cacheKey = CreateCacheKey();
if (_cache.TryGetValue<MsrcAccessToken>(cacheKey, out var cachedToken) &&
cachedToken is not null &&
!cachedToken.IsExpired(_timeProvider.GetUtcNow()))
{
return cachedToken;
}
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cache.TryGetValue<MsrcAccessToken>(cacheKey, out cachedToken) &&
cachedToken is not null &&
!cachedToken.IsExpired(_timeProvider.GetUtcNow()))
{
return cachedToken;
}
var token = await RequestTokenAsync(cancellationToken).ConfigureAwait(false);
var absoluteExpiration = token.ExpiresAt == DateTimeOffset.MaxValue
? (DateTimeOffset?)null
: token.ExpiresAt;
var options = new MemoryCacheEntryOptions();
if (absoluteExpiration.HasValue)
{
options.AbsoluteExpiration = absoluteExpiration.Value;
}
_cache.Set(cacheKey, token, options);
return token;
}
finally
{
_refreshLock.Release();
}
}
private MsrcAccessToken LoadOfflineToken()
{
if (!string.IsNullOrWhiteSpace(_options.StaticAccessToken))
{
return new MsrcAccessToken(_options.StaticAccessToken!, "Bearer", DateTimeOffset.MaxValue);
}
if (string.IsNullOrWhiteSpace(_options.OfflineTokenPath))
{
throw new InvalidOperationException("Offline token mode is enabled but no token was provided.");
}
if (!_fileSystem.File.Exists(_options.OfflineTokenPath))
{
throw new InvalidOperationException($"Offline token path '{_options.OfflineTokenPath}' does not exist.");
}
var token = _fileSystem.File.ReadAllText(_options.OfflineTokenPath).Trim();
if (string.IsNullOrEmpty(token))
{
throw new InvalidOperationException("Offline token file was empty.");
}
return new MsrcAccessToken(token, "Bearer", DateTimeOffset.MaxValue);
}
private async Task<MsrcAccessToken> RequestTokenAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Fetching MSRC AAD access token for tenant {TenantId}.", _options.TenantId);
var client = _httpClientFactory.CreateClient(MsrcConnectorOptions.TokenClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, BuildTokenUri())
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = _options.ClientId,
["client_secret"] = _options.ClientSecret!,
["grant_type"] = "client_credentials",
["scope"] = string.IsNullOrWhiteSpace(_options.Scope) ? MsrcConnectorOptions.DefaultScope : _options.Scope,
}),
};
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to acquire MSRC access token ({(int)response.StatusCode}). Response: {payload}");
}
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Token endpoint returned an empty payload.");
if (string.IsNullOrWhiteSpace(tokenResponse.AccessToken))
{
throw new InvalidOperationException("Token endpoint response did not include an access_token.");
}
var now = _timeProvider.GetUtcNow();
var expiresAt = tokenResponse.ExpiresIn > _options.ExpiryLeewaySeconds
? now.AddSeconds(tokenResponse.ExpiresIn - _options.ExpiryLeewaySeconds)
: now.AddMinutes(5);
return new MsrcAccessToken(tokenResponse.AccessToken!, tokenResponse.TokenType ?? "Bearer", expiresAt);
}
private string CreateCacheKey()
=> $"{CachePrefix}:{_options.TenantId}:{_options.ClientId}:{_options.Scope}";
private Uri BuildTokenUri()
=> new($"https://login.microsoftonline.com/{_options.TenantId}/oauth2/v2.0/token");
public void Dispose() => _refreshLock.Dispose();
private sealed record TokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
[JsonPropertyName("token_type")]
public string? TokenType { get; init; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
}
}
public sealed record MsrcAccessToken(string Value, string Type, DateTimeOffset ExpiresAt)
{
public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt;
}

View File

@@ -1,211 +1,211 @@
using System;
using System.Globalization;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
public sealed class MsrcConnectorOptions
{
public const string TokenClientName = "excititor.connector.msrc.token";
public const string DefaultScope = "https://api.msrc.microsoft.com/.default";
public const string ApiClientName = "excititor.connector.msrc.api";
public const string DefaultBaseUri = "https://api.msrc.microsoft.com/sug/v2.0/";
public const string DefaultLocale = "en-US";
public const string DefaultApiVersion = "2024-08-01";
/// <summary>
/// Azure AD tenant identifier (GUID or domain).
/// </summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Azure AD application (client) identifier.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Azure AD application secret for client credential flow.
/// </summary>
public string? ClientSecret { get; set; }
/// <summary>
/// OAuth scope requested for MSRC API access.
/// </summary>
public string Scope { get; set; } = DefaultScope;
/// <summary>
/// When true, token acquisition is skipped and the connector expects offline handling.
/// </summary>
public bool PreferOfflineToken { get; set; }
/// <summary>
/// Optional path to a pre-provisioned bearer token used when <see cref="PreferOfflineToken"/> is enabled.
/// </summary>
public string? OfflineTokenPath { get; set; }
/// <summary>
/// Optional fixed bearer token for constrained environments (e.g., short-lived offline bundles).
/// </summary>
public string? StaticAccessToken { get; set; }
/// <summary>
/// Minimum buffer (seconds) subtracted from token expiry before refresh.
/// </summary>
public int ExpiryLeewaySeconds { get; set; } = 60;
/// <summary>
/// Base URI for MSRC Security Update Guide API.
/// </summary>
public Uri BaseUri { get; set; } = new(DefaultBaseUri, UriKind.Absolute);
/// <summary>
/// Locale requested when fetching summaries.
/// </summary>
public string Locale { get; set; } = DefaultLocale;
/// <summary>
/// API version appended to MSRC requests.
/// </summary>
public string ApiVersion { get; set; } = DefaultApiVersion;
/// <summary>
/// Page size used while enumerating summaries.
/// </summary>
public int PageSize { get; set; } = 100;
/// <summary>
/// Maximum CSAF advisories fetched per connector run.
/// </summary>
public int MaxAdvisoriesPerFetch { get; set; } = 200;
/// <summary>
/// Overlap window applied when resuming from the last modified cursor.
/// </summary>
public TimeSpan CursorOverlap { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Delay between CSAF downloads to respect rate limits.
/// </summary>
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Maximum retry attempts for summary/detail fetch operations.
/// </summary>
public int MaxRetryAttempts { get; set; } = 3;
/// <summary>
/// Base delay applied between retries (jitter handled by connector).
/// </summary>
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Optional lower bound for initial synchronisation when no cursor is stored.
/// </summary>
public DateTimeOffset? InitialLastModified { get; set; } = DateTimeOffset.UtcNow.AddDays(-30);
/// <summary>
/// Maximum number of document digests persisted for deduplication.
/// </summary>
public int MaxTrackedDigests { get; set; } = 2048;
public void Validate(IFileSystem? fileSystem = null)
{
if (PreferOfflineToken)
{
if (string.IsNullOrWhiteSpace(OfflineTokenPath) && string.IsNullOrWhiteSpace(StaticAccessToken))
{
throw new InvalidOperationException("OfflineTokenPath or StaticAccessToken must be provided when PreferOfflineToken is enabled.");
}
}
else
{
if (string.IsNullOrWhiteSpace(TenantId))
{
throw new InvalidOperationException("TenantId is required when not operating in offline token mode.");
}
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("ClientId is required when not operating in offline token mode.");
}
if (string.IsNullOrWhiteSpace(ClientSecret))
{
throw new InvalidOperationException("ClientSecret is required when not operating in offline token mode.");
}
}
if (string.IsNullOrWhiteSpace(Scope))
{
Scope = DefaultScope;
}
if (ExpiryLeewaySeconds < 10)
{
ExpiryLeewaySeconds = 10;
}
if (BaseUri is null || !BaseUri.IsAbsoluteUri)
{
throw new InvalidOperationException("BaseUri must be an absolute URI.");
}
if (string.IsNullOrWhiteSpace(Locale))
{
throw new InvalidOperationException("Locale must be provided.");
}
if (!CultureInfo.GetCultures(CultureTypes.AllCultures).Any(c => string.Equals(c.Name, Locale, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"Locale '{Locale}' is not recognised.");
}
if (string.IsNullOrWhiteSpace(ApiVersion))
{
throw new InvalidOperationException("ApiVersion must be provided.");
}
if (PageSize <= 0 || PageSize > 500)
{
throw new InvalidOperationException($"{nameof(PageSize)} must be between 1 and 500.");
}
if (MaxAdvisoriesPerFetch <= 0)
{
throw new InvalidOperationException($"{nameof(MaxAdvisoriesPerFetch)} must be greater than zero.");
}
if (CursorOverlap < TimeSpan.Zero || CursorOverlap > TimeSpan.FromHours(6))
{
throw new InvalidOperationException($"{nameof(CursorOverlap)} must be within 0-6 hours.");
}
if (RequestDelay < TimeSpan.Zero || RequestDelay > TimeSpan.FromSeconds(10))
{
throw new InvalidOperationException($"{nameof(RequestDelay)} must be between 0 and 10 seconds.");
}
if (MaxRetryAttempts <= 0 || MaxRetryAttempts > 10)
{
throw new InvalidOperationException($"{nameof(MaxRetryAttempts)} must be between 1 and 10.");
}
if (RetryBaseDelay < TimeSpan.Zero || RetryBaseDelay > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException($"{nameof(RetryBaseDelay)} must be between 0 and 5 minutes.");
}
if (MaxTrackedDigests <= 0 || MaxTrackedDigests > 10000)
{
throw new InvalidOperationException($"{nameof(MaxTrackedDigests)} must be between 1 and 10000.");
}
if (!string.IsNullOrWhiteSpace(OfflineTokenPath))
{
var fs = fileSystem ?? new FileSystem();
var directory = Path.GetDirectoryName(OfflineTokenPath);
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
{
fs.Directory.CreateDirectory(directory);
}
}
}
}
using System;
using System.Globalization;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
public sealed class MsrcConnectorOptions
{
public const string TokenClientName = "excititor.connector.msrc.token";
public const string DefaultScope = "https://api.msrc.microsoft.com/.default";
public const string ApiClientName = "excititor.connector.msrc.api";
public const string DefaultBaseUri = "https://api.msrc.microsoft.com/sug/v2.0/";
public const string DefaultLocale = "en-US";
public const string DefaultApiVersion = "2024-08-01";
/// <summary>
/// Azure AD tenant identifier (GUID or domain).
/// </summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Azure AD application (client) identifier.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Azure AD application secret for client credential flow.
/// </summary>
public string? ClientSecret { get; set; }
/// <summary>
/// OAuth scope requested for MSRC API access.
/// </summary>
public string Scope { get; set; } = DefaultScope;
/// <summary>
/// When true, token acquisition is skipped and the connector expects offline handling.
/// </summary>
public bool PreferOfflineToken { get; set; }
/// <summary>
/// Optional path to a pre-provisioned bearer token used when <see cref="PreferOfflineToken"/> is enabled.
/// </summary>
public string? OfflineTokenPath { get; set; }
/// <summary>
/// Optional fixed bearer token for constrained environments (e.g., short-lived offline bundles).
/// </summary>
public string? StaticAccessToken { get; set; }
/// <summary>
/// Minimum buffer (seconds) subtracted from token expiry before refresh.
/// </summary>
public int ExpiryLeewaySeconds { get; set; } = 60;
/// <summary>
/// Base URI for MSRC Security Update Guide API.
/// </summary>
public Uri BaseUri { get; set; } = new(DefaultBaseUri, UriKind.Absolute);
/// <summary>
/// Locale requested when fetching summaries.
/// </summary>
public string Locale { get; set; } = DefaultLocale;
/// <summary>
/// API version appended to MSRC requests.
/// </summary>
public string ApiVersion { get; set; } = DefaultApiVersion;
/// <summary>
/// Page size used while enumerating summaries.
/// </summary>
public int PageSize { get; set; } = 100;
/// <summary>
/// Maximum CSAF advisories fetched per connector run.
/// </summary>
public int MaxAdvisoriesPerFetch { get; set; } = 200;
/// <summary>
/// Overlap window applied when resuming from the last modified cursor.
/// </summary>
public TimeSpan CursorOverlap { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Delay between CSAF downloads to respect rate limits.
/// </summary>
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Maximum retry attempts for summary/detail fetch operations.
/// </summary>
public int MaxRetryAttempts { get; set; } = 3;
/// <summary>
/// Base delay applied between retries (jitter handled by connector).
/// </summary>
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Optional lower bound for initial synchronisation when no cursor is stored.
/// </summary>
public DateTimeOffset? InitialLastModified { get; set; } = DateTimeOffset.UtcNow.AddDays(-30);
/// <summary>
/// Maximum number of document digests persisted for deduplication.
/// </summary>
public int MaxTrackedDigests { get; set; } = 2048;
public void Validate(IFileSystem? fileSystem = null)
{
if (PreferOfflineToken)
{
if (string.IsNullOrWhiteSpace(OfflineTokenPath) && string.IsNullOrWhiteSpace(StaticAccessToken))
{
throw new InvalidOperationException("OfflineTokenPath or StaticAccessToken must be provided when PreferOfflineToken is enabled.");
}
}
else
{
if (string.IsNullOrWhiteSpace(TenantId))
{
throw new InvalidOperationException("TenantId is required when not operating in offline token mode.");
}
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("ClientId is required when not operating in offline token mode.");
}
if (string.IsNullOrWhiteSpace(ClientSecret))
{
throw new InvalidOperationException("ClientSecret is required when not operating in offline token mode.");
}
}
if (string.IsNullOrWhiteSpace(Scope))
{
Scope = DefaultScope;
}
if (ExpiryLeewaySeconds < 10)
{
ExpiryLeewaySeconds = 10;
}
if (BaseUri is null || !BaseUri.IsAbsoluteUri)
{
throw new InvalidOperationException("BaseUri must be an absolute URI.");
}
if (string.IsNullOrWhiteSpace(Locale))
{
throw new InvalidOperationException("Locale must be provided.");
}
if (!CultureInfo.GetCultures(CultureTypes.AllCultures).Any(c => string.Equals(c.Name, Locale, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"Locale '{Locale}' is not recognised.");
}
if (string.IsNullOrWhiteSpace(ApiVersion))
{
throw new InvalidOperationException("ApiVersion must be provided.");
}
if (PageSize <= 0 || PageSize > 500)
{
throw new InvalidOperationException($"{nameof(PageSize)} must be between 1 and 500.");
}
if (MaxAdvisoriesPerFetch <= 0)
{
throw new InvalidOperationException($"{nameof(MaxAdvisoriesPerFetch)} must be greater than zero.");
}
if (CursorOverlap < TimeSpan.Zero || CursorOverlap > TimeSpan.FromHours(6))
{
throw new InvalidOperationException($"{nameof(CursorOverlap)} must be within 0-6 hours.");
}
if (RequestDelay < TimeSpan.Zero || RequestDelay > TimeSpan.FromSeconds(10))
{
throw new InvalidOperationException($"{nameof(RequestDelay)} must be between 0 and 10 seconds.");
}
if (MaxRetryAttempts <= 0 || MaxRetryAttempts > 10)
{
throw new InvalidOperationException($"{nameof(MaxRetryAttempts)} must be between 1 and 10.");
}
if (RetryBaseDelay < TimeSpan.Zero || RetryBaseDelay > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException($"{nameof(RetryBaseDelay)} must be between 0 and 5 minutes.");
}
if (MaxTrackedDigests <= 0 || MaxTrackedDigests > 10000)
{
throw new InvalidOperationException($"{nameof(MaxTrackedDigests)} must be between 1 and 10000.");
}
if (!string.IsNullOrWhiteSpace(OfflineTokenPath))
{
var fs = fileSystem ?? new FileSystem();
var directory = Path.GetDirectoryName(OfflineTokenPath);
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
{
fs.Directory.CreateDirectory(directory);
}
}
}
}

View File

@@ -1,58 +1,58 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using System.IO.Abstractions;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.DependencyInjection;
public static class MsrcConnectorServiceCollectionExtensions
{
public static IServiceCollection AddMsrcCsafConnector(this IServiceCollection services, Action<MsrcConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<MsrcConnectorOptions>()
.Configure(options => configure?.Invoke(options));
services.AddHttpClient(MsrcConnectorOptions.TokenClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.MSRC.CSAF/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddHttpClient(MsrcConnectorOptions.ApiClientName)
.ConfigureHttpClient((provider, client) =>
{
var options = provider.GetRequiredService<IOptions<MsrcConnectorOptions>>().Value;
client.BaseAddress = options.BaseUri;
client.Timeout = TimeSpan.FromSeconds(60);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.MSRC.CSAF/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddSingleton<IMsrcTokenProvider, MsrcTokenProvider>();
services.AddSingleton<IVexConnector, MsrcCsafConnector>();
return services;
}
}
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using System.IO.Abstractions;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.DependencyInjection;
public static class MsrcConnectorServiceCollectionExtensions
{
public static IServiceCollection AddMsrcCsafConnector(this IServiceCollection services, Action<MsrcConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<MsrcConnectorOptions>()
.Configure(options => configure?.Invoke(options));
services.AddHttpClient(MsrcConnectorOptions.TokenClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.MSRC.CSAF/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddHttpClient(MsrcConnectorOptions.ApiClientName)
.ConfigureHttpClient((provider, client) =>
{
var options = provider.GetRequiredService<IOptions<MsrcConnectorOptions>>().Value;
client.BaseAddress = options.BaseUri;
client.Timeout = TimeSpan.FromSeconds(60);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.MSRC.CSAF/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddSingleton<IMsrcTokenProvider, MsrcTokenProvider>();
services.AddSingleton<IVexConnector, MsrcCsafConnector>();
return services;
}
}

View File

@@ -1,110 +1,110 @@
using System;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
public sealed record CosignKeylessIdentity(
string Issuer,
string Subject,
Uri? FulcioUrl,
Uri? RekorUrl,
string? ClientId,
string? ClientSecret,
string? Audience,
string? IdentityToken);
public sealed record CosignKeyPairIdentity(
string PrivateKeyPath,
string? Password,
string? CertificatePath,
Uri? RekorUrl,
string? FulcioRootPath);
public sealed record OciCosignAuthority(
CosignCredentialMode Mode,
CosignKeylessIdentity? Keyless,
CosignKeyPairIdentity? KeyPair,
bool RequireSignature,
TimeSpan VerifyTimeout);
public static class OciCosignAuthorityFactory
{
public static OciCosignAuthority Create(OciCosignVerificationOptions options, IFileSystem? fileSystem = null)
{
ArgumentNullException.ThrowIfNull(options);
CosignKeylessIdentity? keyless = null;
CosignKeyPairIdentity? keyPair = null;
switch (options.Mode)
{
case CosignCredentialMode.None:
break;
case CosignCredentialMode.Keyless:
keyless = CreateKeyless(options.Keyless);
break;
case CosignCredentialMode.KeyPair:
keyPair = CreateKeyPair(options.KeyPair, fileSystem);
break;
default:
throw new InvalidOperationException($"Unsupported Cosign credential mode '{options.Mode}'.");
}
return new OciCosignAuthority(
Mode: options.Mode,
Keyless: keyless,
KeyPair: keyPair,
RequireSignature: options.RequireSignature,
VerifyTimeout: options.VerifyTimeout);
}
private static CosignKeylessIdentity CreateKeyless(CosignKeylessOptions options)
{
ArgumentNullException.ThrowIfNull(options);
Uri? fulcio = null;
Uri? rekor = null;
if (!string.IsNullOrWhiteSpace(options.FulcioUrl))
{
fulcio = new Uri(options.FulcioUrl, UriKind.Absolute);
}
if (!string.IsNullOrWhiteSpace(options.RekorUrl))
{
rekor = new Uri(options.RekorUrl, UriKind.Absolute);
}
return new CosignKeylessIdentity(
Issuer: options.Issuer!,
Subject: options.Subject!,
FulcioUrl: fulcio,
RekorUrl: rekor,
ClientId: options.ClientId,
ClientSecret: options.ClientSecret,
Audience: options.Audience,
IdentityToken: options.IdentityToken);
}
private static CosignKeyPairIdentity CreateKeyPair(CosignKeyPairOptions options, IFileSystem? fileSystem)
{
ArgumentNullException.ThrowIfNull(options);
Uri? rekor = null;
if (!string.IsNullOrWhiteSpace(options.RekorUrl))
{
rekor = new Uri(options.RekorUrl, UriKind.Absolute);
}
return new CosignKeyPairIdentity(
PrivateKeyPath: options.PrivateKeyPath!,
Password: options.Password,
CertificatePath: options.CertificatePath,
RekorUrl: rekor,
FulcioRootPath: options.FulcioRootPath);
}
}
using System;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
public sealed record CosignKeylessIdentity(
string Issuer,
string Subject,
Uri? FulcioUrl,
Uri? RekorUrl,
string? ClientId,
string? ClientSecret,
string? Audience,
string? IdentityToken);
public sealed record CosignKeyPairIdentity(
string PrivateKeyPath,
string? Password,
string? CertificatePath,
Uri? RekorUrl,
string? FulcioRootPath);
public sealed record OciCosignAuthority(
CosignCredentialMode Mode,
CosignKeylessIdentity? Keyless,
CosignKeyPairIdentity? KeyPair,
bool RequireSignature,
TimeSpan VerifyTimeout);
public static class OciCosignAuthorityFactory
{
public static OciCosignAuthority Create(OciCosignVerificationOptions options, IFileSystem? fileSystem = null)
{
ArgumentNullException.ThrowIfNull(options);
CosignKeylessIdentity? keyless = null;
CosignKeyPairIdentity? keyPair = null;
switch (options.Mode)
{
case CosignCredentialMode.None:
break;
case CosignCredentialMode.Keyless:
keyless = CreateKeyless(options.Keyless);
break;
case CosignCredentialMode.KeyPair:
keyPair = CreateKeyPair(options.KeyPair, fileSystem);
break;
default:
throw new InvalidOperationException($"Unsupported Cosign credential mode '{options.Mode}'.");
}
return new OciCosignAuthority(
Mode: options.Mode,
Keyless: keyless,
KeyPair: keyPair,
RequireSignature: options.RequireSignature,
VerifyTimeout: options.VerifyTimeout);
}
private static CosignKeylessIdentity CreateKeyless(CosignKeylessOptions options)
{
ArgumentNullException.ThrowIfNull(options);
Uri? fulcio = null;
Uri? rekor = null;
if (!string.IsNullOrWhiteSpace(options.FulcioUrl))
{
fulcio = new Uri(options.FulcioUrl, UriKind.Absolute);
}
if (!string.IsNullOrWhiteSpace(options.RekorUrl))
{
rekor = new Uri(options.RekorUrl, UriKind.Absolute);
}
return new CosignKeylessIdentity(
Issuer: options.Issuer!,
Subject: options.Subject!,
FulcioUrl: fulcio,
RekorUrl: rekor,
ClientId: options.ClientId,
ClientSecret: options.ClientSecret,
Audience: options.Audience,
IdentityToken: options.IdentityToken);
}
private static CosignKeyPairIdentity CreateKeyPair(CosignKeyPairOptions options, IFileSystem? fileSystem)
{
ArgumentNullException.ThrowIfNull(options);
Uri? rekor = null;
if (!string.IsNullOrWhiteSpace(options.RekorUrl))
{
rekor = new Uri(options.RekorUrl, UriKind.Absolute);
}
return new CosignKeyPairIdentity(
PrivateKeyPath: options.PrivateKeyPath!,
Password: options.Password,
CertificatePath: options.CertificatePath,
RekorUrl: rekor,
FulcioRootPath: options.FulcioRootPath);
}
}

View File

@@ -1,59 +1,59 @@
using System;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
public enum OciRegistryAuthMode
{
Anonymous = 0,
Basic = 1,
IdentityToken = 2,
RefreshToken = 3,
}
public sealed record OciRegistryAuthorization(
string? RegistryAuthority,
OciRegistryAuthMode Mode,
string? Username,
string? Password,
string? IdentityToken,
string? RefreshToken,
bool AllowAnonymousFallback)
{
public static OciRegistryAuthorization Create(OciRegistryAuthenticationOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var mode = OciRegistryAuthMode.Anonymous;
string? username = null;
string? password = null;
string? identityToken = null;
string? refreshToken = null;
if (!string.IsNullOrWhiteSpace(options.IdentityToken))
{
mode = OciRegistryAuthMode.IdentityToken;
identityToken = options.IdentityToken;
}
else if (!string.IsNullOrWhiteSpace(options.RefreshToken))
{
mode = OciRegistryAuthMode.RefreshToken;
refreshToken = options.RefreshToken;
}
else if (!string.IsNullOrWhiteSpace(options.Username))
{
mode = OciRegistryAuthMode.Basic;
username = options.Username;
password = options.Password;
}
return new OciRegistryAuthorization(
RegistryAuthority: options.RegistryAuthority,
Mode: mode,
Username: username,
Password: password,
IdentityToken: identityToken,
RefreshToken: refreshToken,
AllowAnonymousFallback: options.AllowAnonymousFallback);
}
}
using System;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
public enum OciRegistryAuthMode
{
Anonymous = 0,
Basic = 1,
IdentityToken = 2,
RefreshToken = 3,
}
public sealed record OciRegistryAuthorization(
string? RegistryAuthority,
OciRegistryAuthMode Mode,
string? Username,
string? Password,
string? IdentityToken,
string? RefreshToken,
bool AllowAnonymousFallback)
{
public static OciRegistryAuthorization Create(OciRegistryAuthenticationOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var mode = OciRegistryAuthMode.Anonymous;
string? username = null;
string? password = null;
string? identityToken = null;
string? refreshToken = null;
if (!string.IsNullOrWhiteSpace(options.IdentityToken))
{
mode = OciRegistryAuthMode.IdentityToken;
identityToken = options.IdentityToken;
}
else if (!string.IsNullOrWhiteSpace(options.RefreshToken))
{
mode = OciRegistryAuthMode.RefreshToken;
refreshToken = options.RefreshToken;
}
else if (!string.IsNullOrWhiteSpace(options.Username))
{
mode = OciRegistryAuthMode.Basic;
username = options.Username;
password = options.Password;
}
return new OciRegistryAuthorization(
RegistryAuthority: options.RegistryAuthority,
Mode: mode,
Username: username,
Password: password,
IdentityToken: identityToken,
RefreshToken: refreshToken,
AllowAnonymousFallback: options.AllowAnonymousFallback);
}
}

View File

@@ -1,321 +1,321 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
public sealed class OciOpenVexAttestationConnectorOptions
{
public const string HttpClientName = "excititor.connector.oci.openvex.attest";
public IList<OciImageSubscriptionOptions> Images { get; } = new List<OciImageSubscriptionOptions>();
public OciRegistryAuthenticationOptions Registry { get; } = new();
public OciCosignVerificationOptions Cosign { get; } = new();
public OciOfflineBundleOptions Offline { get; } = new();
public TimeSpan DiscoveryCacheDuration { get; set; } = TimeSpan.FromMinutes(15);
public int MaxParallelResolutions { get; set; } = 4;
public bool AllowHttpRegistries { get; set; }
public void Validate(IFileSystem? fileSystem = null)
{
if (Images.Count == 0)
{
throw new InvalidOperationException("At least one OCI image reference must be configured.");
}
foreach (var image in Images)
{
image.Validate();
}
if (MaxParallelResolutions <= 0 || MaxParallelResolutions > 32)
{
throw new InvalidOperationException("MaxParallelResolutions must be between 1 and 32.");
}
if (DiscoveryCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("DiscoveryCacheDuration must be a positive time span.");
}
Registry.Validate();
Cosign.Validate(fileSystem);
Offline.Validate(fileSystem);
if (!AllowHttpRegistries && Images.Any(i => i.Reference is not null && i.Reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException("HTTP (non-TLS) registries are disabled. Enable AllowHttpRegistries to permit them.");
}
}
}
public sealed class OciImageSubscriptionOptions
{
private OciImageReference? _parsedReference;
/// <summary>
/// Gets or sets the OCI reference (e.g. registry.example.com/repository:tag or registry.example.com/repository@sha256:abcdef).
/// </summary>
public string? Reference { get; set; }
/// <summary>
/// Optional friendly name used in logs when referencing this subscription.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
/// Optional file path for an offline attestation bundle associated with this image.
/// </summary>
public string? OfflineBundlePath { get; set; }
/// <summary>
/// Optional override for the expected subject digest. When provided, discovery will verify resolved digests match.
/// </summary>
public string? ExpectedSubjectDigest { get; set; }
internal OciImageReference? ParsedReference => _parsedReference;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Reference))
{
throw new InvalidOperationException("Image Reference is required for OCI OpenVEX attestation connector.");
}
_parsedReference = OciImageReferenceParser.Parse(Reference);
if (!string.IsNullOrWhiteSpace(ExpectedSubjectDigest))
{
if (!ExpectedSubjectDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("ExpectedSubjectDigest must start with 'sha256:'.");
}
if (ExpectedSubjectDigest.Length != "sha256:".Length + 64)
{
throw new InvalidOperationException("ExpectedSubjectDigest must contain a 64-character hexadecimal hash.");
}
}
}
}
public sealed class OciRegistryAuthenticationOptions
{
/// <summary>
/// Optional registry authority filter (e.g. registry.example.com:5000). When set it must match image references.
/// </summary>
public string? RegistryAuthority { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? IdentityToken { get; set; }
public string? RefreshToken { get; set; }
public bool AllowAnonymousFallback { get; set; } = true;
public void Validate()
{
var hasUser = !string.IsNullOrWhiteSpace(Username);
var hasPassword = !string.IsNullOrWhiteSpace(Password);
var hasIdentityToken = !string.IsNullOrWhiteSpace(IdentityToken);
var hasRefreshToken = !string.IsNullOrWhiteSpace(RefreshToken);
if (hasIdentityToken && (hasUser || hasPassword))
{
throw new InvalidOperationException("IdentityToken cannot be combined with Username/Password for OCI registry authentication.");
}
if (hasRefreshToken && (hasUser || hasPassword))
{
throw new InvalidOperationException("RefreshToken cannot be combined with Username/Password for OCI registry authentication.");
}
if (hasUser != hasPassword)
{
throw new InvalidOperationException("Username and Password must be provided together for OCI registry authentication.");
}
if (!string.IsNullOrWhiteSpace(RegistryAuthority) && RegistryAuthority.Contains('/', StringComparison.Ordinal))
{
throw new InvalidOperationException("RegistryAuthority must not contain path segments.");
}
}
}
public sealed class OciCosignVerificationOptions
{
public CosignCredentialMode Mode { get; set; } = CosignCredentialMode.Keyless;
public CosignKeylessOptions Keyless { get; } = new();
public CosignKeyPairOptions KeyPair { get; } = new();
public bool RequireSignature { get; set; } = true;
public TimeSpan VerifyTimeout { get; set; } = TimeSpan.FromSeconds(30);
public void Validate(IFileSystem? fileSystem = null)
{
if (VerifyTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("VerifyTimeout must be a positive time span.");
}
switch (Mode)
{
case CosignCredentialMode.None:
break;
case CosignCredentialMode.Keyless:
Keyless.Validate();
break;
case CosignCredentialMode.KeyPair:
KeyPair.Validate(fileSystem);
break;
default:
throw new InvalidOperationException($"Unsupported Cosign credential mode '{Mode}'.");
}
}
}
public enum CosignCredentialMode
{
None = 0,
Keyless = 1,
KeyPair = 2,
}
public sealed class CosignKeylessOptions
{
public string? Issuer { get; set; }
public string? Subject { get; set; }
public string? FulcioUrl { get; set; } = "https://fulcio.sigstore.dev";
public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev";
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? Audience { get; set; }
public string? IdentityToken { get; set; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Cosign keyless Issuer must be provided.");
}
if (string.IsNullOrWhiteSpace(Subject))
{
throw new InvalidOperationException("Cosign keyless Subject must be provided.");
}
if (!string.IsNullOrWhiteSpace(FulcioUrl) && !Uri.TryCreate(FulcioUrl, UriKind.Absolute, out var fulcio))
{
throw new InvalidOperationException("FulcioUrl must be an absolute URI when provided.");
}
if (!string.IsNullOrWhiteSpace(RekorUrl) && !Uri.TryCreate(RekorUrl, UriKind.Absolute, out var rekor))
{
throw new InvalidOperationException("RekorUrl must be an absolute URI when provided.");
}
if (!string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("Cosign keyless ClientId must be provided when ClientSecret is specified.");
}
}
}
public sealed class CosignKeyPairOptions
{
public string? PrivateKeyPath { get; set; }
public string? Password { get; set; }
public string? CertificatePath { get; set; }
public string? RekorUrl { get; set; }
public string? FulcioRootPath { get; set; }
public void Validate(IFileSystem? fileSystem = null)
{
if (string.IsNullOrWhiteSpace(PrivateKeyPath))
{
throw new InvalidOperationException("PrivateKeyPath must be provided for Cosign key pair mode.");
}
var fs = fileSystem ?? new FileSystem();
if (!fs.File.Exists(PrivateKeyPath))
{
throw new InvalidOperationException($"Cosign private key file not found: {PrivateKeyPath}");
}
if (!string.IsNullOrWhiteSpace(CertificatePath) && !fs.File.Exists(CertificatePath))
{
throw new InvalidOperationException($"Cosign certificate file not found: {CertificatePath}");
}
if (!string.IsNullOrWhiteSpace(FulcioRootPath) && !fs.File.Exists(FulcioRootPath))
{
throw new InvalidOperationException($"Cosign Fulcio root file not found: {FulcioRootPath}");
}
if (!string.IsNullOrWhiteSpace(RekorUrl) && !Uri.TryCreate(RekorUrl, UriKind.Absolute, out _))
{
throw new InvalidOperationException("RekorUrl must be an absolute URI when provided for Cosign key pair mode.");
}
}
}
public sealed class OciOfflineBundleOptions
{
public string? RootDirectory { get; set; }
public bool PreferOffline { get; set; }
public bool AllowNetworkFallback { get; set; } = true;
public string? DefaultBundleFileName { get; set; } = "openvex-attestations.tgz";
public bool RequireBundles { get; set; }
public void Validate(IFileSystem? fileSystem = null)
{
if (string.IsNullOrWhiteSpace(RootDirectory))
{
return;
}
var fs = fileSystem ?? new FileSystem();
if (!fs.Directory.Exists(RootDirectory))
{
if (PreferOffline || RequireBundles)
{
throw new InvalidOperationException($"Offline bundle root directory '{RootDirectory}' does not exist.");
}
fs.Directory.CreateDirectory(RootDirectory);
}
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
public sealed class OciOpenVexAttestationConnectorOptions
{
public const string HttpClientName = "excititor.connector.oci.openvex.attest";
public IList<OciImageSubscriptionOptions> Images { get; } = new List<OciImageSubscriptionOptions>();
public OciRegistryAuthenticationOptions Registry { get; } = new();
public OciCosignVerificationOptions Cosign { get; } = new();
public OciOfflineBundleOptions Offline { get; } = new();
public TimeSpan DiscoveryCacheDuration { get; set; } = TimeSpan.FromMinutes(15);
public int MaxParallelResolutions { get; set; } = 4;
public bool AllowHttpRegistries { get; set; }
public void Validate(IFileSystem? fileSystem = null)
{
if (Images.Count == 0)
{
throw new InvalidOperationException("At least one OCI image reference must be configured.");
}
foreach (var image in Images)
{
image.Validate();
}
if (MaxParallelResolutions <= 0 || MaxParallelResolutions > 32)
{
throw new InvalidOperationException("MaxParallelResolutions must be between 1 and 32.");
}
if (DiscoveryCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("DiscoveryCacheDuration must be a positive time span.");
}
Registry.Validate();
Cosign.Validate(fileSystem);
Offline.Validate(fileSystem);
if (!AllowHttpRegistries && Images.Any(i => i.Reference is not null && i.Reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException("HTTP (non-TLS) registries are disabled. Enable AllowHttpRegistries to permit them.");
}
}
}
public sealed class OciImageSubscriptionOptions
{
private OciImageReference? _parsedReference;
/// <summary>
/// Gets or sets the OCI reference (e.g. registry.example.com/repository:tag or registry.example.com/repository@sha256:abcdef).
/// </summary>
public string? Reference { get; set; }
/// <summary>
/// Optional friendly name used in logs when referencing this subscription.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
/// Optional file path for an offline attestation bundle associated with this image.
/// </summary>
public string? OfflineBundlePath { get; set; }
/// <summary>
/// Optional override for the expected subject digest. When provided, discovery will verify resolved digests match.
/// </summary>
public string? ExpectedSubjectDigest { get; set; }
internal OciImageReference? ParsedReference => _parsedReference;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Reference))
{
throw new InvalidOperationException("Image Reference is required for OCI OpenVEX attestation connector.");
}
_parsedReference = OciImageReferenceParser.Parse(Reference);
if (!string.IsNullOrWhiteSpace(ExpectedSubjectDigest))
{
if (!ExpectedSubjectDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("ExpectedSubjectDigest must start with 'sha256:'.");
}
if (ExpectedSubjectDigest.Length != "sha256:".Length + 64)
{
throw new InvalidOperationException("ExpectedSubjectDigest must contain a 64-character hexadecimal hash.");
}
}
}
}
public sealed class OciRegistryAuthenticationOptions
{
/// <summary>
/// Optional registry authority filter (e.g. registry.example.com:5000). When set it must match image references.
/// </summary>
public string? RegistryAuthority { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? IdentityToken { get; set; }
public string? RefreshToken { get; set; }
public bool AllowAnonymousFallback { get; set; } = true;
public void Validate()
{
var hasUser = !string.IsNullOrWhiteSpace(Username);
var hasPassword = !string.IsNullOrWhiteSpace(Password);
var hasIdentityToken = !string.IsNullOrWhiteSpace(IdentityToken);
var hasRefreshToken = !string.IsNullOrWhiteSpace(RefreshToken);
if (hasIdentityToken && (hasUser || hasPassword))
{
throw new InvalidOperationException("IdentityToken cannot be combined with Username/Password for OCI registry authentication.");
}
if (hasRefreshToken && (hasUser || hasPassword))
{
throw new InvalidOperationException("RefreshToken cannot be combined with Username/Password for OCI registry authentication.");
}
if (hasUser != hasPassword)
{
throw new InvalidOperationException("Username and Password must be provided together for OCI registry authentication.");
}
if (!string.IsNullOrWhiteSpace(RegistryAuthority) && RegistryAuthority.Contains('/', StringComparison.Ordinal))
{
throw new InvalidOperationException("RegistryAuthority must not contain path segments.");
}
}
}
public sealed class OciCosignVerificationOptions
{
public CosignCredentialMode Mode { get; set; } = CosignCredentialMode.Keyless;
public CosignKeylessOptions Keyless { get; } = new();
public CosignKeyPairOptions KeyPair { get; } = new();
public bool RequireSignature { get; set; } = true;
public TimeSpan VerifyTimeout { get; set; } = TimeSpan.FromSeconds(30);
public void Validate(IFileSystem? fileSystem = null)
{
if (VerifyTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("VerifyTimeout must be a positive time span.");
}
switch (Mode)
{
case CosignCredentialMode.None:
break;
case CosignCredentialMode.Keyless:
Keyless.Validate();
break;
case CosignCredentialMode.KeyPair:
KeyPair.Validate(fileSystem);
break;
default:
throw new InvalidOperationException($"Unsupported Cosign credential mode '{Mode}'.");
}
}
}
public enum CosignCredentialMode
{
None = 0,
Keyless = 1,
KeyPair = 2,
}
public sealed class CosignKeylessOptions
{
public string? Issuer { get; set; }
public string? Subject { get; set; }
public string? FulcioUrl { get; set; } = "https://fulcio.sigstore.dev";
public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev";
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? Audience { get; set; }
public string? IdentityToken { get; set; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Cosign keyless Issuer must be provided.");
}
if (string.IsNullOrWhiteSpace(Subject))
{
throw new InvalidOperationException("Cosign keyless Subject must be provided.");
}
if (!string.IsNullOrWhiteSpace(FulcioUrl) && !Uri.TryCreate(FulcioUrl, UriKind.Absolute, out var fulcio))
{
throw new InvalidOperationException("FulcioUrl must be an absolute URI when provided.");
}
if (!string.IsNullOrWhiteSpace(RekorUrl) && !Uri.TryCreate(RekorUrl, UriKind.Absolute, out var rekor))
{
throw new InvalidOperationException("RekorUrl must be an absolute URI when provided.");
}
if (!string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException("Cosign keyless ClientId must be provided when ClientSecret is specified.");
}
}
}
public sealed class CosignKeyPairOptions
{
public string? PrivateKeyPath { get; set; }
public string? Password { get; set; }
public string? CertificatePath { get; set; }
public string? RekorUrl { get; set; }
public string? FulcioRootPath { get; set; }
public void Validate(IFileSystem? fileSystem = null)
{
if (string.IsNullOrWhiteSpace(PrivateKeyPath))
{
throw new InvalidOperationException("PrivateKeyPath must be provided for Cosign key pair mode.");
}
var fs = fileSystem ?? new FileSystem();
if (!fs.File.Exists(PrivateKeyPath))
{
throw new InvalidOperationException($"Cosign private key file not found: {PrivateKeyPath}");
}
if (!string.IsNullOrWhiteSpace(CertificatePath) && !fs.File.Exists(CertificatePath))
{
throw new InvalidOperationException($"Cosign certificate file not found: {CertificatePath}");
}
if (!string.IsNullOrWhiteSpace(FulcioRootPath) && !fs.File.Exists(FulcioRootPath))
{
throw new InvalidOperationException($"Cosign Fulcio root file not found: {FulcioRootPath}");
}
if (!string.IsNullOrWhiteSpace(RekorUrl) && !Uri.TryCreate(RekorUrl, UriKind.Absolute, out _))
{
throw new InvalidOperationException("RekorUrl must be an absolute URI when provided for Cosign key pair mode.");
}
}
}
public sealed class OciOfflineBundleOptions
{
public string? RootDirectory { get; set; }
public bool PreferOffline { get; set; }
public bool AllowNetworkFallback { get; set; } = true;
public string? DefaultBundleFileName { get; set; } = "openvex-attestations.tgz";
public bool RequireBundles { get; set; }
public void Validate(IFileSystem? fileSystem = null)
{
if (string.IsNullOrWhiteSpace(RootDirectory))
{
return;
}
var fs = fileSystem ?? new FileSystem();
if (!fs.Directory.Exists(RootDirectory))
{
if (PreferOffline || RequireBundles)
{
throw new InvalidOperationException($"Offline bundle root directory '{RootDirectory}' does not exist.");
}
fs.Directory.CreateDirectory(RootDirectory);
}
}
}

View File

@@ -1,35 +1,35 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
public sealed class OciOpenVexAttestationConnectorOptionsValidator : IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>
{
private readonly IFileSystem _fileSystem;
public OciOpenVexAttestationConnectorOptionsValidator(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public void Validate(
VexConnectorDescriptor descriptor,
OciOpenVexAttestationConnectorOptions options,
IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
try
{
options.Validate(_fileSystem);
}
catch (Exception ex)
{
errors.Add(ex.Message);
}
}
}
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
public sealed class OciOpenVexAttestationConnectorOptionsValidator : IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>
{
private readonly IFileSystem _fileSystem;
public OciOpenVexAttestationConnectorOptionsValidator(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public void Validate(
VexConnectorDescriptor descriptor,
OciOpenVexAttestationConnectorOptions options,
IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
try
{
options.Validate(_fileSystem);
}
catch (Exception ex)
{
errors.Add(ex.Message);
}
}
}

View File

@@ -1,52 +1,52 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
public static class OciOpenVexAttestationConnectorServiceCollectionExtensions
{
public static IServiceCollection AddOciOpenVexAttestationConnector(
this IServiceCollection services,
Action<OciOpenVexAttestationConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<OciOpenVexAttestationConnectorOptions>()
.Configure(options =>
{
configure?.Invoke(options);
});
services.AddSingleton<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>, OciOpenVexAttestationConnectorOptionsValidator>();
services.AddSingleton<OciAttestationDiscoveryService>();
services.AddSingleton<OciAttestationFetcher>();
services.AddSingleton<IVexConnector, OciOpenVexAttestationConnector>();
services.AddHttpClient(OciOpenVexAttestationConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.cncf.openvex.v1+json");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
return services;
}
}
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
public static class OciOpenVexAttestationConnectorServiceCollectionExtensions
{
public static IServiceCollection AddOciOpenVexAttestationConnector(
this IServiceCollection services,
Action<OciOpenVexAttestationConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<OciOpenVexAttestationConnectorOptions>()
.Configure(options =>
{
configure?.Invoke(options);
});
services.AddSingleton<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>, OciOpenVexAttestationConnectorOptionsValidator>();
services.AddSingleton<OciAttestationDiscoveryService>();
services.AddSingleton<OciAttestationFetcher>();
services.AddSingleton<IVexConnector, OciOpenVexAttestationConnector>();
services.AddHttpClient(OciOpenVexAttestationConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.cncf.openvex.v1+json");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
return services;
}
}

View File

@@ -1,11 +1,11 @@
using System.Collections.Immutable;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciAttestationDiscoveryResult(
ImmutableArray<OciAttestationTarget> Targets,
OciRegistryAuthorization RegistryAuthorization,
OciCosignAuthority CosignAuthority,
bool PreferOffline,
bool AllowNetworkFallback);
using System.Collections.Immutable;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciAttestationDiscoveryResult(
ImmutableArray<OciAttestationTarget> Targets,
OciRegistryAuthorization RegistryAuthorization,
OciCosignAuthority CosignAuthority,
bool PreferOffline,
bool AllowNetworkFallback);

View File

@@ -1,188 +1,188 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed class OciAttestationDiscoveryService
{
private readonly IMemoryCache _memoryCache;
private readonly IFileSystem _fileSystem;
private readonly ILogger<OciAttestationDiscoveryService> _logger;
public OciAttestationDiscoveryService(
IMemoryCache memoryCache,
IFileSystem fileSystem,
ILogger<OciAttestationDiscoveryService> logger)
{
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<OciAttestationDiscoveryResult> LoadAsync(
OciOpenVexAttestationConnectorOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var cacheKey = CreateCacheKey(options);
if (_memoryCache.TryGetValue(cacheKey, out OciAttestationDiscoveryResult? cached) && cached is not null)
{
_logger.LogDebug("Using cached OCI attestation discovery result for {ImageCount} images.", cached.Targets.Length);
return Task.FromResult(cached);
}
var targets = new List<OciAttestationTarget>(options.Images.Count);
foreach (var image in options.Images)
{
cancellationToken.ThrowIfCancellationRequested();
var parsed = image.ParsedReference ?? OciImageReferenceParser.Parse(image.Reference!);
var offlinePath = ResolveOfflinePath(options, image, parsed);
OciOfflineBundleReference? offline = null;
if (!string.IsNullOrWhiteSpace(offlinePath))
{
var fullPath = _fileSystem.Path.GetFullPath(offlinePath!);
var exists = _fileSystem.File.Exists(fullPath) || _fileSystem.Directory.Exists(fullPath);
if (!exists && options.Offline.RequireBundles)
{
throw new InvalidOperationException($"Required offline bundle '{fullPath}' for reference '{parsed.Canonical}' was not found.");
}
offline = new OciOfflineBundleReference(fullPath, exists, image.ExpectedSubjectDigest);
}
targets.Add(new OciAttestationTarget(parsed, image.ExpectedSubjectDigest, offline));
}
var authorization = OciRegistryAuthorization.Create(options.Registry);
var cosignAuthority = OciCosignAuthorityFactory.Create(options.Cosign, _fileSystem);
var result = new OciAttestationDiscoveryResult(
targets.ToImmutableArray(),
authorization,
cosignAuthority,
options.Offline.PreferOffline,
options.Offline.AllowNetworkFallback);
_memoryCache.Set(cacheKey, result, options.DiscoveryCacheDuration);
return Task.FromResult(result);
}
private string? ResolveOfflinePath(
OciOpenVexAttestationConnectorOptions options,
OciImageSubscriptionOptions image,
OciImageReference parsed)
{
if (!string.IsNullOrWhiteSpace(image.OfflineBundlePath))
{
return image.OfflineBundlePath;
}
if (string.IsNullOrWhiteSpace(options.Offline.RootDirectory))
{
return null;
}
var root = options.Offline.RootDirectory!;
var segments = new List<string> { SanitizeSegment(parsed.Registry) };
var repositoryParts = parsed.Repository.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (repositoryParts.Length == 0)
{
segments.Add(SanitizeSegment(parsed.Repository));
}
else
{
foreach (var part in repositoryParts)
{
segments.Add(SanitizeSegment(part));
}
}
var versionSegment = parsed.Digest is not null
? SanitizeSegment(parsed.Digest)
: SanitizeSegment(parsed.Tag ?? "latest");
segments.Add(versionSegment);
var combined = _fileSystem.Path.Combine(new[] { root }.Concat(segments).ToArray());
if (!string.IsNullOrWhiteSpace(options.Offline.DefaultBundleFileName))
{
combined = _fileSystem.Path.Combine(combined, options.Offline.DefaultBundleFileName!);
}
return combined;
}
private static string SanitizeSegment(string value)
{
if (string.IsNullOrEmpty(value))
{
return "_";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.')
{
builder.Append(ch);
}
else
{
builder.Append('_');
}
}
return builder.Length == 0 ? "_" : builder.ToString();
}
private static string CreateCacheKey(OciOpenVexAttestationConnectorOptions options)
{
using var sha = SHA256.Create();
var builder = new StringBuilder();
builder.AppendLine("oci-openvex-attest");
builder.AppendLine(options.MaxParallelResolutions.ToString());
builder.AppendLine(options.AllowHttpRegistries.ToString());
builder.AppendLine(options.Offline.PreferOffline.ToString());
builder.AppendLine(options.Offline.AllowNetworkFallback.ToString());
foreach (var image in options.Images)
{
builder.AppendLine(image.Reference ?? string.Empty);
builder.AppendLine(image.ExpectedSubjectDigest ?? string.Empty);
builder.AppendLine(image.OfflineBundlePath ?? string.Empty);
}
if (!string.IsNullOrWhiteSpace(options.Offline.RootDirectory))
{
builder.AppendLine(options.Offline.RootDirectory);
builder.AppendLine(options.Offline.DefaultBundleFileName ?? string.Empty);
}
builder.AppendLine(options.Registry.RegistryAuthority ?? string.Empty);
builder.AppendLine(options.Registry.AllowAnonymousFallback.ToString());
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
var hashBytes = sha.ComputeHash(bytes);
return Convert.ToHexString(hashBytes);
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed class OciAttestationDiscoveryService
{
private readonly IMemoryCache _memoryCache;
private readonly IFileSystem _fileSystem;
private readonly ILogger<OciAttestationDiscoveryService> _logger;
public OciAttestationDiscoveryService(
IMemoryCache memoryCache,
IFileSystem fileSystem,
ILogger<OciAttestationDiscoveryService> logger)
{
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<OciAttestationDiscoveryResult> LoadAsync(
OciOpenVexAttestationConnectorOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var cacheKey = CreateCacheKey(options);
if (_memoryCache.TryGetValue(cacheKey, out OciAttestationDiscoveryResult? cached) && cached is not null)
{
_logger.LogDebug("Using cached OCI attestation discovery result for {ImageCount} images.", cached.Targets.Length);
return Task.FromResult(cached);
}
var targets = new List<OciAttestationTarget>(options.Images.Count);
foreach (var image in options.Images)
{
cancellationToken.ThrowIfCancellationRequested();
var parsed = image.ParsedReference ?? OciImageReferenceParser.Parse(image.Reference!);
var offlinePath = ResolveOfflinePath(options, image, parsed);
OciOfflineBundleReference? offline = null;
if (!string.IsNullOrWhiteSpace(offlinePath))
{
var fullPath = _fileSystem.Path.GetFullPath(offlinePath!);
var exists = _fileSystem.File.Exists(fullPath) || _fileSystem.Directory.Exists(fullPath);
if (!exists && options.Offline.RequireBundles)
{
throw new InvalidOperationException($"Required offline bundle '{fullPath}' for reference '{parsed.Canonical}' was not found.");
}
offline = new OciOfflineBundleReference(fullPath, exists, image.ExpectedSubjectDigest);
}
targets.Add(new OciAttestationTarget(parsed, image.ExpectedSubjectDigest, offline));
}
var authorization = OciRegistryAuthorization.Create(options.Registry);
var cosignAuthority = OciCosignAuthorityFactory.Create(options.Cosign, _fileSystem);
var result = new OciAttestationDiscoveryResult(
targets.ToImmutableArray(),
authorization,
cosignAuthority,
options.Offline.PreferOffline,
options.Offline.AllowNetworkFallback);
_memoryCache.Set(cacheKey, result, options.DiscoveryCacheDuration);
return Task.FromResult(result);
}
private string? ResolveOfflinePath(
OciOpenVexAttestationConnectorOptions options,
OciImageSubscriptionOptions image,
OciImageReference parsed)
{
if (!string.IsNullOrWhiteSpace(image.OfflineBundlePath))
{
return image.OfflineBundlePath;
}
if (string.IsNullOrWhiteSpace(options.Offline.RootDirectory))
{
return null;
}
var root = options.Offline.RootDirectory!;
var segments = new List<string> { SanitizeSegment(parsed.Registry) };
var repositoryParts = parsed.Repository.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (repositoryParts.Length == 0)
{
segments.Add(SanitizeSegment(parsed.Repository));
}
else
{
foreach (var part in repositoryParts)
{
segments.Add(SanitizeSegment(part));
}
}
var versionSegment = parsed.Digest is not null
? SanitizeSegment(parsed.Digest)
: SanitizeSegment(parsed.Tag ?? "latest");
segments.Add(versionSegment);
var combined = _fileSystem.Path.Combine(new[] { root }.Concat(segments).ToArray());
if (!string.IsNullOrWhiteSpace(options.Offline.DefaultBundleFileName))
{
combined = _fileSystem.Path.Combine(combined, options.Offline.DefaultBundleFileName!);
}
return combined;
}
private static string SanitizeSegment(string value)
{
if (string.IsNullOrEmpty(value))
{
return "_";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.')
{
builder.Append(ch);
}
else
{
builder.Append('_');
}
}
return builder.Length == 0 ? "_" : builder.ToString();
}
private static string CreateCacheKey(OciOpenVexAttestationConnectorOptions options)
{
using var sha = SHA256.Create();
var builder = new StringBuilder();
builder.AppendLine("oci-openvex-attest");
builder.AppendLine(options.MaxParallelResolutions.ToString());
builder.AppendLine(options.AllowHttpRegistries.ToString());
builder.AppendLine(options.Offline.PreferOffline.ToString());
builder.AppendLine(options.Offline.AllowNetworkFallback.ToString());
foreach (var image in options.Images)
{
builder.AppendLine(image.Reference ?? string.Empty);
builder.AppendLine(image.ExpectedSubjectDigest ?? string.Empty);
builder.AppendLine(image.OfflineBundlePath ?? string.Empty);
}
if (!string.IsNullOrWhiteSpace(options.Offline.RootDirectory))
{
builder.AppendLine(options.Offline.RootDirectory);
builder.AppendLine(options.Offline.DefaultBundleFileName ?? string.Empty);
}
builder.AppendLine(options.Registry.RegistryAuthority ?? string.Empty);
builder.AppendLine(options.Registry.AllowAnonymousFallback.ToString());
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
var hashBytes = sha.ComputeHash(bytes);
return Convert.ToHexString(hashBytes);
}
}

View File

@@ -1,6 +1,6 @@
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciAttestationTarget(
OciImageReference Image,
string? ExpectedSubjectDigest,
OciOfflineBundleReference? OfflineBundle);
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciAttestationTarget(
OciImageReference Image,
string? ExpectedSubjectDigest,
OciOfflineBundleReference? OfflineBundle);

View File

@@ -1,27 +1,27 @@
using System;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciImageReference(string Registry, string Repository, string? Tag, string? Digest, string OriginalReference, string Scheme = "https")
{
public string Canonical =>
Digest is not null
? $"{Registry}/{Repository}@{Digest}"
: Tag is not null
? $"{Registry}/{Repository}:{Tag}"
: $"{Registry}/{Repository}";
public bool HasDigest => !string.IsNullOrWhiteSpace(Digest);
public bool HasTag => !string.IsNullOrWhiteSpace(Tag);
public OciImageReference WithDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Digest must be provided.", nameof(digest));
}
return this with { Digest = digest };
}
}
using System;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciImageReference(string Registry, string Repository, string? Tag, string? Digest, string OriginalReference, string Scheme = "https")
{
public string Canonical =>
Digest is not null
? $"{Registry}/{Repository}@{Digest}"
: Tag is not null
? $"{Registry}/{Repository}:{Tag}"
: $"{Registry}/{Repository}";
public bool HasDigest => !string.IsNullOrWhiteSpace(Digest);
public bool HasTag => !string.IsNullOrWhiteSpace(Tag);
public OciImageReference WithDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Digest must be provided.", nameof(digest));
}
return this with { Digest = digest };
}
}

View File

@@ -1,129 +1,129 @@
using System;
using System.Text.RegularExpressions;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
internal static class OciImageReferenceParser
{
private static readonly Regex DigestRegex = new(@"^(?<algorithm>[A-Za-z0-9+._-]+):(?<hash>[A-Fa-f0-9]{32,})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex RepositoryRegex = new(@"^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static OciImageReference Parse(string reference)
{
if (string.IsNullOrWhiteSpace(reference))
{
throw new InvalidOperationException("OCI reference cannot be empty.");
}
var trimmed = reference.Trim();
string original = trimmed;
var scheme = "https";
if (trimmed.StartsWith("oci://", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed.Substring("oci://".Length);
}
if (trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed.Substring("https://".Length);
scheme = "https";
}
else if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed.Substring("http://".Length);
scheme = "http";
}
var firstSlash = trimmed.IndexOf('/');
if (firstSlash <= 0)
{
throw new InvalidOperationException($"OCI reference '{reference}' must include a registry and repository component.");
}
var registry = trimmed[..firstSlash];
var remainder = trimmed[(firstSlash + 1)..];
if (!LooksLikeRegistry(registry))
{
throw new InvalidOperationException($"OCI reference '{reference}' is missing an explicit registry component.");
}
string? digest = null;
string? tag = null;
var digestIndex = remainder.IndexOf('@');
if (digestIndex >= 0)
{
digest = remainder[(digestIndex + 1)..];
remainder = remainder[..digestIndex];
if (!DigestRegex.IsMatch(digest))
{
throw new InvalidOperationException($"Digest segment '{digest}' is not a valid OCI digest.");
}
}
var tagIndex = remainder.LastIndexOf(':');
if (tagIndex >= 0)
{
tag = remainder[(tagIndex + 1)..];
remainder = remainder[..tagIndex];
if (string.IsNullOrWhiteSpace(tag))
{
throw new InvalidOperationException("OCI tag segment cannot be empty.");
}
if (tag.Contains('/', StringComparison.Ordinal))
{
throw new InvalidOperationException("OCI tag segment cannot contain '/'.");
}
}
var repository = remainder;
if (string.IsNullOrWhiteSpace(repository))
{
throw new InvalidOperationException("OCI repository segment cannot be empty.");
}
if (!RepositoryRegex.IsMatch(repository))
{
throw new InvalidOperationException($"Repository segment '{repository}' is not valid per OCI distribution rules.");
}
return new OciImageReference(
Registry: registry,
Repository: repository,
Tag: tag,
Digest: digest,
OriginalReference: original,
Scheme: scheme);
}
private static bool LooksLikeRegistry(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
if (value.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (value.Contains('.', StringComparison.Ordinal) || value.Contains(':', StringComparison.Ordinal))
{
return true;
}
// IPv4/IPv6 simplified check
if (value.Length >= 3 && char.IsDigit(value[0]))
{
return true;
}
return false;
}
}
using System;
using System.Text.RegularExpressions;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
internal static class OciImageReferenceParser
{
private static readonly Regex DigestRegex = new(@"^(?<algorithm>[A-Za-z0-9+._-]+):(?<hash>[A-Fa-f0-9]{32,})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex RepositoryRegex = new(@"^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static OciImageReference Parse(string reference)
{
if (string.IsNullOrWhiteSpace(reference))
{
throw new InvalidOperationException("OCI reference cannot be empty.");
}
var trimmed = reference.Trim();
string original = trimmed;
var scheme = "https";
if (trimmed.StartsWith("oci://", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed.Substring("oci://".Length);
}
if (trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed.Substring("https://".Length);
scheme = "https";
}
else if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed.Substring("http://".Length);
scheme = "http";
}
var firstSlash = trimmed.IndexOf('/');
if (firstSlash <= 0)
{
throw new InvalidOperationException($"OCI reference '{reference}' must include a registry and repository component.");
}
var registry = trimmed[..firstSlash];
var remainder = trimmed[(firstSlash + 1)..];
if (!LooksLikeRegistry(registry))
{
throw new InvalidOperationException($"OCI reference '{reference}' is missing an explicit registry component.");
}
string? digest = null;
string? tag = null;
var digestIndex = remainder.IndexOf('@');
if (digestIndex >= 0)
{
digest = remainder[(digestIndex + 1)..];
remainder = remainder[..digestIndex];
if (!DigestRegex.IsMatch(digest))
{
throw new InvalidOperationException($"Digest segment '{digest}' is not a valid OCI digest.");
}
}
var tagIndex = remainder.LastIndexOf(':');
if (tagIndex >= 0)
{
tag = remainder[(tagIndex + 1)..];
remainder = remainder[..tagIndex];
if (string.IsNullOrWhiteSpace(tag))
{
throw new InvalidOperationException("OCI tag segment cannot be empty.");
}
if (tag.Contains('/', StringComparison.Ordinal))
{
throw new InvalidOperationException("OCI tag segment cannot contain '/'.");
}
}
var repository = remainder;
if (string.IsNullOrWhiteSpace(repository))
{
throw new InvalidOperationException("OCI repository segment cannot be empty.");
}
if (!RepositoryRegex.IsMatch(repository))
{
throw new InvalidOperationException($"Repository segment '{repository}' is not valid per OCI distribution rules.");
}
return new OciImageReference(
Registry: registry,
Repository: repository,
Tag: tag,
Digest: digest,
OriginalReference: original,
Scheme: scheme);
}
private static bool LooksLikeRegistry(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
if (value.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (value.Contains('.', StringComparison.Ordinal) || value.Contains(':', StringComparison.Ordinal))
{
return true;
}
// IPv4/IPv6 simplified check
if (value.Length >= 3 && char.IsDigit(value[0]))
{
return true;
}
return false;
}
}

View File

@@ -1,5 +1,5 @@
using System;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciOfflineBundleReference(string Path, bool Exists, string? ExpectedSubjectDigest);
using System;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
public sealed record OciOfflineBundleReference(string Path, bool Exists, string? ExpectedSubjectDigest);

View File

@@ -1,14 +1,14 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
internal sealed record OciArtifactDescriptor(
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("mediaType")] string MediaType,
[property: JsonPropertyName("artifactType")] string? ArtifactType,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string>? Annotations);
internal sealed record OciReferrerIndex(
[property: JsonPropertyName("referrers")] IReadOnlyList<OciArtifactDescriptor> Referrers);
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
internal sealed record OciArtifactDescriptor(
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("mediaType")] string MediaType,
[property: JsonPropertyName("artifactType")] string? ArtifactType,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string>? Annotations);
internal sealed record OciReferrerIndex(
[property: JsonPropertyName("referrers")] IReadOnlyList<OciArtifactDescriptor> Referrers);

View File

@@ -1,13 +1,13 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
public sealed record OciAttestationDocument(
Uri SourceUri,
ReadOnlyMemory<byte> Content,
ImmutableDictionary<string, string> Metadata,
string? SubjectDigest,
string? ArtifactDigest,
string? ArtifactType,
string SourceKind);
using System;
using System.Collections.Immutable;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
public sealed record OciAttestationDocument(
Uri SourceUri,
ReadOnlyMemory<byte> Content,
ImmutableDictionary<string, string> Metadata,
string? SubjectDigest,
string? ArtifactDigest,
string? ArtifactType,
string SourceKind);

View File

@@ -1,258 +1,258 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.IO.Abstractions;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using System.Formats.Tar;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
public sealed class OciAttestationFetcher
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IFileSystem _fileSystem;
private readonly ILogger<OciAttestationFetcher> _logger;
public OciAttestationFetcher(
IHttpClientFactory httpClientFactory,
IFileSystem fileSystem,
ILogger<OciAttestationFetcher> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async IAsyncEnumerable<OciAttestationDocument> FetchAsync(
OciAttestationDiscoveryResult discovery,
OciOpenVexAttestationConnectorOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(discovery);
ArgumentNullException.ThrowIfNull(options);
foreach (var target in discovery.Targets)
{
cancellationToken.ThrowIfCancellationRequested();
bool yieldedOffline = false;
if (target.OfflineBundle is not null && target.OfflineBundle.Exists)
{
await foreach (var offlineDocument in ReadOfflineAsync(target, cancellationToken))
{
yieldedOffline = true;
yield return offlineDocument;
}
if (!discovery.AllowNetworkFallback)
{
continue;
}
}
if (discovery.PreferOffline && yieldedOffline && !discovery.AllowNetworkFallback)
{
continue;
}
if (!discovery.PreferOffline || discovery.AllowNetworkFallback || !yieldedOffline)
{
await foreach (var registryDocument in FetchFromRegistryAsync(discovery, options, target, cancellationToken))
{
yield return registryDocument;
}
}
}
}
private async IAsyncEnumerable<OciAttestationDocument> ReadOfflineAsync(
OciAttestationTarget target,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var offline = target.OfflineBundle!;
var path = _fileSystem.Path.GetFullPath(offline.Path);
if (!_fileSystem.File.Exists(path))
{
if (offline.Exists)
{
_logger.LogWarning("Offline bundle {Path} disappeared before processing.", path);
}
yield break;
}
var extension = _fileSystem.Path.GetExtension(path).ToLowerInvariant();
var subjectDigest = target.Image.Digest ?? target.ExpectedSubjectDigest;
if (string.Equals(extension, ".json", StringComparison.OrdinalIgnoreCase) ||
string.Equals(extension, ".dsse", StringComparison.OrdinalIgnoreCase))
{
var bytes = await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
var metadata = BuildOfflineMetadata(target, path, entryName: null, subjectDigest);
yield return new OciAttestationDocument(
new Uri(path, UriKind.Absolute),
bytes,
metadata,
subjectDigest,
null,
null,
"offline");
yield break;
}
if (string.Equals(extension, ".tgz", StringComparison.OrdinalIgnoreCase) ||
string.Equals(extension, ".gz", StringComparison.OrdinalIgnoreCase) ||
string.Equals(extension, ".tar", StringComparison.OrdinalIgnoreCase))
{
await foreach (var document in ReadTarArchiveAsync(target, path, subjectDigest, cancellationToken))
{
yield return document;
}
yield break;
}
// Default: treat as binary blob.
var fallbackBytes = await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
var fallbackMetadata = BuildOfflineMetadata(target, path, entryName: null, subjectDigest);
yield return new OciAttestationDocument(
new Uri(path, UriKind.Absolute),
fallbackBytes,
fallbackMetadata,
subjectDigest,
null,
null,
"offline");
}
private async IAsyncEnumerable<OciAttestationDocument> ReadTarArchiveAsync(
OciAttestationTarget target,
string path,
string? subjectDigest,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var fileStream = _fileSystem.File.OpenRead(path);
Stream archiveStream = fileStream;
if (path.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
{
archiveStream = new GZipStream(fileStream, CompressionMode.Decompress, leaveOpen: false);
}
using var tarReader = new TarReader(archiveStream, leaveOpen: false);
TarEntry? entry;
while ((entry = await tarReader.GetNextEntryAsync(copyData: false, cancellationToken).ConfigureAwait(false)) is not null)
{
if (entry.EntryType is not TarEntryType.RegularFile || entry.DataStream is null)
{
continue;
}
await using var entryStream = entry.DataStream;
using var buffer = new MemoryStream();
await entryStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
var metadata = BuildOfflineMetadata(target, path, entry.Name, subjectDigest);
var sourceUri = new Uri($"{_fileSystem.Path.GetFullPath(path)}#{entry.Name}", UriKind.Absolute);
yield return new OciAttestationDocument(
sourceUri,
buffer.ToArray(),
metadata,
subjectDigest,
null,
null,
"offline");
}
}
private async IAsyncEnumerable<OciAttestationDocument> FetchFromRegistryAsync(
OciAttestationDiscoveryResult discovery,
OciOpenVexAttestationConnectorOptions options,
OciAttestationTarget target,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var registryClient = new OciRegistryClient(
_httpClientFactory,
_logger,
discovery.RegistryAuthorization,
options);
var subjectDigest = target.Image.Digest ?? target.ExpectedSubjectDigest;
if (string.IsNullOrWhiteSpace(subjectDigest))
{
subjectDigest = await registryClient.ResolveDigestAsync(target.Image, cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(subjectDigest))
{
_logger.LogWarning("Unable to resolve subject digest for {Reference}; skipping registry fetch.", target.Image.Canonical);
yield break;
}
if (!string.IsNullOrWhiteSpace(target.ExpectedSubjectDigest) &&
!string.Equals(target.ExpectedSubjectDigest, subjectDigest, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Resolved digest {Resolved} does not match expected digest {Expected} for {Reference}.",
subjectDigest,
target.ExpectedSubjectDigest,
target.Image.Canonical);
}
var descriptors = await registryClient.ListReferrersAsync(target.Image, subjectDigest, cancellationToken).ConfigureAwait(false);
if (descriptors.Count == 0)
{
yield break;
}
foreach (var descriptor in descriptors)
{
cancellationToken.ThrowIfCancellationRequested();
var document = await registryClient.DownloadAttestationAsync(target.Image, descriptor, subjectDigest, cancellationToken).ConfigureAwait(false);
if (document is not null)
{
yield return document;
}
}
}
private static ImmutableDictionary<string, string> BuildOfflineMetadata(
OciAttestationTarget target,
string bundlePath,
string? entryName,
string? subjectDigest)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["oci.image.registry"] = target.Image.Registry;
builder["oci.image.repository"] = target.Image.Repository;
builder["oci.image.reference"] = target.Image.Canonical;
if (!string.IsNullOrWhiteSpace(subjectDigest))
{
builder["oci.image.subjectDigest"] = subjectDigest;
}
if (!string.IsNullOrWhiteSpace(target.ExpectedSubjectDigest))
{
builder["oci.image.expectedSubjectDigest"] = target.ExpectedSubjectDigest!;
}
builder["oci.attestation.sourceKind"] = "offline";
builder["oci.attestation.source"] = bundlePath;
if (!string.IsNullOrWhiteSpace(entryName))
{
builder["oci.attestation.bundleEntry"] = entryName!;
}
return builder.ToImmutable();
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.IO.Abstractions;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using System.Formats.Tar;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
public sealed class OciAttestationFetcher
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IFileSystem _fileSystem;
private readonly ILogger<OciAttestationFetcher> _logger;
public OciAttestationFetcher(
IHttpClientFactory httpClientFactory,
IFileSystem fileSystem,
ILogger<OciAttestationFetcher> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async IAsyncEnumerable<OciAttestationDocument> FetchAsync(
OciAttestationDiscoveryResult discovery,
OciOpenVexAttestationConnectorOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(discovery);
ArgumentNullException.ThrowIfNull(options);
foreach (var target in discovery.Targets)
{
cancellationToken.ThrowIfCancellationRequested();
bool yieldedOffline = false;
if (target.OfflineBundle is not null && target.OfflineBundle.Exists)
{
await foreach (var offlineDocument in ReadOfflineAsync(target, cancellationToken))
{
yieldedOffline = true;
yield return offlineDocument;
}
if (!discovery.AllowNetworkFallback)
{
continue;
}
}
if (discovery.PreferOffline && yieldedOffline && !discovery.AllowNetworkFallback)
{
continue;
}
if (!discovery.PreferOffline || discovery.AllowNetworkFallback || !yieldedOffline)
{
await foreach (var registryDocument in FetchFromRegistryAsync(discovery, options, target, cancellationToken))
{
yield return registryDocument;
}
}
}
}
private async IAsyncEnumerable<OciAttestationDocument> ReadOfflineAsync(
OciAttestationTarget target,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var offline = target.OfflineBundle!;
var path = _fileSystem.Path.GetFullPath(offline.Path);
if (!_fileSystem.File.Exists(path))
{
if (offline.Exists)
{
_logger.LogWarning("Offline bundle {Path} disappeared before processing.", path);
}
yield break;
}
var extension = _fileSystem.Path.GetExtension(path).ToLowerInvariant();
var subjectDigest = target.Image.Digest ?? target.ExpectedSubjectDigest;
if (string.Equals(extension, ".json", StringComparison.OrdinalIgnoreCase) ||
string.Equals(extension, ".dsse", StringComparison.OrdinalIgnoreCase))
{
var bytes = await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
var metadata = BuildOfflineMetadata(target, path, entryName: null, subjectDigest);
yield return new OciAttestationDocument(
new Uri(path, UriKind.Absolute),
bytes,
metadata,
subjectDigest,
null,
null,
"offline");
yield break;
}
if (string.Equals(extension, ".tgz", StringComparison.OrdinalIgnoreCase) ||
string.Equals(extension, ".gz", StringComparison.OrdinalIgnoreCase) ||
string.Equals(extension, ".tar", StringComparison.OrdinalIgnoreCase))
{
await foreach (var document in ReadTarArchiveAsync(target, path, subjectDigest, cancellationToken))
{
yield return document;
}
yield break;
}
// Default: treat as binary blob.
var fallbackBytes = await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
var fallbackMetadata = BuildOfflineMetadata(target, path, entryName: null, subjectDigest);
yield return new OciAttestationDocument(
new Uri(path, UriKind.Absolute),
fallbackBytes,
fallbackMetadata,
subjectDigest,
null,
null,
"offline");
}
private async IAsyncEnumerable<OciAttestationDocument> ReadTarArchiveAsync(
OciAttestationTarget target,
string path,
string? subjectDigest,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var fileStream = _fileSystem.File.OpenRead(path);
Stream archiveStream = fileStream;
if (path.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
{
archiveStream = new GZipStream(fileStream, CompressionMode.Decompress, leaveOpen: false);
}
using var tarReader = new TarReader(archiveStream, leaveOpen: false);
TarEntry? entry;
while ((entry = await tarReader.GetNextEntryAsync(copyData: false, cancellationToken).ConfigureAwait(false)) is not null)
{
if (entry.EntryType is not TarEntryType.RegularFile || entry.DataStream is null)
{
continue;
}
await using var entryStream = entry.DataStream;
using var buffer = new MemoryStream();
await entryStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
var metadata = BuildOfflineMetadata(target, path, entry.Name, subjectDigest);
var sourceUri = new Uri($"{_fileSystem.Path.GetFullPath(path)}#{entry.Name}", UriKind.Absolute);
yield return new OciAttestationDocument(
sourceUri,
buffer.ToArray(),
metadata,
subjectDigest,
null,
null,
"offline");
}
}
private async IAsyncEnumerable<OciAttestationDocument> FetchFromRegistryAsync(
OciAttestationDiscoveryResult discovery,
OciOpenVexAttestationConnectorOptions options,
OciAttestationTarget target,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var registryClient = new OciRegistryClient(
_httpClientFactory,
_logger,
discovery.RegistryAuthorization,
options);
var subjectDigest = target.Image.Digest ?? target.ExpectedSubjectDigest;
if (string.IsNullOrWhiteSpace(subjectDigest))
{
subjectDigest = await registryClient.ResolveDigestAsync(target.Image, cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(subjectDigest))
{
_logger.LogWarning("Unable to resolve subject digest for {Reference}; skipping registry fetch.", target.Image.Canonical);
yield break;
}
if (!string.IsNullOrWhiteSpace(target.ExpectedSubjectDigest) &&
!string.Equals(target.ExpectedSubjectDigest, subjectDigest, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Resolved digest {Resolved} does not match expected digest {Expected} for {Reference}.",
subjectDigest,
target.ExpectedSubjectDigest,
target.Image.Canonical);
}
var descriptors = await registryClient.ListReferrersAsync(target.Image, subjectDigest, cancellationToken).ConfigureAwait(false);
if (descriptors.Count == 0)
{
yield break;
}
foreach (var descriptor in descriptors)
{
cancellationToken.ThrowIfCancellationRequested();
var document = await registryClient.DownloadAttestationAsync(target.Image, descriptor, subjectDigest, cancellationToken).ConfigureAwait(false);
if (document is not null)
{
yield return document;
}
}
}
private static ImmutableDictionary<string, string> BuildOfflineMetadata(
OciAttestationTarget target,
string bundlePath,
string? entryName,
string? subjectDigest)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["oci.image.registry"] = target.Image.Registry;
builder["oci.image.repository"] = target.Image.Repository;
builder["oci.image.reference"] = target.Image.Canonical;
if (!string.IsNullOrWhiteSpace(subjectDigest))
{
builder["oci.image.subjectDigest"] = subjectDigest;
}
if (!string.IsNullOrWhiteSpace(target.ExpectedSubjectDigest))
{
builder["oci.image.expectedSubjectDigest"] = target.ExpectedSubjectDigest!;
}
builder["oci.attestation.sourceKind"] = "offline";
builder["oci.attestation.source"] = bundlePath;
if (!string.IsNullOrWhiteSpace(entryName))
{
builder["oci.attestation.bundleEntry"] = entryName!;
}
return builder.ToImmutable();
}
}

View File

@@ -1,362 +1,362 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
internal sealed class OciRegistryClient
{
private const string ManifestMediaType = "application/vnd.oci.image.manifest.v1+json";
private const string ReferrersArtifactType = "application/vnd.dsse.envelope.v1+json";
private const string DsseMediaType = "application/vnd.dsse.envelope.v1+json";
private const string OpenVexMediaType = "application/vnd.cncf.openvex.v1+json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _logger;
private readonly OciRegistryAuthorization _authorization;
private readonly OciOpenVexAttestationConnectorOptions _options;
public OciRegistryClient(
IHttpClientFactory httpClientFactory,
ILogger logger,
OciRegistryAuthorization authorization,
OciOpenVexAttestationConnectorOptions options)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public async Task<string?> ResolveDigestAsync(OciImageReference image, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(image);
if (image.HasDigest)
{
return image.Digest;
}
var requestUri = BuildRegistryUri(image, $"manifests/{EscapeReference(image.Tag ?? "latest")}");
async Task<HttpRequestMessage> RequestFactory()
{
var request = new HttpRequestMessage(HttpMethod.Head, requestUri);
request.Headers.Accept.ParseAdd(ManifestMediaType);
ApplyAuthentication(request);
return await Task.FromResult(request).ConfigureAwait(false);
}
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogWarning("Failed to resolve digest for {Reference}; registry returned 404.", image.Canonical);
return null;
}
response.EnsureSuccessStatusCode();
}
if (response.Headers.TryGetValues("Docker-Content-Digest", out var values))
{
var digest = values.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
}
// Manifest may have been returned without digest header; fall back to GET.
async Task<HttpRequestMessage> ManifestRequestFactory()
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Accept.ParseAdd(ManifestMediaType);
ApplyAuthentication(request);
return await Task.FromResult(request).ConfigureAwait(false);
}
using var manifestResponse = await SendAsync(ManifestRequestFactory, cancellationToken).ConfigureAwait(false);
manifestResponse.EnsureSuccessStatusCode();
if (manifestResponse.Headers.TryGetValues("Docker-Content-Digest", out var manifestValues))
{
return manifestValues.FirstOrDefault();
}
_logger.LogWarning("Registry {Registry} did not provide Docker-Content-Digest header for {Reference}.", image.Registry, image.Canonical);
return null;
}
public async Task<IReadOnlyList<OciArtifactDescriptor>> ListReferrersAsync(
OciImageReference image,
string subjectDigest,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(image);
ArgumentNullException.ThrowIfNull(subjectDigest);
var query = $"artifactType={Uri.EscapeDataString(ReferrersArtifactType)}";
var requestUri = BuildRegistryUri(image, $"referrers/{subjectDigest}", query);
async Task<HttpRequestMessage> RequestFactory()
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
ApplyAuthentication(request);
request.Headers.Accept.ParseAdd("application/json");
return await Task.FromResult(request).ConfigureAwait(false);
}
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("Registry returned 404 for referrers on {Subject}.", subjectDigest);
return Array.Empty<OciArtifactDescriptor>();
}
response.EnsureSuccessStatusCode();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var index = await JsonSerializer.DeserializeAsync<OciReferrerIndex>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
return index?.Referrers ?? Array.Empty<OciArtifactDescriptor>();
}
public async Task<OciAttestationDocument?> DownloadAttestationAsync(
OciImageReference image,
OciArtifactDescriptor descriptor,
string subjectDigest,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(image);
ArgumentNullException.ThrowIfNull(descriptor);
if (!IsSupportedDescriptor(descriptor))
{
return null;
}
var requestUri = BuildRegistryUri(image, $"blobs/{descriptor.Digest}");
async Task<HttpRequestMessage> RequestFactory()
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
ApplyAuthentication(request);
request.Headers.Accept.ParseAdd(descriptor.MediaType ?? "application/octet-stream");
return await Task.FromResult(request).ConfigureAwait(false);
}
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogWarning("Registry returned 404 while downloading attestation {Digest} for {Subject}.", descriptor.Digest, subjectDigest);
return null;
}
response.EnsureSuccessStatusCode();
}
var buffer = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var metadata = BuildMetadata(image, descriptor, "registry", requestUri.ToString(), subjectDigest);
return new OciAttestationDocument(
requestUri,
buffer,
metadata,
subjectDigest,
descriptor.Digest,
descriptor.ArtifactType,
"registry");
}
private static bool IsSupportedDescriptor(OciArtifactDescriptor descriptor)
{
if (descriptor is null)
{
return false;
}
if (!string.IsNullOrWhiteSpace(descriptor.ArtifactType) &&
descriptor.ArtifactType.Equals(OpenVexMediaType, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.IsNullOrWhiteSpace(descriptor.MediaType) &&
(descriptor.MediaType.Equals(DsseMediaType, StringComparison.OrdinalIgnoreCase) ||
descriptor.MediaType.Equals(OpenVexMediaType, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
return false;
}
private async Task<HttpResponseMessage> SendAsync(
Func<Task<HttpRequestMessage>> requestFactory,
CancellationToken cancellationToken)
{
const int maxAttempts = 3;
TimeSpan delay = TimeSpan.FromSeconds(1);
Exception? lastError = null;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
using var request = await requestFactory().ConfigureAwait(false);
var client = _httpClientFactory.CreateClient(OciOpenVexAttestationConnectorOptions.HttpClientName);
try
{
var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return response;
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (_authorization.Mode == OciRegistryAuthMode.Anonymous && !_authorization.AllowAnonymousFallback)
{
var message = $"Registry request to {request.RequestUri} was unauthorized and anonymous fallback is disabled.";
response.Dispose();
throw new HttpRequestException(message);
}
lastError = new HttpRequestException($"Registry returned 401 Unauthorized for {request.RequestUri}.");
}
else if ((int)response.StatusCode >= 500 || response.StatusCode == (HttpStatusCode)429)
{
lastError = new HttpRequestException($"Registry returned status {(int)response.StatusCode} ({response.ReasonPhrase}) for {request.RequestUri}.");
}
else
{
response.EnsureSuccessStatusCode();
}
response.Dispose();
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
lastError = ex;
}
if (attempt < maxAttempts)
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 10));
}
}
throw new HttpRequestException("Failed to execute OCI registry request after multiple attempts.", lastError);
}
private void ApplyAuthentication(HttpRequestMessage request)
{
switch (_authorization.Mode)
{
case OciRegistryAuthMode.Basic when
!string.IsNullOrEmpty(_authorization.Username) &&
!string.IsNullOrEmpty(_authorization.Password):
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_authorization.Username}:{_authorization.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
break;
case OciRegistryAuthMode.IdentityToken when !string.IsNullOrWhiteSpace(_authorization.IdentityToken):
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authorization.IdentityToken);
break;
case OciRegistryAuthMode.RefreshToken when !string.IsNullOrWhiteSpace(_authorization.RefreshToken):
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authorization.RefreshToken);
break;
default:
if (_authorization.Mode != OciRegistryAuthMode.Anonymous && !_authorization.AllowAnonymousFallback)
{
_logger.LogDebug("No authentication header applied for request to {Uri} (mode {Mode}).", request.RequestUri, _authorization.Mode);
}
break;
}
}
private Uri BuildRegistryUri(OciImageReference image, string relativePath, string? query = null)
{
var scheme = image.Scheme;
if (!string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) && !_options.AllowHttpRegistries)
{
throw new InvalidOperationException($"HTTP access to registry '{image.Registry}' is disabled. Set AllowHttpRegistries to true to enable.");
}
var builder = new UriBuilder($"{scheme}://{image.Registry}")
{
Path = $"v2/{BuildRepositoryPath(image.Repository)}/{relativePath}"
};
if (!string.IsNullOrWhiteSpace(query))
{
builder.Query = query;
}
return builder.Uri;
}
private static string BuildRepositoryPath(string repository)
{
var segments = repository.Split('/', StringSplitOptions.RemoveEmptyEntries);
return string.Join('/', segments.Select(Uri.EscapeDataString));
}
private static string EscapeReference(string reference)
{
return Uri.EscapeDataString(reference);
}
private static ImmutableDictionary<string, string> BuildMetadata(
OciImageReference image,
OciArtifactDescriptor descriptor,
string sourceKind,
string sourcePath,
string subjectDigest)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["oci.image.registry"] = image.Registry;
builder["oci.image.repository"] = image.Repository;
builder["oci.image.reference"] = image.Canonical;
builder["oci.image.subjectDigest"] = subjectDigest;
builder["oci.attestation.sourceKind"] = sourceKind;
builder["oci.attestation.source"] = sourcePath;
builder["oci.attestation.artifactDigest"] = descriptor.Digest;
builder["oci.attestation.mediaType"] = descriptor.MediaType ?? string.Empty;
builder["oci.attestation.artifactType"] = descriptor.ArtifactType ?? string.Empty;
builder["oci.attestation.size"] = descriptor.Size.ToString(CultureInfo.InvariantCulture);
if (descriptor.Annotations is not null)
{
foreach (var annotation in descriptor.Annotations)
{
builder[$"oci.attestation.annotations.{annotation.Key}"] = annotation.Value;
}
}
return builder.ToImmutable();
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
internal sealed class OciRegistryClient
{
private const string ManifestMediaType = "application/vnd.oci.image.manifest.v1+json";
private const string ReferrersArtifactType = "application/vnd.dsse.envelope.v1+json";
private const string DsseMediaType = "application/vnd.dsse.envelope.v1+json";
private const string OpenVexMediaType = "application/vnd.cncf.openvex.v1+json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _logger;
private readonly OciRegistryAuthorization _authorization;
private readonly OciOpenVexAttestationConnectorOptions _options;
public OciRegistryClient(
IHttpClientFactory httpClientFactory,
ILogger logger,
OciRegistryAuthorization authorization,
OciOpenVexAttestationConnectorOptions options)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public async Task<string?> ResolveDigestAsync(OciImageReference image, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(image);
if (image.HasDigest)
{
return image.Digest;
}
var requestUri = BuildRegistryUri(image, $"manifests/{EscapeReference(image.Tag ?? "latest")}");
async Task<HttpRequestMessage> RequestFactory()
{
var request = new HttpRequestMessage(HttpMethod.Head, requestUri);
request.Headers.Accept.ParseAdd(ManifestMediaType);
ApplyAuthentication(request);
return await Task.FromResult(request).ConfigureAwait(false);
}
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogWarning("Failed to resolve digest for {Reference}; registry returned 404.", image.Canonical);
return null;
}
response.EnsureSuccessStatusCode();
}
if (response.Headers.TryGetValues("Docker-Content-Digest", out var values))
{
var digest = values.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
}
// Manifest may have been returned without digest header; fall back to GET.
async Task<HttpRequestMessage> ManifestRequestFactory()
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Accept.ParseAdd(ManifestMediaType);
ApplyAuthentication(request);
return await Task.FromResult(request).ConfigureAwait(false);
}
using var manifestResponse = await SendAsync(ManifestRequestFactory, cancellationToken).ConfigureAwait(false);
manifestResponse.EnsureSuccessStatusCode();
if (manifestResponse.Headers.TryGetValues("Docker-Content-Digest", out var manifestValues))
{
return manifestValues.FirstOrDefault();
}
_logger.LogWarning("Registry {Registry} did not provide Docker-Content-Digest header for {Reference}.", image.Registry, image.Canonical);
return null;
}
public async Task<IReadOnlyList<OciArtifactDescriptor>> ListReferrersAsync(
OciImageReference image,
string subjectDigest,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(image);
ArgumentNullException.ThrowIfNull(subjectDigest);
var query = $"artifactType={Uri.EscapeDataString(ReferrersArtifactType)}";
var requestUri = BuildRegistryUri(image, $"referrers/{subjectDigest}", query);
async Task<HttpRequestMessage> RequestFactory()
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
ApplyAuthentication(request);
request.Headers.Accept.ParseAdd("application/json");
return await Task.FromResult(request).ConfigureAwait(false);
}
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("Registry returned 404 for referrers on {Subject}.", subjectDigest);
return Array.Empty<OciArtifactDescriptor>();
}
response.EnsureSuccessStatusCode();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var index = await JsonSerializer.DeserializeAsync<OciReferrerIndex>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
return index?.Referrers ?? Array.Empty<OciArtifactDescriptor>();
}
public async Task<OciAttestationDocument?> DownloadAttestationAsync(
OciImageReference image,
OciArtifactDescriptor descriptor,
string subjectDigest,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(image);
ArgumentNullException.ThrowIfNull(descriptor);
if (!IsSupportedDescriptor(descriptor))
{
return null;
}
var requestUri = BuildRegistryUri(image, $"blobs/{descriptor.Digest}");
async Task<HttpRequestMessage> RequestFactory()
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
ApplyAuthentication(request);
request.Headers.Accept.ParseAdd(descriptor.MediaType ?? "application/octet-stream");
return await Task.FromResult(request).ConfigureAwait(false);
}
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogWarning("Registry returned 404 while downloading attestation {Digest} for {Subject}.", descriptor.Digest, subjectDigest);
return null;
}
response.EnsureSuccessStatusCode();
}
var buffer = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var metadata = BuildMetadata(image, descriptor, "registry", requestUri.ToString(), subjectDigest);
return new OciAttestationDocument(
requestUri,
buffer,
metadata,
subjectDigest,
descriptor.Digest,
descriptor.ArtifactType,
"registry");
}
private static bool IsSupportedDescriptor(OciArtifactDescriptor descriptor)
{
if (descriptor is null)
{
return false;
}
if (!string.IsNullOrWhiteSpace(descriptor.ArtifactType) &&
descriptor.ArtifactType.Equals(OpenVexMediaType, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.IsNullOrWhiteSpace(descriptor.MediaType) &&
(descriptor.MediaType.Equals(DsseMediaType, StringComparison.OrdinalIgnoreCase) ||
descriptor.MediaType.Equals(OpenVexMediaType, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
return false;
}
private async Task<HttpResponseMessage> SendAsync(
Func<Task<HttpRequestMessage>> requestFactory,
CancellationToken cancellationToken)
{
const int maxAttempts = 3;
TimeSpan delay = TimeSpan.FromSeconds(1);
Exception? lastError = null;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
using var request = await requestFactory().ConfigureAwait(false);
var client = _httpClientFactory.CreateClient(OciOpenVexAttestationConnectorOptions.HttpClientName);
try
{
var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return response;
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (_authorization.Mode == OciRegistryAuthMode.Anonymous && !_authorization.AllowAnonymousFallback)
{
var message = $"Registry request to {request.RequestUri} was unauthorized and anonymous fallback is disabled.";
response.Dispose();
throw new HttpRequestException(message);
}
lastError = new HttpRequestException($"Registry returned 401 Unauthorized for {request.RequestUri}.");
}
else if ((int)response.StatusCode >= 500 || response.StatusCode == (HttpStatusCode)429)
{
lastError = new HttpRequestException($"Registry returned status {(int)response.StatusCode} ({response.ReasonPhrase}) for {request.RequestUri}.");
}
else
{
response.EnsureSuccessStatusCode();
}
response.Dispose();
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
lastError = ex;
}
if (attempt < maxAttempts)
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 10));
}
}
throw new HttpRequestException("Failed to execute OCI registry request after multiple attempts.", lastError);
}
private void ApplyAuthentication(HttpRequestMessage request)
{
switch (_authorization.Mode)
{
case OciRegistryAuthMode.Basic when
!string.IsNullOrEmpty(_authorization.Username) &&
!string.IsNullOrEmpty(_authorization.Password):
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_authorization.Username}:{_authorization.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
break;
case OciRegistryAuthMode.IdentityToken when !string.IsNullOrWhiteSpace(_authorization.IdentityToken):
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authorization.IdentityToken);
break;
case OciRegistryAuthMode.RefreshToken when !string.IsNullOrWhiteSpace(_authorization.RefreshToken):
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authorization.RefreshToken);
break;
default:
if (_authorization.Mode != OciRegistryAuthMode.Anonymous && !_authorization.AllowAnonymousFallback)
{
_logger.LogDebug("No authentication header applied for request to {Uri} (mode {Mode}).", request.RequestUri, _authorization.Mode);
}
break;
}
}
private Uri BuildRegistryUri(OciImageReference image, string relativePath, string? query = null)
{
var scheme = image.Scheme;
if (!string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) && !_options.AllowHttpRegistries)
{
throw new InvalidOperationException($"HTTP access to registry '{image.Registry}' is disabled. Set AllowHttpRegistries to true to enable.");
}
var builder = new UriBuilder($"{scheme}://{image.Registry}")
{
Path = $"v2/{BuildRepositoryPath(image.Repository)}/{relativePath}"
};
if (!string.IsNullOrWhiteSpace(query))
{
builder.Query = query;
}
return builder.Uri;
}
private static string BuildRepositoryPath(string repository)
{
var segments = repository.Split('/', StringSplitOptions.RemoveEmptyEntries);
return string.Join('/', segments.Select(Uri.EscapeDataString));
}
private static string EscapeReference(string reference)
{
return Uri.EscapeDataString(reference);
}
private static ImmutableDictionary<string, string> BuildMetadata(
OciImageReference image,
OciArtifactDescriptor descriptor,
string sourceKind,
string sourcePath,
string subjectDigest)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["oci.image.registry"] = image.Registry;
builder["oci.image.repository"] = image.Repository;
builder["oci.image.reference"] = image.Canonical;
builder["oci.image.subjectDigest"] = subjectDigest;
builder["oci.attestation.sourceKind"] = sourceKind;
builder["oci.attestation.source"] = sourcePath;
builder["oci.attestation.artifactDigest"] = descriptor.Digest;
builder["oci.attestation.mediaType"] = descriptor.MediaType ?? string.Empty;
builder["oci.attestation.artifactType"] = descriptor.ArtifactType ?? string.Empty;
builder["oci.attestation.size"] = descriptor.Size.ToString(CultureInfo.InvariantCulture);
if (descriptor.Annotations is not null)
{
foreach (var annotation in descriptor.Annotations)
{
builder[$"oci.attestation.annotations.{annotation.Key}"] = annotation.Value;
}
}
return builder.ToImmutable();
}
}

View File

@@ -8,211 +8,211 @@ 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;
public sealed class OciOpenVexAttestationConnector : VexConnectorBase
{
private static readonly VexConnectorDescriptor StaticDescriptor = new(
id: "excititor:oci.openvex.attest",
kind: VexProviderKind.Attestation,
displayName: "OCI OpenVEX Attestations")
{
Tags = ImmutableArray.Create("oci", "openvex", "attestation", "cosign", "offline"),
};
private readonly OciAttestationDiscoveryService _discoveryService;
private readonly OciAttestationFetcher _fetcher;
private readonly IEnumerable<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>> _validators;
private OciOpenVexAttestationConnectorOptions? _options;
private OciAttestationDiscoveryResult? _discovery;
public OciOpenVexAttestationConnector(
OciAttestationDiscoveryService discoveryService,
OciAttestationFetcher fetcher,
ILogger<OciOpenVexAttestationConnector> logger,
TimeProvider timeProvider,
IEnumerable<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>>? validators = null)
: base(StaticDescriptor, logger, timeProvider)
{
_discoveryService = discoveryService ?? throw new ArgumentNullException(nameof(discoveryService));
_fetcher = fetcher ?? throw new ArgumentNullException(nameof(fetcher));
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>>();
}
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
_options = VexConnectorOptionsBinder.Bind(
Descriptor,
settings,
validators: _validators);
_discovery = await _discoveryService.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Information, "validate", "Resolved OCI attestation targets.", new Dictionary<string, object?>
{
["targets"] = _discovery.Targets.Length,
["offlinePreferred"] = _discovery.PreferOffline,
["allowNetworkFallback"] = _discovery.AllowNetworkFallback,
["authMode"] = _discovery.RegistryAuthorization.Mode.ToString(),
["cosignMode"] = _discovery.CosignAuthority.Mode.ToString(),
});
}
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (_options is null)
{
throw new InvalidOperationException("Connector must be validated before fetch operations.");
}
if (_discovery is null)
{
_discovery = await _discoveryService.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
}
var documentCount = 0;
await foreach (var document in _fetcher.FetchAsync(_discovery, _options, cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
var verificationDocument = CreateRawDocument(
VexDocumentFormat.OciAttestation,
document.SourceUri,
document.Content,
document.Metadata);
var signatureMetadata = await context.SignatureVerifier.VerifyAsync(verificationDocument, cancellationToken).ConfigureAwait(false);
if (signatureMetadata is not null)
{
LogConnectorEvent(LogLevel.Debug, "signature", "Signature metadata captured for attestation.", new Dictionary<string, object?>
{
["subject"] = signatureMetadata.Subject,
["type"] = signatureMetadata.Type,
});
}
var enrichedMetadata = BuildProvenanceMetadata(document, signatureMetadata);
var rawDocument = CreateRawDocument(
VexDocumentFormat.OciAttestation,
document.SourceUri,
document.Content,
enrichedMetadata);
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
documentCount++;
yield return rawDocument;
}
LogConnectorEvent(LogLevel.Information, "fetch", "OCI attestation fetch completed.", new Dictionary<string, object?>
{
["documents"] = documentCount,
["since"] = context.Since?.ToString("O"),
});
}
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> throw new NotSupportedException("Attestation documents rely on dedicated normalizers, to be wired in EXCITITOR-CONN-OCI-01-002.");
public OciAttestationDiscoveryResult? GetCachedDiscovery() => _discovery;
private ImmutableDictionary<string, string> BuildProvenanceMetadata(OciAttestationDocument document, VexSignatureMetadata? signature)
{
var builder = document.Metadata.ToBuilder();
if (!string.IsNullOrWhiteSpace(document.SourceKind))
{
builder["vex.provenance.sourceKind"] = document.SourceKind;
}
if (!string.IsNullOrWhiteSpace(document.SubjectDigest))
{
builder["vex.provenance.subjectDigest"] = document.SubjectDigest!;
}
if (!string.IsNullOrWhiteSpace(document.ArtifactDigest))
{
builder["vex.provenance.artifactDigest"] = document.ArtifactDigest!;
}
if (!string.IsNullOrWhiteSpace(document.ArtifactType))
{
builder["vex.provenance.artifactType"] = document.ArtifactType!;
}
if (_discovery is not null)
{
builder["vex.provenance.registryAuthMode"] = _discovery.RegistryAuthorization.Mode.ToString();
var registryAuthority = _discovery.RegistryAuthorization.RegistryAuthority;
if (string.IsNullOrWhiteSpace(registryAuthority))
{
if (builder.TryGetValue("oci.image.registry", out var metadataRegistry) && !string.IsNullOrWhiteSpace(metadataRegistry))
{
registryAuthority = metadataRegistry;
}
}
if (!string.IsNullOrWhiteSpace(registryAuthority))
{
builder["vex.provenance.registryAuthority"] = registryAuthority!;
}
builder["vex.provenance.cosign.mode"] = _discovery.CosignAuthority.Mode.ToString();
if (_discovery.CosignAuthority.Keyless is not null)
{
var keyless = _discovery.CosignAuthority.Keyless;
builder["vex.provenance.cosign.issuer"] = keyless!.Issuer;
builder["vex.provenance.cosign.subject"] = keyless.Subject;
if (keyless.FulcioUrl is not null)
{
builder["vex.provenance.cosign.fulcioUrl"] = keyless.FulcioUrl!.ToString();
}
if (keyless.RekorUrl is not null)
{
builder["vex.provenance.cosign.rekorUrl"] = keyless.RekorUrl!.ToString();
}
}
else if (_discovery.CosignAuthority.KeyPair is not null)
{
var keyPair = _discovery.CosignAuthority.KeyPair;
builder["vex.provenance.cosign.keyPair"] = "true";
if (keyPair!.RekorUrl is not null)
{
builder["vex.provenance.cosign.rekorUrl"] = keyPair.RekorUrl!.ToString();
}
}
}
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
public sealed class OciOpenVexAttestationConnector : VexConnectorBase
{
private static readonly VexConnectorDescriptor StaticDescriptor = new(
id: "excititor:oci.openvex.attest",
kind: VexProviderKind.Attestation,
displayName: "OCI OpenVEX Attestations")
{
Tags = ImmutableArray.Create("oci", "openvex", "attestation", "cosign", "offline"),
};
private readonly OciAttestationDiscoveryService _discoveryService;
private readonly OciAttestationFetcher _fetcher;
private readonly IEnumerable<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>> _validators;
private OciOpenVexAttestationConnectorOptions? _options;
private OciAttestationDiscoveryResult? _discovery;
public OciOpenVexAttestationConnector(
OciAttestationDiscoveryService discoveryService,
OciAttestationFetcher fetcher,
ILogger<OciOpenVexAttestationConnector> logger,
TimeProvider timeProvider,
IEnumerable<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>>? validators = null)
: base(StaticDescriptor, logger, timeProvider)
{
_discoveryService = discoveryService ?? throw new ArgumentNullException(nameof(discoveryService));
_fetcher = fetcher ?? throw new ArgumentNullException(nameof(fetcher));
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>>();
}
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
_options = VexConnectorOptionsBinder.Bind(
Descriptor,
settings,
validators: _validators);
_discovery = await _discoveryService.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Information, "validate", "Resolved OCI attestation targets.", new Dictionary<string, object?>
{
["targets"] = _discovery.Targets.Length,
["offlinePreferred"] = _discovery.PreferOffline,
["allowNetworkFallback"] = _discovery.AllowNetworkFallback,
["authMode"] = _discovery.RegistryAuthorization.Mode.ToString(),
["cosignMode"] = _discovery.CosignAuthority.Mode.ToString(),
});
}
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (_options is null)
{
throw new InvalidOperationException("Connector must be validated before fetch operations.");
}
if (_discovery is null)
{
_discovery = await _discoveryService.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
}
var documentCount = 0;
await foreach (var document in _fetcher.FetchAsync(_discovery, _options, cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
var verificationDocument = CreateRawDocument(
VexDocumentFormat.OciAttestation,
document.SourceUri,
document.Content,
document.Metadata);
var signatureMetadata = await context.SignatureVerifier.VerifyAsync(verificationDocument, cancellationToken).ConfigureAwait(false);
if (signatureMetadata is not null)
{
LogConnectorEvent(LogLevel.Debug, "signature", "Signature metadata captured for attestation.", new Dictionary<string, object?>
{
["subject"] = signatureMetadata.Subject,
["type"] = signatureMetadata.Type,
});
}
var enrichedMetadata = BuildProvenanceMetadata(document, signatureMetadata);
var rawDocument = CreateRawDocument(
VexDocumentFormat.OciAttestation,
document.SourceUri,
document.Content,
enrichedMetadata);
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
documentCount++;
yield return rawDocument;
}
LogConnectorEvent(LogLevel.Information, "fetch", "OCI attestation fetch completed.", new Dictionary<string, object?>
{
["documents"] = documentCount,
["since"] = context.Since?.ToString("O"),
});
}
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> throw new NotSupportedException("Attestation documents rely on dedicated normalizers, to be wired in EXCITITOR-CONN-OCI-01-002.");
public OciAttestationDiscoveryResult? GetCachedDiscovery() => _discovery;
private ImmutableDictionary<string, string> BuildProvenanceMetadata(OciAttestationDocument document, VexSignatureMetadata? signature)
{
var builder = document.Metadata.ToBuilder();
if (!string.IsNullOrWhiteSpace(document.SourceKind))
{
builder["vex.provenance.sourceKind"] = document.SourceKind;
}
if (!string.IsNullOrWhiteSpace(document.SubjectDigest))
{
builder["vex.provenance.subjectDigest"] = document.SubjectDigest!;
}
if (!string.IsNullOrWhiteSpace(document.ArtifactDigest))
{
builder["vex.provenance.artifactDigest"] = document.ArtifactDigest!;
}
if (!string.IsNullOrWhiteSpace(document.ArtifactType))
{
builder["vex.provenance.artifactType"] = document.ArtifactType!;
}
if (_discovery is not null)
{
builder["vex.provenance.registryAuthMode"] = _discovery.RegistryAuthorization.Mode.ToString();
var registryAuthority = _discovery.RegistryAuthorization.RegistryAuthority;
if (string.IsNullOrWhiteSpace(registryAuthority))
{
if (builder.TryGetValue("oci.image.registry", out var metadataRegistry) && !string.IsNullOrWhiteSpace(metadataRegistry))
{
registryAuthority = metadataRegistry;
}
}
if (!string.IsNullOrWhiteSpace(registryAuthority))
{
builder["vex.provenance.registryAuthority"] = registryAuthority!;
}
builder["vex.provenance.cosign.mode"] = _discovery.CosignAuthority.Mode.ToString();
if (_discovery.CosignAuthority.Keyless is not null)
{
var keyless = _discovery.CosignAuthority.Keyless;
builder["vex.provenance.cosign.issuer"] = keyless!.Issuer;
builder["vex.provenance.cosign.subject"] = keyless.Subject;
if (keyless.FulcioUrl is not null)
{
builder["vex.provenance.cosign.fulcioUrl"] = keyless.FulcioUrl!.ToString();
}
if (keyless.RekorUrl is not null)
{
builder["vex.provenance.cosign.rekorUrl"] = keyless.RekorUrl!.ToString();
}
}
else if (_discovery.CosignAuthority.KeyPair is not null)
{
var keyPair = _discovery.CosignAuthority.KeyPair;
builder["vex.provenance.cosign.keyPair"] = "true";
if (keyPair!.RekorUrl is not null)
{
builder["vex.provenance.cosign.rekorUrl"] = keyPair.RekorUrl!.ToString();
}
}
}
if (signature is not null)
{
builder["vex.signature.type"] = signature.Type;
if (!string.IsNullOrWhiteSpace(signature.Subject))
{
builder["vex.signature.subject"] = signature.Subject!;
}
if (!string.IsNullOrWhiteSpace(signature.Issuer))
{
builder["vex.signature.issuer"] = signature.Issuer!;
}
if (!string.IsNullOrWhiteSpace(signature.KeyId))
{
builder["vex.signature.keyId"] = signature.KeyId!;
}
if (signature.VerifiedAt is not null)
{
builder["vex.signature.verifiedAt"] = signature.VerifiedAt.Value.ToString("O");
}
if (!string.IsNullOrWhiteSpace(signature.TransparencyLogReference))
builder["vex.signature.subject"] = signature.Subject!;
}
if (!string.IsNullOrWhiteSpace(signature.Issuer))
{
builder["vex.signature.issuer"] = signature.Issuer!;
}
if (!string.IsNullOrWhiteSpace(signature.KeyId))
{
builder["vex.signature.keyId"] = signature.KeyId!;
}
if (signature.VerifiedAt is not null)
{
builder["vex.signature.verifiedAt"] = signature.VerifiedAt.Value.ToString("O");
}
if (!string.IsNullOrWhiteSpace(signature.TransparencyLogReference))
{
builder["vex.signature.transparencyLogReference"] = signature.TransparencyLogReference!;
}

View File

@@ -1,85 +1,85 @@
using System;
using System.IO;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
public sealed class OracleConnectorOptions
{
public const string HttpClientName = "excititor.connector.oracle.catalog";
/// <summary>
/// Oracle CSAF catalog endpoint hosting advisory metadata.
/// </summary>
public Uri CatalogUri { get; set; } = new("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json");
/// <summary>
/// Optional CPU calendar endpoint providing upcoming release dates.
/// </summary>
public Uri? CpuCalendarUri { get; set; }
/// <summary>
/// Duration the discovery metadata should be cached before refresh.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(6);
/// <summary>
/// When true, the loader will prefer offline snapshot data over network fetches.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// Optional file path for persisting or ingesting catalog snapshots.
/// </summary>
public string? OfflineSnapshotPath { get; set; }
/// <summary>
/// Enables writing fresh catalog responses to <see cref="OfflineSnapshotPath"/>.
/// </summary>
public bool PersistOfflineSnapshot { get; set; } = true;
/// <summary>
/// Optional request delay when iterating catalogue entries (for rate limiting).
/// </summary>
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
public void Validate(IFileSystem? fileSystem = null)
{
if (CatalogUri is null || !CatalogUri.IsAbsoluteUri)
{
throw new InvalidOperationException("CatalogUri must be an absolute URI.");
}
if (CatalogUri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("CatalogUri must use HTTP or HTTPS.");
}
if (CpuCalendarUri is not null && (!CpuCalendarUri.IsAbsoluteUri || CpuCalendarUri.Scheme is not ("http" or "https")))
{
throw new InvalidOperationException("CpuCalendarUri must be an absolute HTTP(S) URI when provided.");
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("MetadataCacheDuration must be positive.");
}
if (RequestDelay < TimeSpan.Zero)
{
throw new InvalidOperationException("RequestDelay cannot be negative.");
}
if (PreferOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
throw new InvalidOperationException("OfflineSnapshotPath must be provided when PreferOfflineSnapshot is enabled.");
}
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
var fs = fileSystem ?? new FileSystem();
var directory = Path.GetDirectoryName(OfflineSnapshotPath);
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
{
fs.Directory.CreateDirectory(directory);
}
}
}
}
using System;
using System.IO;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
public sealed class OracleConnectorOptions
{
public const string HttpClientName = "excititor.connector.oracle.catalog";
/// <summary>
/// Oracle CSAF catalog endpoint hosting advisory metadata.
/// </summary>
public Uri CatalogUri { get; set; } = new("https://www.oracle.com/security-alerts/cpu/csaf/catalog.json");
/// <summary>
/// Optional CPU calendar endpoint providing upcoming release dates.
/// </summary>
public Uri? CpuCalendarUri { get; set; }
/// <summary>
/// Duration the discovery metadata should be cached before refresh.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(6);
/// <summary>
/// When true, the loader will prefer offline snapshot data over network fetches.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// Optional file path for persisting or ingesting catalog snapshots.
/// </summary>
public string? OfflineSnapshotPath { get; set; }
/// <summary>
/// Enables writing fresh catalog responses to <see cref="OfflineSnapshotPath"/>.
/// </summary>
public bool PersistOfflineSnapshot { get; set; } = true;
/// <summary>
/// Optional request delay when iterating catalogue entries (for rate limiting).
/// </summary>
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
public void Validate(IFileSystem? fileSystem = null)
{
if (CatalogUri is null || !CatalogUri.IsAbsoluteUri)
{
throw new InvalidOperationException("CatalogUri must be an absolute URI.");
}
if (CatalogUri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("CatalogUri must use HTTP or HTTPS.");
}
if (CpuCalendarUri is not null && (!CpuCalendarUri.IsAbsoluteUri || CpuCalendarUri.Scheme is not ("http" or "https")))
{
throw new InvalidOperationException("CpuCalendarUri must be an absolute HTTP(S) URI when provided.");
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("MetadataCacheDuration must be positive.");
}
if (RequestDelay < TimeSpan.Zero)
{
throw new InvalidOperationException("RequestDelay cannot be negative.");
}
if (PreferOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
throw new InvalidOperationException("OfflineSnapshotPath must be provided when PreferOfflineSnapshot is enabled.");
}
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
var fs = fileSystem ?? new FileSystem();
var directory = Path.GetDirectoryName(OfflineSnapshotPath);
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
{
fs.Directory.CreateDirectory(directory);
}
}
}
}

View File

@@ -1,32 +1,32 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
public sealed class OracleConnectorOptionsValidator : IVexConnectorOptionsValidator<OracleConnectorOptions>
{
private readonly IFileSystem _fileSystem;
public OracleConnectorOptionsValidator(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public void Validate(VexConnectorDescriptor descriptor, OracleConnectorOptions options, IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
try
{
options.Validate(_fileSystem);
}
catch (Exception ex)
{
errors.Add(ex.Message);
}
}
}
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
public sealed class OracleConnectorOptionsValidator : IVexConnectorOptionsValidator<OracleConnectorOptions>
{
private readonly IFileSystem _fileSystem;
public OracleConnectorOptionsValidator(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public void Validate(VexConnectorDescriptor descriptor, OracleConnectorOptions options, IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
try
{
options.Validate(_fileSystem);
}
catch (Exception ex)
{
errors.Add(ex.Message);
}
}
}

View File

@@ -1,45 +1,45 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.DependencyInjection;
public static class OracleConnectorServiceCollectionExtensions
{
public static IServiceCollection AddOracleCsafConnector(this IServiceCollection services, Action<OracleConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<OracleConnectorOptions>()
.Configure(options => configure?.Invoke(options));
services.AddSingleton<IVexConnectorOptionsValidator<OracleConnectorOptions>, OracleConnectorOptionsValidator>();
services.AddHttpClient(OracleConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(60);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.Oracle.CSAF/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddSingleton<OracleCatalogLoader>();
services.AddSingleton<IVexConnector, OracleCsafConnector>();
return services;
}
}
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.DependencyInjection;
public static class OracleConnectorServiceCollectionExtensions
{
public static IServiceCollection AddOracleCsafConnector(this IServiceCollection services, Action<OracleConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<OracleConnectorOptions>()
.Configure(options => configure?.Invoke(options));
services.AddSingleton<IVexConnectorOptionsValidator<OracleConnectorOptions>, OracleConnectorOptionsValidator>();
services.AddHttpClient(OracleConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(60);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.Oracle.CSAF/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddSingleton<OracleCatalogLoader>();
services.AddSingleton<IVexConnector, OracleCsafConnector>();
return services;
}
}

View File

@@ -1,418 +1,418 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
public sealed class OracleCatalogLoader
{
public const string CachePrefix = "StellaOps.Excititor.Connectors.Oracle.CSAF.Catalog";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly IFileSystem _fileSystem;
private readonly ILogger<OracleCatalogLoader> _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
public OracleCatalogLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IFileSystem fileSystem,
ILogger<OracleCatalogLoader> logger,
TimeProvider? timeProvider = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<OracleCatalogResult> LoadAsync(OracleConnectorOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate(_fileSystem);
var cacheKey = CreateCacheKey(options);
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out var cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
{
return cached.ToResult(fromCache: true);
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
{
return cached.ToResult(fromCache: true);
}
CacheEntry? entry = null;
if (options.PreferOfflineSnapshot)
{
entry = LoadFromOffline(options);
if (entry is null)
{
throw new InvalidOperationException("PreferOfflineSnapshot is enabled but no offline snapshot was found or could be loaded.");
}
}
else
{
entry = await TryFetchFromNetworkAsync(options, cancellationToken).ConfigureAwait(false)
?? LoadFromOffline(options);
}
if (entry is null)
{
throw new InvalidOperationException("Unable to load Oracle CSAF catalog from network or offline snapshot.");
}
var expiration = entry.MetadataCacheDuration == TimeSpan.Zero
? (DateTimeOffset?)null
: _timeProvider.GetUtcNow().Add(entry.MetadataCacheDuration);
var cacheEntryOptions = new MemoryCacheEntryOptions();
if (expiration.HasValue)
{
cacheEntryOptions.AbsoluteExpiration = expiration.Value;
}
_memoryCache.Set(cacheKey, entry with { CachedAt = _timeProvider.GetUtcNow() }, cacheEntryOptions);
return entry.ToResult(fromCache: false);
}
finally
{
_semaphore.Release();
}
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(OracleConnectorOptions options, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(OracleConnectorOptions.HttpClientName);
using var response = await client.GetAsync(options.CatalogUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var catalogPayload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
string? calendarPayload = null;
if (options.CpuCalendarUri is not null)
{
try
{
using var calendarResponse = await client.GetAsync(options.CpuCalendarUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
calendarResponse.EnsureSuccessStatusCode();
calendarPayload = await calendarResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to fetch Oracle CPU calendar from {Uri}; continuing without schedule.", options.CpuCalendarUri);
}
}
var metadata = ParseMetadata(catalogPayload, calendarPayload);
var fetchedAt = _timeProvider.GetUtcNow();
var entry = new CacheEntry(metadata, fetchedAt, fetchedAt, options.MetadataCacheDuration, false);
PersistSnapshotIfNeeded(options, metadata, fetchedAt);
return entry;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to fetch Oracle CSAF catalog from {Uri}; attempting offline fallback if available.", options.CatalogUri);
return null;
}
}
private CacheEntry? LoadFromOffline(OracleConnectorOptions options)
{
if (string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(options.OfflineSnapshotPath))
{
_logger.LogWarning("Oracle offline snapshot path {Path} does not exist.", options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(options.OfflineSnapshotPath);
var snapshot = JsonSerializer.Deserialize<OracleCatalogSnapshot>(payload, _serializerOptions);
if (snapshot is null)
{
throw new InvalidOperationException("Offline snapshot payload was empty.");
}
return new CacheEntry(snapshot.Metadata, snapshot.FetchedAt, _timeProvider.GetUtcNow(), options.MetadataCacheDuration, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Oracle CSAF catalog from offline snapshot {Path}.", options.OfflineSnapshotPath);
return null;
}
}
private OracleCatalogMetadata ParseMetadata(string catalogPayload, string? calendarPayload)
{
if (string.IsNullOrWhiteSpace(catalogPayload))
{
throw new InvalidOperationException("Oracle catalog payload was empty.");
}
using var document = JsonDocument.Parse(catalogPayload);
var root = document.RootElement;
var generatedAt = root.TryGetProperty("generated", out var generatedElement) && generatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(generatedElement.GetString(), out var generated)
? generated
: _timeProvider.GetUtcNow();
var entries = ParseEntries(root);
var schedule = ParseSchedule(root);
if (!string.IsNullOrWhiteSpace(calendarPayload))
{
schedule = MergeSchedule(schedule, calendarPayload);
}
return new OracleCatalogMetadata(generatedAt, entries, schedule);
}
private ImmutableArray<OracleCatalogEntry> ParseEntries(JsonElement root)
{
if (!root.TryGetProperty("catalog", out var catalogElement) || catalogElement.ValueKind is not JsonValueKind.Array)
{
return ImmutableArray<OracleCatalogEntry>.Empty;
}
var builder = ImmutableArray.CreateBuilder<OracleCatalogEntry>();
foreach (var entry in catalogElement.EnumerateArray())
{
var id = entry.TryGetProperty("id", out var idElement) && idElement.ValueKind == JsonValueKind.String ? idElement.GetString() : null;
var title = entry.TryGetProperty("title", out var titleElement) && titleElement.ValueKind == JsonValueKind.String ? titleElement.GetString() : null;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(title))
{
continue;
}
DateTimeOffset publishedAt = default;
if (entry.TryGetProperty("published", out var publishedElement) && publishedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(publishedElement.GetString(), out var publishedParsed))
{
publishedAt = publishedParsed;
}
string? revision = null;
if (entry.TryGetProperty("revision", out var revisionElement) && revisionElement.ValueKind == JsonValueKind.String)
{
revision = revisionElement.GetString();
}
ImmutableArray<string> products = ImmutableArray<string>.Empty;
if (entry.TryGetProperty("products", out var productsElement))
{
products = ParseStringArray(productsElement);
}
Uri? documentUri = null;
string? sha256 = null;
long? size = null;
if (entry.TryGetProperty("document", out var documentElement) && documentElement.ValueKind == JsonValueKind.Object)
{
if (documentElement.TryGetProperty("url", out var urlElement) && urlElement.ValueKind == JsonValueKind.String && Uri.TryCreate(urlElement.GetString(), UriKind.Absolute, out var parsedUri))
{
documentUri = parsedUri;
}
if (documentElement.TryGetProperty("sha256", out var hashElement) && hashElement.ValueKind == JsonValueKind.String)
{
sha256 = hashElement.GetString();
}
if (documentElement.TryGetProperty("size", out var sizeElement) && sizeElement.ValueKind == JsonValueKind.Number && sizeElement.TryGetInt64(out var parsedSize))
{
size = parsedSize;
}
}
if (documentUri is null)
{
continue;
}
builder.Add(new OracleCatalogEntry(id!, title!, documentUri, publishedAt, revision, sha256, size, products));
}
return builder.ToImmutable();
}
private ImmutableArray<OracleCpuRelease> ParseSchedule(JsonElement root)
{
if (!root.TryGetProperty("schedule", out var scheduleElement) || scheduleElement.ValueKind is not JsonValueKind.Array)
{
return ImmutableArray<OracleCpuRelease>.Empty;
}
var builder = ImmutableArray.CreateBuilder<OracleCpuRelease>();
foreach (var item in scheduleElement.EnumerateArray())
{
var window = item.TryGetProperty("window", out var windowElement) && windowElement.ValueKind == JsonValueKind.String ? windowElement.GetString() : null;
if (string.IsNullOrWhiteSpace(window))
{
continue;
}
DateTimeOffset releaseDate = default;
if (item.TryGetProperty("releaseDate", out var releaseElement) && releaseElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(releaseElement.GetString(), out var parsed))
{
releaseDate = parsed;
}
builder.Add(new OracleCpuRelease(window!, releaseDate));
}
return builder.ToImmutable();
}
private ImmutableArray<OracleCpuRelease> MergeSchedule(ImmutableArray<OracleCpuRelease> existing, string calendarPayload)
{
try
{
using var document = JsonDocument.Parse(calendarPayload);
var root = document.RootElement;
if (!root.TryGetProperty("cpuWindows", out var windowsElement) || windowsElement.ValueKind is not JsonValueKind.Array)
{
return existing;
}
var builder = existing.ToBuilder();
var known = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in builder)
{
known.Add(item.Window);
}
foreach (var windowElement in windowsElement.EnumerateArray())
{
var name = windowElement.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String ? nameElement.GetString() : null;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
if (!known.Add(name))
{
continue;
}
DateTimeOffset releaseDate = default;
if (windowElement.TryGetProperty("releaseDate", out var releaseElement) && releaseElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(releaseElement.GetString(), out var parsed))
{
releaseDate = parsed;
}
builder.Add(new OracleCpuRelease(name!, releaseDate));
}
return builder.ToImmutable();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to parse Oracle CPU calendar payload; continuing with existing schedule data.");
return existing;
}
}
private ImmutableArray<string> ParseStringArray(JsonElement element)
{
if (element.ValueKind is not JsonValueKind.Array)
{
return ImmutableArray<string>.Empty;
}
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
builder.Add(value);
}
}
}
return builder.ToImmutable();
}
private void PersistSnapshotIfNeeded(OracleConnectorOptions options, OracleCatalogMetadata metadata, DateTimeOffset fetchedAt)
{
if (!options.PersistOfflineSnapshot || string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return;
}
try
{
var snapshot = new OracleCatalogSnapshot(metadata, fetchedAt);
var payload = JsonSerializer.Serialize(snapshot, _serializerOptions);
_fileSystem.File.WriteAllText(options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Oracle CSAF catalog snapshot to {Path}.", options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Oracle CSAF catalog snapshot to {Path}.", options.OfflineSnapshotPath);
}
}
private static string CreateCacheKey(OracleConnectorOptions options)
=> $"{CachePrefix}:{options.CatalogUri}:{options.CpuCalendarUri}";
private sealed record CacheEntry(OracleCatalogMetadata Metadata, DateTimeOffset FetchedAt, DateTimeOffset CachedAt, TimeSpan MetadataCacheDuration, bool FromOfflineSnapshot)
{
public bool IsExpired(DateTimeOffset now)
=> MetadataCacheDuration > TimeSpan.Zero && now >= CachedAt + MetadataCacheDuration;
public OracleCatalogResult ToResult(bool fromCache)
=> new(Metadata, FetchedAt, fromCache, FromOfflineSnapshot);
}
private sealed record OracleCatalogSnapshot(OracleCatalogMetadata Metadata, DateTimeOffset FetchedAt);
}
public sealed record OracleCatalogMetadata(
DateTimeOffset GeneratedAt,
ImmutableArray<OracleCatalogEntry> Entries,
ImmutableArray<OracleCpuRelease> CpuSchedule);
public sealed record OracleCatalogEntry(
string Id,
string Title,
Uri DocumentUri,
DateTimeOffset PublishedAt,
string? Revision,
string? Sha256,
long? Size,
ImmutableArray<string> Products);
public sealed record OracleCpuRelease(string Window, DateTimeOffset ReleaseDate);
public sealed record OracleCatalogResult(
OracleCatalogMetadata Metadata,
DateTimeOffset FetchedAt,
bool FromCache,
bool FromOfflineSnapshot);
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
public sealed class OracleCatalogLoader
{
public const string CachePrefix = "StellaOps.Excititor.Connectors.Oracle.CSAF.Catalog";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly IFileSystem _fileSystem;
private readonly ILogger<OracleCatalogLoader> _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
public OracleCatalogLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IFileSystem fileSystem,
ILogger<OracleCatalogLoader> logger,
TimeProvider? timeProvider = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<OracleCatalogResult> LoadAsync(OracleConnectorOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate(_fileSystem);
var cacheKey = CreateCacheKey(options);
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out var cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
{
return cached.ToResult(fromCache: true);
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
{
return cached.ToResult(fromCache: true);
}
CacheEntry? entry = null;
if (options.PreferOfflineSnapshot)
{
entry = LoadFromOffline(options);
if (entry is null)
{
throw new InvalidOperationException("PreferOfflineSnapshot is enabled but no offline snapshot was found or could be loaded.");
}
}
else
{
entry = await TryFetchFromNetworkAsync(options, cancellationToken).ConfigureAwait(false)
?? LoadFromOffline(options);
}
if (entry is null)
{
throw new InvalidOperationException("Unable to load Oracle CSAF catalog from network or offline snapshot.");
}
var expiration = entry.MetadataCacheDuration == TimeSpan.Zero
? (DateTimeOffset?)null
: _timeProvider.GetUtcNow().Add(entry.MetadataCacheDuration);
var cacheEntryOptions = new MemoryCacheEntryOptions();
if (expiration.HasValue)
{
cacheEntryOptions.AbsoluteExpiration = expiration.Value;
}
_memoryCache.Set(cacheKey, entry with { CachedAt = _timeProvider.GetUtcNow() }, cacheEntryOptions);
return entry.ToResult(fromCache: false);
}
finally
{
_semaphore.Release();
}
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(OracleConnectorOptions options, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(OracleConnectorOptions.HttpClientName);
using var response = await client.GetAsync(options.CatalogUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var catalogPayload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
string? calendarPayload = null;
if (options.CpuCalendarUri is not null)
{
try
{
using var calendarResponse = await client.GetAsync(options.CpuCalendarUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
calendarResponse.EnsureSuccessStatusCode();
calendarPayload = await calendarResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to fetch Oracle CPU calendar from {Uri}; continuing without schedule.", options.CpuCalendarUri);
}
}
var metadata = ParseMetadata(catalogPayload, calendarPayload);
var fetchedAt = _timeProvider.GetUtcNow();
var entry = new CacheEntry(metadata, fetchedAt, fetchedAt, options.MetadataCacheDuration, false);
PersistSnapshotIfNeeded(options, metadata, fetchedAt);
return entry;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to fetch Oracle CSAF catalog from {Uri}; attempting offline fallback if available.", options.CatalogUri);
return null;
}
}
private CacheEntry? LoadFromOffline(OracleConnectorOptions options)
{
if (string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(options.OfflineSnapshotPath))
{
_logger.LogWarning("Oracle offline snapshot path {Path} does not exist.", options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(options.OfflineSnapshotPath);
var snapshot = JsonSerializer.Deserialize<OracleCatalogSnapshot>(payload, _serializerOptions);
if (snapshot is null)
{
throw new InvalidOperationException("Offline snapshot payload was empty.");
}
return new CacheEntry(snapshot.Metadata, snapshot.FetchedAt, _timeProvider.GetUtcNow(), options.MetadataCacheDuration, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Oracle CSAF catalog from offline snapshot {Path}.", options.OfflineSnapshotPath);
return null;
}
}
private OracleCatalogMetadata ParseMetadata(string catalogPayload, string? calendarPayload)
{
if (string.IsNullOrWhiteSpace(catalogPayload))
{
throw new InvalidOperationException("Oracle catalog payload was empty.");
}
using var document = JsonDocument.Parse(catalogPayload);
var root = document.RootElement;
var generatedAt = root.TryGetProperty("generated", out var generatedElement) && generatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(generatedElement.GetString(), out var generated)
? generated
: _timeProvider.GetUtcNow();
var entries = ParseEntries(root);
var schedule = ParseSchedule(root);
if (!string.IsNullOrWhiteSpace(calendarPayload))
{
schedule = MergeSchedule(schedule, calendarPayload);
}
return new OracleCatalogMetadata(generatedAt, entries, schedule);
}
private ImmutableArray<OracleCatalogEntry> ParseEntries(JsonElement root)
{
if (!root.TryGetProperty("catalog", out var catalogElement) || catalogElement.ValueKind is not JsonValueKind.Array)
{
return ImmutableArray<OracleCatalogEntry>.Empty;
}
var builder = ImmutableArray.CreateBuilder<OracleCatalogEntry>();
foreach (var entry in catalogElement.EnumerateArray())
{
var id = entry.TryGetProperty("id", out var idElement) && idElement.ValueKind == JsonValueKind.String ? idElement.GetString() : null;
var title = entry.TryGetProperty("title", out var titleElement) && titleElement.ValueKind == JsonValueKind.String ? titleElement.GetString() : null;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(title))
{
continue;
}
DateTimeOffset publishedAt = default;
if (entry.TryGetProperty("published", out var publishedElement) && publishedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(publishedElement.GetString(), out var publishedParsed))
{
publishedAt = publishedParsed;
}
string? revision = null;
if (entry.TryGetProperty("revision", out var revisionElement) && revisionElement.ValueKind == JsonValueKind.String)
{
revision = revisionElement.GetString();
}
ImmutableArray<string> products = ImmutableArray<string>.Empty;
if (entry.TryGetProperty("products", out var productsElement))
{
products = ParseStringArray(productsElement);
}
Uri? documentUri = null;
string? sha256 = null;
long? size = null;
if (entry.TryGetProperty("document", out var documentElement) && documentElement.ValueKind == JsonValueKind.Object)
{
if (documentElement.TryGetProperty("url", out var urlElement) && urlElement.ValueKind == JsonValueKind.String && Uri.TryCreate(urlElement.GetString(), UriKind.Absolute, out var parsedUri))
{
documentUri = parsedUri;
}
if (documentElement.TryGetProperty("sha256", out var hashElement) && hashElement.ValueKind == JsonValueKind.String)
{
sha256 = hashElement.GetString();
}
if (documentElement.TryGetProperty("size", out var sizeElement) && sizeElement.ValueKind == JsonValueKind.Number && sizeElement.TryGetInt64(out var parsedSize))
{
size = parsedSize;
}
}
if (documentUri is null)
{
continue;
}
builder.Add(new OracleCatalogEntry(id!, title!, documentUri, publishedAt, revision, sha256, size, products));
}
return builder.ToImmutable();
}
private ImmutableArray<OracleCpuRelease> ParseSchedule(JsonElement root)
{
if (!root.TryGetProperty("schedule", out var scheduleElement) || scheduleElement.ValueKind is not JsonValueKind.Array)
{
return ImmutableArray<OracleCpuRelease>.Empty;
}
var builder = ImmutableArray.CreateBuilder<OracleCpuRelease>();
foreach (var item in scheduleElement.EnumerateArray())
{
var window = item.TryGetProperty("window", out var windowElement) && windowElement.ValueKind == JsonValueKind.String ? windowElement.GetString() : null;
if (string.IsNullOrWhiteSpace(window))
{
continue;
}
DateTimeOffset releaseDate = default;
if (item.TryGetProperty("releaseDate", out var releaseElement) && releaseElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(releaseElement.GetString(), out var parsed))
{
releaseDate = parsed;
}
builder.Add(new OracleCpuRelease(window!, releaseDate));
}
return builder.ToImmutable();
}
private ImmutableArray<OracleCpuRelease> MergeSchedule(ImmutableArray<OracleCpuRelease> existing, string calendarPayload)
{
try
{
using var document = JsonDocument.Parse(calendarPayload);
var root = document.RootElement;
if (!root.TryGetProperty("cpuWindows", out var windowsElement) || windowsElement.ValueKind is not JsonValueKind.Array)
{
return existing;
}
var builder = existing.ToBuilder();
var known = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in builder)
{
known.Add(item.Window);
}
foreach (var windowElement in windowsElement.EnumerateArray())
{
var name = windowElement.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String ? nameElement.GetString() : null;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
if (!known.Add(name))
{
continue;
}
DateTimeOffset releaseDate = default;
if (windowElement.TryGetProperty("releaseDate", out var releaseElement) && releaseElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(releaseElement.GetString(), out var parsed))
{
releaseDate = parsed;
}
builder.Add(new OracleCpuRelease(name!, releaseDate));
}
return builder.ToImmutable();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to parse Oracle CPU calendar payload; continuing with existing schedule data.");
return existing;
}
}
private ImmutableArray<string> ParseStringArray(JsonElement element)
{
if (element.ValueKind is not JsonValueKind.Array)
{
return ImmutableArray<string>.Empty;
}
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
builder.Add(value);
}
}
}
return builder.ToImmutable();
}
private void PersistSnapshotIfNeeded(OracleConnectorOptions options, OracleCatalogMetadata metadata, DateTimeOffset fetchedAt)
{
if (!options.PersistOfflineSnapshot || string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return;
}
try
{
var snapshot = new OracleCatalogSnapshot(metadata, fetchedAt);
var payload = JsonSerializer.Serialize(snapshot, _serializerOptions);
_fileSystem.File.WriteAllText(options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Oracle CSAF catalog snapshot to {Path}.", options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Oracle CSAF catalog snapshot to {Path}.", options.OfflineSnapshotPath);
}
}
private static string CreateCacheKey(OracleConnectorOptions options)
=> $"{CachePrefix}:{options.CatalogUri}:{options.CpuCalendarUri}";
private sealed record CacheEntry(OracleCatalogMetadata Metadata, DateTimeOffset FetchedAt, DateTimeOffset CachedAt, TimeSpan MetadataCacheDuration, bool FromOfflineSnapshot)
{
public bool IsExpired(DateTimeOffset now)
=> MetadataCacheDuration > TimeSpan.Zero && now >= CachedAt + MetadataCacheDuration;
public OracleCatalogResult ToResult(bool fromCache)
=> new(Metadata, FetchedAt, fromCache, FromOfflineSnapshot);
}
private sealed record OracleCatalogSnapshot(OracleCatalogMetadata Metadata, DateTimeOffset FetchedAt);
}
public sealed record OracleCatalogMetadata(
DateTimeOffset GeneratedAt,
ImmutableArray<OracleCatalogEntry> Entries,
ImmutableArray<OracleCpuRelease> CpuSchedule);
public sealed record OracleCatalogEntry(
string Id,
string Title,
Uri DocumentUri,
DateTimeOffset PublishedAt,
string? Revision,
string? Sha256,
long? Size,
ImmutableArray<string> Products);
public sealed record OracleCpuRelease(string Window, DateTimeOffset ReleaseDate);
public sealed record OracleCatalogResult(
OracleCatalogMetadata Metadata,
DateTimeOffset FetchedAt,
bool FromCache,
bool FromOfflineSnapshot);

View File

@@ -1,104 +1,104 @@
using System.Collections.Generic;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
public sealed class RedHatConnectorOptions
{
public static readonly Uri DefaultMetadataUri = new("https://access.redhat.com/.well-known/csaf/provider-metadata.json");
/// <summary>
/// HTTP client name registered for the connector.
/// </summary>
public const string HttpClientName = "excititor.connector.redhat";
/// <summary>
/// URI of the CSAF provider metadata document.
/// </summary>
public Uri MetadataUri { get; set; } = DefaultMetadataUri;
/// <summary>
/// Duration to cache loaded metadata before refreshing.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Optional file path used to store or source offline metadata snapshots.
/// </summary>
public string? OfflineSnapshotPath { get; set; }
/// <summary>
/// When true, the loader prefers the offline snapshot without attempting a network fetch.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// Enables writing fresh metadata responses to <see cref="OfflineSnapshotPath"/>.
/// </summary>
public bool PersistOfflineSnapshot { get; set; } = true;
/// <summary>
/// Explicit trust weight override applied to the provider entry.
/// </summary>
public double TrustWeight { get; set; } = 1.0;
/// <summary>
/// Sigstore/Cosign issuer used to verify CSAF signatures, if published.
/// </summary>
public string? CosignIssuer { get; set; } = "https://access.redhat.com";
/// <summary>
/// Identity pattern matched against the Cosign certificate subject.
/// </summary>
public string? CosignIdentityPattern { get; set; } = "^https://access\\.redhat\\.com/.+$";
/// <summary>
/// Optional list of PGP fingerprints recognised for Red Hat CSAF artifacts.
/// </summary>
public IList<string> PgpFingerprints { get; } = new List<string>();
public void Validate(IFileSystem? fileSystem = null)
{
if (MetadataUri is null || !MetadataUri.IsAbsoluteUri)
{
throw new InvalidOperationException("Metadata URI must be absolute.");
}
if (MetadataUri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("Metadata URI must use HTTP or HTTPS.");
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("Metadata cache duration must be positive.");
}
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
var fs = fileSystem ?? new FileSystem();
var directory = Path.GetDirectoryName(OfflineSnapshotPath);
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
{
fs.Directory.CreateDirectory(directory);
}
}
if (double.IsNaN(TrustWeight) || double.IsInfinity(TrustWeight) || TrustWeight <= 0)
{
TrustWeight = 1.0;
}
else if (TrustWeight > 1.0)
{
TrustWeight = 1.0;
}
if (CosignIssuer is not null)
{
if (string.IsNullOrWhiteSpace(CosignIdentityPattern))
{
throw new InvalidOperationException("CosignIdentityPattern must be provided when CosignIssuer is specified.");
}
}
}
}
using System.Collections.Generic;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
public sealed class RedHatConnectorOptions
{
public static readonly Uri DefaultMetadataUri = new("https://access.redhat.com/.well-known/csaf/provider-metadata.json");
/// <summary>
/// HTTP client name registered for the connector.
/// </summary>
public const string HttpClientName = "excititor.connector.redhat";
/// <summary>
/// URI of the CSAF provider metadata document.
/// </summary>
public Uri MetadataUri { get; set; } = DefaultMetadataUri;
/// <summary>
/// Duration to cache loaded metadata before refreshing.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Optional file path used to store or source offline metadata snapshots.
/// </summary>
public string? OfflineSnapshotPath { get; set; }
/// <summary>
/// When true, the loader prefers the offline snapshot without attempting a network fetch.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// Enables writing fresh metadata responses to <see cref="OfflineSnapshotPath"/>.
/// </summary>
public bool PersistOfflineSnapshot { get; set; } = true;
/// <summary>
/// Explicit trust weight override applied to the provider entry.
/// </summary>
public double TrustWeight { get; set; } = 1.0;
/// <summary>
/// Sigstore/Cosign issuer used to verify CSAF signatures, if published.
/// </summary>
public string? CosignIssuer { get; set; } = "https://access.redhat.com";
/// <summary>
/// Identity pattern matched against the Cosign certificate subject.
/// </summary>
public string? CosignIdentityPattern { get; set; } = "^https://access\\.redhat\\.com/.+$";
/// <summary>
/// Optional list of PGP fingerprints recognised for Red Hat CSAF artifacts.
/// </summary>
public IList<string> PgpFingerprints { get; } = new List<string>();
public void Validate(IFileSystem? fileSystem = null)
{
if (MetadataUri is null || !MetadataUri.IsAbsoluteUri)
{
throw new InvalidOperationException("Metadata URI must be absolute.");
}
if (MetadataUri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("Metadata URI must use HTTP or HTTPS.");
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("Metadata cache duration must be positive.");
}
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
var fs = fileSystem ?? new FileSystem();
var directory = Path.GetDirectoryName(OfflineSnapshotPath);
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
{
fs.Directory.CreateDirectory(directory);
}
}
if (double.IsNaN(TrustWeight) || double.IsInfinity(TrustWeight) || TrustWeight <= 0)
{
TrustWeight = 1.0;
}
else if (TrustWeight > 1.0)
{
TrustWeight = 1.0;
}
if (CosignIssuer is not null)
{
if (string.IsNullOrWhiteSpace(CosignIdentityPattern))
{
throw new InvalidOperationException("CosignIdentityPattern must be provided when CosignIssuer is specified.");
}
}
}
}

View File

@@ -1,45 +1,45 @@
using System.Net;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
public static class RedHatConnectorServiceCollectionExtensions
{
public static IServiceCollection AddRedHatCsafConnector(this IServiceCollection services, Action<RedHatConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<RedHatConnectorOptions>()
.Configure(options =>
{
configure?.Invoke(options);
})
.PostConfigure(options => options.Validate());
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddHttpClient(RedHatConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.RedHat/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddSingleton<RedHatProviderMetadataLoader>();
services.AddSingleton<IVexConnector, RedHatCsafConnector>();
return services;
}
}
using System.Net;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
public static class RedHatConnectorServiceCollectionExtensions
{
public static IServiceCollection AddRedHatCsafConnector(this IServiceCollection services, Action<RedHatConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<RedHatConnectorOptions>()
.Configure(options =>
{
configure?.Invoke(options);
})
.PostConfigure(options => options.Validate());
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddHttpClient(RedHatConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.RedHat/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddSingleton<RedHatProviderMetadataLoader>();
services.AddSingleton<IVexConnector, RedHatCsafConnector>();
return services;
}
}

View File

@@ -1,312 +1,312 @@
using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
public sealed class RedHatProviderMetadataLoader
{
public const string CacheKey = "StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly ILogger<RedHatProviderMetadataLoader> _logger;
private readonly RedHatConnectorOptions _options;
private readonly IFileSystem _fileSystem;
private readonly JsonSerializerOptions _serializerOptions;
private readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
public RedHatProviderMetadataLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IOptions<RedHatConnectorOptions> options,
ILogger<RedHatProviderMetadataLoader> logger,
IFileSystem? fileSystem = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_cache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
_fileSystem = fileSystem ?? new FileSystem();
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
}
public async Task<RedHatProviderMetadataResult> LoadAsync(CancellationToken cancellationToken)
{
if (_cache.TryGetValue<CacheEntry>(CacheKey, out var cached) && cached is { } cachedEntry && !cachedEntry.IsExpired())
{
_logger.LogDebug("Returning cached Red Hat provider metadata (expires {Expires}).", cachedEntry.ExpiresAt);
return new RedHatProviderMetadataResult(cachedEntry.Provider, cachedEntry.FetchedAt, true, cachedEntry.FromOffline);
}
await _refreshSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cache.TryGetValue<CacheEntry>(CacheKey, out cached) && cached is { } cachedAfterLock && !cachedAfterLock.IsExpired())
{
return new RedHatProviderMetadataResult(cachedAfterLock.Provider, cachedAfterLock.FetchedAt, true, cachedAfterLock.FromOffline);
}
CacheEntry? previous = cached;
// Attempt live fetch unless offline preferred.
if (!_options.PreferOfflineSnapshot)
{
var httpResult = await TryFetchFromNetworkAsync(previous, cancellationToken).ConfigureAwait(false);
if (httpResult is not null)
{
StoreCache(httpResult);
return new RedHatProviderMetadataResult(httpResult.Provider, httpResult.FetchedAt, false, false);
}
}
var offlineResult = TryLoadFromOffline();
if (offlineResult is not null)
{
var offlineEntry = offlineResult with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
FromOffline = true,
};
StoreCache(offlineEntry);
return new RedHatProviderMetadataResult(offlineEntry.Provider, offlineEntry.FetchedAt, false, true);
}
throw new InvalidOperationException("Unable to load Red Hat CSAF provider metadata from network or offline snapshot.");
}
finally
{
_refreshSemaphore.Release();
}
}
private void StoreCache(CacheEntry entry)
{
var cacheEntryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpiration = entry.ExpiresAt,
};
_cache.Set(CacheKey, entry, cacheEntryOptions);
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(CacheEntry? previous, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(RedHatConnectorOptions.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, _options.MetadataUri);
if (!string.IsNullOrWhiteSpace(previous?.ETag))
{
if (EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
{
request.Headers.IfNoneMatch.Add(etag);
}
}
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
{
_logger.LogDebug("Red Hat provider metadata not modified (etag {ETag}).", previous.ETag);
return previous with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
};
}
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var provider = ParseAndValidate(payload);
var etagHeader = response.Headers.ETag?.ToString();
if (_options.PersistOfflineSnapshot && !string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
{
try
{
_fileSystem.File.WriteAllText(_options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Red Hat metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Red Hat metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
}
}
return new CacheEntry(
provider,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
etagHeader,
FromOffline: false);
}
catch (Exception ex) when (ex is not OperationCanceledException && !_options.PreferOfflineSnapshot)
{
_logger.LogWarning(ex, "Failed to fetch Red Hat provider metadata from {Uri}, will attempt offline snapshot.", _options.MetadataUri);
return null;
}
}
private CacheEntry? TryLoadFromOffline()
{
if (string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(_options.OfflineSnapshotPath))
{
_logger.LogWarning("Offline snapshot path {Path} does not exist.", _options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(_options.OfflineSnapshotPath);
var provider = ParseAndValidate(payload);
return new CacheEntry(provider, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow + _options.MetadataCacheDuration, ETag: null, FromOffline: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Red Hat provider metadata from offline snapshot {Path}.", _options.OfflineSnapshotPath);
return null;
}
}
private VexProvider ParseAndValidate(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("Provider metadata payload was empty.");
}
ProviderMetadataDocument? document;
try
{
document = JsonSerializer.Deserialize<ProviderMetadataDocument>(payload, _serializerOptions);
}
catch (JsonException ex)
{
throw new InvalidOperationException("Provider metadata payload could not be parsed.", ex);
}
if (document is null)
{
throw new InvalidOperationException("Provider metadata payload was null after parsing.");
}
if (document.Metadata?.Provider?.Name is null)
{
throw new InvalidOperationException("Provider metadata missing provider name.");
}
var distributions = document.Distributions?
.Select(static d => d.Directory)
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => CreateUri(s!, nameof(ProviderMetadataDistribution.Directory)))
.ToImmutableArray() ?? ImmutableArray<Uri>.Empty;
if (distributions.IsDefaultOrEmpty)
{
throw new InvalidOperationException("Provider metadata did not include any valid distribution directories.");
}
Uri? rolieFeed = null;
if (document.Rolie?.Feeds is not null)
{
foreach (var feed in document.Rolie.Feeds)
{
if (!string.IsNullOrWhiteSpace(feed.Url))
{
rolieFeed = CreateUri(feed.Url, "rolie.feeds[].url");
break;
}
}
}
var trust = BuildTrust();
return new VexProvider(
id: "excititor:redhat",
displayName: document.Metadata.Provider.Name,
kind: VexProviderKind.Distro,
baseUris: distributions,
discovery: new VexProviderDiscovery(_options.MetadataUri, rolieFeed),
trust: trust);
}
private VexProviderTrust BuildTrust()
{
VexCosignTrust? cosign = null;
if (!string.IsNullOrWhiteSpace(_options.CosignIssuer) && !string.IsNullOrWhiteSpace(_options.CosignIdentityPattern))
{
cosign = new VexCosignTrust(_options.CosignIssuer!, _options.CosignIdentityPattern!);
}
return new VexProviderTrust(
_options.TrustWeight,
cosign,
_options.PgpFingerprints);
}
private static Uri CreateUri(string value, string propertyName)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException($"Provider metadata field '{propertyName}' must be an absolute HTTP(S) URI.");
}
return uri;
}
private sealed record ProviderMetadataDocument(
[property: JsonPropertyName("metadata")] ProviderMetadata? Metadata,
[property: JsonPropertyName("distributions")] IReadOnlyList<ProviderMetadataDistribution>? Distributions,
[property: JsonPropertyName("rolie")] ProviderMetadataRolie? Rolie);
private sealed record ProviderMetadata(
[property: JsonPropertyName("provider")] ProviderMetadataProvider? Provider);
private sealed record ProviderMetadataProvider(
[property: JsonPropertyName("name")] string? Name);
private sealed record ProviderMetadataDistribution(
[property: JsonPropertyName("directory")] string? Directory);
private sealed record ProviderMetadataRolie(
[property: JsonPropertyName("feeds")] IReadOnlyList<ProviderMetadataRolieFeed>? Feeds);
private sealed record ProviderMetadataRolieFeed(
[property: JsonPropertyName("url")] string? Url);
private sealed record CacheEntry(
VexProvider Provider,
DateTimeOffset FetchedAt,
DateTimeOffset ExpiresAt,
string? ETag,
bool FromOffline)
{
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
}
}
public sealed record RedHatProviderMetadataResult(
VexProvider Provider,
DateTimeOffset FetchedAt,
bool FromCache,
bool FromOfflineSnapshot);
using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
public sealed class RedHatProviderMetadataLoader
{
public const string CacheKey = "StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly ILogger<RedHatProviderMetadataLoader> _logger;
private readonly RedHatConnectorOptions _options;
private readonly IFileSystem _fileSystem;
private readonly JsonSerializerOptions _serializerOptions;
private readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
public RedHatProviderMetadataLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IOptions<RedHatConnectorOptions> options,
ILogger<RedHatProviderMetadataLoader> logger,
IFileSystem? fileSystem = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_cache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
_fileSystem = fileSystem ?? new FileSystem();
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
}
public async Task<RedHatProviderMetadataResult> LoadAsync(CancellationToken cancellationToken)
{
if (_cache.TryGetValue<CacheEntry>(CacheKey, out var cached) && cached is { } cachedEntry && !cachedEntry.IsExpired())
{
_logger.LogDebug("Returning cached Red Hat provider metadata (expires {Expires}).", cachedEntry.ExpiresAt);
return new RedHatProviderMetadataResult(cachedEntry.Provider, cachedEntry.FetchedAt, true, cachedEntry.FromOffline);
}
await _refreshSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cache.TryGetValue<CacheEntry>(CacheKey, out cached) && cached is { } cachedAfterLock && !cachedAfterLock.IsExpired())
{
return new RedHatProviderMetadataResult(cachedAfterLock.Provider, cachedAfterLock.FetchedAt, true, cachedAfterLock.FromOffline);
}
CacheEntry? previous = cached;
// Attempt live fetch unless offline preferred.
if (!_options.PreferOfflineSnapshot)
{
var httpResult = await TryFetchFromNetworkAsync(previous, cancellationToken).ConfigureAwait(false);
if (httpResult is not null)
{
StoreCache(httpResult);
return new RedHatProviderMetadataResult(httpResult.Provider, httpResult.FetchedAt, false, false);
}
}
var offlineResult = TryLoadFromOffline();
if (offlineResult is not null)
{
var offlineEntry = offlineResult with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
FromOffline = true,
};
StoreCache(offlineEntry);
return new RedHatProviderMetadataResult(offlineEntry.Provider, offlineEntry.FetchedAt, false, true);
}
throw new InvalidOperationException("Unable to load Red Hat CSAF provider metadata from network or offline snapshot.");
}
finally
{
_refreshSemaphore.Release();
}
}
private void StoreCache(CacheEntry entry)
{
var cacheEntryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpiration = entry.ExpiresAt,
};
_cache.Set(CacheKey, entry, cacheEntryOptions);
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(CacheEntry? previous, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(RedHatConnectorOptions.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, _options.MetadataUri);
if (!string.IsNullOrWhiteSpace(previous?.ETag))
{
if (EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
{
request.Headers.IfNoneMatch.Add(etag);
}
}
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
{
_logger.LogDebug("Red Hat provider metadata not modified (etag {ETag}).", previous.ETag);
return previous with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
};
}
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var provider = ParseAndValidate(payload);
var etagHeader = response.Headers.ETag?.ToString();
if (_options.PersistOfflineSnapshot && !string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
{
try
{
_fileSystem.File.WriteAllText(_options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Red Hat metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Red Hat metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
}
}
return new CacheEntry(
provider,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
etagHeader,
FromOffline: false);
}
catch (Exception ex) when (ex is not OperationCanceledException && !_options.PreferOfflineSnapshot)
{
_logger.LogWarning(ex, "Failed to fetch Red Hat provider metadata from {Uri}, will attempt offline snapshot.", _options.MetadataUri);
return null;
}
}
private CacheEntry? TryLoadFromOffline()
{
if (string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(_options.OfflineSnapshotPath))
{
_logger.LogWarning("Offline snapshot path {Path} does not exist.", _options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(_options.OfflineSnapshotPath);
var provider = ParseAndValidate(payload);
return new CacheEntry(provider, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow + _options.MetadataCacheDuration, ETag: null, FromOffline: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Red Hat provider metadata from offline snapshot {Path}.", _options.OfflineSnapshotPath);
return null;
}
}
private VexProvider ParseAndValidate(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("Provider metadata payload was empty.");
}
ProviderMetadataDocument? document;
try
{
document = JsonSerializer.Deserialize<ProviderMetadataDocument>(payload, _serializerOptions);
}
catch (JsonException ex)
{
throw new InvalidOperationException("Provider metadata payload could not be parsed.", ex);
}
if (document is null)
{
throw new InvalidOperationException("Provider metadata payload was null after parsing.");
}
if (document.Metadata?.Provider?.Name is null)
{
throw new InvalidOperationException("Provider metadata missing provider name.");
}
var distributions = document.Distributions?
.Select(static d => d.Directory)
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => CreateUri(s!, nameof(ProviderMetadataDistribution.Directory)))
.ToImmutableArray() ?? ImmutableArray<Uri>.Empty;
if (distributions.IsDefaultOrEmpty)
{
throw new InvalidOperationException("Provider metadata did not include any valid distribution directories.");
}
Uri? rolieFeed = null;
if (document.Rolie?.Feeds is not null)
{
foreach (var feed in document.Rolie.Feeds)
{
if (!string.IsNullOrWhiteSpace(feed.Url))
{
rolieFeed = CreateUri(feed.Url, "rolie.feeds[].url");
break;
}
}
}
var trust = BuildTrust();
return new VexProvider(
id: "excititor:redhat",
displayName: document.Metadata.Provider.Name,
kind: VexProviderKind.Distro,
baseUris: distributions,
discovery: new VexProviderDiscovery(_options.MetadataUri, rolieFeed),
trust: trust);
}
private VexProviderTrust BuildTrust()
{
VexCosignTrust? cosign = null;
if (!string.IsNullOrWhiteSpace(_options.CosignIssuer) && !string.IsNullOrWhiteSpace(_options.CosignIdentityPattern))
{
cosign = new VexCosignTrust(_options.CosignIssuer!, _options.CosignIdentityPattern!);
}
return new VexProviderTrust(
_options.TrustWeight,
cosign,
_options.PgpFingerprints);
}
private static Uri CreateUri(string value, string propertyName)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException($"Provider metadata field '{propertyName}' must be an absolute HTTP(S) URI.");
}
return uri;
}
private sealed record ProviderMetadataDocument(
[property: JsonPropertyName("metadata")] ProviderMetadata? Metadata,
[property: JsonPropertyName("distributions")] IReadOnlyList<ProviderMetadataDistribution>? Distributions,
[property: JsonPropertyName("rolie")] ProviderMetadataRolie? Rolie);
private sealed record ProviderMetadata(
[property: JsonPropertyName("provider")] ProviderMetadataProvider? Provider);
private sealed record ProviderMetadataProvider(
[property: JsonPropertyName("name")] string? Name);
private sealed record ProviderMetadataDistribution(
[property: JsonPropertyName("directory")] string? Directory);
private sealed record ProviderMetadataRolie(
[property: JsonPropertyName("feeds")] IReadOnlyList<ProviderMetadataRolieFeed>? Feeds);
private sealed record ProviderMetadataRolieFeed(
[property: JsonPropertyName("url")] string? Url);
private sealed record CacheEntry(
VexProvider Provider,
DateTimeOffset FetchedAt,
DateTimeOffset ExpiresAt,
string? ETag,
bool FromOffline)
{
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
}
}
public sealed record RedHatProviderMetadataResult(
VexProvider Provider,
DateTimeOffset FetchedAt,
bool FromCache,
bool FromOfflineSnapshot);

View File

@@ -1,196 +1,196 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF;
public sealed class RedHatCsafConnector : VexConnectorBase
{
private readonly RedHatProviderMetadataLoader _metadataLoader;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IVexConnectorStateRepository _stateRepository;
public RedHatCsafConnector(
VexConnectorDescriptor descriptor,
RedHatProviderMetadataLoader metadataLoader,
IHttpClientFactory httpClientFactory,
IVexConnectorStateRepository stateRepository,
ILogger<RedHatCsafConnector> logger,
TimeProvider timeProvider)
: base(descriptor, logger, timeProvider)
{
_metadataLoader = metadataLoader ?? throw new ArgumentNullException(nameof(metadataLoader));
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
}
public override ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
// No connector-specific settings yet.
return ValueTask.CompletedTask;
}
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var metadataResult = await _metadataLoader.LoadAsync(cancellationToken).ConfigureAwait(false);
if (metadataResult.Provider.Discovery.RolIeService is null)
{
throw new InvalidOperationException("Red Hat provider metadata did not specify a ROLIE feed.");
}
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
var sinceTimestamp = context.Since;
if (state?.LastUpdated is { } persisted && (sinceTimestamp is null || persisted > sinceTimestamp))
{
sinceTimestamp = persisted;
}
var knownDigests = state?.DocumentDigests ?? ImmutableArray<string>.Empty;
var digestList = new List<string>(knownDigests);
var digestSet = new HashSet<string>(knownDigests, StringComparer.OrdinalIgnoreCase);
var latestUpdated = state?.LastUpdated ?? sinceTimestamp ?? DateTimeOffset.MinValue;
var stateChanged = false;
foreach (var entry in await FetchRolieEntriesAsync(metadataResult.Provider.Discovery.RolIeService, cancellationToken).ConfigureAwait(false))
{
if (sinceTimestamp is not null && entry.Updated is DateTimeOffset updated && updated <= sinceTimestamp)
{
continue;
}
if (entry.DocumentUri is null)
{
Logger.LogDebug("Skipping ROLIE entry {Id} because no document link was provided.", entry.Id);
continue;
}
var rawDocument = await DownloadCsafDocumentAsync(entry, cancellationToken).ConfigureAwait(false);
if (!digestSet.Add(rawDocument.Digest))
{
Logger.LogDebug("Skipping CSAF document {Uri} because digest {Digest} was already processed.", rawDocument.SourceUri, rawDocument.Digest);
continue;
}
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
digestList.Add(rawDocument.Digest);
stateChanged = true;
if (entry.Updated is DateTimeOffset entryUpdated && entryUpdated > latestUpdated)
{
latestUpdated = entryUpdated;
}
yield return rawDocument;
}
if (stateChanged)
{
var newLastUpdated = latestUpdated == DateTimeOffset.MinValue ? state?.LastUpdated : latestUpdated;
var baseState = state ?? new VexConnectorState(
Descriptor.Id,
null,
ImmutableArray<string>.Empty,
ImmutableDictionary<string, string>.Empty,
null,
0,
null,
null);
var updatedState = baseState with
{
LastUpdated = newLastUpdated,
DocumentDigests = digestList.ToImmutableArray(),
};
await _stateRepository.SaveAsync(updatedState, cancellationToken).ConfigureAwait(false);
}
}
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
{
// This connector relies on format-specific normalizers registered elsewhere.
throw new NotSupportedException("RedHatCsafConnector does not perform in-line normalization; use the CSAF normalizer component.");
}
private async Task<IReadOnlyList<RolieEntry>> FetchRolieEntriesAsync(Uri feedUri, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient(RedHatConnectorOptions.HttpClientName);
using var response = await client.GetAsync(feedUri, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var document = XDocument.Load(stream);
var ns = document.Root?.Name.Namespace ?? "http://www.w3.org/2005/Atom";
var entries = document.Root?
.Elements(ns + "entry")
.Select(e => new RolieEntry(
Id: (string?)e.Element(ns + "id"),
Updated: ParseUpdated((string?)e.Element(ns + "updated")),
DocumentUri: ParseDocumentLink(e, ns)))
.Where(entry => entry.Id is not null && entry.Updated is not null)
.OrderBy(entry => entry.Updated)
.ToList() ?? new List<RolieEntry>();
return entries;
}
private static DateTimeOffset? ParseUpdated(string? value)
=> DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
private static Uri? ParseDocumentLink(XElement entry, XNamespace ns)
{
var linkElements = entry.Elements(ns + "link");
foreach (var link in linkElements)
{
var rel = (string?)link.Attribute("rel");
var href = (string?)link.Attribute("href");
if (string.IsNullOrWhiteSpace(href))
{
continue;
}
if (rel is null || rel.Equals("enclosure", StringComparison.OrdinalIgnoreCase) || rel.Equals("alternate", StringComparison.OrdinalIgnoreCase))
{
if (Uri.TryCreate(href, UriKind.Absolute, out var uri))
{
return uri;
}
}
}
return null;
}
private async Task<VexRawDocument> DownloadCsafDocumentAsync(RolieEntry entry, CancellationToken cancellationToken)
{
var documentUri = entry.DocumentUri ?? throw new InvalidOperationException("ROLIE entry missing document URI.");
var client = _httpClientFactory.CreateClient(RedHatConnectorOptions.HttpClientName);
using var response = await client.GetAsync(documentUri, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var contentBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var metadata = BuildMetadata(builder => builder
.Add("redhat.csaf.entryId", entry.Id)
.Add("redhat.csaf.documentUri", documentUri.ToString())
.Add("redhat.csaf.updated", entry.Updated?.ToString("O")));
return CreateRawDocument(VexDocumentFormat.Csaf, documentUri, contentBytes, metadata);
}
private sealed record RolieEntry(string? Id, DateTimeOffset? Updated, Uri? DocumentUri);
}
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration;
using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Connectors.RedHat.CSAF;
public sealed class RedHatCsafConnector : VexConnectorBase
{
private readonly RedHatProviderMetadataLoader _metadataLoader;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IVexConnectorStateRepository _stateRepository;
public RedHatCsafConnector(
VexConnectorDescriptor descriptor,
RedHatProviderMetadataLoader metadataLoader,
IHttpClientFactory httpClientFactory,
IVexConnectorStateRepository stateRepository,
ILogger<RedHatCsafConnector> logger,
TimeProvider timeProvider)
: base(descriptor, logger, timeProvider)
{
_metadataLoader = metadataLoader ?? throw new ArgumentNullException(nameof(metadataLoader));
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
}
public override ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
// No connector-specific settings yet.
return ValueTask.CompletedTask;
}
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var metadataResult = await _metadataLoader.LoadAsync(cancellationToken).ConfigureAwait(false);
if (metadataResult.Provider.Discovery.RolIeService is null)
{
throw new InvalidOperationException("Red Hat provider metadata did not specify a ROLIE feed.");
}
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
var sinceTimestamp = context.Since;
if (state?.LastUpdated is { } persisted && (sinceTimestamp is null || persisted > sinceTimestamp))
{
sinceTimestamp = persisted;
}
var knownDigests = state?.DocumentDigests ?? ImmutableArray<string>.Empty;
var digestList = new List<string>(knownDigests);
var digestSet = new HashSet<string>(knownDigests, StringComparer.OrdinalIgnoreCase);
var latestUpdated = state?.LastUpdated ?? sinceTimestamp ?? DateTimeOffset.MinValue;
var stateChanged = false;
foreach (var entry in await FetchRolieEntriesAsync(metadataResult.Provider.Discovery.RolIeService, cancellationToken).ConfigureAwait(false))
{
if (sinceTimestamp is not null && entry.Updated is DateTimeOffset updated && updated <= sinceTimestamp)
{
continue;
}
if (entry.DocumentUri is null)
{
Logger.LogDebug("Skipping ROLIE entry {Id} because no document link was provided.", entry.Id);
continue;
}
var rawDocument = await DownloadCsafDocumentAsync(entry, cancellationToken).ConfigureAwait(false);
if (!digestSet.Add(rawDocument.Digest))
{
Logger.LogDebug("Skipping CSAF document {Uri} because digest {Digest} was already processed.", rawDocument.SourceUri, rawDocument.Digest);
continue;
}
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
digestList.Add(rawDocument.Digest);
stateChanged = true;
if (entry.Updated is DateTimeOffset entryUpdated && entryUpdated > latestUpdated)
{
latestUpdated = entryUpdated;
}
yield return rawDocument;
}
if (stateChanged)
{
var newLastUpdated = latestUpdated == DateTimeOffset.MinValue ? state?.LastUpdated : latestUpdated;
var baseState = state ?? new VexConnectorState(
Descriptor.Id,
null,
ImmutableArray<string>.Empty,
ImmutableDictionary<string, string>.Empty,
null,
0,
null,
null);
var updatedState = baseState with
{
LastUpdated = newLastUpdated,
DocumentDigests = digestList.ToImmutableArray(),
};
await _stateRepository.SaveAsync(updatedState, cancellationToken).ConfigureAwait(false);
}
}
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
{
// This connector relies on format-specific normalizers registered elsewhere.
throw new NotSupportedException("RedHatCsafConnector does not perform in-line normalization; use the CSAF normalizer component.");
}
private async Task<IReadOnlyList<RolieEntry>> FetchRolieEntriesAsync(Uri feedUri, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient(RedHatConnectorOptions.HttpClientName);
using var response = await client.GetAsync(feedUri, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var document = XDocument.Load(stream);
var ns = document.Root?.Name.Namespace ?? "http://www.w3.org/2005/Atom";
var entries = document.Root?
.Elements(ns + "entry")
.Select(e => new RolieEntry(
Id: (string?)e.Element(ns + "id"),
Updated: ParseUpdated((string?)e.Element(ns + "updated")),
DocumentUri: ParseDocumentLink(e, ns)))
.Where(entry => entry.Id is not null && entry.Updated is not null)
.OrderBy(entry => entry.Updated)
.ToList() ?? new List<RolieEntry>();
return entries;
}
private static DateTimeOffset? ParseUpdated(string? value)
=> DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
private static Uri? ParseDocumentLink(XElement entry, XNamespace ns)
{
var linkElements = entry.Elements(ns + "link");
foreach (var link in linkElements)
{
var rel = (string?)link.Attribute("rel");
var href = (string?)link.Attribute("href");
if (string.IsNullOrWhiteSpace(href))
{
continue;
}
if (rel is null || rel.Equals("enclosure", StringComparison.OrdinalIgnoreCase) || rel.Equals("alternate", StringComparison.OrdinalIgnoreCase))
{
if (Uri.TryCreate(href, UriKind.Absolute, out var uri))
{
return uri;
}
}
}
return null;
}
private async Task<VexRawDocument> DownloadCsafDocumentAsync(RolieEntry entry, CancellationToken cancellationToken)
{
var documentUri = entry.DocumentUri ?? throw new InvalidOperationException("ROLIE entry missing document URI.");
var client = _httpClientFactory.CreateClient(RedHatConnectorOptions.HttpClientName);
using var response = await client.GetAsync(documentUri, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var contentBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var metadata = BuildMetadata(builder => builder
.Add("redhat.csaf.entryId", entry.Id)
.Add("redhat.csaf.documentUri", documentUri.ToString())
.Add("redhat.csaf.updated", entry.Updated?.ToString("O")));
return CreateRawDocument(VexDocumentFormat.Csaf, documentUri, contentBytes, metadata);
}
private sealed record RolieEntry(string? Id, DateTimeOffset? Updated, Uri? DocumentUri);
}

View File

@@ -1,171 +1,171 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
public sealed class RancherHubTokenProvider
{
private const string CachePrefix = "StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Token";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly ILogger<RancherHubTokenProvider> _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public RancherHubTokenProvider(IHttpClientFactory httpClientFactory, IMemoryCache cache, ILogger<RancherHubTokenProvider> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<RancherHubAccessToken?> GetAccessTokenAsync(RancherHubConnectorOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
if (options.PreferOfflineSnapshot)
{
_logger.LogDebug("Skipping token request because PreferOfflineSnapshot is enabled.");
return null;
}
var hasCredentials = !string.IsNullOrWhiteSpace(options.ClientId) &&
!string.IsNullOrWhiteSpace(options.ClientSecret) &&
options.TokenEndpoint is not null;
if (!hasCredentials)
{
if (!options.AllowAnonymousDiscovery)
{
_logger.LogDebug("No Rancher hub credentials configured; proceeding without Authorization header.");
}
return null;
}
var cacheKey = $"{CachePrefix}:{options.ClientId}";
if (_cache.TryGetValue<RancherHubAccessToken>(cacheKey, out var cachedToken) && cachedToken is not null && !cachedToken.IsExpired())
{
return cachedToken;
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cache.TryGetValue<RancherHubAccessToken>(cacheKey, out cachedToken) && cachedToken is not null && !cachedToken.IsExpired())
{
return cachedToken;
}
var token = await RequestTokenAsync(options, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var lifetime = token.ExpiresAt - DateTimeOffset.UtcNow;
if (lifetime <= TimeSpan.Zero)
{
lifetime = TimeSpan.FromMinutes(5);
}
var absoluteExpiration = lifetime > TimeSpan.FromSeconds(30)
? DateTimeOffset.UtcNow + lifetime - TimeSpan.FromSeconds(30)
: DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10);
_cache.Set(cacheKey, token, new MemoryCacheEntryOptions
{
AbsoluteExpiration = absoluteExpiration,
});
}
return token;
}
finally
{
_semaphore.Release();
}
}
private async Task<RancherHubAccessToken?> RequestTokenAsync(RancherHubConnectorOptions options, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint);
request.Headers.Accept.ParseAdd("application/json");
var parameters = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
};
if (options.Scopes.Count > 0)
{
parameters["scope"] = string.Join(' ', options.Scopes);
}
if (!string.IsNullOrWhiteSpace(options.Audience))
{
parameters["audience"] = options.Audience!;
}
if (string.Equals(options.ClientAuthenticationScheme, "client_secret_basic", StringComparison.Ordinal))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.ClientId}:{options.ClientSecret}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
else
{
parameters["client_id"] = options.ClientId!;
parameters["client_secret"] = options.ClientSecret!;
}
request.Content = new FormUrlEncodedContent(parameters);
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to acquire Rancher hub access token ({response.StatusCode}): {payload}");
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
if (!root.TryGetProperty("access_token", out var accessTokenProperty) || accessTokenProperty.ValueKind is not JsonValueKind.String)
{
throw new InvalidOperationException("Token endpoint response missing access_token.");
}
var token = accessTokenProperty.GetString();
if (string.IsNullOrWhiteSpace(token))
{
throw new InvalidOperationException("Token endpoint response contained an empty access_token.");
}
var tokenType = root.TryGetProperty("token_type", out var tokenTypeElement) && tokenTypeElement.ValueKind is JsonValueKind.String
? tokenTypeElement.GetString() ?? "Bearer"
: "Bearer";
var expires = root.TryGetProperty("expires_in", out var expiresElement) &&
expiresElement.ValueKind is JsonValueKind.Number &&
expiresElement.TryGetInt32(out var expiresSeconds)
? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(Math.Max(30, expiresSeconds))
: DateTimeOffset.UtcNow + TimeSpan.FromMinutes(30);
_logger.LogDebug("Acquired Rancher hub access token (expires {Expires}).", expires);
return new RancherHubAccessToken(token, tokenType, expires);
}
}
public sealed record RancherHubAccessToken(string Value, string TokenType, DateTimeOffset ExpiresAt)
{
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt - TimeSpan.FromMinutes(1);
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
public sealed class RancherHubTokenProvider
{
private const string CachePrefix = "StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Token";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly ILogger<RancherHubTokenProvider> _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public RancherHubTokenProvider(IHttpClientFactory httpClientFactory, IMemoryCache cache, ILogger<RancherHubTokenProvider> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<RancherHubAccessToken?> GetAccessTokenAsync(RancherHubConnectorOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
if (options.PreferOfflineSnapshot)
{
_logger.LogDebug("Skipping token request because PreferOfflineSnapshot is enabled.");
return null;
}
var hasCredentials = !string.IsNullOrWhiteSpace(options.ClientId) &&
!string.IsNullOrWhiteSpace(options.ClientSecret) &&
options.TokenEndpoint is not null;
if (!hasCredentials)
{
if (!options.AllowAnonymousDiscovery)
{
_logger.LogDebug("No Rancher hub credentials configured; proceeding without Authorization header.");
}
return null;
}
var cacheKey = $"{CachePrefix}:{options.ClientId}";
if (_cache.TryGetValue<RancherHubAccessToken>(cacheKey, out var cachedToken) && cachedToken is not null && !cachedToken.IsExpired())
{
return cachedToken;
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_cache.TryGetValue<RancherHubAccessToken>(cacheKey, out cachedToken) && cachedToken is not null && !cachedToken.IsExpired())
{
return cachedToken;
}
var token = await RequestTokenAsync(options, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var lifetime = token.ExpiresAt - DateTimeOffset.UtcNow;
if (lifetime <= TimeSpan.Zero)
{
lifetime = TimeSpan.FromMinutes(5);
}
var absoluteExpiration = lifetime > TimeSpan.FromSeconds(30)
? DateTimeOffset.UtcNow + lifetime - TimeSpan.FromSeconds(30)
: DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10);
_cache.Set(cacheKey, token, new MemoryCacheEntryOptions
{
AbsoluteExpiration = absoluteExpiration,
});
}
return token;
}
finally
{
_semaphore.Release();
}
}
private async Task<RancherHubAccessToken?> RequestTokenAsync(RancherHubConnectorOptions options, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint);
request.Headers.Accept.ParseAdd("application/json");
var parameters = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
};
if (options.Scopes.Count > 0)
{
parameters["scope"] = string.Join(' ', options.Scopes);
}
if (!string.IsNullOrWhiteSpace(options.Audience))
{
parameters["audience"] = options.Audience!;
}
if (string.Equals(options.ClientAuthenticationScheme, "client_secret_basic", StringComparison.Ordinal))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.ClientId}:{options.ClientSecret}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
else
{
parameters["client_id"] = options.ClientId!;
parameters["client_secret"] = options.ClientSecret!;
}
request.Content = new FormUrlEncodedContent(parameters);
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to acquire Rancher hub access token ({response.StatusCode}): {payload}");
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
if (!root.TryGetProperty("access_token", out var accessTokenProperty) || accessTokenProperty.ValueKind is not JsonValueKind.String)
{
throw new InvalidOperationException("Token endpoint response missing access_token.");
}
var token = accessTokenProperty.GetString();
if (string.IsNullOrWhiteSpace(token))
{
throw new InvalidOperationException("Token endpoint response contained an empty access_token.");
}
var tokenType = root.TryGetProperty("token_type", out var tokenTypeElement) && tokenTypeElement.ValueKind is JsonValueKind.String
? tokenTypeElement.GetString() ?? "Bearer"
: "Bearer";
var expires = root.TryGetProperty("expires_in", out var expiresElement) &&
expiresElement.ValueKind is JsonValueKind.Number &&
expiresElement.TryGetInt32(out var expiresSeconds)
? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(Math.Max(30, expiresSeconds))
: DateTimeOffset.UtcNow + TimeSpan.FromMinutes(30);
_logger.LogDebug("Acquired Rancher hub access token (expires {Expires}).", expires);
return new RancherHubAccessToken(token, tokenType, expires);
}
}
public sealed record RancherHubAccessToken(string Value, string TokenType, DateTimeOffset ExpiresAt)
{
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt - TimeSpan.FromMinutes(1);
}

View File

@@ -1,186 +1,186 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
public sealed class RancherHubConnectorOptions
{
public static readonly Uri DefaultDiscoveryUri = new("https://vexhub.suse.com/.well-known/vex/rancher-hub.json");
/// <summary>
/// HTTP client name registered for the connector.
/// </summary>
public const string HttpClientName = "excititor.connector.suse.rancherhub";
/// <summary>
/// URI for the Rancher VEX hub discovery document.
/// </summary>
public Uri DiscoveryUri { get; set; } = DefaultDiscoveryUri;
/// <summary>
/// Optional OAuth2/OIDC token endpoint used for hub authentication.
/// </summary>
public Uri? TokenEndpoint { get; set; }
/// <summary>
/// Client identifier used when requesting hub access tokens.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Client secret used when requesting hub access tokens.
/// </summary>
public string? ClientSecret { get; set; }
/// <summary>
/// OAuth scopes requested for hub access; defaults align with Rancher hub reader role.
/// </summary>
public IList<string> Scopes { get; } = new List<string> { "hub.read" };
/// <summary>
/// Optional audience claim passed when requesting tokens (client credential grant).
/// </summary>
public string? Audience { get; set; }
/// <summary>
/// Preferred authentication scheme. Supported: client_secret_basic (default) or client_secret_post.
/// </summary>
public string ClientAuthenticationScheme { get; set; } = "client_secret_basic";
/// <summary>
/// Duration to cache discovery metadata before re-fetching.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Optional file path for discovery metadata snapshots.
/// </summary>
public string? OfflineSnapshotPath { get; set; }
/// <summary>
/// When true, the loader prefers the offline snapshot prior to attempting network discovery.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// Enables persisting freshly fetched discovery documents to <see cref="OfflineSnapshotPath"/>.
/// </summary>
public bool PersistOfflineSnapshot { get; set; } = true;
/// <summary>
/// Weight applied to the provider entry; hubs default below direct vendor feeds.
/// </summary>
public double TrustWeight { get; set; } = 0.6;
/// <summary>
/// Optional Sigstore/Cosign issuer for verifying hub-delivered attestations.
/// </summary>
public string? CosignIssuer { get; set; }
/// <summary>
/// Cosign identity pattern matched against transparency log subjects.
/// </summary>
public string? CosignIdentityPattern { get; set; }
/// <summary>
/// Additional trusted PGP fingerprints declared by the hub.
/// </summary>
public IList<string> PgpFingerprints { get; } = new List<string>();
/// <summary>
/// Allows falling back to unauthenticated discovery requests when credentials are absent.
/// </summary>
public bool AllowAnonymousDiscovery { get; set; }
public void Validate(IFileSystem? fileSystem = null)
{
if (DiscoveryUri is null || !DiscoveryUri.IsAbsoluteUri)
{
throw new InvalidOperationException("DiscoveryUri must be an absolute URI.");
}
if (DiscoveryUri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("DiscoveryUri must use HTTP or HTTPS.");
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("MetadataCacheDuration must be positive.");
}
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
var fs = fileSystem ?? new FileSystem();
var directory = Path.GetDirectoryName(OfflineSnapshotPath);
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
{
fs.Directory.CreateDirectory(directory);
}
}
if (PreferOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
throw new InvalidOperationException("OfflineSnapshotPath must be provided when PreferOfflineSnapshot is enabled.");
}
var hasClientId = !string.IsNullOrWhiteSpace(ClientId);
var hasClientSecret = !string.IsNullOrWhiteSpace(ClientSecret);
var hasTokenEndpoint = TokenEndpoint is not null;
if (hasClientId || hasClientSecret || hasTokenEndpoint)
{
if (!(hasClientId && hasClientSecret && hasTokenEndpoint))
{
throw new InvalidOperationException("ClientId, ClientSecret, and TokenEndpoint must be provided together for authenticated discovery.");
}
if (TokenEndpoint is not null && (!TokenEndpoint.IsAbsoluteUri || TokenEndpoint.Scheme is not ("http" or "https")))
{
throw new InvalidOperationException("TokenEndpoint must be an absolute HTTP(S) URI.");
}
}
if (double.IsNaN(TrustWeight) || double.IsInfinity(TrustWeight))
{
TrustWeight = 0.6;
}
else if (TrustWeight <= 0)
{
TrustWeight = 0.1;
}
else if (TrustWeight > 1.0)
{
TrustWeight = 1.0;
}
if (!string.IsNullOrWhiteSpace(CosignIssuer) && string.IsNullOrWhiteSpace(CosignIdentityPattern))
{
throw new InvalidOperationException("CosignIdentityPattern must be provided when CosignIssuer is specified.");
}
if (!string.Equals(ClientAuthenticationScheme, "client_secret_basic", StringComparison.Ordinal) &&
!string.Equals(ClientAuthenticationScheme, "client_secret_post", StringComparison.Ordinal))
{
throw new InvalidOperationException("ClientAuthenticationScheme must be 'client_secret_basic' or 'client_secret_post'.");
}
// Remove any empty scopes to avoid token request issues.
if (Scopes.Count > 0)
{
for (var i = Scopes.Count - 1; i >= 0; i--)
{
if (string.IsNullOrWhiteSpace(Scopes[i]))
{
Scopes.RemoveAt(i);
}
}
}
if (Scopes.Count == 0)
{
Scopes.Add("hub.read");
}
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
public sealed class RancherHubConnectorOptions
{
public static readonly Uri DefaultDiscoveryUri = new("https://vexhub.suse.com/.well-known/vex/rancher-hub.json");
/// <summary>
/// HTTP client name registered for the connector.
/// </summary>
public const string HttpClientName = "excititor.connector.suse.rancherhub";
/// <summary>
/// URI for the Rancher VEX hub discovery document.
/// </summary>
public Uri DiscoveryUri { get; set; } = DefaultDiscoveryUri;
/// <summary>
/// Optional OAuth2/OIDC token endpoint used for hub authentication.
/// </summary>
public Uri? TokenEndpoint { get; set; }
/// <summary>
/// Client identifier used when requesting hub access tokens.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Client secret used when requesting hub access tokens.
/// </summary>
public string? ClientSecret { get; set; }
/// <summary>
/// OAuth scopes requested for hub access; defaults align with Rancher hub reader role.
/// </summary>
public IList<string> Scopes { get; } = new List<string> { "hub.read" };
/// <summary>
/// Optional audience claim passed when requesting tokens (client credential grant).
/// </summary>
public string? Audience { get; set; }
/// <summary>
/// Preferred authentication scheme. Supported: client_secret_basic (default) or client_secret_post.
/// </summary>
public string ClientAuthenticationScheme { get; set; } = "client_secret_basic";
/// <summary>
/// Duration to cache discovery metadata before re-fetching.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Optional file path for discovery metadata snapshots.
/// </summary>
public string? OfflineSnapshotPath { get; set; }
/// <summary>
/// When true, the loader prefers the offline snapshot prior to attempting network discovery.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// Enables persisting freshly fetched discovery documents to <see cref="OfflineSnapshotPath"/>.
/// </summary>
public bool PersistOfflineSnapshot { get; set; } = true;
/// <summary>
/// Weight applied to the provider entry; hubs default below direct vendor feeds.
/// </summary>
public double TrustWeight { get; set; } = 0.6;
/// <summary>
/// Optional Sigstore/Cosign issuer for verifying hub-delivered attestations.
/// </summary>
public string? CosignIssuer { get; set; }
/// <summary>
/// Cosign identity pattern matched against transparency log subjects.
/// </summary>
public string? CosignIdentityPattern { get; set; }
/// <summary>
/// Additional trusted PGP fingerprints declared by the hub.
/// </summary>
public IList<string> PgpFingerprints { get; } = new List<string>();
/// <summary>
/// Allows falling back to unauthenticated discovery requests when credentials are absent.
/// </summary>
public bool AllowAnonymousDiscovery { get; set; }
public void Validate(IFileSystem? fileSystem = null)
{
if (DiscoveryUri is null || !DiscoveryUri.IsAbsoluteUri)
{
throw new InvalidOperationException("DiscoveryUri must be an absolute URI.");
}
if (DiscoveryUri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("DiscoveryUri must use HTTP or HTTPS.");
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("MetadataCacheDuration must be positive.");
}
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
var fs = fileSystem ?? new FileSystem();
var directory = Path.GetDirectoryName(OfflineSnapshotPath);
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
{
fs.Directory.CreateDirectory(directory);
}
}
if (PreferOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
throw new InvalidOperationException("OfflineSnapshotPath must be provided when PreferOfflineSnapshot is enabled.");
}
var hasClientId = !string.IsNullOrWhiteSpace(ClientId);
var hasClientSecret = !string.IsNullOrWhiteSpace(ClientSecret);
var hasTokenEndpoint = TokenEndpoint is not null;
if (hasClientId || hasClientSecret || hasTokenEndpoint)
{
if (!(hasClientId && hasClientSecret && hasTokenEndpoint))
{
throw new InvalidOperationException("ClientId, ClientSecret, and TokenEndpoint must be provided together for authenticated discovery.");
}
if (TokenEndpoint is not null && (!TokenEndpoint.IsAbsoluteUri || TokenEndpoint.Scheme is not ("http" or "https")))
{
throw new InvalidOperationException("TokenEndpoint must be an absolute HTTP(S) URI.");
}
}
if (double.IsNaN(TrustWeight) || double.IsInfinity(TrustWeight))
{
TrustWeight = 0.6;
}
else if (TrustWeight <= 0)
{
TrustWeight = 0.1;
}
else if (TrustWeight > 1.0)
{
TrustWeight = 1.0;
}
if (!string.IsNullOrWhiteSpace(CosignIssuer) && string.IsNullOrWhiteSpace(CosignIdentityPattern))
{
throw new InvalidOperationException("CosignIdentityPattern must be provided when CosignIssuer is specified.");
}
if (!string.Equals(ClientAuthenticationScheme, "client_secret_basic", StringComparison.Ordinal) &&
!string.Equals(ClientAuthenticationScheme, "client_secret_post", StringComparison.Ordinal))
{
throw new InvalidOperationException("ClientAuthenticationScheme must be 'client_secret_basic' or 'client_secret_post'.");
}
// Remove any empty scopes to avoid token request issues.
if (Scopes.Count > 0)
{
for (var i = Scopes.Count - 1; i >= 0; i--)
{
if (string.IsNullOrWhiteSpace(Scopes[i]))
{
Scopes.RemoveAt(i);
}
}
}
if (Scopes.Count == 0)
{
Scopes.Add("hub.read");
}
}
}

View File

@@ -1,32 +1,32 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
public sealed class RancherHubConnectorOptionsValidator : IVexConnectorOptionsValidator<RancherHubConnectorOptions>
{
private readonly IFileSystem _fileSystem;
public RancherHubConnectorOptionsValidator(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public void Validate(VexConnectorDescriptor descriptor, RancherHubConnectorOptions options, IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
try
{
options.Validate(_fileSystem);
}
catch (Exception ex)
{
errors.Add(ex.Message);
}
}
}
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
public sealed class RancherHubConnectorOptionsValidator : IVexConnectorOptionsValidator<RancherHubConnectorOptions>
{
private readonly IFileSystem _fileSystem;
public RancherHubConnectorOptionsValidator(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public void Validate(VexConnectorDescriptor descriptor, RancherHubConnectorOptions options, IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
try
{
options.Validate(_fileSystem);
}
catch (Exception ex)
{
errors.Add(ex.Message);
}
}
}

View File

@@ -1,7 +1,7 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.Abstractions;
@@ -10,44 +10,44 @@ using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.DependencyInjection;
public static class RancherHubConnectorServiceCollectionExtensions
{
public static IServiceCollection AddRancherHubConnector(this IServiceCollection services, Action<RancherHubConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<RancherHubConnectorOptions>()
.Configure(options =>
{
configure?.Invoke(options);
});
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.DependencyInjection;
public static class RancherHubConnectorServiceCollectionExtensions
{
public static IServiceCollection AddRancherHubConnector(this IServiceCollection services, Action<RancherHubConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<RancherHubConnectorOptions>()
.Configure(options =>
{
configure?.Invoke(options);
});
services.AddSingleton<IVexConnectorOptionsValidator<RancherHubConnectorOptions>, RancherHubConnectorOptionsValidator>();
services.AddSingleton<RancherHubCheckpointManager>();
services.AddSingleton<RancherHubEventClient>();
services.AddSingleton<RancherHubTokenProvider>();
services.AddSingleton<RancherHubMetadataLoader>();
services.AddSingleton<IVexConnector, RancherHubConnector>();
services.AddHttpClient(RancherHubConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
return services;
}
}
services.AddHttpClient(RancherHubConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
return services;
}
}

View File

@@ -1,311 +1,311 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
public sealed class RancherHubEventClient
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly RancherHubTokenProvider _tokenProvider;
private readonly IFileSystem _fileSystem;
private readonly ILogger<RancherHubEventClient> _logger;
private readonly JsonDocumentOptions _documentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private const string CheckpointPrefix = "checkpoint";
public RancherHubEventClient(
IHttpClientFactory httpClientFactory,
RancherHubTokenProvider tokenProvider,
IFileSystem fileSystem,
ILogger<RancherHubEventClient> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async IAsyncEnumerable<RancherHubEventBatch> FetchEventBatchesAsync(
RancherHubConnectorOptions options,
RancherHubMetadata metadata,
string? cursor,
DateTimeOffset? since,
ImmutableArray<string> channels,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(metadata);
if (options.PreferOfflineSnapshot && metadata.OfflineSnapshot is not null)
{
var offline = await LoadOfflineSnapshotAsync(metadata.OfflineSnapshot, cancellationToken).ConfigureAwait(false);
if (offline is not null)
{
yield return offline;
}
yield break;
}
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
var currentCursor = cursor;
var currentSince = since;
var firstRequest = true;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var requestUri = BuildRequestUri(metadata.Subscription.EventsUri, currentCursor, currentSince, channels);
using var request = await CreateRequestAsync(options, metadata, requestUri, cancellationToken).ConfigureAwait(false);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Rancher hub events request failed ({(int)response.StatusCode} {response.StatusCode}). Payload: {payload}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var batch = ParseBatch(json, fromOfflineSnapshot: false);
yield return batch;
if (string.IsNullOrWhiteSpace(batch.NextCursor))
{
break;
}
if (!firstRequest && string.Equals(batch.NextCursor, currentCursor, StringComparison.Ordinal))
{
_logger.LogWarning("Detected stable cursor {Cursor}; stopping to avoid loop.", batch.NextCursor);
break;
}
currentCursor = batch.NextCursor;
currentSince = null; // cursor supersedes since parameter
firstRequest = false;
}
}
private async Task<RancherHubEventBatch?> LoadOfflineSnapshotAsync(RancherHubOfflineSnapshotMetadata offline, CancellationToken cancellationToken)
{
try
{
string payload;
if (offline.SnapshotUri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase))
{
var path = offline.SnapshotUri.LocalPath;
payload = await _fileSystem.File.ReadAllTextAsync(path, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
}
else
{
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var response = await client.GetAsync(offline.SnapshotUri, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(offline.Sha256))
{
var computed = ComputeSha256(payload);
if (!string.Equals(computed, offline.Sha256, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Offline snapshot digest mismatch (expected {Expected}, computed {Computed}); proceeding anyway.",
offline.Sha256,
computed);
}
}
return ParseBatch(payload, fromOfflineSnapshot: true);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to load Rancher hub offline snapshot from {Uri}.", offline.SnapshotUri);
return null;
}
}
private async Task<HttpRequestMessage> CreateRequestAsync(RancherHubConnectorOptions options, RancherHubMetadata metadata, Uri requestUri, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Accept.ParseAdd("application/json");
if (metadata.Subscription.RequiresAuthentication)
{
var token = await _tokenProvider.GetAccessTokenAsync(options, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var scheme = string.IsNullOrWhiteSpace(token.TokenType) ? "Bearer" : token.TokenType;
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token.Value);
}
}
return request;
}
private RancherHubEventBatch ParseBatch(string payload, bool fromOfflineSnapshot)
{
using var document = JsonDocument.Parse(payload, _documentOptions);
var root = document.RootElement;
var cursor = ReadString(root, "cursor", "currentCursor", "checkpoint");
var nextCursor = ReadString(root, "nextCursor", "next", "continuation", "continuationToken");
var eventsElement = TryGetProperty(root, "events", "items", "data") ?? default;
var events = ImmutableArray.CreateBuilder<RancherHubEventRecord>();
if (eventsElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in eventsElement.EnumerateArray())
{
events.Add(ParseEvent(item));
}
}
return new RancherHubEventBatch(cursor, nextCursor, events.ToImmutable(), fromOfflineSnapshot, payload);
}
private RancherHubEventRecord ParseEvent(JsonElement element)
{
var rawJson = element.GetRawText();
var id = ReadString(element, "id", "eventId", "uuid");
var type = ReadString(element, "type", "eventType");
var channel = ReadString(element, "channel", "product", "stream");
var publishedAt = ParseDate(ReadString(element, "publishedAt", "timestamp", "createdAt"));
Uri? documentUri = null;
string? documentDigest = null;
string? documentFormat = null;
var documentElement = TryGetProperty(element, "document", "payload", "statement");
if (documentElement.HasValue)
{
documentUri = ParseUri(ReadString(documentElement.Value, "uri", "url", "href"));
documentDigest = ReadString(documentElement.Value, "sha256", "digest", "checksum");
documentFormat = ReadString(documentElement.Value, "format", "kind", "type");
}
else
{
documentUri = ParseUri(ReadString(element, "documentUri", "uri", "url"));
documentDigest = ReadString(element, "documentSha256", "sha256", "digest");
documentFormat = ReadString(element, "documentFormat", "format");
}
return new RancherHubEventRecord(rawJson, id, type, channel, publishedAt, documentUri, documentDigest, documentFormat);
}
private static Uri? ParseUri(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null;
private static DateTimeOffset? ParseDate(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
private static string? ReadString(JsonElement element, params string[] propertyNames)
{
var property = TryGetProperty(element, propertyNames);
if (!property.HasValue || property.Value.ValueKind is not JsonValueKind.String)
{
return null;
}
var value = property.Value.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static JsonElement? TryGetProperty(JsonElement element, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
if (element.TryGetProperty(propertyName, out var property) && property.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
return property;
}
}
return null;
}
private static string BuildQueryString(Dictionary<string, string> parameters)
{
if (parameters.Count == 0)
{
return string.Empty;
}
var builder = new StringBuilder();
var first = true;
foreach (var kvp in parameters)
{
if (string.IsNullOrEmpty(kvp.Value))
{
continue;
}
if (!first)
{
builder.Append('&');
}
builder.Append(Uri.EscapeDataString(kvp.Key));
builder.Append('=');
builder.Append(Uri.EscapeDataString(kvp.Value));
first = false;
}
return builder.ToString();
}
private static Uri BuildRequestUri(Uri baseUri, string? cursor, DateTimeOffset? since, ImmutableArray<string> channels)
{
var builder = new UriBuilder(baseUri);
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(cursor))
{
parameters["cursor"] = cursor;
}
else if (since is not null)
{
parameters["since"] = since.Value.ToUniversalTime().ToString("O");
}
if (!channels.IsDefaultOrEmpty && channels.Length > 0)
{
parameters["channels"] = string.Join(',', channels);
}
var query = BuildQueryString(parameters);
builder.Query = string.IsNullOrEmpty(query) ? null : query;
return builder.Uri;
}
private static string ComputeSha256(string payload)
{
var bytes = Encoding.UTF8.GetBytes(payload);
Span<byte> hash = stackalloc byte[32];
if (SHA256.TryHashData(bytes, hash, out _))
{
return Convert.ToHexString(hash).ToLowerInvariant();
}
using var sha = SHA256.Create();
return Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
}
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly RancherHubTokenProvider _tokenProvider;
private readonly IFileSystem _fileSystem;
private readonly ILogger<RancherHubEventClient> _logger;
private readonly JsonDocumentOptions _documentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private const string CheckpointPrefix = "checkpoint";
public RancherHubEventClient(
IHttpClientFactory httpClientFactory,
RancherHubTokenProvider tokenProvider,
IFileSystem fileSystem,
ILogger<RancherHubEventClient> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async IAsyncEnumerable<RancherHubEventBatch> FetchEventBatchesAsync(
RancherHubConnectorOptions options,
RancherHubMetadata metadata,
string? cursor,
DateTimeOffset? since,
ImmutableArray<string> channels,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(metadata);
if (options.PreferOfflineSnapshot && metadata.OfflineSnapshot is not null)
{
var offline = await LoadOfflineSnapshotAsync(metadata.OfflineSnapshot, cancellationToken).ConfigureAwait(false);
if (offline is not null)
{
yield return offline;
}
yield break;
}
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
var currentCursor = cursor;
var currentSince = since;
var firstRequest = true;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var requestUri = BuildRequestUri(metadata.Subscription.EventsUri, currentCursor, currentSince, channels);
using var request = await CreateRequestAsync(options, metadata, requestUri, cancellationToken).ConfigureAwait(false);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Rancher hub events request failed ({(int)response.StatusCode} {response.StatusCode}). Payload: {payload}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var batch = ParseBatch(json, fromOfflineSnapshot: false);
yield return batch;
if (string.IsNullOrWhiteSpace(batch.NextCursor))
{
break;
}
if (!firstRequest && string.Equals(batch.NextCursor, currentCursor, StringComparison.Ordinal))
{
_logger.LogWarning("Detected stable cursor {Cursor}; stopping to avoid loop.", batch.NextCursor);
break;
}
currentCursor = batch.NextCursor;
currentSince = null; // cursor supersedes since parameter
firstRequest = false;
}
}
private async Task<RancherHubEventBatch?> LoadOfflineSnapshotAsync(RancherHubOfflineSnapshotMetadata offline, CancellationToken cancellationToken)
{
try
{
string payload;
if (offline.SnapshotUri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase))
{
var path = offline.SnapshotUri.LocalPath;
payload = await _fileSystem.File.ReadAllTextAsync(path, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
}
else
{
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var response = await client.GetAsync(offline.SnapshotUri, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(offline.Sha256))
{
var computed = ComputeSha256(payload);
if (!string.Equals(computed, offline.Sha256, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Offline snapshot digest mismatch (expected {Expected}, computed {Computed}); proceeding anyway.",
offline.Sha256,
computed);
}
}
return ParseBatch(payload, fromOfflineSnapshot: true);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to load Rancher hub offline snapshot from {Uri}.", offline.SnapshotUri);
return null;
}
}
private async Task<HttpRequestMessage> CreateRequestAsync(RancherHubConnectorOptions options, RancherHubMetadata metadata, Uri requestUri, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Accept.ParseAdd("application/json");
if (metadata.Subscription.RequiresAuthentication)
{
var token = await _tokenProvider.GetAccessTokenAsync(options, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var scheme = string.IsNullOrWhiteSpace(token.TokenType) ? "Bearer" : token.TokenType;
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token.Value);
}
}
return request;
}
private RancherHubEventBatch ParseBatch(string payload, bool fromOfflineSnapshot)
{
using var document = JsonDocument.Parse(payload, _documentOptions);
var root = document.RootElement;
var cursor = ReadString(root, "cursor", "currentCursor", "checkpoint");
var nextCursor = ReadString(root, "nextCursor", "next", "continuation", "continuationToken");
var eventsElement = TryGetProperty(root, "events", "items", "data") ?? default;
var events = ImmutableArray.CreateBuilder<RancherHubEventRecord>();
if (eventsElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in eventsElement.EnumerateArray())
{
events.Add(ParseEvent(item));
}
}
return new RancherHubEventBatch(cursor, nextCursor, events.ToImmutable(), fromOfflineSnapshot, payload);
}
private RancherHubEventRecord ParseEvent(JsonElement element)
{
var rawJson = element.GetRawText();
var id = ReadString(element, "id", "eventId", "uuid");
var type = ReadString(element, "type", "eventType");
var channel = ReadString(element, "channel", "product", "stream");
var publishedAt = ParseDate(ReadString(element, "publishedAt", "timestamp", "createdAt"));
Uri? documentUri = null;
string? documentDigest = null;
string? documentFormat = null;
var documentElement = TryGetProperty(element, "document", "payload", "statement");
if (documentElement.HasValue)
{
documentUri = ParseUri(ReadString(documentElement.Value, "uri", "url", "href"));
documentDigest = ReadString(documentElement.Value, "sha256", "digest", "checksum");
documentFormat = ReadString(documentElement.Value, "format", "kind", "type");
}
else
{
documentUri = ParseUri(ReadString(element, "documentUri", "uri", "url"));
documentDigest = ReadString(element, "documentSha256", "sha256", "digest");
documentFormat = ReadString(element, "documentFormat", "format");
}
return new RancherHubEventRecord(rawJson, id, type, channel, publishedAt, documentUri, documentDigest, documentFormat);
}
private static Uri? ParseUri(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null;
private static DateTimeOffset? ParseDate(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
private static string? ReadString(JsonElement element, params string[] propertyNames)
{
var property = TryGetProperty(element, propertyNames);
if (!property.HasValue || property.Value.ValueKind is not JsonValueKind.String)
{
return null;
}
var value = property.Value.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static JsonElement? TryGetProperty(JsonElement element, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
if (element.TryGetProperty(propertyName, out var property) && property.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
return property;
}
}
return null;
}
private static string BuildQueryString(Dictionary<string, string> parameters)
{
if (parameters.Count == 0)
{
return string.Empty;
}
var builder = new StringBuilder();
var first = true;
foreach (var kvp in parameters)
{
if (string.IsNullOrEmpty(kvp.Value))
{
continue;
}
if (!first)
{
builder.Append('&');
}
builder.Append(Uri.EscapeDataString(kvp.Key));
builder.Append('=');
builder.Append(Uri.EscapeDataString(kvp.Value));
first = false;
}
return builder.ToString();
}
private static Uri BuildRequestUri(Uri baseUri, string? cursor, DateTimeOffset? since, ImmutableArray<string> channels)
{
var builder = new UriBuilder(baseUri);
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(cursor))
{
parameters["cursor"] = cursor;
}
else if (since is not null)
{
parameters["since"] = since.Value.ToUniversalTime().ToString("O");
}
if (!channels.IsDefaultOrEmpty && channels.Length > 0)
{
parameters["channels"] = string.Join(',', channels);
}
var query = BuildQueryString(parameters);
builder.Query = string.IsNullOrEmpty(query) ? null : query;
return builder.Uri;
}
private static string ComputeSha256(string payload)
{
var bytes = Encoding.UTF8.GetBytes(payload);
Span<byte> hash = stackalloc byte[32];
if (SHA256.TryHashData(bytes, hash, out _))
{
return Convert.ToHexString(hash).ToLowerInvariant();
}
using var sha = SHA256.Create();
return Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
}

View File

@@ -1,455 +1,455 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
public sealed class RancherHubMetadataLoader
{
public const string CachePrefix = "StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly RancherHubTokenProvider _tokenProvider;
private readonly IFileSystem _fileSystem;
private readonly ILogger<RancherHubMetadataLoader> _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly JsonDocumentOptions _documentOptions;
public RancherHubMetadataLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
RancherHubTokenProvider tokenProvider,
IFileSystem fileSystem,
ILogger<RancherHubMetadataLoader> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_documentOptions = new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
}
public async Task<RancherHubMetadataResult> LoadAsync(RancherHubConnectorOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
var cacheKey = CreateCacheKey(options);
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out var cached) && cached is not null && !cached.IsExpired())
{
_logger.LogDebug("Returning cached Rancher hub metadata (expires {Expires}).", cached.ExpiresAt);
return new RancherHubMetadataResult(cached.Metadata, cached.FetchedAt, FromCache: true, cached.FromOfflineSnapshot);
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out cached) && cached is not null && !cached.IsExpired())
{
return new RancherHubMetadataResult(cached.Metadata, cached.FetchedAt, FromCache: true, cached.FromOfflineSnapshot);
}
CacheEntry? previous = cached;
CacheEntry? entry = null;
if (options.PreferOfflineSnapshot)
{
entry = TryLoadFromOffline(options);
if (entry is null)
{
throw new InvalidOperationException("PreferOfflineSnapshot is enabled but no offline discovery snapshot was found.");
}
}
else
{
entry = await TryFetchFromNetworkAsync(options, previous, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
entry = TryLoadFromOffline(options);
}
}
if (entry is null)
{
throw new InvalidOperationException("Unable to load Rancher hub discovery metadata from network or offline snapshot.");
}
_memoryCache.Set(cacheKey, entry, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = options.MetadataCacheDuration,
});
return new RancherHubMetadataResult(entry.Metadata, entry.FetchedAt, FromCache: false, entry.FromOfflineSnapshot);
}
finally
{
_semaphore.Release();
}
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(RancherHubConnectorOptions options, CacheEntry? previous, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, options.DiscoveryUri);
request.Headers.Accept.ParseAdd("application/json");
if (!string.IsNullOrWhiteSpace(previous?.ETag) && EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
{
request.Headers.IfNoneMatch.Add(etag);
}
var token = await _tokenProvider.GetAccessTokenAsync(options, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var scheme = string.IsNullOrWhiteSpace(token.TokenType) ? "Bearer" : token.TokenType;
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token.Value);
}
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
{
_logger.LogDebug("Rancher hub discovery document not modified (etag {ETag}).", previous.ETag);
return previous with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + options.MetadataCacheDuration,
FromOfflineSnapshot = false,
};
}
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var metadata = ParseMetadata(payload, options);
var entry = new CacheEntry(
metadata,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow + options.MetadataCacheDuration,
response.Headers.ETag?.ToString(),
FromOfflineSnapshot: false,
Payload: payload);
PersistOfflineSnapshot(options, payload);
return entry;
}
catch (Exception ex) when (ex is not OperationCanceledException && !options.PreferOfflineSnapshot)
{
_logger.LogWarning(ex, "Failed to fetch Rancher hub discovery document; attempting offline snapshot fallback.");
return null;
}
}
private CacheEntry? TryLoadFromOffline(RancherHubConnectorOptions options)
{
if (string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(options.OfflineSnapshotPath))
{
_logger.LogWarning("Rancher hub offline snapshot path {Path} does not exist.", options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(options.OfflineSnapshotPath);
var metadata = ParseMetadata(payload, options);
return new CacheEntry(
metadata,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow + options.MetadataCacheDuration,
ETag: null,
FromOfflineSnapshot: true,
Payload: payload);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Rancher hub discovery metadata from offline snapshot {Path}.", options.OfflineSnapshotPath);
return null;
}
}
private void PersistOfflineSnapshot(RancherHubConnectorOptions options, string payload)
{
if (!options.PersistOfflineSnapshot || string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return;
}
try
{
var directory = _fileSystem.Path.GetDirectoryName(options.OfflineSnapshotPath);
if (!string.IsNullOrWhiteSpace(directory))
{
_fileSystem.Directory.CreateDirectory(directory);
}
_fileSystem.File.WriteAllText(options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Rancher hub discovery snapshot to {Path}.", options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Rancher hub discovery snapshot to {Path}.", options.OfflineSnapshotPath);
}
}
private RancherHubMetadata ParseMetadata(string payload, RancherHubConnectorOptions options)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("Rancher hub discovery payload was empty.");
}
try
{
using var document = JsonDocument.Parse(payload, _documentOptions);
var root = document.RootElement;
var hubId = ReadString(root, "hubId") ?? "excititor:suse:rancher";
var title = ReadString(root, "title") ?? ReadString(root, "displayName") ?? "SUSE Rancher VEX Hub";
var baseUri = ReadUri(root, "baseUri");
var subscriptionElement = TryGetProperty(root, "subscription");
if (!subscriptionElement.HasValue)
{
throw new InvalidOperationException("Discovery payload missing subscription section.");
}
var subscription = subscriptionElement.Value;
var eventsUri = ReadRequiredUri(subscription, "eventsUri", "eventsUrl", "eventsEndpoint");
var checkpointUri = ReadUri(subscription, "checkpointUri", "checkpointUrl", "checkpointEndpoint");
var channels = ReadStringArray(subscription, "channels", "defaultChannels", "products");
var scopes = ReadStringArray(subscription, "scopes", "defaultScopes");
var requiresAuth = ReadBoolean(subscription, "requiresAuthentication", defaultValue: options.TokenEndpoint is not null);
var authenticationElement = TryGetProperty(root, "authentication");
var tokenEndpointFromMetadata = authenticationElement.HasValue
? ReadUri(authenticationElement.Value, "tokenUri", "tokenEndpoint") ?? options.TokenEndpoint
: options.TokenEndpoint;
var audience = authenticationElement.HasValue
? ReadString(authenticationElement.Value, "audience", "aud") ?? options.Audience
: options.Audience;
var offlineElement = TryGetProperty(root, "offline", "snapshot");
var offlineSnapshot = offlineElement.HasValue
? BuildOfflineSnapshot(offlineElement.Value, options)
: null;
var provider = BuildProvider(hubId, title, baseUri, eventsUri, options);
var subscriptionMetadata = new RancherHubSubscriptionMetadata(eventsUri, checkpointUri, channels, scopes, requiresAuth);
var authenticationMetadata = new RancherHubAuthenticationMetadata(tokenEndpointFromMetadata, audience);
return new RancherHubMetadata(provider, subscriptionMetadata, authenticationMetadata, offlineSnapshot);
}
catch (JsonException ex)
{
throw new InvalidOperationException("Failed to parse Rancher hub discovery payload.", ex);
}
}
private RancherHubOfflineSnapshotMetadata? BuildOfflineSnapshot(JsonElement element, RancherHubConnectorOptions options)
{
var snapshotUri = ReadUri(element, "snapshotUri", "uri", "url");
if (snapshotUri is null)
{
return null;
}
var checksum = ReadString(element, "sha256", "checksum", "digest");
DateTimeOffset? updatedAt = null;
var updatedString = ReadString(element, "updated", "lastModified", "timestamp");
if (!string.IsNullOrWhiteSpace(updatedString) && DateTimeOffset.TryParse(updatedString, out var parsed))
{
updatedAt = parsed;
}
return new RancherHubOfflineSnapshotMetadata(snapshotUri, checksum, updatedAt);
}
private VexProvider BuildProvider(string hubId, string title, Uri? baseUri, Uri eventsUri, RancherHubConnectorOptions options)
{
var baseUris = new List<Uri>();
if (baseUri is not null)
{
baseUris.Add(baseUri);
}
baseUris.Add(eventsUri);
VexCosignTrust? cosign = null;
if (!string.IsNullOrWhiteSpace(options.CosignIssuer) && !string.IsNullOrWhiteSpace(options.CosignIdentityPattern))
{
cosign = new VexCosignTrust(options.CosignIssuer!, options.CosignIdentityPattern!);
}
var trust = new VexProviderTrust(options.TrustWeight, cosign, options.PgpFingerprints);
return new VexProvider(hubId, title, VexProviderKind.Hub, baseUris, new VexProviderDiscovery(options.DiscoveryUri, null), trust);
}
private static string CreateCacheKey(RancherHubConnectorOptions options)
=> $"{CachePrefix}:{options.DiscoveryUri}";
private static JsonElement? TryGetProperty(JsonElement element, params string[] propertyNames)
{
foreach (var name in propertyNames)
{
if (element.TryGetProperty(name, out var value) && value.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
return value;
}
}
return null;
}
private static string? ReadString(JsonElement element, params string[] propertyNames)
{
var property = TryGetProperty(element, propertyNames);
if (property is null || property.Value.ValueKind is not JsonValueKind.String)
{
return null;
}
var value = property.Value.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static bool ReadBoolean(JsonElement element, string propertyName, bool defaultValue)
{
if (!element.TryGetProperty(propertyName, out var property))
{
return defaultValue;
}
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed,
_ => defaultValue,
};
}
private static ImmutableArray<string> ReadStringArray(JsonElement element, params string[] propertyNames)
{
var property = TryGetProperty(element, propertyNames);
if (property is null)
{
return ImmutableArray<string>.Empty;
}
if (property.Value.ValueKind is JsonValueKind.Array)
{
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var item in property.Value.EnumerateArray())
{
if (item.ValueKind is JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
builder.Add(value!);
}
}
}
return builder.Count == 0 ? ImmutableArray<string>.Empty : builder.ToImmutable();
}
if (property.Value.ValueKind is JsonValueKind.String)
{
var single = property.Value.GetString();
return string.IsNullOrWhiteSpace(single)
? ImmutableArray<string>.Empty
: ImmutableArray.Create(single!);
}
return ImmutableArray<string>.Empty;
}
private static Uri? ReadUri(JsonElement element, params string[] propertyNames)
{
var value = ReadString(element, propertyNames);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException($"Discovery field '{string.Join("/", propertyNames)}' must be an absolute HTTP(S) URI.");
}
return uri;
}
private static Uri ReadRequiredUri(JsonElement element, params string[] propertyNames)
{
var uri = ReadUri(element, propertyNames);
if (uri is null)
{
throw new InvalidOperationException($"Discovery payload missing required URI field '{string.Join("/", propertyNames)}'.");
}
return uri;
}
private sealed record CacheEntry(
RancherHubMetadata Metadata,
DateTimeOffset FetchedAt,
DateTimeOffset ExpiresAt,
string? ETag,
bool FromOfflineSnapshot,
string? Payload)
{
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
}
}
public sealed record RancherHubMetadata(
VexProvider Provider,
RancherHubSubscriptionMetadata Subscription,
RancherHubAuthenticationMetadata Authentication,
RancherHubOfflineSnapshotMetadata? OfflineSnapshot);
public sealed record RancherHubSubscriptionMetadata(
Uri EventsUri,
Uri? CheckpointUri,
ImmutableArray<string> Channels,
ImmutableArray<string> Scopes,
bool RequiresAuthentication);
public sealed record RancherHubAuthenticationMetadata(
Uri? TokenEndpoint,
string? Audience);
public sealed record RancherHubOfflineSnapshotMetadata(
Uri SnapshotUri,
string? Sha256,
DateTimeOffset? UpdatedAt);
public sealed record RancherHubMetadataResult(
RancherHubMetadata Metadata,
DateTimeOffset FetchedAt,
bool FromCache,
bool FromOfflineSnapshot);
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
public sealed class RancherHubMetadataLoader
{
public const string CachePrefix = "StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly RancherHubTokenProvider _tokenProvider;
private readonly IFileSystem _fileSystem;
private readonly ILogger<RancherHubMetadataLoader> _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly JsonDocumentOptions _documentOptions;
public RancherHubMetadataLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
RancherHubTokenProvider tokenProvider,
IFileSystem fileSystem,
ILogger<RancherHubMetadataLoader> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_documentOptions = new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
}
public async Task<RancherHubMetadataResult> LoadAsync(RancherHubConnectorOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
var cacheKey = CreateCacheKey(options);
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out var cached) && cached is not null && !cached.IsExpired())
{
_logger.LogDebug("Returning cached Rancher hub metadata (expires {Expires}).", cached.ExpiresAt);
return new RancherHubMetadataResult(cached.Metadata, cached.FetchedAt, FromCache: true, cached.FromOfflineSnapshot);
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out cached) && cached is not null && !cached.IsExpired())
{
return new RancherHubMetadataResult(cached.Metadata, cached.FetchedAt, FromCache: true, cached.FromOfflineSnapshot);
}
CacheEntry? previous = cached;
CacheEntry? entry = null;
if (options.PreferOfflineSnapshot)
{
entry = TryLoadFromOffline(options);
if (entry is null)
{
throw new InvalidOperationException("PreferOfflineSnapshot is enabled but no offline discovery snapshot was found.");
}
}
else
{
entry = await TryFetchFromNetworkAsync(options, previous, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
entry = TryLoadFromOffline(options);
}
}
if (entry is null)
{
throw new InvalidOperationException("Unable to load Rancher hub discovery metadata from network or offline snapshot.");
}
_memoryCache.Set(cacheKey, entry, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = options.MetadataCacheDuration,
});
return new RancherHubMetadataResult(entry.Metadata, entry.FetchedAt, FromCache: false, entry.FromOfflineSnapshot);
}
finally
{
_semaphore.Release();
}
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(RancherHubConnectorOptions options, CacheEntry? previous, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, options.DiscoveryUri);
request.Headers.Accept.ParseAdd("application/json");
if (!string.IsNullOrWhiteSpace(previous?.ETag) && EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
{
request.Headers.IfNoneMatch.Add(etag);
}
var token = await _tokenProvider.GetAccessTokenAsync(options, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var scheme = string.IsNullOrWhiteSpace(token.TokenType) ? "Bearer" : token.TokenType;
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token.Value);
}
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
{
_logger.LogDebug("Rancher hub discovery document not modified (etag {ETag}).", previous.ETag);
return previous with
{
FetchedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow + options.MetadataCacheDuration,
FromOfflineSnapshot = false,
};
}
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var metadata = ParseMetadata(payload, options);
var entry = new CacheEntry(
metadata,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow + options.MetadataCacheDuration,
response.Headers.ETag?.ToString(),
FromOfflineSnapshot: false,
Payload: payload);
PersistOfflineSnapshot(options, payload);
return entry;
}
catch (Exception ex) when (ex is not OperationCanceledException && !options.PreferOfflineSnapshot)
{
_logger.LogWarning(ex, "Failed to fetch Rancher hub discovery document; attempting offline snapshot fallback.");
return null;
}
}
private CacheEntry? TryLoadFromOffline(RancherHubConnectorOptions options)
{
if (string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(options.OfflineSnapshotPath))
{
_logger.LogWarning("Rancher hub offline snapshot path {Path} does not exist.", options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(options.OfflineSnapshotPath);
var metadata = ParseMetadata(payload, options);
return new CacheEntry(
metadata,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow + options.MetadataCacheDuration,
ETag: null,
FromOfflineSnapshot: true,
Payload: payload);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Rancher hub discovery metadata from offline snapshot {Path}.", options.OfflineSnapshotPath);
return null;
}
}
private void PersistOfflineSnapshot(RancherHubConnectorOptions options, string payload)
{
if (!options.PersistOfflineSnapshot || string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return;
}
try
{
var directory = _fileSystem.Path.GetDirectoryName(options.OfflineSnapshotPath);
if (!string.IsNullOrWhiteSpace(directory))
{
_fileSystem.Directory.CreateDirectory(directory);
}
_fileSystem.File.WriteAllText(options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Rancher hub discovery snapshot to {Path}.", options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Rancher hub discovery snapshot to {Path}.", options.OfflineSnapshotPath);
}
}
private RancherHubMetadata ParseMetadata(string payload, RancherHubConnectorOptions options)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("Rancher hub discovery payload was empty.");
}
try
{
using var document = JsonDocument.Parse(payload, _documentOptions);
var root = document.RootElement;
var hubId = ReadString(root, "hubId") ?? "excititor:suse:rancher";
var title = ReadString(root, "title") ?? ReadString(root, "displayName") ?? "SUSE Rancher VEX Hub";
var baseUri = ReadUri(root, "baseUri");
var subscriptionElement = TryGetProperty(root, "subscription");
if (!subscriptionElement.HasValue)
{
throw new InvalidOperationException("Discovery payload missing subscription section.");
}
var subscription = subscriptionElement.Value;
var eventsUri = ReadRequiredUri(subscription, "eventsUri", "eventsUrl", "eventsEndpoint");
var checkpointUri = ReadUri(subscription, "checkpointUri", "checkpointUrl", "checkpointEndpoint");
var channels = ReadStringArray(subscription, "channels", "defaultChannels", "products");
var scopes = ReadStringArray(subscription, "scopes", "defaultScopes");
var requiresAuth = ReadBoolean(subscription, "requiresAuthentication", defaultValue: options.TokenEndpoint is not null);
var authenticationElement = TryGetProperty(root, "authentication");
var tokenEndpointFromMetadata = authenticationElement.HasValue
? ReadUri(authenticationElement.Value, "tokenUri", "tokenEndpoint") ?? options.TokenEndpoint
: options.TokenEndpoint;
var audience = authenticationElement.HasValue
? ReadString(authenticationElement.Value, "audience", "aud") ?? options.Audience
: options.Audience;
var offlineElement = TryGetProperty(root, "offline", "snapshot");
var offlineSnapshot = offlineElement.HasValue
? BuildOfflineSnapshot(offlineElement.Value, options)
: null;
var provider = BuildProvider(hubId, title, baseUri, eventsUri, options);
var subscriptionMetadata = new RancherHubSubscriptionMetadata(eventsUri, checkpointUri, channels, scopes, requiresAuth);
var authenticationMetadata = new RancherHubAuthenticationMetadata(tokenEndpointFromMetadata, audience);
return new RancherHubMetadata(provider, subscriptionMetadata, authenticationMetadata, offlineSnapshot);
}
catch (JsonException ex)
{
throw new InvalidOperationException("Failed to parse Rancher hub discovery payload.", ex);
}
}
private RancherHubOfflineSnapshotMetadata? BuildOfflineSnapshot(JsonElement element, RancherHubConnectorOptions options)
{
var snapshotUri = ReadUri(element, "snapshotUri", "uri", "url");
if (snapshotUri is null)
{
return null;
}
var checksum = ReadString(element, "sha256", "checksum", "digest");
DateTimeOffset? updatedAt = null;
var updatedString = ReadString(element, "updated", "lastModified", "timestamp");
if (!string.IsNullOrWhiteSpace(updatedString) && DateTimeOffset.TryParse(updatedString, out var parsed))
{
updatedAt = parsed;
}
return new RancherHubOfflineSnapshotMetadata(snapshotUri, checksum, updatedAt);
}
private VexProvider BuildProvider(string hubId, string title, Uri? baseUri, Uri eventsUri, RancherHubConnectorOptions options)
{
var baseUris = new List<Uri>();
if (baseUri is not null)
{
baseUris.Add(baseUri);
}
baseUris.Add(eventsUri);
VexCosignTrust? cosign = null;
if (!string.IsNullOrWhiteSpace(options.CosignIssuer) && !string.IsNullOrWhiteSpace(options.CosignIdentityPattern))
{
cosign = new VexCosignTrust(options.CosignIssuer!, options.CosignIdentityPattern!);
}
var trust = new VexProviderTrust(options.TrustWeight, cosign, options.PgpFingerprints);
return new VexProvider(hubId, title, VexProviderKind.Hub, baseUris, new VexProviderDiscovery(options.DiscoveryUri, null), trust);
}
private static string CreateCacheKey(RancherHubConnectorOptions options)
=> $"{CachePrefix}:{options.DiscoveryUri}";
private static JsonElement? TryGetProperty(JsonElement element, params string[] propertyNames)
{
foreach (var name in propertyNames)
{
if (element.TryGetProperty(name, out var value) && value.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
return value;
}
}
return null;
}
private static string? ReadString(JsonElement element, params string[] propertyNames)
{
var property = TryGetProperty(element, propertyNames);
if (property is null || property.Value.ValueKind is not JsonValueKind.String)
{
return null;
}
var value = property.Value.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static bool ReadBoolean(JsonElement element, string propertyName, bool defaultValue)
{
if (!element.TryGetProperty(propertyName, out var property))
{
return defaultValue;
}
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed,
_ => defaultValue,
};
}
private static ImmutableArray<string> ReadStringArray(JsonElement element, params string[] propertyNames)
{
var property = TryGetProperty(element, propertyNames);
if (property is null)
{
return ImmutableArray<string>.Empty;
}
if (property.Value.ValueKind is JsonValueKind.Array)
{
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var item in property.Value.EnumerateArray())
{
if (item.ValueKind is JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
builder.Add(value!);
}
}
}
return builder.Count == 0 ? ImmutableArray<string>.Empty : builder.ToImmutable();
}
if (property.Value.ValueKind is JsonValueKind.String)
{
var single = property.Value.GetString();
return string.IsNullOrWhiteSpace(single)
? ImmutableArray<string>.Empty
: ImmutableArray.Create(single!);
}
return ImmutableArray<string>.Empty;
}
private static Uri? ReadUri(JsonElement element, params string[] propertyNames)
{
var value = ReadString(element, propertyNames);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException($"Discovery field '{string.Join("/", propertyNames)}' must be an absolute HTTP(S) URI.");
}
return uri;
}
private static Uri ReadRequiredUri(JsonElement element, params string[] propertyNames)
{
var uri = ReadUri(element, propertyNames);
if (uri is null)
{
throw new InvalidOperationException($"Discovery payload missing required URI field '{string.Join("/", propertyNames)}'.");
}
return uri;
}
private sealed record CacheEntry(
RancherHubMetadata Metadata,
DateTimeOffset FetchedAt,
DateTimeOffset ExpiresAt,
string? ETag,
bool FromOfflineSnapshot,
string? Payload)
{
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
}
}
public sealed record RancherHubMetadata(
VexProvider Provider,
RancherHubSubscriptionMetadata Subscription,
RancherHubAuthenticationMetadata Authentication,
RancherHubOfflineSnapshotMetadata? OfflineSnapshot);
public sealed record RancherHubSubscriptionMetadata(
Uri EventsUri,
Uri? CheckpointUri,
ImmutableArray<string> Channels,
ImmutableArray<string> Scopes,
bool RequiresAuthentication);
public sealed record RancherHubAuthenticationMetadata(
Uri? TokenEndpoint,
string? Audience);
public sealed record RancherHubOfflineSnapshotMetadata(
Uri SnapshotUri,
string? Sha256,
DateTimeOffset? UpdatedAt);
public sealed record RancherHubMetadataResult(
RancherHubMetadata Metadata,
DateTimeOffset FetchedAt,
bool FromCache,
bool FromOfflineSnapshot);

View File

@@ -1,435 +1,435 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub;
public sealed class RancherHubConnector : VexConnectorBase
{
private const int MaxDigestHistory = 200;
private static readonly VexConnectorDescriptor StaticDescriptor = new(
id: "excititor:suse.rancher",
kind: VexProviderKind.Hub,
displayName: "SUSE Rancher VEX Hub")
{
Tags = ImmutableArray.Create("hub", "suse", "offline"),
};
private readonly RancherHubMetadataLoader _metadataLoader;
private readonly RancherHubEventClient _eventClient;
private readonly RancherHubCheckpointManager _checkpointManager;
private readonly RancherHubTokenProvider _tokenProvider;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IEnumerable<IVexConnectorOptionsValidator<RancherHubConnectorOptions>> _validators;
private RancherHubConnectorOptions? _options;
private RancherHubMetadataResult? _metadata;
public RancherHubConnector(
RancherHubMetadataLoader metadataLoader,
RancherHubEventClient eventClient,
RancherHubCheckpointManager checkpointManager,
RancherHubTokenProvider tokenProvider,
IHttpClientFactory httpClientFactory,
ILogger<RancherHubConnector> logger,
TimeProvider timeProvider,
IEnumerable<IVexConnectorOptionsValidator<RancherHubConnectorOptions>>? validators = null)
: base(StaticDescriptor, logger, timeProvider)
{
_metadataLoader = metadataLoader ?? throw new ArgumentNullException(nameof(metadataLoader));
_eventClient = eventClient ?? throw new ArgumentNullException(nameof(eventClient));
_checkpointManager = checkpointManager ?? throw new ArgumentNullException(nameof(checkpointManager));
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<RancherHubConnectorOptions>>();
}
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
_options = VexConnectorOptionsBinder.Bind(
Descriptor,
settings,
validators: _validators);
_metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Information, "validate", "Rancher hub discovery loaded.", new Dictionary<string, object?>
{
["discoveryUri"] = _options.DiscoveryUri.ToString(),
["subscriptionUri"] = _metadata.Metadata.Subscription.EventsUri.ToString(),
["requiresAuth"] = _metadata.Metadata.Subscription.RequiresAuthentication,
["fromOffline"] = _metadata.FromOfflineSnapshot,
});
}
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (_options is null)
{
throw new InvalidOperationException("Connector must be validated before fetch operations.");
}
if (_metadata is null)
{
_metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
}
await UpsertProviderAsync(context.Services, _metadata.Metadata.Provider, cancellationToken).ConfigureAwait(false);
var checkpoint = await _checkpointManager.LoadAsync(Descriptor.Id, context, cancellationToken).ConfigureAwait(false);
var digestHistory = checkpoint.Digests.ToList();
var dedupeSet = new HashSet<string>(checkpoint.Digests, StringComparer.OrdinalIgnoreCase);
var latestCursor = checkpoint.Cursor;
var latestPublishedAt = checkpoint.LastPublishedAt ?? checkpoint.EffectiveSince;
var stateChanged = false;
LogConnectorEvent(LogLevel.Information, "fetch_start", "Starting Rancher hub event ingestion.", new Dictionary<string, object?>
{
["since"] = checkpoint.EffectiveSince?.ToString("O"),
["cursor"] = checkpoint.Cursor,
["subscriptionUri"] = _metadata.Metadata.Subscription.EventsUri.ToString(),
["offline"] = checkpoint.Cursor is null && _options.PreferOfflineSnapshot,
});
await foreach (var batch in _eventClient.FetchEventBatchesAsync(
_options,
_metadata.Metadata,
checkpoint.Cursor,
checkpoint.EffectiveSince,
_metadata.Metadata.Subscription.Channels,
cancellationToken).ConfigureAwait(false))
{
LogConnectorEvent(LogLevel.Debug, "batch", "Processing Rancher hub batch.", new Dictionary<string, object?>
{
["cursor"] = batch.Cursor,
["nextCursor"] = batch.NextCursor,
["count"] = batch.Events.Length,
["offline"] = batch.FromOfflineSnapshot,
});
if (!string.IsNullOrWhiteSpace(batch.NextCursor) && !string.Equals(batch.NextCursor, latestCursor, StringComparison.Ordinal))
{
latestCursor = batch.NextCursor;
stateChanged = true;
}
else if (string.IsNullOrWhiteSpace(latestCursor) && !string.IsNullOrWhiteSpace(batch.Cursor))
{
latestCursor = batch.Cursor;
}
foreach (var record in batch.Events)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ProcessEventAsync(record, batch, context, dedupeSet, digestHistory, cancellationToken).ConfigureAwait(false);
if (result.ProcessedDocument is not null)
{
yield return result.ProcessedDocument;
stateChanged = true;
if (result.PublishedAt is { } published && (latestPublishedAt is null || published > latestPublishedAt))
{
latestPublishedAt = published;
}
}
else if (result.Quarantined)
{
stateChanged = true;
}
}
}
var trimmed = TrimHistory(digestHistory);
if (trimmed)
{
stateChanged = true;
}
if (stateChanged || !string.Equals(latestCursor, checkpoint.Cursor, StringComparison.Ordinal) || latestPublishedAt != checkpoint.LastPublishedAt)
{
await _checkpointManager.SaveAsync(
Descriptor.Id,
latestCursor,
latestPublishedAt,
digestHistory.ToImmutableArray(),
cancellationToken).ConfigureAwait(false);
}
}
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> throw new NotSupportedException("RancherHubConnector relies on format-specific normalizers for CSAF/OpenVEX payloads.");
public RancherHubMetadata? GetCachedMetadata() => _metadata?.Metadata;
private async Task<EventProcessingResult> ProcessEventAsync(
RancherHubEventRecord record,
RancherHubEventBatch batch,
VexConnectorContext context,
HashSet<string> dedupeSet,
List<string> digestHistory,
CancellationToken cancellationToken)
{
var quarantineKey = BuildQuarantineKey(record);
if (dedupeSet.Contains(quarantineKey))
{
return EventProcessingResult.QuarantinedOnly;
}
if (record.DocumentUri is null || string.IsNullOrWhiteSpace(record.Id))
{
await QuarantineAsync(record, batch, "missing documentUri or id", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var request = await CreateDocumentRequestAsync(record.DocumentUri, cancellationToken).ConfigureAwait(false);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
await QuarantineAsync(record, batch, $"document fetch failed ({(int)response.StatusCode} {response.StatusCode})", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
var contentBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var publishedAt = record.PublishedAt ?? UtcNow();
var metadata = BuildMetadata(builder =>
{
builder
.Add("rancher.event.id", record.Id)
.Add("rancher.event.type", record.Type)
.Add("rancher.event.channel", record.Channel)
.Add("rancher.event.published", publishedAt)
.Add("rancher.event.cursor", batch.NextCursor ?? batch.Cursor)
.Add("rancher.event.offline", batch.FromOfflineSnapshot ? "true" : "false")
.Add("rancher.event.declaredDigest", record.DocumentDigest);
AddProvenanceMetadata(builder);
});
var format = ResolveFormat(record.DocumentFormat);
var document = CreateRawDocument(format, record.DocumentUri, contentBytes, metadata);
if (!string.IsNullOrWhiteSpace(record.DocumentDigest))
{
var declared = NormalizeDigest(record.DocumentDigest);
var computed = NormalizeDigest(document.Digest);
if (!string.Equals(declared, computed, StringComparison.OrdinalIgnoreCase))
{
await QuarantineAsync(record, batch, $"digest mismatch (declared {record.DocumentDigest}, computed {document.Digest})", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
}
if (!dedupeSet.Add(document.Digest))
{
return EventProcessingResult.Skipped;
}
digestHistory.Add(document.Digest);
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
return new EventProcessingResult(document, false, publishedAt);
}
private void AddProvenanceMetadata(VexConnectorMetadataBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
var provider = _metadata?.Metadata.Provider;
if (provider is null)
{
return;
}
builder
.Add("vex.provenance.provider", provider.Id)
.Add("vex.provenance.providerName", provider.DisplayName)
.Add("vex.provenance.providerKind", provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture))
.Add("vex.provenance.trust.weight", provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture));
if (provider.Trust.Cosign is { } cosign)
{
builder
.Add("vex.provenance.cosign.issuer", cosign.Issuer)
.Add("vex.provenance.cosign.identityPattern", cosign.IdentityPattern);
}
if (!provider.Trust.PgpFingerprints.IsDefaultOrEmpty && provider.Trust.PgpFingerprints.Length > 0)
{
builder.Add("vex.provenance.pgp.fingerprints", string.Join(',', provider.Trust.PgpFingerprints));
}
var tier = provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture);
builder
.Add("vex.provenance.trust.tier", tier)
.Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
// Enrich with connector signer metadata (fingerprints, issuer tier, bundle info)
// from external signer metadata file (STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH)
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, Logger);
}
private static bool TrimHistory(List<string> digestHistory)
{
if (digestHistory.Count <= MaxDigestHistory)
{
return false;
}
var excess = digestHistory.Count - MaxDigestHistory;
digestHistory.RemoveRange(0, excess);
return true;
}
private async Task<HttpRequestMessage> CreateDocumentRequestAsync(Uri documentUri, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, documentUri);
if (_metadata?.Metadata.Subscription.RequiresAuthentication ?? false)
{
var token = await _tokenProvider.GetAccessTokenAsync(_options!, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var scheme = string.IsNullOrWhiteSpace(token.TokenType) ? "Bearer" : token.TokenType;
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token.Value);
}
}
return request;
}
private static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)
{
if (services is null)
{
return;
}
var store = services.GetService<IVexProviderStore>();
if (store is null)
{
return;
}
await store.SaveAsync(provider, cancellationToken).ConfigureAwait(false);
}
private async Task QuarantineAsync(
RancherHubEventRecord record,
RancherHubEventBatch batch,
string reason,
VexConnectorContext context,
CancellationToken cancellationToken)
{
var metadata = BuildMetadata(builder =>
{
builder
.Add("rancher.event.id", record.Id)
.Add("rancher.event.type", record.Type)
.Add("rancher.event.channel", record.Channel)
.Add("rancher.event.quarantine", "true")
.Add("rancher.event.error", reason)
.Add("rancher.event.cursor", batch.NextCursor ?? batch.Cursor)
.Add("rancher.event.offline", batch.FromOfflineSnapshot ? "true" : "false");
AddProvenanceMetadata(builder);
});
var sourceUri = record.DocumentUri ?? _metadata?.Metadata.Subscription.EventsUri ?? _options!.DiscoveryUri;
var payload = Encoding.UTF8.GetBytes(record.RawJson);
var document = CreateRawDocument(VexDocumentFormat.Csaf, sourceUri, payload, metadata);
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Warning, "quarantine", "Rancher hub event moved to quarantine.", new Dictionary<string, object?>
{
["eventId"] = record.Id ?? "(missing)",
["reason"] = reason,
});
}
private static void AddQuarantineDigest(string key, HashSet<string> dedupeSet, List<string> digestHistory)
{
if (dedupeSet.Add(key))
{
digestHistory.Add(key);
}
}
private static string BuildQuarantineKey(RancherHubEventRecord record)
{
if (!string.IsNullOrWhiteSpace(record.Id))
{
return $"quarantine:{record.Id}";
}
Span<byte> hash = stackalloc byte[32];
var bytes = Encoding.UTF8.GetBytes(record.RawJson);
if (!SHA256.TryHashData(bytes, hash, out _))
{
using var sha = SHA256.Create();
hash = sha.ComputeHash(bytes);
}
return $"quarantine:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string NormalizeDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return digest;
}
var trimmed = digest.Trim();
return trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? trimmed.ToLowerInvariant()
: $"sha256:{trimmed.ToLowerInvariant()}";
}
private static VexDocumentFormat ResolveFormat(string? format)
{
if (string.IsNullOrWhiteSpace(format))
{
return VexDocumentFormat.Csaf;
}
return format.ToLowerInvariant() switch
{
"csaf" or "csaf_json" or "json" => VexDocumentFormat.Csaf,
"cyclonedx" or "cyclonedx_vex" => VexDocumentFormat.CycloneDx,
"openvex" => VexDocumentFormat.OpenVex,
"oci" or "oci_attestation" or "attestation" => VexDocumentFormat.OciAttestation,
_ => VexDocumentFormat.Csaf,
};
}
private sealed record EventProcessingResult(VexRawDocument? ProcessedDocument, bool Quarantined, DateTimeOffset? PublishedAt)
{
public static EventProcessingResult QuarantinedOnly { get; } = new(null, true, null);
public static EventProcessingResult Skipped { get; } = new(null, false, null);
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub;
public sealed class RancherHubConnector : VexConnectorBase
{
private const int MaxDigestHistory = 200;
private static readonly VexConnectorDescriptor StaticDescriptor = new(
id: "excititor:suse.rancher",
kind: VexProviderKind.Hub,
displayName: "SUSE Rancher VEX Hub")
{
Tags = ImmutableArray.Create("hub", "suse", "offline"),
};
private readonly RancherHubMetadataLoader _metadataLoader;
private readonly RancherHubEventClient _eventClient;
private readonly RancherHubCheckpointManager _checkpointManager;
private readonly RancherHubTokenProvider _tokenProvider;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IEnumerable<IVexConnectorOptionsValidator<RancherHubConnectorOptions>> _validators;
private RancherHubConnectorOptions? _options;
private RancherHubMetadataResult? _metadata;
public RancherHubConnector(
RancherHubMetadataLoader metadataLoader,
RancherHubEventClient eventClient,
RancherHubCheckpointManager checkpointManager,
RancherHubTokenProvider tokenProvider,
IHttpClientFactory httpClientFactory,
ILogger<RancherHubConnector> logger,
TimeProvider timeProvider,
IEnumerable<IVexConnectorOptionsValidator<RancherHubConnectorOptions>>? validators = null)
: base(StaticDescriptor, logger, timeProvider)
{
_metadataLoader = metadataLoader ?? throw new ArgumentNullException(nameof(metadataLoader));
_eventClient = eventClient ?? throw new ArgumentNullException(nameof(eventClient));
_checkpointManager = checkpointManager ?? throw new ArgumentNullException(nameof(checkpointManager));
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<RancherHubConnectorOptions>>();
}
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
_options = VexConnectorOptionsBinder.Bind(
Descriptor,
settings,
validators: _validators);
_metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Information, "validate", "Rancher hub discovery loaded.", new Dictionary<string, object?>
{
["discoveryUri"] = _options.DiscoveryUri.ToString(),
["subscriptionUri"] = _metadata.Metadata.Subscription.EventsUri.ToString(),
["requiresAuth"] = _metadata.Metadata.Subscription.RequiresAuthentication,
["fromOffline"] = _metadata.FromOfflineSnapshot,
});
}
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (_options is null)
{
throw new InvalidOperationException("Connector must be validated before fetch operations.");
}
if (_metadata is null)
{
_metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
}
await UpsertProviderAsync(context.Services, _metadata.Metadata.Provider, cancellationToken).ConfigureAwait(false);
var checkpoint = await _checkpointManager.LoadAsync(Descriptor.Id, context, cancellationToken).ConfigureAwait(false);
var digestHistory = checkpoint.Digests.ToList();
var dedupeSet = new HashSet<string>(checkpoint.Digests, StringComparer.OrdinalIgnoreCase);
var latestCursor = checkpoint.Cursor;
var latestPublishedAt = checkpoint.LastPublishedAt ?? checkpoint.EffectiveSince;
var stateChanged = false;
LogConnectorEvent(LogLevel.Information, "fetch_start", "Starting Rancher hub event ingestion.", new Dictionary<string, object?>
{
["since"] = checkpoint.EffectiveSince?.ToString("O"),
["cursor"] = checkpoint.Cursor,
["subscriptionUri"] = _metadata.Metadata.Subscription.EventsUri.ToString(),
["offline"] = checkpoint.Cursor is null && _options.PreferOfflineSnapshot,
});
await foreach (var batch in _eventClient.FetchEventBatchesAsync(
_options,
_metadata.Metadata,
checkpoint.Cursor,
checkpoint.EffectiveSince,
_metadata.Metadata.Subscription.Channels,
cancellationToken).ConfigureAwait(false))
{
LogConnectorEvent(LogLevel.Debug, "batch", "Processing Rancher hub batch.", new Dictionary<string, object?>
{
["cursor"] = batch.Cursor,
["nextCursor"] = batch.NextCursor,
["count"] = batch.Events.Length,
["offline"] = batch.FromOfflineSnapshot,
});
if (!string.IsNullOrWhiteSpace(batch.NextCursor) && !string.Equals(batch.NextCursor, latestCursor, StringComparison.Ordinal))
{
latestCursor = batch.NextCursor;
stateChanged = true;
}
else if (string.IsNullOrWhiteSpace(latestCursor) && !string.IsNullOrWhiteSpace(batch.Cursor))
{
latestCursor = batch.Cursor;
}
foreach (var record in batch.Events)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ProcessEventAsync(record, batch, context, dedupeSet, digestHistory, cancellationToken).ConfigureAwait(false);
if (result.ProcessedDocument is not null)
{
yield return result.ProcessedDocument;
stateChanged = true;
if (result.PublishedAt is { } published && (latestPublishedAt is null || published > latestPublishedAt))
{
latestPublishedAt = published;
}
}
else if (result.Quarantined)
{
stateChanged = true;
}
}
}
var trimmed = TrimHistory(digestHistory);
if (trimmed)
{
stateChanged = true;
}
if (stateChanged || !string.Equals(latestCursor, checkpoint.Cursor, StringComparison.Ordinal) || latestPublishedAt != checkpoint.LastPublishedAt)
{
await _checkpointManager.SaveAsync(
Descriptor.Id,
latestCursor,
latestPublishedAt,
digestHistory.ToImmutableArray(),
cancellationToken).ConfigureAwait(false);
}
}
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> throw new NotSupportedException("RancherHubConnector relies on format-specific normalizers for CSAF/OpenVEX payloads.");
public RancherHubMetadata? GetCachedMetadata() => _metadata?.Metadata;
private async Task<EventProcessingResult> ProcessEventAsync(
RancherHubEventRecord record,
RancherHubEventBatch batch,
VexConnectorContext context,
HashSet<string> dedupeSet,
List<string> digestHistory,
CancellationToken cancellationToken)
{
var quarantineKey = BuildQuarantineKey(record);
if (dedupeSet.Contains(quarantineKey))
{
return EventProcessingResult.QuarantinedOnly;
}
if (record.DocumentUri is null || string.IsNullOrWhiteSpace(record.Id))
{
await QuarantineAsync(record, batch, "missing documentUri or id", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var request = await CreateDocumentRequestAsync(record.DocumentUri, cancellationToken).ConfigureAwait(false);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
await QuarantineAsync(record, batch, $"document fetch failed ({(int)response.StatusCode} {response.StatusCode})", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
var contentBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var publishedAt = record.PublishedAt ?? UtcNow();
var metadata = BuildMetadata(builder =>
{
builder
.Add("rancher.event.id", record.Id)
.Add("rancher.event.type", record.Type)
.Add("rancher.event.channel", record.Channel)
.Add("rancher.event.published", publishedAt)
.Add("rancher.event.cursor", batch.NextCursor ?? batch.Cursor)
.Add("rancher.event.offline", batch.FromOfflineSnapshot ? "true" : "false")
.Add("rancher.event.declaredDigest", record.DocumentDigest);
AddProvenanceMetadata(builder);
});
var format = ResolveFormat(record.DocumentFormat);
var document = CreateRawDocument(format, record.DocumentUri, contentBytes, metadata);
if (!string.IsNullOrWhiteSpace(record.DocumentDigest))
{
var declared = NormalizeDigest(record.DocumentDigest);
var computed = NormalizeDigest(document.Digest);
if (!string.Equals(declared, computed, StringComparison.OrdinalIgnoreCase))
{
await QuarantineAsync(record, batch, $"digest mismatch (declared {record.DocumentDigest}, computed {document.Digest})", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
}
if (!dedupeSet.Add(document.Digest))
{
return EventProcessingResult.Skipped;
}
digestHistory.Add(document.Digest);
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
return new EventProcessingResult(document, false, publishedAt);
}
private void AddProvenanceMetadata(VexConnectorMetadataBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
var provider = _metadata?.Metadata.Provider;
if (provider is null)
{
return;
}
builder
.Add("vex.provenance.provider", provider.Id)
.Add("vex.provenance.providerName", provider.DisplayName)
.Add("vex.provenance.providerKind", provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture))
.Add("vex.provenance.trust.weight", provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture));
if (provider.Trust.Cosign is { } cosign)
{
builder
.Add("vex.provenance.cosign.issuer", cosign.Issuer)
.Add("vex.provenance.cosign.identityPattern", cosign.IdentityPattern);
}
if (!provider.Trust.PgpFingerprints.IsDefaultOrEmpty && provider.Trust.PgpFingerprints.Length > 0)
{
builder.Add("vex.provenance.pgp.fingerprints", string.Join(',', provider.Trust.PgpFingerprints));
}
var tier = provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture);
builder
.Add("vex.provenance.trust.tier", tier)
.Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
// Enrich with connector signer metadata (fingerprints, issuer tier, bundle info)
// from external signer metadata file (STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH)
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, Logger);
}
private static bool TrimHistory(List<string> digestHistory)
{
if (digestHistory.Count <= MaxDigestHistory)
{
return false;
}
var excess = digestHistory.Count - MaxDigestHistory;
digestHistory.RemoveRange(0, excess);
return true;
}
private async Task<HttpRequestMessage> CreateDocumentRequestAsync(Uri documentUri, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, documentUri);
if (_metadata?.Metadata.Subscription.RequiresAuthentication ?? false)
{
var token = await _tokenProvider.GetAccessTokenAsync(_options!, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var scheme = string.IsNullOrWhiteSpace(token.TokenType) ? "Bearer" : token.TokenType;
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token.Value);
}
}
return request;
}
private static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)
{
if (services is null)
{
return;
}
var store = services.GetService<IVexProviderStore>();
if (store is null)
{
return;
}
await store.SaveAsync(provider, cancellationToken).ConfigureAwait(false);
}
private async Task QuarantineAsync(
RancherHubEventRecord record,
RancherHubEventBatch batch,
string reason,
VexConnectorContext context,
CancellationToken cancellationToken)
{
var metadata = BuildMetadata(builder =>
{
builder
.Add("rancher.event.id", record.Id)
.Add("rancher.event.type", record.Type)
.Add("rancher.event.channel", record.Channel)
.Add("rancher.event.quarantine", "true")
.Add("rancher.event.error", reason)
.Add("rancher.event.cursor", batch.NextCursor ?? batch.Cursor)
.Add("rancher.event.offline", batch.FromOfflineSnapshot ? "true" : "false");
AddProvenanceMetadata(builder);
});
var sourceUri = record.DocumentUri ?? _metadata?.Metadata.Subscription.EventsUri ?? _options!.DiscoveryUri;
var payload = Encoding.UTF8.GetBytes(record.RawJson);
var document = CreateRawDocument(VexDocumentFormat.Csaf, sourceUri, payload, metadata);
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Warning, "quarantine", "Rancher hub event moved to quarantine.", new Dictionary<string, object?>
{
["eventId"] = record.Id ?? "(missing)",
["reason"] = reason,
});
}
private static void AddQuarantineDigest(string key, HashSet<string> dedupeSet, List<string> digestHistory)
{
if (dedupeSet.Add(key))
{
digestHistory.Add(key);
}
}
private static string BuildQuarantineKey(RancherHubEventRecord record)
{
if (!string.IsNullOrWhiteSpace(record.Id))
{
return $"quarantine:{record.Id}";
}
Span<byte> hash = stackalloc byte[32];
var bytes = Encoding.UTF8.GetBytes(record.RawJson);
if (!SHA256.TryHashData(bytes, hash, out _))
{
using var sha = SHA256.Create();
hash = sha.ComputeHash(bytes);
}
return $"quarantine:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string NormalizeDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return digest;
}
var trimmed = digest.Trim();
return trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? trimmed.ToLowerInvariant()
: $"sha256:{trimmed.ToLowerInvariant()}";
}
private static VexDocumentFormat ResolveFormat(string? format)
{
if (string.IsNullOrWhiteSpace(format))
{
return VexDocumentFormat.Csaf;
}
return format.ToLowerInvariant() switch
{
"csaf" or "csaf_json" or "json" => VexDocumentFormat.Csaf,
"cyclonedx" or "cyclonedx_vex" => VexDocumentFormat.CycloneDx,
"openvex" => VexDocumentFormat.OpenVex,
"oci" or "oci_attestation" or "attestation" => VexDocumentFormat.OciAttestation,
_ => VexDocumentFormat.Csaf,
};
}
private sealed record EventProcessingResult(VexRawDocument? ProcessedDocument, bool Quarantined, DateTimeOffset? PublishedAt)
{
public static EventProcessingResult QuarantinedOnly { get; } = new(null, true, null);
public static EventProcessingResult Skipped { get; } = new(null, false, null);
}
}

View File

@@ -1,37 +1,37 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
public sealed class UbuntuConnectorOptions
{
public const string HttpClientName = "excititor.connector.ubuntu.catalog";
/// <summary>
/// Root index that lists Ubuntu CSAF channels.
/// </summary>
public Uri IndexUri { get; set; } = new("https://ubuntu.com/security/csaf/index.json");
/// <summary>
/// Channels to include (e.g. stable, esm, lts).
/// </summary>
public IList<string> Channels { get; } = new List<string> { "stable" };
/// <summary>
/// Duration to cache discovery metadata.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(4);
/// <summary>
/// Prefer offline snapshot when available.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// Optional file path for offline index snapshot.
/// </summary>
public string? OfflineSnapshotPath { get; set; }
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
public sealed class UbuntuConnectorOptions
{
public const string HttpClientName = "excititor.connector.ubuntu.catalog";
/// <summary>
/// Root index that lists Ubuntu CSAF channels.
/// </summary>
public Uri IndexUri { get; set; } = new("https://ubuntu.com/security/csaf/index.json");
/// <summary>
/// Channels to include (e.g. stable, esm, lts).
/// </summary>
public IList<string> Channels { get; } = new List<string> { "stable" };
/// <summary>
/// Duration to cache discovery metadata.
/// </summary>
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(4);
/// <summary>
/// Prefer offline snapshot when available.
/// </summary>
public bool PreferOfflineSnapshot { get; set; }
/// <summary>
/// Optional file path for offline index snapshot.
/// </summary>
public string? OfflineSnapshotPath { get; set; }
/// <summary>
/// Controls persistence of network responses to <see cref="OfflineSnapshotPath"/>.
/// </summary>
@@ -61,47 +61,47 @@ public sealed class UbuntuConnectorOptions
/// Friendly trust tier label surfaced in provenance metadata.
/// </summary>
public string TrustTier { get; set; } = "distro";
public void Validate(IFileSystem? fileSystem = null)
{
if (IndexUri is null || !IndexUri.IsAbsoluteUri)
{
throw new InvalidOperationException("IndexUri must be an absolute URI.");
}
if (IndexUri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("IndexUri must use HTTP or HTTPS.");
}
if (Channels.Count == 0)
{
throw new InvalidOperationException("At least one channel must be specified.");
}
for (var i = Channels.Count - 1; i >= 0; i--)
{
if (string.IsNullOrWhiteSpace(Channels[i]))
{
Channels.RemoveAt(i);
}
}
if (Channels.Count == 0)
{
throw new InvalidOperationException("Channel names cannot be empty.");
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("MetadataCacheDuration must be positive.");
}
if (PreferOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
throw new InvalidOperationException("OfflineSnapshotPath must be provided when PreferOfflineSnapshot is enabled.");
}
public void Validate(IFileSystem? fileSystem = null)
{
if (IndexUri is null || !IndexUri.IsAbsoluteUri)
{
throw new InvalidOperationException("IndexUri must be an absolute URI.");
}
if (IndexUri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("IndexUri must use HTTP or HTTPS.");
}
if (Channels.Count == 0)
{
throw new InvalidOperationException("At least one channel must be specified.");
}
for (var i = Channels.Count - 1; i >= 0; i--)
{
if (string.IsNullOrWhiteSpace(Channels[i]))
{
Channels.RemoveAt(i);
}
}
if (Channels.Count == 0)
{
throw new InvalidOperationException("Channel names cannot be empty.");
}
if (MetadataCacheDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("MetadataCacheDuration must be positive.");
}
if (PreferOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
throw new InvalidOperationException("OfflineSnapshotPath must be provided when PreferOfflineSnapshot is enabled.");
}
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
{
var fs = fileSystem ?? new FileSystem();

View File

@@ -1,32 +1,32 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
public sealed class UbuntuConnectorOptionsValidator : IVexConnectorOptionsValidator<UbuntuConnectorOptions>
{
private readonly IFileSystem _fileSystem;
public UbuntuConnectorOptionsValidator(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public void Validate(VexConnectorDescriptor descriptor, UbuntuConnectorOptions options, IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
try
{
options.Validate(_fileSystem);
}
catch (Exception ex)
{
errors.Add(ex.Message);
}
}
}
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
public sealed class UbuntuConnectorOptionsValidator : IVexConnectorOptionsValidator<UbuntuConnectorOptions>
{
private readonly IFileSystem _fileSystem;
public UbuntuConnectorOptionsValidator(IFileSystem fileSystem)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}
public void Validate(VexConnectorDescriptor descriptor, UbuntuConnectorOptions options, IList<string> errors)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(errors);
try
{
options.Validate(_fileSystem);
}
catch (Exception ex)
{
errors.Add(ex.Message);
}
}
}

View File

@@ -1,45 +1,45 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.DependencyInjection;
public static class UbuntuConnectorServiceCollectionExtensions
{
public static IServiceCollection AddUbuntuCsafConnector(this IServiceCollection services, Action<UbuntuConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<UbuntuConnectorOptions>()
.Configure(options => configure?.Invoke(options));
services.AddSingleton<IVexConnectorOptionsValidator<UbuntuConnectorOptions>, UbuntuConnectorOptionsValidator>();
services.AddHttpClient(UbuntuConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(60);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.Ubuntu.CSAF/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddSingleton<UbuntuCatalogLoader>();
services.AddSingleton<IVexConnector, UbuntuCsafConnector>();
return services;
}
}
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
using System.IO.Abstractions;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.DependencyInjection;
public static class UbuntuConnectorServiceCollectionExtensions
{
public static IServiceCollection AddUbuntuCsafConnector(this IServiceCollection services, Action<UbuntuConnectorOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IMemoryCache, MemoryCache>();
services.TryAddSingleton<IFileSystem, FileSystem>();
services.AddOptions<UbuntuConnectorOptions>()
.Configure(options => configure?.Invoke(options));
services.AddSingleton<IVexConnectorOptionsValidator<UbuntuConnectorOptions>, UbuntuConnectorOptionsValidator>();
services.AddHttpClient(UbuntuConnectorOptions.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(60);
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.Ubuntu.CSAF/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
});
services.AddSingleton<UbuntuCatalogLoader>();
services.AddSingleton<IVexConnector, UbuntuCsafConnector>();
return services;
}
}

View File

@@ -1,248 +1,248 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
public sealed class UbuntuCatalogLoader
{
public const string CachePrefix = "StellaOps.Excititor.Connectors.Ubuntu.CSAF.Index";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly IFileSystem _fileSystem;
private readonly ILogger<UbuntuCatalogLoader> _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
public UbuntuCatalogLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IFileSystem fileSystem,
ILogger<UbuntuCatalogLoader> logger,
TimeProvider? timeProvider = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<UbuntuCatalogResult> LoadAsync(UbuntuConnectorOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate(_fileSystem);
var cacheKey = CreateCacheKey(options);
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out var cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
{
return cached.ToResult(fromCache: true);
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
{
return cached.ToResult(fromCache: true);
}
CacheEntry? entry = null;
if (options.PreferOfflineSnapshot)
{
entry = LoadFromOffline(options);
if (entry is null)
{
throw new InvalidOperationException("PreferOfflineSnapshot is enabled but no offline Ubuntu snapshot was found.");
}
}
else
{
entry = await TryFetchFromNetworkAsync(options, cancellationToken).ConfigureAwait(false)
?? LoadFromOffline(options);
}
if (entry is null)
{
throw new InvalidOperationException("Unable to load Ubuntu CSAF index from network or offline snapshot.");
}
var cacheOptions = new MemoryCacheEntryOptions();
if (entry.MetadataCacheDuration > TimeSpan.Zero)
{
cacheOptions.AbsoluteExpiration = _timeProvider.GetUtcNow().Add(entry.MetadataCacheDuration);
}
_memoryCache.Set(cacheKey, entry with { CachedAt = _timeProvider.GetUtcNow() }, cacheOptions);
return entry.ToResult(fromCache: false);
}
finally
{
_semaphore.Release();
}
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(UbuntuConnectorOptions options, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(UbuntuConnectorOptions.HttpClientName);
using var response = await client.GetAsync(options.IndexUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var metadata = ParseMetadata(payload, options.Channels);
var now = _timeProvider.GetUtcNow();
var entry = new CacheEntry(metadata, now, now, options.MetadataCacheDuration, false);
PersistSnapshotIfNeeded(options, metadata, now);
return entry;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to fetch Ubuntu CSAF index from {Uri}; attempting offline fallback if available.", options.IndexUri);
return null;
}
}
private CacheEntry? LoadFromOffline(UbuntuConnectorOptions options)
{
if (string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(options.OfflineSnapshotPath))
{
_logger.LogWarning("Ubuntu offline snapshot path {Path} does not exist.", options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(options.OfflineSnapshotPath);
var snapshot = JsonSerializer.Deserialize<UbuntuCatalogSnapshot>(payload, _serializerOptions);
if (snapshot is null)
{
throw new InvalidOperationException("Offline snapshot payload was empty.");
}
return new CacheEntry(snapshot.Metadata, snapshot.FetchedAt, _timeProvider.GetUtcNow(), options.MetadataCacheDuration, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Ubuntu CSAF index from offline snapshot {Path}.", options.OfflineSnapshotPath);
return null;
}
}
private UbuntuCatalogMetadata ParseMetadata(string payload, IList<string> channels)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("Ubuntu index payload was empty.");
}
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
var generatedAt = root.TryGetProperty("generated", out var generatedElement) && generatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(generatedElement.GetString(), out var generated)
? generated
: _timeProvider.GetUtcNow();
var channelSet = new HashSet<string>(channels, StringComparer.OrdinalIgnoreCase);
if (!root.TryGetProperty("channels", out var channelsElement) || channelsElement.ValueKind is not JsonValueKind.Array)
{
throw new InvalidOperationException("Ubuntu index did not include a channels array.");
}
var builder = ImmutableArray.CreateBuilder<UbuChannelCatalog>();
foreach (var channelElement in channelsElement.EnumerateArray())
{
var name = channelElement.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String ? nameElement.GetString() : null;
if (string.IsNullOrWhiteSpace(name) || !channelSet.Contains(name))
{
continue;
}
if (!channelElement.TryGetProperty("catalogUrl", out var urlElement) || urlElement.ValueKind != JsonValueKind.String || !Uri.TryCreate(urlElement.GetString(), UriKind.Absolute, out var catalogUri))
{
_logger.LogWarning("Channel {Channel} did not specify a valid catalogUrl.", name);
continue;
}
string? sha256 = null;
if (channelElement.TryGetProperty("sha256", out var shaElement) && shaElement.ValueKind == JsonValueKind.String)
{
sha256 = shaElement.GetString();
}
DateTimeOffset? lastUpdated = null;
if (channelElement.TryGetProperty("lastUpdated", out var updatedElement) && updatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(updatedElement.GetString(), out var updated))
{
lastUpdated = updated;
}
builder.Add(new UbuChannelCatalog(name!, catalogUri, sha256, lastUpdated));
}
if (builder.Count == 0)
{
throw new InvalidOperationException("None of the requested Ubuntu channels were present in the index.");
}
return new UbuntuCatalogMetadata(generatedAt, builder.ToImmutable());
}
private void PersistSnapshotIfNeeded(UbuntuConnectorOptions options, UbuntuCatalogMetadata metadata, DateTimeOffset fetchedAt)
{
if (!options.PersistOfflineSnapshot || string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return;
}
try
{
var snapshot = new UbuntuCatalogSnapshot(metadata, fetchedAt);
var payload = JsonSerializer.Serialize(snapshot, _serializerOptions);
_fileSystem.File.WriteAllText(options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Ubuntu CSAF index snapshot to {Path}.", options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Ubuntu CSAF index snapshot to {Path}.", options.OfflineSnapshotPath);
}
}
private static string CreateCacheKey(UbuntuConnectorOptions options)
=> $"{CachePrefix}:{options.IndexUri}:{string.Join(',', options.Channels)}";
private sealed record CacheEntry(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt, DateTimeOffset CachedAt, TimeSpan MetadataCacheDuration, bool FromOfflineSnapshot)
{
public bool IsExpired(DateTimeOffset now)
=> MetadataCacheDuration > TimeSpan.Zero && now >= CachedAt + MetadataCacheDuration;
public UbuntuCatalogResult ToResult(bool fromCache)
=> new(Metadata, FetchedAt, fromCache, FromOfflineSnapshot);
}
private sealed record UbuntuCatalogSnapshot(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt);
}
public sealed record UbuntuCatalogMetadata(DateTimeOffset GeneratedAt, ImmutableArray<UbuChannelCatalog> Channels);
public sealed record UbuChannelCatalog(string Name, Uri CatalogUri, string? Sha256, DateTimeOffset? LastUpdated);
public sealed record UbuntuCatalogResult(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt, bool FromCache, bool FromOfflineSnapshot);
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
public sealed class UbuntuCatalogLoader
{
public const string CachePrefix = "StellaOps.Excititor.Connectors.Ubuntu.CSAF.Index";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly IFileSystem _fileSystem;
private readonly ILogger<UbuntuCatalogLoader> _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
public UbuntuCatalogLoader(
IHttpClientFactory httpClientFactory,
IMemoryCache memoryCache,
IFileSystem fileSystem,
ILogger<UbuntuCatalogLoader> logger,
TimeProvider? timeProvider = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<UbuntuCatalogResult> LoadAsync(UbuntuConnectorOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate(_fileSystem);
var cacheKey = CreateCacheKey(options);
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out var cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
{
return cached.ToResult(fromCache: true);
}
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
{
return cached.ToResult(fromCache: true);
}
CacheEntry? entry = null;
if (options.PreferOfflineSnapshot)
{
entry = LoadFromOffline(options);
if (entry is null)
{
throw new InvalidOperationException("PreferOfflineSnapshot is enabled but no offline Ubuntu snapshot was found.");
}
}
else
{
entry = await TryFetchFromNetworkAsync(options, cancellationToken).ConfigureAwait(false)
?? LoadFromOffline(options);
}
if (entry is null)
{
throw new InvalidOperationException("Unable to load Ubuntu CSAF index from network or offline snapshot.");
}
var cacheOptions = new MemoryCacheEntryOptions();
if (entry.MetadataCacheDuration > TimeSpan.Zero)
{
cacheOptions.AbsoluteExpiration = _timeProvider.GetUtcNow().Add(entry.MetadataCacheDuration);
}
_memoryCache.Set(cacheKey, entry with { CachedAt = _timeProvider.GetUtcNow() }, cacheOptions);
return entry.ToResult(fromCache: false);
}
finally
{
_semaphore.Release();
}
}
private async Task<CacheEntry?> TryFetchFromNetworkAsync(UbuntuConnectorOptions options, CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient(UbuntuConnectorOptions.HttpClientName);
using var response = await client.GetAsync(options.IndexUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var metadata = ParseMetadata(payload, options.Channels);
var now = _timeProvider.GetUtcNow();
var entry = new CacheEntry(metadata, now, now, options.MetadataCacheDuration, false);
PersistSnapshotIfNeeded(options, metadata, now);
return entry;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to fetch Ubuntu CSAF index from {Uri}; attempting offline fallback if available.", options.IndexUri);
return null;
}
}
private CacheEntry? LoadFromOffline(UbuntuConnectorOptions options)
{
if (string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return null;
}
if (!_fileSystem.File.Exists(options.OfflineSnapshotPath))
{
_logger.LogWarning("Ubuntu offline snapshot path {Path} does not exist.", options.OfflineSnapshotPath);
return null;
}
try
{
var payload = _fileSystem.File.ReadAllText(options.OfflineSnapshotPath);
var snapshot = JsonSerializer.Deserialize<UbuntuCatalogSnapshot>(payload, _serializerOptions);
if (snapshot is null)
{
throw new InvalidOperationException("Offline snapshot payload was empty.");
}
return new CacheEntry(snapshot.Metadata, snapshot.FetchedAt, _timeProvider.GetUtcNow(), options.MetadataCacheDuration, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Ubuntu CSAF index from offline snapshot {Path}.", options.OfflineSnapshotPath);
return null;
}
}
private UbuntuCatalogMetadata ParseMetadata(string payload, IList<string> channels)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("Ubuntu index payload was empty.");
}
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
var generatedAt = root.TryGetProperty("generated", out var generatedElement) && generatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(generatedElement.GetString(), out var generated)
? generated
: _timeProvider.GetUtcNow();
var channelSet = new HashSet<string>(channels, StringComparer.OrdinalIgnoreCase);
if (!root.TryGetProperty("channels", out var channelsElement) || channelsElement.ValueKind is not JsonValueKind.Array)
{
throw new InvalidOperationException("Ubuntu index did not include a channels array.");
}
var builder = ImmutableArray.CreateBuilder<UbuChannelCatalog>();
foreach (var channelElement in channelsElement.EnumerateArray())
{
var name = channelElement.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String ? nameElement.GetString() : null;
if (string.IsNullOrWhiteSpace(name) || !channelSet.Contains(name))
{
continue;
}
if (!channelElement.TryGetProperty("catalogUrl", out var urlElement) || urlElement.ValueKind != JsonValueKind.String || !Uri.TryCreate(urlElement.GetString(), UriKind.Absolute, out var catalogUri))
{
_logger.LogWarning("Channel {Channel} did not specify a valid catalogUrl.", name);
continue;
}
string? sha256 = null;
if (channelElement.TryGetProperty("sha256", out var shaElement) && shaElement.ValueKind == JsonValueKind.String)
{
sha256 = shaElement.GetString();
}
DateTimeOffset? lastUpdated = null;
if (channelElement.TryGetProperty("lastUpdated", out var updatedElement) && updatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(updatedElement.GetString(), out var updated))
{
lastUpdated = updated;
}
builder.Add(new UbuChannelCatalog(name!, catalogUri, sha256, lastUpdated));
}
if (builder.Count == 0)
{
throw new InvalidOperationException("None of the requested Ubuntu channels were present in the index.");
}
return new UbuntuCatalogMetadata(generatedAt, builder.ToImmutable());
}
private void PersistSnapshotIfNeeded(UbuntuConnectorOptions options, UbuntuCatalogMetadata metadata, DateTimeOffset fetchedAt)
{
if (!options.PersistOfflineSnapshot || string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
{
return;
}
try
{
var snapshot = new UbuntuCatalogSnapshot(metadata, fetchedAt);
var payload = JsonSerializer.Serialize(snapshot, _serializerOptions);
_fileSystem.File.WriteAllText(options.OfflineSnapshotPath, payload);
_logger.LogDebug("Persisted Ubuntu CSAF index snapshot to {Path}.", options.OfflineSnapshotPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist Ubuntu CSAF index snapshot to {Path}.", options.OfflineSnapshotPath);
}
}
private static string CreateCacheKey(UbuntuConnectorOptions options)
=> $"{CachePrefix}:{options.IndexUri}:{string.Join(',', options.Channels)}";
private sealed record CacheEntry(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt, DateTimeOffset CachedAt, TimeSpan MetadataCacheDuration, bool FromOfflineSnapshot)
{
public bool IsExpired(DateTimeOffset now)
=> MetadataCacheDuration > TimeSpan.Zero && now >= CachedAt + MetadataCacheDuration;
public UbuntuCatalogResult ToResult(bool fromCache)
=> new(Metadata, FetchedAt, fromCache, FromOfflineSnapshot);
}
private sealed record UbuntuCatalogSnapshot(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt);
}
public sealed record UbuntuCatalogMetadata(DateTimeOffset GeneratedAt, ImmutableArray<UbuChannelCatalog> Channels);
public sealed record UbuChannelCatalog(string Name, Uri CatalogUri, string? Sha256, DateTimeOffset? LastUpdated);
public sealed record UbuntuCatalogResult(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt, bool FromCache, bool FromOfflineSnapshot);

View File

@@ -1,38 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
namespace StellaOps.Excititor.Core.Aoc;
public static class AocServiceCollectionExtensions
{
/// <summary>
/// Registers Aggregation-Only Contract guard services for raw VEX ingestion.
/// </summary>
public static IServiceCollection AddExcititorAocGuards(
this IServiceCollection services,
Action<AocGuardOptions>? configure = null)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddAocGuard();
if (configure is not null)
{
services.Configure(configure);
}
services.TryAddSingleton<IVexRawWriteGuard>(sp =>
{
var guard = sp.GetRequiredService<IAocGuard>();
var options = sp.GetService<IOptions<AocGuardOptions>>();
return new VexRawWriteGuard(guard, options);
});
return services;
}
}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
namespace StellaOps.Excititor.Core.Aoc;
public static class AocServiceCollectionExtensions
{
/// <summary>
/// Registers Aggregation-Only Contract guard services for raw VEX ingestion.
/// </summary>
public static IServiceCollection AddExcititorAocGuards(
this IServiceCollection services,
Action<AocGuardOptions>? configure = null)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddAocGuard();
if (configure is not null)
{
services.Configure(configure);
}
services.TryAddSingleton<IVexRawWriteGuard>(sp =>
{
var guard = sp.GetRequiredService<IAocGuard>();
var options = sp.GetService<IOptions<AocGuardOptions>>();
return new VexRawWriteGuard(guard, options);
});
return services;
}
}

View File

@@ -1,22 +1,22 @@
using System.Collections.Immutable;
using StellaOps.Aoc;
namespace StellaOps.Excititor.Core.Aoc;
/// <summary>
/// Exception representing an Aggregation-Only Contract violation for raw VEX documents.
/// </summary>
public sealed class ExcititorAocGuardException : Exception
{
public ExcititorAocGuardException(AocGuardResult result)
: base("AOC guard validation failed for the provided raw VEX document.")
{
Result = result ?? throw new ArgumentNullException(nameof(result));
}
public AocGuardResult Result { get; }
public ImmutableArray<AocViolation> Violations => Result.Violations;
public string PrimaryErrorCode => Violations.IsDefaultOrEmpty ? "ERR_AOC_000" : Violations[0].ErrorCode;
}
using System.Collections.Immutable;
using StellaOps.Aoc;
namespace StellaOps.Excititor.Core.Aoc;
/// <summary>
/// Exception representing an Aggregation-Only Contract violation for raw VEX documents.
/// </summary>
public sealed class ExcititorAocGuardException : Exception
{
public ExcititorAocGuardException(AocGuardResult result)
: base("AOC guard validation failed for the provided raw VEX document.")
{
Result = result ?? throw new ArgumentNullException(nameof(result));
}
public AocGuardResult Result { get; }
public ImmutableArray<AocViolation> Violations => Result.Violations;
public string PrimaryErrorCode => Violations.IsDefaultOrEmpty ? "ERR_AOC_000" : Violations[0].ErrorCode;
}

View File

@@ -1,16 +1,16 @@
using RawVexDocument = StellaOps.Concelier.RawModels.VexRawDocument;
namespace StellaOps.Excititor.Core.Aoc;
/// <summary>
/// Validates raw VEX documents against the Aggregation-Only Contract (AOC) prior to persistence.
/// </summary>
public interface IVexRawWriteGuard
{
/// <summary>
/// Ensures the supplied raw VEX document complies with the AOC guard rules.
/// Throws when violations are detected.
/// </summary>
/// <param name="document">Raw VEX document to validate.</param>
void EnsureValid(RawVexDocument document);
}
using RawVexDocument = StellaOps.Concelier.RawModels.VexRawDocument;
namespace StellaOps.Excititor.Core.Aoc;
/// <summary>
/// Validates raw VEX documents against the Aggregation-Only Contract (AOC) prior to persistence.
/// </summary>
public interface IVexRawWriteGuard
{
/// <summary>
/// Ensures the supplied raw VEX document complies with the AOC guard rules.
/// Throws when violations are detected.
/// </summary>
/// <param name="document">Raw VEX document to validate.</param>
void EnsureValid(RawVexDocument document);
}

View File

@@ -4,25 +4,25 @@ using Microsoft.Extensions.Options;
using StellaOps.Aoc;
using RawVexDocument = StellaOps.Concelier.RawModels.VexRawDocument;
using StellaOps.Ingestion.Telemetry;
namespace StellaOps.Excititor.Core.Aoc;
/// <summary>
/// Aggregation-Only Contract guard for raw VEX documents.
/// </summary>
public sealed class VexRawWriteGuard : IVexRawWriteGuard
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IAocGuard _guard;
private readonly AocGuardOptions _options;
public VexRawWriteGuard(IAocGuard guard, IOptions<AocGuardOptions>? options = null)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_options = options?.Value ?? AocGuardOptions.Default;
}
namespace StellaOps.Excititor.Core.Aoc;
/// <summary>
/// Aggregation-Only Contract guard for raw VEX documents.
/// </summary>
public sealed class VexRawWriteGuard : IVexRawWriteGuard
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IAocGuard _guard;
private readonly AocGuardOptions _options;
public VexRawWriteGuard(IAocGuard guard, IOptions<AocGuardOptions>? options = null)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_options = options?.Value ?? AocGuardOptions.Default;
}
public void EnsureValid(RawVexDocument document)
{
ArgumentNullException.ThrowIfNull(document);

View File

@@ -1,67 +1,67 @@
namespace StellaOps.Excititor.Core;
/// <summary>
/// Baseline consensus policy applying tier-based weights and enforcing justification gates.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed class BaselineVexConsensusPolicy : IVexConsensusPolicy
{
private readonly VexConsensusPolicyOptions _options;
public BaselineVexConsensusPolicy(VexConsensusPolicyOptions? options = null)
{
_options = options ?? new VexConsensusPolicyOptions();
}
public string Version => _options.Version;
public double GetProviderWeight(VexProvider provider)
{
if (provider is null)
{
throw new ArgumentNullException(nameof(provider));
}
if (_options.ProviderOverrides.TryGetValue(provider.Id, out var overrideWeight))
{
return overrideWeight;
}
return provider.Kind switch
{
VexProviderKind.Vendor => _options.VendorWeight,
VexProviderKind.Distro => _options.DistroWeight,
VexProviderKind.Platform => _options.PlatformWeight,
VexProviderKind.Hub => _options.HubWeight,
VexProviderKind.Attestation => _options.AttestationWeight,
_ => 0,
};
}
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
{
if (claim is null)
{
throw new ArgumentNullException(nameof(claim));
}
if (provider is null)
{
throw new ArgumentNullException(nameof(provider));
}
if (claim.Status is VexClaimStatus.NotAffected && claim.Justification is null)
{
rejectionReason = "missing_justification";
return false;
}
rejectionReason = null;
return true;
}
}
namespace StellaOps.Excititor.Core;
/// <summary>
/// Baseline consensus policy applying tier-based weights and enforcing justification gates.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed class BaselineVexConsensusPolicy : IVexConsensusPolicy
{
private readonly VexConsensusPolicyOptions _options;
public BaselineVexConsensusPolicy(VexConsensusPolicyOptions? options = null)
{
_options = options ?? new VexConsensusPolicyOptions();
}
public string Version => _options.Version;
public double GetProviderWeight(VexProvider provider)
{
if (provider is null)
{
throw new ArgumentNullException(nameof(provider));
}
if (_options.ProviderOverrides.TryGetValue(provider.Id, out var overrideWeight))
{
return overrideWeight;
}
return provider.Kind switch
{
VexProviderKind.Vendor => _options.VendorWeight,
VexProviderKind.Distro => _options.DistroWeight,
VexProviderKind.Platform => _options.PlatformWeight,
VexProviderKind.Hub => _options.HubWeight,
VexProviderKind.Attestation => _options.AttestationWeight,
_ => 0,
};
}
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
{
if (claim is null)
{
throw new ArgumentNullException(nameof(claim));
}
if (provider is null)
{
throw new ArgumentNullException(nameof(provider));
}
if (claim.Status is VexClaimStatus.NotAffected && claim.Justification is null)
{
rejectionReason = "missing_justification";
return false;
}
rejectionReason = null;
return true;
}
}

View File

@@ -1,32 +1,32 @@
namespace StellaOps.Excititor.Core;
/// <summary>
/// Policy abstraction supplying trust weights and gating logic for consensus decisions.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public interface IVexConsensusPolicy
{
/// <summary>
/// Semantic version describing the active policy.
/// </summary>
string Version { get; }
/// <summary>
/// Returns the effective weight (bounded by the policy ceiling) to apply for the provided VEX source.
/// </summary>
double GetProviderWeight(VexProvider provider);
/// <summary>
/// Determines whether the claim is eligible to participate in consensus.
/// </summary>
/// <param name="claim">Normalized claim to evaluate.</param>
/// <param name="provider">Provider metadata for the claim.</param>
/// <param name="rejectionReason">Textual reason when the claim is rejected.</param>
/// <returns><c>true</c> if the claim should participate; <c>false</c> otherwise.</returns>
bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason);
}
namespace StellaOps.Excititor.Core;
/// <summary>
/// Policy abstraction supplying trust weights and gating logic for consensus decisions.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public interface IVexConsensusPolicy
{
/// <summary>
/// Semantic version describing the active policy.
/// </summary>
string Version { get; }
/// <summary>
/// Returns the effective weight (bounded by the policy ceiling) to apply for the provided VEX source.
/// </summary>
double GetProviderWeight(VexProvider provider);
/// <summary>
/// Determines whether the claim is eligible to participate in consensus.
/// </summary>
/// <param name="claim">Normalized claim to evaluate.</param>
/// <param name="provider">Provider metadata for the claim.</param>
/// <param name="rejectionReason">Textual reason when the claim is rejected.</param>
/// <returns><c>true</c> if the claim should participate; <c>false</c> otherwise.</returns>
bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason);
}

View File

@@ -1,32 +1,32 @@
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Abstraction over the VEX observation persistence layer used for overlay queries.
/// </summary>
public interface IVexObservationLookup
{
/// <summary>
/// Lists the available VEX observations for the specified tenant.
/// </summary>
ValueTask<IReadOnlyList<VexObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken);
/// <summary>
/// Finds VEX observations matching the supplied filters.
/// </summary>
ValueTask<IReadOnlyList<VexObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> vulnerabilityIds,
IReadOnlyCollection<string> productKeys,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
IReadOnlyCollection<string> providerIds,
IReadOnlyCollection<VexClaimStatus> statuses,
VexObservationCursor? cursor,
int limit,
CancellationToken cancellationToken);
}
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Abstraction over the VEX observation persistence layer used for overlay queries.
/// </summary>
public interface IVexObservationLookup
{
/// <summary>
/// Lists the available VEX observations for the specified tenant.
/// </summary>
ValueTask<IReadOnlyList<VexObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken);
/// <summary>
/// Finds VEX observations matching the supplied filters.
/// </summary>
ValueTask<IReadOnlyList<VexObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> vulnerabilityIds,
IReadOnlyCollection<string> productKeys,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
IReadOnlyCollection<string> providerIds,
IReadOnlyCollection<VexClaimStatus> statuses,
VexObservationCursor? cursor,
int limit,
CancellationToken cancellationToken);
}

View File

@@ -1,11 +1,11 @@
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Queries raw VEX observations and returns overlay-friendly projections.
/// </summary>
public interface IVexObservationQueryService
{
ValueTask<VexObservationQueryResult> QueryAsync(
VexObservationQueryOptions options,
CancellationToken cancellationToken);
}
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Queries raw VEX observations and returns overlay-friendly projections.
/// </summary>
public interface IVexObservationQueryService
{
ValueTask<VexObservationQueryResult> QueryAsync(
VexObservationQueryOptions options,
CancellationToken cancellationToken);
}

View File

@@ -1,358 +1,358 @@
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Immutable record describing a raw VEX observation produced by Excititor ingestion.
/// </summary>
public sealed record VexObservation
{
public VexObservation(
string observationId,
string tenant,
string providerId,
string streamId,
VexObservationUpstream upstream,
ImmutableArray<VexObservationStatement> statements,
VexObservationContent content,
VexObservationLinkset linkset,
DateTimeOffset createdAt,
ImmutableArray<string>? supersedes = null,
ImmutableDictionary<string, string>? attributes = null)
{
ObservationId = EnsureNotNullOrWhiteSpace(observationId, nameof(observationId));
Tenant = EnsureNotNullOrWhiteSpace(tenant, nameof(tenant)).ToLowerInvariant();
ProviderId = EnsureNotNullOrWhiteSpace(providerId, nameof(providerId)).ToLowerInvariant();
StreamId = EnsureNotNullOrWhiteSpace(streamId, nameof(streamId));
Upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
Statements = NormalizeStatements(statements);
Content = content ?? throw new ArgumentNullException(nameof(content));
Linkset = linkset ?? throw new ArgumentNullException(nameof(linkset));
CreatedAt = createdAt.ToUniversalTime();
Supersedes = NormalizeSupersedes(supersedes);
Attributes = NormalizeAttributes(attributes);
}
public string ObservationId { get; }
public string Tenant { get; }
public string ProviderId { get; }
public string StreamId { get; }
public VexObservationUpstream Upstream { get; }
public ImmutableArray<VexObservationStatement> Statements { get; }
public VexObservationContent Content { get; }
public VexObservationLinkset Linkset { get; }
public DateTimeOffset CreatedAt { get; }
public ImmutableArray<string> Supersedes { get; }
public ImmutableDictionary<string, string> Attributes { get; }
private static ImmutableArray<VexObservationStatement> NormalizeStatements(ImmutableArray<VexObservationStatement> statements)
{
if (statements.IsDefault)
{
throw new ArgumentNullException(nameof(statements));
}
if (statements.Length == 0)
{
return ImmutableArray<VexObservationStatement>.Empty;
}
return statements.ToImmutableArray();
}
private static ImmutableArray<string> NormalizeSupersedes(ImmutableArray<string>? supersedes)
{
if (!supersedes.HasValue || supersedes.Value.IsDefaultOrEmpty)
{
return ImmutableArray<string>.Empty;
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var value in supersedes.Value)
{
var normalized = TrimToNull(value);
if (normalized is null)
{
continue;
}
set.Add(normalized);
}
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
}
private static ImmutableDictionary<string, string> NormalizeAttributes(ImmutableDictionary<string, string>? attributes)
{
if (attributes is null || attributes.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in attributes)
{
var key = TrimToNull(pair.Key);
if (key is null || pair.Value is null)
{
continue;
}
builder[key] = pair.Value;
}
return builder.ToImmutable();
}
internal static string EnsureNotNullOrWhiteSpace(string value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{name} must be provided.", name);
}
return value.Trim();
}
internal static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record VexObservationUpstream
{
public VexObservationUpstream(
string upstreamId,
string? documentVersion,
DateTimeOffset fetchedAt,
DateTimeOffset receivedAt,
string contentHash,
VexObservationSignature signature,
ImmutableDictionary<string, string>? metadata = null)
{
UpstreamId = VexObservation.EnsureNotNullOrWhiteSpace(upstreamId, nameof(upstreamId));
DocumentVersion = VexObservation.TrimToNull(documentVersion);
FetchedAt = fetchedAt.ToUniversalTime();
ReceivedAt = receivedAt.ToUniversalTime();
ContentHash = VexObservation.EnsureNotNullOrWhiteSpace(contentHash, nameof(contentHash));
Signature = signature ?? throw new ArgumentNullException(nameof(signature));
Metadata = NormalizeMetadata(metadata);
}
public string UpstreamId { get; }
public string? DocumentVersion { get; }
public DateTimeOffset FetchedAt { get; }
public DateTimeOffset ReceivedAt { get; }
public string ContentHash { get; }
public VexObservationSignature Signature { get; }
public ImmutableDictionary<string, string> Metadata { get; }
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = VexObservation.TrimToNull(pair.Key);
if (key is null || pair.Value is null)
{
continue;
}
builder[key] = pair.Value;
}
return builder.ToImmutable();
}
}
public sealed record VexObservationSignature
{
public VexObservationSignature(
bool present,
string? format,
string? keyId,
string? signature)
{
Present = present;
Format = VexObservation.TrimToNull(format);
KeyId = VexObservation.TrimToNull(keyId);
Signature = VexObservation.TrimToNull(signature);
}
public bool Present { get; }
public string? Format { get; }
public string? KeyId { get; }
public string? Signature { get; }
}
public sealed record VexObservationContent
{
public VexObservationContent(
string format,
string? specVersion,
JsonNode raw,
ImmutableDictionary<string, string>? metadata = null)
{
Format = VexObservation.EnsureNotNullOrWhiteSpace(format, nameof(format));
SpecVersion = VexObservation.TrimToNull(specVersion);
Raw = raw?.DeepClone() ?? throw new ArgumentNullException(nameof(raw));
Metadata = NormalizeMetadata(metadata);
}
public string Format { get; }
public string? SpecVersion { get; }
public JsonNode Raw { get; }
public ImmutableDictionary<string, string> Metadata { get; }
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = VexObservation.TrimToNull(pair.Key);
if (key is null || pair.Value is null)
{
continue;
}
builder[key] = pair.Value;
}
return builder.ToImmutable();
}
}
public sealed record VexObservationStatement
{
public VexObservationStatement(
string vulnerabilityId,
string productKey,
VexClaimStatus status,
DateTimeOffset? lastObserved,
string? locator = null,
VexJustification? justification = null,
string? introducedVersion = null,
string? fixedVersion = null,
string? purl = null,
string? cpe = null,
ImmutableArray<JsonNode>? evidence = null,
ImmutableDictionary<string, string>? metadata = null)
{
VulnerabilityId = VexObservation.EnsureNotNullOrWhiteSpace(vulnerabilityId, nameof(vulnerabilityId));
ProductKey = VexObservation.EnsureNotNullOrWhiteSpace(productKey, nameof(productKey));
Status = status;
LastObserved = lastObserved?.ToUniversalTime();
Locator = VexObservation.TrimToNull(locator);
Justification = justification;
IntroducedVersion = VexObservation.TrimToNull(introducedVersion);
FixedVersion = VexObservation.TrimToNull(fixedVersion);
Purl = VexObservation.TrimToNull(purl);
Cpe = VexObservation.TrimToNull(cpe);
Evidence = NormalizeEvidence(evidence);
Metadata = NormalizeMetadata(metadata);
}
public string VulnerabilityId { get; }
public string ProductKey { get; }
public VexClaimStatus Status { get; }
public DateTimeOffset? LastObserved { get; }
public string? Locator { get; }
public VexJustification? Justification { get; }
public string? IntroducedVersion { get; }
public string? FixedVersion { get; }
public string? Purl { get; }
public string? Cpe { get; }
public ImmutableArray<JsonNode> Evidence { get; }
public ImmutableDictionary<string, string> Metadata { get; }
private static ImmutableArray<JsonNode> NormalizeEvidence(ImmutableArray<JsonNode>? evidence)
{
if (!evidence.HasValue || evidence.Value.IsDefaultOrEmpty)
{
return ImmutableArray<JsonNode>.Empty;
}
var builder = ImmutableArray.CreateBuilder<JsonNode>(evidence.Value.Length);
foreach (var node in evidence.Value)
{
if (node is null)
{
continue;
}
builder.Add(node.DeepClone());
}
return builder.ToImmutable();
}
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = VexObservation.TrimToNull(pair.Key);
if (key is null || pair.Value is null)
{
continue;
}
builder[key] = pair.Value;
}
return builder.ToImmutable();
}
}
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Immutable record describing a raw VEX observation produced by Excititor ingestion.
/// </summary>
public sealed record VexObservation
{
public VexObservation(
string observationId,
string tenant,
string providerId,
string streamId,
VexObservationUpstream upstream,
ImmutableArray<VexObservationStatement> statements,
VexObservationContent content,
VexObservationLinkset linkset,
DateTimeOffset createdAt,
ImmutableArray<string>? supersedes = null,
ImmutableDictionary<string, string>? attributes = null)
{
ObservationId = EnsureNotNullOrWhiteSpace(observationId, nameof(observationId));
Tenant = EnsureNotNullOrWhiteSpace(tenant, nameof(tenant)).ToLowerInvariant();
ProviderId = EnsureNotNullOrWhiteSpace(providerId, nameof(providerId)).ToLowerInvariant();
StreamId = EnsureNotNullOrWhiteSpace(streamId, nameof(streamId));
Upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
Statements = NormalizeStatements(statements);
Content = content ?? throw new ArgumentNullException(nameof(content));
Linkset = linkset ?? throw new ArgumentNullException(nameof(linkset));
CreatedAt = createdAt.ToUniversalTime();
Supersedes = NormalizeSupersedes(supersedes);
Attributes = NormalizeAttributes(attributes);
}
public string ObservationId { get; }
public string Tenant { get; }
public string ProviderId { get; }
public string StreamId { get; }
public VexObservationUpstream Upstream { get; }
public ImmutableArray<VexObservationStatement> Statements { get; }
public VexObservationContent Content { get; }
public VexObservationLinkset Linkset { get; }
public DateTimeOffset CreatedAt { get; }
public ImmutableArray<string> Supersedes { get; }
public ImmutableDictionary<string, string> Attributes { get; }
private static ImmutableArray<VexObservationStatement> NormalizeStatements(ImmutableArray<VexObservationStatement> statements)
{
if (statements.IsDefault)
{
throw new ArgumentNullException(nameof(statements));
}
if (statements.Length == 0)
{
return ImmutableArray<VexObservationStatement>.Empty;
}
return statements.ToImmutableArray();
}
private static ImmutableArray<string> NormalizeSupersedes(ImmutableArray<string>? supersedes)
{
if (!supersedes.HasValue || supersedes.Value.IsDefaultOrEmpty)
{
return ImmutableArray<string>.Empty;
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var value in supersedes.Value)
{
var normalized = TrimToNull(value);
if (normalized is null)
{
continue;
}
set.Add(normalized);
}
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
}
private static ImmutableDictionary<string, string> NormalizeAttributes(ImmutableDictionary<string, string>? attributes)
{
if (attributes is null || attributes.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in attributes)
{
var key = TrimToNull(pair.Key);
if (key is null || pair.Value is null)
{
continue;
}
builder[key] = pair.Value;
}
return builder.ToImmutable();
}
internal static string EnsureNotNullOrWhiteSpace(string value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{name} must be provided.", name);
}
return value.Trim();
}
internal static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record VexObservationUpstream
{
public VexObservationUpstream(
string upstreamId,
string? documentVersion,
DateTimeOffset fetchedAt,
DateTimeOffset receivedAt,
string contentHash,
VexObservationSignature signature,
ImmutableDictionary<string, string>? metadata = null)
{
UpstreamId = VexObservation.EnsureNotNullOrWhiteSpace(upstreamId, nameof(upstreamId));
DocumentVersion = VexObservation.TrimToNull(documentVersion);
FetchedAt = fetchedAt.ToUniversalTime();
ReceivedAt = receivedAt.ToUniversalTime();
ContentHash = VexObservation.EnsureNotNullOrWhiteSpace(contentHash, nameof(contentHash));
Signature = signature ?? throw new ArgumentNullException(nameof(signature));
Metadata = NormalizeMetadata(metadata);
}
public string UpstreamId { get; }
public string? DocumentVersion { get; }
public DateTimeOffset FetchedAt { get; }
public DateTimeOffset ReceivedAt { get; }
public string ContentHash { get; }
public VexObservationSignature Signature { get; }
public ImmutableDictionary<string, string> Metadata { get; }
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = VexObservation.TrimToNull(pair.Key);
if (key is null || pair.Value is null)
{
continue;
}
builder[key] = pair.Value;
}
return builder.ToImmutable();
}
}
public sealed record VexObservationSignature
{
public VexObservationSignature(
bool present,
string? format,
string? keyId,
string? signature)
{
Present = present;
Format = VexObservation.TrimToNull(format);
KeyId = VexObservation.TrimToNull(keyId);
Signature = VexObservation.TrimToNull(signature);
}
public bool Present { get; }
public string? Format { get; }
public string? KeyId { get; }
public string? Signature { get; }
}
public sealed record VexObservationContent
{
public VexObservationContent(
string format,
string? specVersion,
JsonNode raw,
ImmutableDictionary<string, string>? metadata = null)
{
Format = VexObservation.EnsureNotNullOrWhiteSpace(format, nameof(format));
SpecVersion = VexObservation.TrimToNull(specVersion);
Raw = raw?.DeepClone() ?? throw new ArgumentNullException(nameof(raw));
Metadata = NormalizeMetadata(metadata);
}
public string Format { get; }
public string? SpecVersion { get; }
public JsonNode Raw { get; }
public ImmutableDictionary<string, string> Metadata { get; }
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = VexObservation.TrimToNull(pair.Key);
if (key is null || pair.Value is null)
{
continue;
}
builder[key] = pair.Value;
}
return builder.ToImmutable();
}
}
public sealed record VexObservationStatement
{
public VexObservationStatement(
string vulnerabilityId,
string productKey,
VexClaimStatus status,
DateTimeOffset? lastObserved,
string? locator = null,
VexJustification? justification = null,
string? introducedVersion = null,
string? fixedVersion = null,
string? purl = null,
string? cpe = null,
ImmutableArray<JsonNode>? evidence = null,
ImmutableDictionary<string, string>? metadata = null)
{
VulnerabilityId = VexObservation.EnsureNotNullOrWhiteSpace(vulnerabilityId, nameof(vulnerabilityId));
ProductKey = VexObservation.EnsureNotNullOrWhiteSpace(productKey, nameof(productKey));
Status = status;
LastObserved = lastObserved?.ToUniversalTime();
Locator = VexObservation.TrimToNull(locator);
Justification = justification;
IntroducedVersion = VexObservation.TrimToNull(introducedVersion);
FixedVersion = VexObservation.TrimToNull(fixedVersion);
Purl = VexObservation.TrimToNull(purl);
Cpe = VexObservation.TrimToNull(cpe);
Evidence = NormalizeEvidence(evidence);
Metadata = NormalizeMetadata(metadata);
}
public string VulnerabilityId { get; }
public string ProductKey { get; }
public VexClaimStatus Status { get; }
public DateTimeOffset? LastObserved { get; }
public string? Locator { get; }
public VexJustification? Justification { get; }
public string? IntroducedVersion { get; }
public string? FixedVersion { get; }
public string? Purl { get; }
public string? Cpe { get; }
public ImmutableArray<JsonNode> Evidence { get; }
public ImmutableDictionary<string, string> Metadata { get; }
private static ImmutableArray<JsonNode> NormalizeEvidence(ImmutableArray<JsonNode>? evidence)
{
if (!evidence.HasValue || evidence.Value.IsDefaultOrEmpty)
{
return ImmutableArray<JsonNode>.Empty;
}
var builder = ImmutableArray.CreateBuilder<JsonNode>(evidence.Value.Length);
foreach (var node in evidence.Value)
{
if (node is null)
{
continue;
}
builder.Add(node.DeepClone());
}
return builder.ToImmutable();
}
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = VexObservation.TrimToNull(pair.Key);
if (key is null || pair.Value is null)
{
continue;
}
builder[key] = pair.Value;
}
return builder.ToImmutable();
}
}
public sealed record VexObservationLinkset
{
public VexObservationLinkset(
@@ -372,13 +372,13 @@ public sealed record VexObservationLinkset
Disagreements = NormalizeDisagreements(disagreements);
Observations = NormalizeObservationRefs(observationRefs);
}
public ImmutableArray<string> Aliases { get; }
public ImmutableArray<string> Purls { get; }
public ImmutableArray<string> Cpes { get; }
public ImmutableArray<string> Aliases { get; }
public ImmutableArray<string> Purls { get; }
public ImmutableArray<string> Cpes { get; }
public ImmutableArray<VexObservationReference> References { get; }
public ImmutableArray<string> ReconciledFrom { get; }
@@ -386,48 +386,48 @@ public sealed record VexObservationLinkset
public ImmutableArray<VexObservationDisagreement> Disagreements { get; }
public ImmutableArray<VexLinksetObservationRefModel> Observations { get; }
private static ImmutableArray<string> NormalizeSet(IEnumerable<string>? values, bool toLower)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var comparer = StringComparer.Ordinal;
var set = new SortedSet<string>(comparer);
foreach (var value in values)
{
var normalized = VexObservation.TrimToNull(value);
if (normalized is null)
{
continue;
}
set.Add(toLower ? normalized.ToLowerInvariant() : normalized);
}
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
}
private static ImmutableArray<VexObservationReference> NormalizeReferences(IEnumerable<VexObservationReference>? references)
{
if (references is null)
{
return ImmutableArray<VexObservationReference>.Empty;
}
var set = new HashSet<VexObservationReference>();
foreach (var reference in references)
{
if (reference is null)
{
continue;
}
set.Add(reference);
}
private static ImmutableArray<string> NormalizeSet(IEnumerable<string>? values, bool toLower)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var comparer = StringComparer.Ordinal;
var set = new SortedSet<string>(comparer);
foreach (var value in values)
{
var normalized = VexObservation.TrimToNull(value);
if (normalized is null)
{
continue;
}
set.Add(toLower ? normalized.ToLowerInvariant() : normalized);
}
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
}
private static ImmutableArray<VexObservationReference> NormalizeReferences(IEnumerable<VexObservationReference>? references)
{
if (references is null)
{
return ImmutableArray<VexObservationReference>.Empty;
}
var set = new HashSet<VexObservationReference>();
foreach (var reference in references)
{
if (reference is null)
{
continue;
}
set.Add(reference);
}
return set.Count == 0 ? ImmutableArray<VexObservationReference>.Empty : set.ToImmutableArray();
}
@@ -544,8 +544,8 @@ public sealed record VexObservationReference
Type = VexObservation.EnsureNotNullOrWhiteSpace(type, nameof(type));
Url = VexObservation.EnsureNotNullOrWhiteSpace(url, nameof(url));
}
public string Type { get; }
public string Type { get; }
public string Url { get; }
}

View File

@@ -1,79 +1,79 @@
using System.Collections.Immutable;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Query options for retrieving VEX observations scoped to a tenant.
/// </summary>
public sealed record VexObservationQueryOptions
{
public VexObservationQueryOptions(
string tenant,
IReadOnlyCollection<string>? observationIds = null,
IReadOnlyCollection<string>? vulnerabilityIds = null,
IReadOnlyCollection<string>? productKeys = null,
IReadOnlyCollection<string>? purls = null,
IReadOnlyCollection<string>? cpes = null,
IReadOnlyCollection<string>? providerIds = null,
IReadOnlyCollection<VexClaimStatus>? statuses = null,
int? limit = null,
string? cursor = null)
{
Tenant = VexObservation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
ObservationIds = observationIds ?? Array.Empty<string>();
VulnerabilityIds = vulnerabilityIds ?? Array.Empty<string>();
ProductKeys = productKeys ?? Array.Empty<string>();
Purls = purls ?? Array.Empty<string>();
Cpes = cpes ?? Array.Empty<string>();
ProviderIds = providerIds ?? Array.Empty<string>();
Statuses = statuses ?? Array.Empty<VexClaimStatus>();
Limit = limit;
Cursor = cursor;
}
public string Tenant { get; }
public IReadOnlyCollection<string> ObservationIds { get; }
public IReadOnlyCollection<string> VulnerabilityIds { get; }
public IReadOnlyCollection<string> ProductKeys { get; }
public IReadOnlyCollection<string> Purls { get; }
public IReadOnlyCollection<string> Cpes { get; }
public IReadOnlyCollection<string> ProviderIds { get; }
public IReadOnlyCollection<VexClaimStatus> Statuses { get; }
public int? Limit { get; }
public string? Cursor { get; }
}
/// <summary>
/// Cursor used for pagination.
/// </summary>
public sealed record VexObservationCursor(DateTimeOffset CreatedAt, string ObservationId);
/// <summary>
/// Query result returning observations and an aggregate summary.
/// </summary>
public sealed record VexObservationQueryResult(
ImmutableArray<VexObservation> Observations,
VexObservationAggregate Aggregate,
string? NextCursor,
bool HasMore);
/// <summary>
/// Aggregate metadata calculated from the returned observations.
/// </summary>
public sealed record VexObservationAggregate(
ImmutableArray<string> VulnerabilityIds,
ImmutableArray<string> ProductKeys,
ImmutableArray<string> Purls,
ImmutableArray<string> Cpes,
ImmutableArray<VexObservationReference> References,
ImmutableArray<string> ProviderIds);
using System.Collections.Immutable;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Query options for retrieving VEX observations scoped to a tenant.
/// </summary>
public sealed record VexObservationQueryOptions
{
public VexObservationQueryOptions(
string tenant,
IReadOnlyCollection<string>? observationIds = null,
IReadOnlyCollection<string>? vulnerabilityIds = null,
IReadOnlyCollection<string>? productKeys = null,
IReadOnlyCollection<string>? purls = null,
IReadOnlyCollection<string>? cpes = null,
IReadOnlyCollection<string>? providerIds = null,
IReadOnlyCollection<VexClaimStatus>? statuses = null,
int? limit = null,
string? cursor = null)
{
Tenant = VexObservation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
ObservationIds = observationIds ?? Array.Empty<string>();
VulnerabilityIds = vulnerabilityIds ?? Array.Empty<string>();
ProductKeys = productKeys ?? Array.Empty<string>();
Purls = purls ?? Array.Empty<string>();
Cpes = cpes ?? Array.Empty<string>();
ProviderIds = providerIds ?? Array.Empty<string>();
Statuses = statuses ?? Array.Empty<VexClaimStatus>();
Limit = limit;
Cursor = cursor;
}
public string Tenant { get; }
public IReadOnlyCollection<string> ObservationIds { get; }
public IReadOnlyCollection<string> VulnerabilityIds { get; }
public IReadOnlyCollection<string> ProductKeys { get; }
public IReadOnlyCollection<string> Purls { get; }
public IReadOnlyCollection<string> Cpes { get; }
public IReadOnlyCollection<string> ProviderIds { get; }
public IReadOnlyCollection<VexClaimStatus> Statuses { get; }
public int? Limit { get; }
public string? Cursor { get; }
}
/// <summary>
/// Cursor used for pagination.
/// </summary>
public sealed record VexObservationCursor(DateTimeOffset CreatedAt, string ObservationId);
/// <summary>
/// Query result returning observations and an aggregate summary.
/// </summary>
public sealed record VexObservationQueryResult(
ImmutableArray<VexObservation> Observations,
VexObservationAggregate Aggregate,
string? NextCursor,
bool HasMore);
/// <summary>
/// Aggregate metadata calculated from the returned observations.
/// </summary>
public sealed record VexObservationAggregate(
ImmutableArray<string> VulnerabilityIds,
ImmutableArray<string> ProductKeys,
ImmutableArray<string> Purls,
ImmutableArray<string> Cpes,
ImmutableArray<VexObservationReference> References,
ImmutableArray<string> ProviderIds);

View File

@@ -1,311 +1,311 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Default implementation of <see cref="IVexObservationQueryService"/> that projects raw VEX observations for overlay consumers.
/// </summary>
public sealed class VexObservationQueryService : IVexObservationQueryService
{
private const int DefaultPageSize = 200;
private const int MaxPageSize = 500;
private readonly IVexObservationLookup _lookup;
public VexObservationQueryService(IVexObservationLookup lookup)
{
_lookup = lookup ?? throw new ArgumentNullException(nameof(lookup));
}
public async ValueTask<VexObservationQueryResult> QueryAsync(
VexObservationQueryOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var tenant = NormalizeTenant(options.Tenant);
var observationIds = NormalizeSet(options.ObservationIds, static value => value, StringComparer.Ordinal);
var vulnerabilityIds = NormalizeSet(options.VulnerabilityIds, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var productKeys = NormalizeSet(options.ProductKeys, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var purls = NormalizeSet(options.Purls, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var cpes = NormalizeSet(options.Cpes, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var providerIds = NormalizeSet(options.ProviderIds, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var statuses = NormalizeStatuses(options.Statuses);
var limit = NormalizeLimit(options.Limit);
var fetchSize = checked(limit + 1);
var cursor = DecodeCursor(options.Cursor);
var observations = await _lookup
.FindByFiltersAsync(
tenant,
observationIds,
vulnerabilityIds,
productKeys,
purls,
cpes,
providerIds,
statuses,
cursor,
fetchSize,
cancellationToken)
.ConfigureAwait(false);
var ordered = observations
.Where(observation => Matches(observation, observationIds, vulnerabilityIds, productKeys, purls, cpes, providerIds, statuses))
.OrderByDescending(static observation => observation.CreatedAt)
.ThenBy(static observation => observation.ObservationId, StringComparer.Ordinal)
.ToImmutableArray();
var hasMore = ordered.Length > limit;
var page = hasMore ? ordered.Take(limit).ToImmutableArray() : ordered;
var nextCursor = hasMore ? EncodeCursor(page[^1]) : null;
var aggregate = BuildAggregate(page);
return new VexObservationQueryResult(page, aggregate, nextCursor, hasMore);
}
private static string NormalizeTenant(string tenant)
=> VexObservation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant)).ToLowerInvariant();
private static ImmutableHashSet<string> NormalizeSet(
IEnumerable<string>? values,
Func<string, string> projector,
StringComparer comparer)
{
if (values is null)
{
return ImmutableHashSet<string>.Empty;
}
var builder = ImmutableHashSet.CreateBuilder<string>(comparer);
foreach (var value in values)
{
var normalized = VexObservation.TrimToNull(value);
if (normalized is null)
{
continue;
}
builder.Add(projector(normalized));
}
return builder.ToImmutable();
}
private static ImmutableHashSet<VexClaimStatus> NormalizeStatuses(IEnumerable<VexClaimStatus>? statuses)
{
if (statuses is null)
{
return ImmutableHashSet<VexClaimStatus>.Empty;
}
return statuses.Aggregate(
ImmutableHashSet<VexClaimStatus>.Empty,
static (set, status) => set.Add(status));
}
private static int NormalizeLimit(int? limit)
{
if (!limit.HasValue || limit.Value <= 0)
{
return DefaultPageSize;
}
return Math.Min(limit.Value, MaxPageSize);
}
private static VexObservationCursor? DecodeCursor(string? cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
{
return null;
}
try
{
var decoded = Convert.FromBase64String(cursor.Trim());
var payload = Encoding.UTF8.GetString(decoded);
var separator = payload.IndexOf(':');
if (separator <= 0 || separator >= payload.Length - 1)
{
throw new FormatException("Cursor is malformed.");
}
if (!long.TryParse(payload.AsSpan(0, separator), NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks))
{
throw new FormatException("Cursor timestamp is invalid.");
}
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(new DateTime(ticks), DateTimeKind.Utc));
var observationId = payload[(separator + 1)..];
if (string.IsNullOrWhiteSpace(observationId))
{
throw new FormatException("Cursor observation id is missing.");
}
return new VexObservationCursor(createdAt, observationId);
}
catch (FormatException)
{
throw;
}
catch (Exception ex)
{
throw new FormatException("Cursor is malformed.", ex);
}
}
private static string? EncodeCursor(VexObservation observation)
{
if (observation is null)
{
return null;
}
var payload = $"{observation.CreatedAt.UtcTicks.ToString(CultureInfo.InvariantCulture)}:{observation.ObservationId}";
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
}
private static bool Matches(
VexObservation observation,
ImmutableHashSet<string> observationIds,
ImmutableHashSet<string> vulnerabilities,
ImmutableHashSet<string> productKeys,
ImmutableHashSet<string> purls,
ImmutableHashSet<string> cpes,
ImmutableHashSet<string> providerIds,
ImmutableHashSet<VexClaimStatus> statuses)
{
ArgumentNullException.ThrowIfNull(observation);
if (observationIds.Count > 0 && !observationIds.Contains(observation.ObservationId))
{
return false;
}
if (providerIds.Count > 0 && !providerIds.Contains(observation.ProviderId.ToLowerInvariant()))
{
return false;
}
if (!MatchesStatements(observation, vulnerabilities, productKeys, statuses))
{
return false;
}
if (purls.Count > 0 && !observation.Linkset.Purls.Any(purl => purls.Contains(purl.ToLowerInvariant())))
{
return false;
}
if (cpes.Count > 0 && !observation.Linkset.Cpes.Any(cpe => cpes.Contains(cpe.ToLowerInvariant())))
{
return false;
}
return true;
}
private static bool MatchesStatements(
VexObservation observation,
ImmutableHashSet<string> vulnerabilities,
ImmutableHashSet<string> productKeys,
ImmutableHashSet<VexClaimStatus> statuses)
{
if (vulnerabilities.Count == 0 && productKeys.Count == 0 && statuses.Count == 0)
{
return true;
}
foreach (var statement in observation.Statements)
{
var vulnerabilityMatches = vulnerabilities.Count == 0
|| vulnerabilities.Contains(statement.VulnerabilityId.ToLowerInvariant());
var productMatches = productKeys.Count == 0
|| productKeys.Contains(statement.ProductKey.ToLowerInvariant());
var statusMatches = statuses.Count == 0
|| statuses.Contains(statement.Status);
if (vulnerabilityMatches && productMatches && statusMatches)
{
return true;
}
}
return false;
}
private static VexObservationAggregate BuildAggregate(ImmutableArray<VexObservation> observations)
{
if (observations.IsDefaultOrEmpty)
{
return new VexObservationAggregate(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<VexObservationReference>.Empty,
ImmutableArray<string>.Empty);
}
var vulnerabilitySet = new HashSet<string>(StringComparer.Ordinal);
var productSet = new HashSet<string>(StringComparer.Ordinal);
var purlSet = new HashSet<string>(StringComparer.Ordinal);
var cpeSet = new HashSet<string>(StringComparer.Ordinal);
var referenceSet = new HashSet<VexObservationReference>();
var providerSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var observation in observations)
{
providerSet.Add(observation.ProviderId);
foreach (var statement in observation.Statements)
{
vulnerabilitySet.Add(statement.VulnerabilityId);
productSet.Add(statement.ProductKey);
if (!string.IsNullOrWhiteSpace(statement.Purl))
{
purlSet.Add(statement.Purl);
}
if (!string.IsNullOrWhiteSpace(statement.Cpe))
{
cpeSet.Add(statement.Cpe);
}
}
foreach (var purl in observation.Linkset.Purls)
{
purlSet.Add(purl);
}
foreach (var cpe in observation.Linkset.Cpes)
{
cpeSet.Add(cpe);
}
foreach (var reference in observation.Linkset.References)
{
referenceSet.Add(reference);
}
}
return new VexObservationAggregate(
vulnerabilitySet.OrderBy(static v => v, StringComparer.Ordinal).ToImmutableArray(),
productSet.OrderBy(static p => p, StringComparer.Ordinal).ToImmutableArray(),
purlSet.OrderBy(static p => p, StringComparer.Ordinal).ToImmutableArray(),
cpeSet.OrderBy(static c => c, StringComparer.Ordinal).ToImmutableArray(),
referenceSet
.OrderBy(static reference => reference.Type, StringComparer.Ordinal)
.ThenBy(static reference => reference.Url, StringComparer.Ordinal)
.ToImmutableArray(),
providerSet.OrderBy(static provider => provider, StringComparer.OrdinalIgnoreCase).ToImmutableArray());
}
}
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Core.Observations;
/// <summary>
/// Default implementation of <see cref="IVexObservationQueryService"/> that projects raw VEX observations for overlay consumers.
/// </summary>
public sealed class VexObservationQueryService : IVexObservationQueryService
{
private const int DefaultPageSize = 200;
private const int MaxPageSize = 500;
private readonly IVexObservationLookup _lookup;
public VexObservationQueryService(IVexObservationLookup lookup)
{
_lookup = lookup ?? throw new ArgumentNullException(nameof(lookup));
}
public async ValueTask<VexObservationQueryResult> QueryAsync(
VexObservationQueryOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var tenant = NormalizeTenant(options.Tenant);
var observationIds = NormalizeSet(options.ObservationIds, static value => value, StringComparer.Ordinal);
var vulnerabilityIds = NormalizeSet(options.VulnerabilityIds, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var productKeys = NormalizeSet(options.ProductKeys, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var purls = NormalizeSet(options.Purls, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var cpes = NormalizeSet(options.Cpes, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var providerIds = NormalizeSet(options.ProviderIds, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var statuses = NormalizeStatuses(options.Statuses);
var limit = NormalizeLimit(options.Limit);
var fetchSize = checked(limit + 1);
var cursor = DecodeCursor(options.Cursor);
var observations = await _lookup
.FindByFiltersAsync(
tenant,
observationIds,
vulnerabilityIds,
productKeys,
purls,
cpes,
providerIds,
statuses,
cursor,
fetchSize,
cancellationToken)
.ConfigureAwait(false);
var ordered = observations
.Where(observation => Matches(observation, observationIds, vulnerabilityIds, productKeys, purls, cpes, providerIds, statuses))
.OrderByDescending(static observation => observation.CreatedAt)
.ThenBy(static observation => observation.ObservationId, StringComparer.Ordinal)
.ToImmutableArray();
var hasMore = ordered.Length > limit;
var page = hasMore ? ordered.Take(limit).ToImmutableArray() : ordered;
var nextCursor = hasMore ? EncodeCursor(page[^1]) : null;
var aggregate = BuildAggregate(page);
return new VexObservationQueryResult(page, aggregate, nextCursor, hasMore);
}
private static string NormalizeTenant(string tenant)
=> VexObservation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant)).ToLowerInvariant();
private static ImmutableHashSet<string> NormalizeSet(
IEnumerable<string>? values,
Func<string, string> projector,
StringComparer comparer)
{
if (values is null)
{
return ImmutableHashSet<string>.Empty;
}
var builder = ImmutableHashSet.CreateBuilder<string>(comparer);
foreach (var value in values)
{
var normalized = VexObservation.TrimToNull(value);
if (normalized is null)
{
continue;
}
builder.Add(projector(normalized));
}
return builder.ToImmutable();
}
private static ImmutableHashSet<VexClaimStatus> NormalizeStatuses(IEnumerable<VexClaimStatus>? statuses)
{
if (statuses is null)
{
return ImmutableHashSet<VexClaimStatus>.Empty;
}
return statuses.Aggregate(
ImmutableHashSet<VexClaimStatus>.Empty,
static (set, status) => set.Add(status));
}
private static int NormalizeLimit(int? limit)
{
if (!limit.HasValue || limit.Value <= 0)
{
return DefaultPageSize;
}
return Math.Min(limit.Value, MaxPageSize);
}
private static VexObservationCursor? DecodeCursor(string? cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
{
return null;
}
try
{
var decoded = Convert.FromBase64String(cursor.Trim());
var payload = Encoding.UTF8.GetString(decoded);
var separator = payload.IndexOf(':');
if (separator <= 0 || separator >= payload.Length - 1)
{
throw new FormatException("Cursor is malformed.");
}
if (!long.TryParse(payload.AsSpan(0, separator), NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks))
{
throw new FormatException("Cursor timestamp is invalid.");
}
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(new DateTime(ticks), DateTimeKind.Utc));
var observationId = payload[(separator + 1)..];
if (string.IsNullOrWhiteSpace(observationId))
{
throw new FormatException("Cursor observation id is missing.");
}
return new VexObservationCursor(createdAt, observationId);
}
catch (FormatException)
{
throw;
}
catch (Exception ex)
{
throw new FormatException("Cursor is malformed.", ex);
}
}
private static string? EncodeCursor(VexObservation observation)
{
if (observation is null)
{
return null;
}
var payload = $"{observation.CreatedAt.UtcTicks.ToString(CultureInfo.InvariantCulture)}:{observation.ObservationId}";
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
}
private static bool Matches(
VexObservation observation,
ImmutableHashSet<string> observationIds,
ImmutableHashSet<string> vulnerabilities,
ImmutableHashSet<string> productKeys,
ImmutableHashSet<string> purls,
ImmutableHashSet<string> cpes,
ImmutableHashSet<string> providerIds,
ImmutableHashSet<VexClaimStatus> statuses)
{
ArgumentNullException.ThrowIfNull(observation);
if (observationIds.Count > 0 && !observationIds.Contains(observation.ObservationId))
{
return false;
}
if (providerIds.Count > 0 && !providerIds.Contains(observation.ProviderId.ToLowerInvariant()))
{
return false;
}
if (!MatchesStatements(observation, vulnerabilities, productKeys, statuses))
{
return false;
}
if (purls.Count > 0 && !observation.Linkset.Purls.Any(purl => purls.Contains(purl.ToLowerInvariant())))
{
return false;
}
if (cpes.Count > 0 && !observation.Linkset.Cpes.Any(cpe => cpes.Contains(cpe.ToLowerInvariant())))
{
return false;
}
return true;
}
private static bool MatchesStatements(
VexObservation observation,
ImmutableHashSet<string> vulnerabilities,
ImmutableHashSet<string> productKeys,
ImmutableHashSet<VexClaimStatus> statuses)
{
if (vulnerabilities.Count == 0 && productKeys.Count == 0 && statuses.Count == 0)
{
return true;
}
foreach (var statement in observation.Statements)
{
var vulnerabilityMatches = vulnerabilities.Count == 0
|| vulnerabilities.Contains(statement.VulnerabilityId.ToLowerInvariant());
var productMatches = productKeys.Count == 0
|| productKeys.Contains(statement.ProductKey.ToLowerInvariant());
var statusMatches = statuses.Count == 0
|| statuses.Contains(statement.Status);
if (vulnerabilityMatches && productMatches && statusMatches)
{
return true;
}
}
return false;
}
private static VexObservationAggregate BuildAggregate(ImmutableArray<VexObservation> observations)
{
if (observations.IsDefaultOrEmpty)
{
return new VexObservationAggregate(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<VexObservationReference>.Empty,
ImmutableArray<string>.Empty);
}
var vulnerabilitySet = new HashSet<string>(StringComparer.Ordinal);
var productSet = new HashSet<string>(StringComparer.Ordinal);
var purlSet = new HashSet<string>(StringComparer.Ordinal);
var cpeSet = new HashSet<string>(StringComparer.Ordinal);
var referenceSet = new HashSet<VexObservationReference>();
var providerSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var observation in observations)
{
providerSet.Add(observation.ProviderId);
foreach (var statement in observation.Statements)
{
vulnerabilitySet.Add(statement.VulnerabilityId);
productSet.Add(statement.ProductKey);
if (!string.IsNullOrWhiteSpace(statement.Purl))
{
purlSet.Add(statement.Purl);
}
if (!string.IsNullOrWhiteSpace(statement.Cpe))
{
cpeSet.Add(statement.Cpe);
}
}
foreach (var purl in observation.Linkset.Purls)
{
purlSet.Add(purl);
}
foreach (var cpe in observation.Linkset.Cpes)
{
cpeSet.Add(cpe);
}
foreach (var reference in observation.Linkset.References)
{
referenceSet.Add(reference);
}
}
return new VexObservationAggregate(
vulnerabilitySet.OrderBy(static v => v, StringComparer.Ordinal).ToImmutableArray(),
productSet.OrderBy(static p => p, StringComparer.Ordinal).ToImmutableArray(),
purlSet.OrderBy(static p => p, StringComparer.Ordinal).ToImmutableArray(),
cpeSet.OrderBy(static c => c, StringComparer.Ordinal).ToImmutableArray(),
referenceSet
.OrderBy(static reference => reference.Type, StringComparer.Ordinal)
.ThenBy(static reference => reference.Url, StringComparer.Ordinal)
.ToImmutableArray(),
providerSet.OrderBy(static provider => provider, StringComparer.OrdinalIgnoreCase).ToImmutableArray());
}
}

View File

@@ -1,11 +1,11 @@
using System;
using System.Collections.Immutable;
using System.Threading;
using System;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Attestation.Verification;
namespace StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Core;
public interface IVexAttestationClient
{
ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken);
@@ -16,12 +16,12 @@ public interface IVexAttestationClient
public sealed record VexAttestationRequest(
string ExportId,
VexQuerySignature QuerySignature,
VexContentAddress Artifact,
VexExportFormat Format,
DateTimeOffset CreatedAt,
ImmutableArray<string> SourceProviders,
ImmutableDictionary<string, string> Metadata);
VexContentAddress Artifact,
VexExportFormat Format,
DateTimeOffset CreatedAt,
ImmutableArray<string> SourceProviders,
ImmutableDictionary<string, string> Metadata);
public sealed record VexAttestationResponse(
VexAttestationMetadata Attestation,
ImmutableDictionary<string, string> Diagnostics);

View File

@@ -1,56 +1,56 @@
using System;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Cached export artifact metadata allowing reuse of previously generated manifests.
/// </summary>
public sealed class VexCacheEntry
{
public VexCacheEntry(
VexQuerySignature querySignature,
VexExportFormat format,
VexContentAddress artifact,
DateTimeOffset createdAt,
long sizeBytes,
string? manifestId = null,
string? gridFsObjectId = null,
DateTimeOffset? expiresAt = null)
{
QuerySignature = querySignature ?? throw new ArgumentNullException(nameof(querySignature));
Artifact = artifact ?? throw new ArgumentNullException(nameof(artifact));
Format = format;
CreatedAt = createdAt;
SizeBytes = sizeBytes >= 0
? sizeBytes
: throw new ArgumentOutOfRangeException(nameof(sizeBytes), sizeBytes, "Size must be non-negative.");
ManifestId = Normalize(manifestId);
GridFsObjectId = Normalize(gridFsObjectId);
if (expiresAt.HasValue && expiresAt.Value < createdAt)
{
throw new ArgumentOutOfRangeException(nameof(expiresAt), expiresAt, "Expiration cannot be before creation.");
}
ExpiresAt = expiresAt;
}
public VexQuerySignature QuerySignature { get; }
public VexExportFormat Format { get; }
public VexContentAddress Artifact { get; }
public DateTimeOffset CreatedAt { get; }
public long SizeBytes { get; }
public string? ManifestId { get; }
public string? GridFsObjectId { get; }
public DateTimeOffset? ExpiresAt { get; }
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
using System;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Cached export artifact metadata allowing reuse of previously generated manifests.
/// </summary>
public sealed class VexCacheEntry
{
public VexCacheEntry(
VexQuerySignature querySignature,
VexExportFormat format,
VexContentAddress artifact,
DateTimeOffset createdAt,
long sizeBytes,
string? manifestId = null,
string? gridFsObjectId = null,
DateTimeOffset? expiresAt = null)
{
QuerySignature = querySignature ?? throw new ArgumentNullException(nameof(querySignature));
Artifact = artifact ?? throw new ArgumentNullException(nameof(artifact));
Format = format;
CreatedAt = createdAt;
SizeBytes = sizeBytes >= 0
? sizeBytes
: throw new ArgumentOutOfRangeException(nameof(sizeBytes), sizeBytes, "Size must be non-negative.");
ManifestId = Normalize(manifestId);
GridFsObjectId = Normalize(gridFsObjectId);
if (expiresAt.HasValue && expiresAt.Value < createdAt)
{
throw new ArgumentOutOfRangeException(nameof(expiresAt), expiresAt, "Expiration cannot be before creation.");
}
ExpiresAt = expiresAt;
}
public VexQuerySignature QuerySignature { get; }
public VexExportFormat Format { get; }
public VexContentAddress Artifact { get; }
public DateTimeOffset CreatedAt { get; }
public long SizeBytes { get; }
public string? ManifestId { get; }
public string? GridFsObjectId { get; }
public DateTimeOffset? ExpiresAt { get; }
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -1,189 +1,189 @@
using System.Collections.Immutable;
using System.Runtime.Serialization;
namespace StellaOps.Excititor.Core;
public sealed record VexClaim
{
public VexClaim(
string vulnerabilityId,
string providerId,
VexProduct product,
VexClaimStatus status,
VexClaimDocument document,
DateTimeOffset firstSeen,
DateTimeOffset lastSeen,
VexJustification? justification = null,
string? detail = null,
VexConfidence? confidence = null,
VexSignalSnapshot? signals = null,
ImmutableDictionary<string, string>? additionalMetadata = null)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
}
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
}
if (lastSeen < firstSeen)
{
throw new ArgumentOutOfRangeException(nameof(lastSeen), "Last seen timestamp cannot be earlier than first seen.");
}
VulnerabilityId = vulnerabilityId.Trim();
ProviderId = providerId.Trim();
Product = product ?? throw new ArgumentNullException(nameof(product));
Status = status;
Document = document ?? throw new ArgumentNullException(nameof(document));
FirstSeen = firstSeen;
LastSeen = lastSeen;
Justification = justification;
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
Confidence = confidence;
Signals = signals;
AdditionalMetadata = NormalizeMetadata(additionalMetadata);
}
public string VulnerabilityId { get; }
public string ProviderId { get; }
public VexProduct Product { get; }
public VexClaimStatus Status { get; }
public VexJustification? Justification { get; }
public string? Detail { get; }
public VexClaimDocument Document { get; }
public DateTimeOffset FirstSeen { get; }
public DateTimeOffset LastSeen { get; }
public VexConfidence? Confidence { get; }
public VexSignalSnapshot? Signals { get; }
public ImmutableSortedDictionary<string, string> AdditionalMetadata { get; }
private static ImmutableSortedDictionary<string, string> NormalizeMetadata(
ImmutableDictionary<string, string>? additionalMetadata)
{
if (additionalMetadata is null || additionalMetadata.Count == 0)
{
return ImmutableSortedDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in additionalMetadata)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
builder[key.Trim()] = value?.Trim() ?? string.Empty;
}
return builder.ToImmutable();
}
}
public sealed record VexProduct
{
public VexProduct(
string key,
string? name,
string? version = null,
string? purl = null,
string? cpe = null,
IEnumerable<string>? componentIdentifiers = null)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException("Product key must be provided.", nameof(key));
}
Key = key.Trim();
Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
Cpe = string.IsNullOrWhiteSpace(cpe) ? null : cpe.Trim();
ComponentIdentifiers = NormalizeComponentIdentifiers(componentIdentifiers);
}
public string Key { get; }
public string? Name { get; }
public string? Version { get; }
public string? Purl { get; }
public string? Cpe { get; }
public ImmutableArray<string> ComponentIdentifiers { get; }
private static ImmutableArray<string> NormalizeComponentIdentifiers(IEnumerable<string>? identifiers)
{
if (identifiers is null)
{
return ImmutableArray<string>.Empty;
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var identifier in identifiers)
{
if (string.IsNullOrWhiteSpace(identifier))
{
continue;
}
set.Add(identifier.Trim());
}
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
}
}
public sealed record VexClaimDocument
{
public VexClaimDocument(
VexDocumentFormat format,
string digest,
Uri sourceUri,
string? revision = null,
VexSignatureMetadata? signature = null)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Document digest must be provided.", nameof(digest));
}
Format = format;
Digest = digest.Trim();
SourceUri = sourceUri ?? throw new ArgumentNullException(nameof(sourceUri));
Revision = string.IsNullOrWhiteSpace(revision) ? null : revision.Trim();
Signature = signature;
}
public VexDocumentFormat Format { get; }
public string Digest { get; }
public Uri SourceUri { get; }
public string? Revision { get; }
public VexSignatureMetadata? Signature { get; }
}
using System.Collections.Immutable;
using System.Runtime.Serialization;
namespace StellaOps.Excititor.Core;
public sealed record VexClaim
{
public VexClaim(
string vulnerabilityId,
string providerId,
VexProduct product,
VexClaimStatus status,
VexClaimDocument document,
DateTimeOffset firstSeen,
DateTimeOffset lastSeen,
VexJustification? justification = null,
string? detail = null,
VexConfidence? confidence = null,
VexSignalSnapshot? signals = null,
ImmutableDictionary<string, string>? additionalMetadata = null)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
}
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
}
if (lastSeen < firstSeen)
{
throw new ArgumentOutOfRangeException(nameof(lastSeen), "Last seen timestamp cannot be earlier than first seen.");
}
VulnerabilityId = vulnerabilityId.Trim();
ProviderId = providerId.Trim();
Product = product ?? throw new ArgumentNullException(nameof(product));
Status = status;
Document = document ?? throw new ArgumentNullException(nameof(document));
FirstSeen = firstSeen;
LastSeen = lastSeen;
Justification = justification;
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
Confidence = confidence;
Signals = signals;
AdditionalMetadata = NormalizeMetadata(additionalMetadata);
}
public string VulnerabilityId { get; }
public string ProviderId { get; }
public VexProduct Product { get; }
public VexClaimStatus Status { get; }
public VexJustification? Justification { get; }
public string? Detail { get; }
public VexClaimDocument Document { get; }
public DateTimeOffset FirstSeen { get; }
public DateTimeOffset LastSeen { get; }
public VexConfidence? Confidence { get; }
public VexSignalSnapshot? Signals { get; }
public ImmutableSortedDictionary<string, string> AdditionalMetadata { get; }
private static ImmutableSortedDictionary<string, string> NormalizeMetadata(
ImmutableDictionary<string, string>? additionalMetadata)
{
if (additionalMetadata is null || additionalMetadata.Count == 0)
{
return ImmutableSortedDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in additionalMetadata)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
builder[key.Trim()] = value?.Trim() ?? string.Empty;
}
return builder.ToImmutable();
}
}
public sealed record VexProduct
{
public VexProduct(
string key,
string? name,
string? version = null,
string? purl = null,
string? cpe = null,
IEnumerable<string>? componentIdentifiers = null)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException("Product key must be provided.", nameof(key));
}
Key = key.Trim();
Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
Cpe = string.IsNullOrWhiteSpace(cpe) ? null : cpe.Trim();
ComponentIdentifiers = NormalizeComponentIdentifiers(componentIdentifiers);
}
public string Key { get; }
public string? Name { get; }
public string? Version { get; }
public string? Purl { get; }
public string? Cpe { get; }
public ImmutableArray<string> ComponentIdentifiers { get; }
private static ImmutableArray<string> NormalizeComponentIdentifiers(IEnumerable<string>? identifiers)
{
if (identifiers is null)
{
return ImmutableArray<string>.Empty;
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var identifier in identifiers)
{
if (string.IsNullOrWhiteSpace(identifier))
{
continue;
}
set.Add(identifier.Trim());
}
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
}
}
public sealed record VexClaimDocument
{
public VexClaimDocument(
VexDocumentFormat format,
string digest,
Uri sourceUri,
string? revision = null,
VexSignatureMetadata? signature = null)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Document digest must be provided.", nameof(digest));
}
Format = format;
Digest = digest.Trim();
SourceUri = sourceUri ?? throw new ArgumentNullException(nameof(sourceUri));
Revision = string.IsNullOrWhiteSpace(revision) ? null : revision.Trim();
Signature = signature;
}
public VexDocumentFormat Format { get; }
public string Digest { get; }
public Uri SourceUri { get; }
public string? Revision { get; }
public VexSignatureMetadata? Signature { get; }
}
public sealed record VexSignatureMetadata
{
public VexSignatureMetadata(
@@ -252,110 +252,110 @@ public sealed record VexSignatureTrustMetadata
public DateTimeOffset RetrievedAtUtc { get; }
}
public sealed record VexConfidence
{
public VexConfidence(string level, double? score = null, string? method = null)
{
if (string.IsNullOrWhiteSpace(level))
{
throw new ArgumentException("Confidence level must be provided.", nameof(level));
}
if (score is not null && (double.IsNaN(score.Value) || double.IsInfinity(score.Value)))
{
throw new ArgumentOutOfRangeException(nameof(score), "Confidence score must be a finite number.");
}
Level = level.Trim();
Score = score;
Method = string.IsNullOrWhiteSpace(method) ? null : method.Trim();
}
public string Level { get; }
public double? Score { get; }
public string? Method { get; }
}
[DataContract]
public enum VexDocumentFormat
{
[EnumMember(Value = "csaf")]
Csaf,
[EnumMember(Value = "cyclonedx")]
CycloneDx,
[EnumMember(Value = "openvex")]
OpenVex,
[EnumMember(Value = "oci_attestation")]
OciAttestation,
}
[DataContract]
public enum VexClaimStatus
{
[EnumMember(Value = "affected")]
Affected,
[EnumMember(Value = "not_affected")]
NotAffected,
[EnumMember(Value = "fixed")]
Fixed,
[EnumMember(Value = "under_investigation")]
UnderInvestigation,
}
[DataContract]
public enum VexJustification
{
[EnumMember(Value = "component_not_present")]
ComponentNotPresent,
[EnumMember(Value = "component_not_configured")]
ComponentNotConfigured,
[EnumMember(Value = "vulnerable_code_not_present")]
VulnerableCodeNotPresent,
[EnumMember(Value = "vulnerable_code_not_in_execute_path")]
VulnerableCodeNotInExecutePath,
[EnumMember(Value = "vulnerable_code_cannot_be_controlled_by_adversary")]
VulnerableCodeCannotBeControlledByAdversary,
[EnumMember(Value = "inline_mitigations_already_exist")]
InlineMitigationsAlreadyExist,
[EnumMember(Value = "protected_by_mitigating_control")]
ProtectedByMitigatingControl,
[EnumMember(Value = "code_not_present")]
CodeNotPresent,
[EnumMember(Value = "code_not_reachable")]
CodeNotReachable,
[EnumMember(Value = "requires_configuration")]
RequiresConfiguration,
[EnumMember(Value = "requires_dependency")]
RequiresDependency,
[EnumMember(Value = "requires_environment")]
RequiresEnvironment,
[EnumMember(Value = "protected_by_compensating_control")]
ProtectedByCompensatingControl,
[EnumMember(Value = "protected_at_perimeter")]
ProtectedAtPerimeter,
[EnumMember(Value = "protected_at_runtime")]
ProtectedAtRuntime,
}
public sealed record VexConfidence
{
public VexConfidence(string level, double? score = null, string? method = null)
{
if (string.IsNullOrWhiteSpace(level))
{
throw new ArgumentException("Confidence level must be provided.", nameof(level));
}
if (score is not null && (double.IsNaN(score.Value) || double.IsInfinity(score.Value)))
{
throw new ArgumentOutOfRangeException(nameof(score), "Confidence score must be a finite number.");
}
Level = level.Trim();
Score = score;
Method = string.IsNullOrWhiteSpace(method) ? null : method.Trim();
}
public string Level { get; }
public double? Score { get; }
public string? Method { get; }
}
[DataContract]
public enum VexDocumentFormat
{
[EnumMember(Value = "csaf")]
Csaf,
[EnumMember(Value = "cyclonedx")]
CycloneDx,
[EnumMember(Value = "openvex")]
OpenVex,
[EnumMember(Value = "oci_attestation")]
OciAttestation,
}
[DataContract]
public enum VexClaimStatus
{
[EnumMember(Value = "affected")]
Affected,
[EnumMember(Value = "not_affected")]
NotAffected,
[EnumMember(Value = "fixed")]
Fixed,
[EnumMember(Value = "under_investigation")]
UnderInvestigation,
}
[DataContract]
public enum VexJustification
{
[EnumMember(Value = "component_not_present")]
ComponentNotPresent,
[EnumMember(Value = "component_not_configured")]
ComponentNotConfigured,
[EnumMember(Value = "vulnerable_code_not_present")]
VulnerableCodeNotPresent,
[EnumMember(Value = "vulnerable_code_not_in_execute_path")]
VulnerableCodeNotInExecutePath,
[EnumMember(Value = "vulnerable_code_cannot_be_controlled_by_adversary")]
VulnerableCodeCannotBeControlledByAdversary,
[EnumMember(Value = "inline_mitigations_already_exist")]
InlineMitigationsAlreadyExist,
[EnumMember(Value = "protected_by_mitigating_control")]
ProtectedByMitigatingControl,
[EnumMember(Value = "code_not_present")]
CodeNotPresent,
[EnumMember(Value = "code_not_reachable")]
CodeNotReachable,
[EnumMember(Value = "requires_configuration")]
RequiresConfiguration,
[EnumMember(Value = "requires_dependency")]
RequiresDependency,
[EnumMember(Value = "requires_environment")]
RequiresEnvironment,
[EnumMember(Value = "protected_by_compensating_control")]
ProtectedByCompensatingControl,
[EnumMember(Value = "protected_at_perimeter")]
ProtectedAtPerimeter,
[EnumMember(Value = "protected_at_runtime")]
ProtectedAtRuntime,
}

View File

@@ -1,29 +1,29 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Shared connector contract for fetching and normalizing provider-specific VEX data.
/// </summary>
public interface IVexConnector
{
string Id { get; }
VexProviderKind Kind { get; }
ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken);
IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken);
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
}
/// <summary>
/// Connector context populated by the orchestrator/worker.
/// </summary>
namespace StellaOps.Excititor.Core;
/// <summary>
/// Shared connector contract for fetching and normalizing provider-specific VEX data.
/// </summary>
public interface IVexConnector
{
string Id { get; }
VexProviderKind Kind { get; }
ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken);
IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken);
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
}
/// <summary>
/// Connector context populated by the orchestrator/worker.
/// </summary>
public sealed record VexConnectorContext(
DateTimeOffset? Since,
VexConnectorSettings Settings,
@@ -32,58 +32,58 @@ public sealed record VexConnectorContext(
IVexNormalizerRouter Normalizers,
IServiceProvider Services,
ImmutableDictionary<string, string> ResumeTokens);
/// <summary>
/// Normalized connector configuration values.
/// </summary>
public sealed record VexConnectorSettings(ImmutableDictionary<string, string> Values)
{
public static VexConnectorSettings Empty { get; } = new(ImmutableDictionary<string, string>.Empty);
}
/// <summary>
/// Raw document retrieved from a connector pull.
/// </summary>
public sealed record VexRawDocument(
string ProviderId,
VexDocumentFormat Format,
Uri SourceUri,
DateTimeOffset RetrievedAt,
string Digest,
ReadOnlyMemory<byte> Content,
ImmutableDictionary<string, string> Metadata)
{
public Guid DocumentId { get; init; } = Guid.NewGuid();
}
/// <summary>
/// Batch of normalized claims derived from a raw document.
/// </summary>
public sealed record VexClaimBatch(
VexRawDocument Source,
ImmutableArray<VexClaim> Claims,
ImmutableDictionary<string, string> Diagnostics);
/// <summary>
/// Sink abstraction allowing connectors to stream raw documents for persistence.
/// </summary>
public interface IVexRawDocumentSink
{
ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken);
}
/// <summary>
/// Signature/attestation verification service used while ingesting documents.
/// </summary>
public interface IVexSignatureVerifier
{
ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken);
}
/// <summary>
/// Normalizer router providing format-specific normalization helpers.
/// </summary>
public interface IVexNormalizerRouter
{
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
}
/// <summary>
/// Normalized connector configuration values.
/// </summary>
public sealed record VexConnectorSettings(ImmutableDictionary<string, string> Values)
{
public static VexConnectorSettings Empty { get; } = new(ImmutableDictionary<string, string>.Empty);
}
/// <summary>
/// Raw document retrieved from a connector pull.
/// </summary>
public sealed record VexRawDocument(
string ProviderId,
VexDocumentFormat Format,
Uri SourceUri,
DateTimeOffset RetrievedAt,
string Digest,
ReadOnlyMemory<byte> Content,
ImmutableDictionary<string, string> Metadata)
{
public Guid DocumentId { get; init; } = Guid.NewGuid();
}
/// <summary>
/// Batch of normalized claims derived from a raw document.
/// </summary>
public sealed record VexClaimBatch(
VexRawDocument Source,
ImmutableArray<VexClaim> Claims,
ImmutableDictionary<string, string> Diagnostics);
/// <summary>
/// Sink abstraction allowing connectors to stream raw documents for persistence.
/// </summary>
public interface IVexRawDocumentSink
{
ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken);
}
/// <summary>
/// Signature/attestation verification service used while ingesting documents.
/// </summary>
public interface IVexSignatureVerifier
{
ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken);
}
/// <summary>
/// Normalizer router providing format-specific normalization helpers.
/// </summary>
public interface IVexNormalizerRouter
{
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
}

View File

@@ -1,215 +1,215 @@
using System.Collections.Immutable;
using System.Runtime.Serialization;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Represents a VEX consensus result from weighted voting.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensus
{
public VexConsensus(
string vulnerabilityId,
VexProduct product,
VexConsensusStatus status,
DateTimeOffset calculatedAt,
IEnumerable<VexConsensusSource> sources,
IEnumerable<VexConsensusConflict>? conflicts = null,
VexSignalSnapshot? signals = null,
string? policyVersion = null,
string? summary = null,
string? policyRevisionId = null,
string? policyDigest = null)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
}
VulnerabilityId = vulnerabilityId.Trim();
Product = product ?? throw new ArgumentNullException(nameof(product));
Status = status;
CalculatedAt = calculatedAt;
Sources = NormalizeSources(sources);
Conflicts = NormalizeConflicts(conflicts);
Signals = signals;
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion.Trim();
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim();
PolicyRevisionId = string.IsNullOrWhiteSpace(policyRevisionId) ? null : policyRevisionId.Trim();
PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim();
}
public string VulnerabilityId { get; }
public VexProduct Product { get; }
public VexConsensusStatus Status { get; }
public DateTimeOffset CalculatedAt { get; }
public ImmutableArray<VexConsensusSource> Sources { get; }
public ImmutableArray<VexConsensusConflict> Conflicts { get; }
public VexSignalSnapshot? Signals { get; }
public string? PolicyVersion { get; }
public string? Summary { get; }
public string? PolicyRevisionId { get; }
public string? PolicyDigest { get; }
private static ImmutableArray<VexConsensusSource> NormalizeSources(IEnumerable<VexConsensusSource> sources)
{
if (sources is null)
{
throw new ArgumentNullException(nameof(sources));
}
var builder = ImmutableArray.CreateBuilder<VexConsensusSource>();
builder.AddRange(sources);
if (builder.Count == 0)
{
return ImmutableArray<VexConsensusSource>.Empty;
}
return builder
.OrderBy(static x => x.ProviderId, StringComparer.Ordinal)
.ThenBy(static x => x.DocumentDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<VexConsensusConflict> NormalizeConflicts(IEnumerable<VexConsensusConflict>? conflicts)
{
if (conflicts is null)
{
return ImmutableArray<VexConsensusConflict>.Empty;
}
var items = conflicts.ToArray();
return items.Length == 0
? ImmutableArray<VexConsensusConflict>.Empty
: items
.OrderBy(static x => x.ProviderId, StringComparer.Ordinal)
.ThenBy(static x => x.DocumentDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
}
public sealed record VexConsensusSource
{
public VexConsensusSource(
string providerId,
VexClaimStatus status,
string documentDigest,
double weight,
VexJustification? justification = null,
string? detail = null,
VexConfidence? confidence = null)
{
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
}
if (string.IsNullOrWhiteSpace(documentDigest))
{
throw new ArgumentException("Document digest must be provided.", nameof(documentDigest));
}
if (double.IsNaN(weight) || double.IsInfinity(weight) || weight < 0)
{
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite, non-negative number.");
}
ProviderId = providerId.Trim();
Status = status;
DocumentDigest = documentDigest.Trim();
Weight = weight;
Justification = justification;
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
Confidence = confidence;
}
public string ProviderId { get; }
public VexClaimStatus Status { get; }
public string DocumentDigest { get; }
public double Weight { get; }
public VexJustification? Justification { get; }
public string? Detail { get; }
public VexConfidence? Confidence { get; }
}
public sealed record VexConsensusConflict
{
public VexConsensusConflict(
string providerId,
VexClaimStatus status,
string documentDigest,
VexJustification? justification = null,
string? detail = null,
string? reason = null)
{
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
}
if (string.IsNullOrWhiteSpace(documentDigest))
{
throw new ArgumentException("Document digest must be provided.", nameof(documentDigest));
}
ProviderId = providerId.Trim();
Status = status;
DocumentDigest = documentDigest.Trim();
Justification = justification;
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
}
public string ProviderId { get; }
public VexClaimStatus Status { get; }
public string DocumentDigest { get; }
public VexJustification? Justification { get; }
public string? Detail { get; }
public string? Reason { get; }
}
[DataContract]
public enum VexConsensusStatus
{
[EnumMember(Value = "affected")]
Affected,
[EnumMember(Value = "not_affected")]
NotAffected,
[EnumMember(Value = "fixed")]
Fixed,
[EnumMember(Value = "under_investigation")]
UnderInvestigation,
[EnumMember(Value = "divergent")]
Divergent,
}
using System.Collections.Immutable;
using System.Runtime.Serialization;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Represents a VEX consensus result from weighted voting.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensus
{
public VexConsensus(
string vulnerabilityId,
VexProduct product,
VexConsensusStatus status,
DateTimeOffset calculatedAt,
IEnumerable<VexConsensusSource> sources,
IEnumerable<VexConsensusConflict>? conflicts = null,
VexSignalSnapshot? signals = null,
string? policyVersion = null,
string? summary = null,
string? policyRevisionId = null,
string? policyDigest = null)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
}
VulnerabilityId = vulnerabilityId.Trim();
Product = product ?? throw new ArgumentNullException(nameof(product));
Status = status;
CalculatedAt = calculatedAt;
Sources = NormalizeSources(sources);
Conflicts = NormalizeConflicts(conflicts);
Signals = signals;
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion.Trim();
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim();
PolicyRevisionId = string.IsNullOrWhiteSpace(policyRevisionId) ? null : policyRevisionId.Trim();
PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim();
}
public string VulnerabilityId { get; }
public VexProduct Product { get; }
public VexConsensusStatus Status { get; }
public DateTimeOffset CalculatedAt { get; }
public ImmutableArray<VexConsensusSource> Sources { get; }
public ImmutableArray<VexConsensusConflict> Conflicts { get; }
public VexSignalSnapshot? Signals { get; }
public string? PolicyVersion { get; }
public string? Summary { get; }
public string? PolicyRevisionId { get; }
public string? PolicyDigest { get; }
private static ImmutableArray<VexConsensusSource> NormalizeSources(IEnumerable<VexConsensusSource> sources)
{
if (sources is null)
{
throw new ArgumentNullException(nameof(sources));
}
var builder = ImmutableArray.CreateBuilder<VexConsensusSource>();
builder.AddRange(sources);
if (builder.Count == 0)
{
return ImmutableArray<VexConsensusSource>.Empty;
}
return builder
.OrderBy(static x => x.ProviderId, StringComparer.Ordinal)
.ThenBy(static x => x.DocumentDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<VexConsensusConflict> NormalizeConflicts(IEnumerable<VexConsensusConflict>? conflicts)
{
if (conflicts is null)
{
return ImmutableArray<VexConsensusConflict>.Empty;
}
var items = conflicts.ToArray();
return items.Length == 0
? ImmutableArray<VexConsensusConflict>.Empty
: items
.OrderBy(static x => x.ProviderId, StringComparer.Ordinal)
.ThenBy(static x => x.DocumentDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
}
public sealed record VexConsensusSource
{
public VexConsensusSource(
string providerId,
VexClaimStatus status,
string documentDigest,
double weight,
VexJustification? justification = null,
string? detail = null,
VexConfidence? confidence = null)
{
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
}
if (string.IsNullOrWhiteSpace(documentDigest))
{
throw new ArgumentException("Document digest must be provided.", nameof(documentDigest));
}
if (double.IsNaN(weight) || double.IsInfinity(weight) || weight < 0)
{
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite, non-negative number.");
}
ProviderId = providerId.Trim();
Status = status;
DocumentDigest = documentDigest.Trim();
Weight = weight;
Justification = justification;
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
Confidence = confidence;
}
public string ProviderId { get; }
public VexClaimStatus Status { get; }
public string DocumentDigest { get; }
public double Weight { get; }
public VexJustification? Justification { get; }
public string? Detail { get; }
public VexConfidence? Confidence { get; }
}
public sealed record VexConsensusConflict
{
public VexConsensusConflict(
string providerId,
VexClaimStatus status,
string documentDigest,
VexJustification? justification = null,
string? detail = null,
string? reason = null)
{
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
}
if (string.IsNullOrWhiteSpace(documentDigest))
{
throw new ArgumentException("Document digest must be provided.", nameof(documentDigest));
}
ProviderId = providerId.Trim();
Status = status;
DocumentDigest = documentDigest.Trim();
Justification = justification;
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
}
public string ProviderId { get; }
public VexClaimStatus Status { get; }
public string DocumentDigest { get; }
public VexJustification? Justification { get; }
public string? Detail { get; }
public string? Reason { get; }
}
[DataContract]
public enum VexConsensusStatus
{
[EnumMember(Value = "affected")]
Affected,
[EnumMember(Value = "not_affected")]
NotAffected,
[EnumMember(Value = "fixed")]
Fixed,
[EnumMember(Value = "under_investigation")]
UnderInvestigation,
[EnumMember(Value = "divergent")]
Divergent,
}

View File

@@ -1,47 +1,47 @@
namespace StellaOps.Excititor.Core;
public sealed record VexConsensusHold
{
public VexConsensusHold(
string vulnerabilityId,
string productKey,
VexConsensus candidate,
DateTimeOffset requestedAt,
DateTimeOffset eligibleAt,
string reason)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
}
if (string.IsNullOrWhiteSpace(productKey))
{
throw new ArgumentException("Product key must be provided.", nameof(productKey));
}
if (eligibleAt < requestedAt)
{
throw new ArgumentOutOfRangeException(nameof(eligibleAt), "EligibleAt cannot be earlier than RequestedAt.");
}
VulnerabilityId = vulnerabilityId.Trim();
ProductKey = productKey.Trim();
Candidate = candidate ?? throw new ArgumentNullException(nameof(candidate));
RequestedAt = requestedAt;
EligibleAt = eligibleAt;
Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim();
}
public string VulnerabilityId { get; }
public string ProductKey { get; }
public VexConsensus Candidate { get; }
public DateTimeOffset RequestedAt { get; }
public DateTimeOffset EligibleAt { get; }
public string Reason { get; }
}
namespace StellaOps.Excititor.Core;
public sealed record VexConsensusHold
{
public VexConsensusHold(
string vulnerabilityId,
string productKey,
VexConsensus candidate,
DateTimeOffset requestedAt,
DateTimeOffset eligibleAt,
string reason)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
}
if (string.IsNullOrWhiteSpace(productKey))
{
throw new ArgumentException("Product key must be provided.", nameof(productKey));
}
if (eligibleAt < requestedAt)
{
throw new ArgumentOutOfRangeException(nameof(eligibleAt), "EligibleAt cannot be earlier than RequestedAt.");
}
VulnerabilityId = vulnerabilityId.Trim();
ProductKey = productKey.Trim();
Candidate = candidate ?? throw new ArgumentNullException(nameof(candidate));
RequestedAt = requestedAt;
EligibleAt = eligibleAt;
Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim();
}
public string VulnerabilityId { get; }
public string ProductKey { get; }
public VexConsensus Candidate { get; }
public DateTimeOffset RequestedAt { get; }
public DateTimeOffset EligibleAt { get; }
public string Reason { get; }
}

View File

@@ -1,155 +1,155 @@
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Configuration options for consensus policy weights.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensusPolicyOptions
{
public const string BaselineVersion = "baseline/v1";
public const double DefaultWeightCeiling = 1.0;
public const double DefaultAlpha = 0.25;
public const double DefaultBeta = 0.5;
public const double MaxSupportedCeiling = 5.0;
public const double MaxSupportedCoefficient = 5.0;
public VexConsensusPolicyOptions(
string? version = null,
double vendorWeight = 1.0,
double distroWeight = 0.9,
double platformWeight = 0.7,
double hubWeight = 0.5,
double attestationWeight = 0.6,
IEnumerable<KeyValuePair<string, double>>? providerOverrides = null,
double weightCeiling = DefaultWeightCeiling,
double alpha = DefaultAlpha,
double beta = DefaultBeta)
{
Version = string.IsNullOrWhiteSpace(version) ? BaselineVersion : version.Trim();
WeightCeiling = NormalizeWeightCeiling(weightCeiling);
VendorWeight = NormalizeWeight(vendorWeight, WeightCeiling);
DistroWeight = NormalizeWeight(distroWeight, WeightCeiling);
PlatformWeight = NormalizeWeight(platformWeight, WeightCeiling);
HubWeight = NormalizeWeight(hubWeight, WeightCeiling);
AttestationWeight = NormalizeWeight(attestationWeight, WeightCeiling);
ProviderOverrides = NormalizeOverrides(providerOverrides, WeightCeiling);
Alpha = NormalizeCoefficient(alpha, nameof(alpha));
Beta = NormalizeCoefficient(beta, nameof(beta));
}
public string Version { get; }
public double VendorWeight { get; }
public double DistroWeight { get; }
public double PlatformWeight { get; }
public double HubWeight { get; }
public double AttestationWeight { get; }
public double WeightCeiling { get; }
public double Alpha { get; }
public double Beta { get; }
public ImmutableDictionary<string, double> ProviderOverrides { get; }
private static double NormalizeWeight(double weight, double ceiling)
{
if (double.IsNaN(weight) || double.IsInfinity(weight))
{
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite number.");
}
if (weight <= 0)
{
return 0;
}
if (weight >= ceiling)
{
return ceiling;
}
return weight;
}
private static ImmutableDictionary<string, double> NormalizeOverrides(
IEnumerable<KeyValuePair<string, double>>? overrides,
double ceiling)
{
if (overrides is null)
{
return ImmutableDictionary<string, double>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.Ordinal);
foreach (var (key, weight) in overrides)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
builder[key.Trim()] = NormalizeWeight(weight, ceiling);
}
return builder.ToImmutable();
}
private static double NormalizeWeightCeiling(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(nameof(value), "Weight ceiling must be a finite number.");
}
if (value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(value), "Weight ceiling must be greater than zero.");
}
if (value < 1)
{
return 1;
}
if (value > MaxSupportedCeiling)
{
return MaxSupportedCeiling;
}
return value;
}
private static double NormalizeCoefficient(double value, string name)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(name, "Coefficient must be a finite number.");
}
if (value < 0)
{
throw new ArgumentOutOfRangeException(name, "Coefficient must be non-negative.");
}
if (value > MaxSupportedCoefficient)
{
return MaxSupportedCoefficient;
}
return value;
}
}
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Configuration options for consensus policy weights.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensusPolicyOptions
{
public const string BaselineVersion = "baseline/v1";
public const double DefaultWeightCeiling = 1.0;
public const double DefaultAlpha = 0.25;
public const double DefaultBeta = 0.5;
public const double MaxSupportedCeiling = 5.0;
public const double MaxSupportedCoefficient = 5.0;
public VexConsensusPolicyOptions(
string? version = null,
double vendorWeight = 1.0,
double distroWeight = 0.9,
double platformWeight = 0.7,
double hubWeight = 0.5,
double attestationWeight = 0.6,
IEnumerable<KeyValuePair<string, double>>? providerOverrides = null,
double weightCeiling = DefaultWeightCeiling,
double alpha = DefaultAlpha,
double beta = DefaultBeta)
{
Version = string.IsNullOrWhiteSpace(version) ? BaselineVersion : version.Trim();
WeightCeiling = NormalizeWeightCeiling(weightCeiling);
VendorWeight = NormalizeWeight(vendorWeight, WeightCeiling);
DistroWeight = NormalizeWeight(distroWeight, WeightCeiling);
PlatformWeight = NormalizeWeight(platformWeight, WeightCeiling);
HubWeight = NormalizeWeight(hubWeight, WeightCeiling);
AttestationWeight = NormalizeWeight(attestationWeight, WeightCeiling);
ProviderOverrides = NormalizeOverrides(providerOverrides, WeightCeiling);
Alpha = NormalizeCoefficient(alpha, nameof(alpha));
Beta = NormalizeCoefficient(beta, nameof(beta));
}
public string Version { get; }
public double VendorWeight { get; }
public double DistroWeight { get; }
public double PlatformWeight { get; }
public double HubWeight { get; }
public double AttestationWeight { get; }
public double WeightCeiling { get; }
public double Alpha { get; }
public double Beta { get; }
public ImmutableDictionary<string, double> ProviderOverrides { get; }
private static double NormalizeWeight(double weight, double ceiling)
{
if (double.IsNaN(weight) || double.IsInfinity(weight))
{
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite number.");
}
if (weight <= 0)
{
return 0;
}
if (weight >= ceiling)
{
return ceiling;
}
return weight;
}
private static ImmutableDictionary<string, double> NormalizeOverrides(
IEnumerable<KeyValuePair<string, double>>? overrides,
double ceiling)
{
if (overrides is null)
{
return ImmutableDictionary<string, double>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.Ordinal);
foreach (var (key, weight) in overrides)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
builder[key.Trim()] = NormalizeWeight(weight, ceiling);
}
return builder.ToImmutable();
}
private static double NormalizeWeightCeiling(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(nameof(value), "Weight ceiling must be a finite number.");
}
if (value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(value), "Weight ceiling must be greater than zero.");
}
if (value < 1)
{
return 1;
}
if (value > MaxSupportedCeiling)
{
return MaxSupportedCeiling;
}
return value;
}
private static double NormalizeCoefficient(double value, string name)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(name, "Coefficient must be a finite number.");
}
if (value < 0)
{
throw new ArgumentOutOfRangeException(name, "Coefficient must be non-negative.");
}
if (value > MaxSupportedCoefficient)
{
return MaxSupportedCoefficient;
}
return value;
}
}

View File

@@ -1,331 +1,331 @@
using System.Collections.Immutable;
using System.Globalization;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Resolves VEX consensus from multiple claims using weighted voting.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed class VexConsensusResolver
{
private readonly IVexConsensusPolicy _policy;
public VexConsensusResolver(IVexConsensusPolicy policy)
{
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
}
public VexConsensusResolution Resolve(VexConsensusRequest request)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var orderedClaims = request.Claims
.OrderBy(static claim => claim.ProviderId, StringComparer.Ordinal)
.ThenBy(static claim => claim.Document.Digest, StringComparer.Ordinal)
.ThenBy(static claim => claim.Document.SourceUri.ToString(), StringComparer.Ordinal)
.ToArray();
var decisions = ImmutableArray.CreateBuilder<VexConsensusDecisionTelemetry>(orderedClaims.Length);
var acceptedSources = new List<VexConsensusSource>(orderedClaims.Length);
var conflicts = new List<VexConsensusConflict>();
var conflictKeys = new HashSet<string>(StringComparer.Ordinal);
var weightByStatus = new Dictionary<VexClaimStatus, double>();
foreach (var claim in orderedClaims)
{
request.Providers.TryGetValue(claim.ProviderId, out var provider);
string? rejectionReason = null;
double weight = 0;
var included = false;
if (provider is null)
{
rejectionReason = "provider_not_registered";
}
else
{
var ceiling = request.WeightCeiling <= 0 || double.IsNaN(request.WeightCeiling) || double.IsInfinity(request.WeightCeiling)
? VexConsensusPolicyOptions.DefaultWeightCeiling
: Math.Clamp(request.WeightCeiling, 0.1, VexConsensusPolicyOptions.MaxSupportedCeiling);
weight = NormalizeWeight(_policy.GetProviderWeight(provider), ceiling);
if (weight <= 0)
{
rejectionReason = "weight_not_positive";
}
else if (!_policy.IsClaimEligible(claim, provider, out rejectionReason))
{
rejectionReason ??= "rejected_by_policy";
}
else
{
included = true;
TrackStatusWeight(weightByStatus, claim.Status, weight);
acceptedSources.Add(new VexConsensusSource(
claim.ProviderId,
claim.Status,
claim.Document.Digest,
weight,
claim.Justification,
claim.Detail,
claim.Confidence));
}
}
if (!included)
{
var conflict = new VexConsensusConflict(
claim.ProviderId,
claim.Status,
claim.Document.Digest,
claim.Justification,
claim.Detail,
rejectionReason);
if (conflictKeys.Add(CreateConflictKey(conflict.ProviderId, conflict.DocumentDigest)))
{
conflicts.Add(conflict);
}
}
decisions.Add(new VexConsensusDecisionTelemetry(
claim.ProviderId,
claim.Document.Digest,
claim.Status,
included,
weight,
rejectionReason,
claim.Justification,
claim.Detail));
}
var consensusStatus = DetermineConsensusStatus(weightByStatus);
var summary = BuildSummary(weightByStatus, consensusStatus);
var consensus = new VexConsensus(
request.VulnerabilityId,
request.Product,
consensusStatus,
request.CalculatedAt,
acceptedSources,
AttachConflictDetails(conflicts, acceptedSources, consensusStatus, conflictKeys),
request.Signals,
_policy.Version,
summary,
request.PolicyRevisionId,
request.PolicyDigest);
return new VexConsensusResolution(consensus, decisions.ToImmutable());
}
private static Dictionary<VexClaimStatus, double> TrackStatusWeight(
Dictionary<VexClaimStatus, double> accumulator,
VexClaimStatus status,
double weight)
{
if (accumulator.TryGetValue(status, out var current))
{
accumulator[status] = current + weight;
}
else
{
accumulator[status] = weight;
}
return accumulator;
}
private static double NormalizeWeight(double weight, double ceiling)
{
if (double.IsNaN(weight) || double.IsInfinity(weight) || weight <= 0)
{
return 0;
}
if (weight >= ceiling)
{
return ceiling;
}
return weight;
}
private static VexConsensusStatus DetermineConsensusStatus(
IReadOnlyDictionary<VexClaimStatus, double> weights)
{
if (weights.Count == 0)
{
return VexConsensusStatus.UnderInvestigation;
}
var ordered = weights
.OrderByDescending(static pair => pair.Value)
.ThenBy(static pair => pair.Key)
.ToArray();
var topStatus = ordered[0].Key;
var topWeight = ordered[0].Value;
var totalWeight = ordered.Sum(static pair => pair.Value);
var remainder = totalWeight - topWeight;
if (topWeight <= 0)
{
return VexConsensusStatus.UnderInvestigation;
}
if (topWeight > remainder)
{
return topStatus switch
{
VexClaimStatus.Affected => VexConsensusStatus.Affected,
VexClaimStatus.Fixed => VexConsensusStatus.Fixed,
VexClaimStatus.NotAffected => VexConsensusStatus.NotAffected,
_ => VexConsensusStatus.UnderInvestigation,
};
}
return VexConsensusStatus.UnderInvestigation;
}
private static string BuildSummary(
IReadOnlyDictionary<VexClaimStatus, double> weights,
VexConsensusStatus status)
{
if (weights.Count == 0)
{
return "No eligible claims met policy requirements.";
}
var breakdown = string.Join(
", ",
weights
.OrderByDescending(static pair => pair.Value)
.ThenBy(static pair => pair.Key)
.Select(pair => $"{FormatStatus(pair.Key)}={pair.Value.ToString("0.###", CultureInfo.InvariantCulture)}"));
if (status == VexConsensusStatus.UnderInvestigation)
{
return $"No majority consensus; weighted breakdown {breakdown}.";
}
return $"{FormatStatus(status)} determined via weighted majority; breakdown {breakdown}.";
}
private static List<VexConsensusConflict> AttachConflictDetails(
List<VexConsensusConflict> conflicts,
IEnumerable<VexConsensusSource> acceptedSources,
VexConsensusStatus status,
HashSet<string> conflictKeys)
{
var consensusClaimStatus = status switch
{
VexConsensusStatus.Affected => VexClaimStatus.Affected,
VexConsensusStatus.NotAffected => VexClaimStatus.NotAffected,
VexConsensusStatus.Fixed => VexClaimStatus.Fixed,
VexConsensusStatus.UnderInvestigation => (VexClaimStatus?)null,
VexConsensusStatus.Divergent => (VexClaimStatus?)null,
_ => null,
};
foreach (var source in acceptedSources)
{
if (consensusClaimStatus is null || source.Status != consensusClaimStatus.Value)
{
var conflict = new VexConsensusConflict(
source.ProviderId,
source.Status,
source.DocumentDigest,
source.Justification,
source.Detail,
consensusClaimStatus is null ? "no_majority" : "status_conflict");
if (conflictKeys.Add(CreateConflictKey(conflict.ProviderId, conflict.DocumentDigest)))
{
conflicts.Add(conflict);
}
}
}
return conflicts;
}
private static string FormatStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => "affected",
VexClaimStatus.NotAffected => "not_affected",
VexClaimStatus.Fixed => "fixed",
VexClaimStatus.UnderInvestigation => "under_investigation",
_ => status.ToString().ToLowerInvariant(),
};
private static string CreateConflictKey(string providerId, string documentDigest)
=> $"{providerId}|{documentDigest}";
private static string FormatStatus(VexConsensusStatus status)
=> status switch
{
VexConsensusStatus.Affected => "affected",
VexConsensusStatus.NotAffected => "not_affected",
VexConsensusStatus.Fixed => "fixed",
VexConsensusStatus.UnderInvestigation => "under_investigation",
VexConsensusStatus.Divergent => "divergent",
_ => status.ToString().ToLowerInvariant(),
};
}
/// <summary>
/// Request model for consensus resolution.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// </remarks>
#pragma warning disable EXCITITOR001 // Using obsolete VexConsensusPolicyOptions
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensusRequest(
string VulnerabilityId,
VexProduct Product,
IReadOnlyList<VexClaim> Claims,
IReadOnlyDictionary<string, VexProvider> Providers,
DateTimeOffset CalculatedAt,
double WeightCeiling = VexConsensusPolicyOptions.DefaultWeightCeiling,
VexSignalSnapshot? Signals = null,
string? PolicyRevisionId = null,
string? PolicyDigest = null);
#pragma warning restore EXCITITOR001
/// <summary>
/// Result of consensus resolution including decision log.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensusResolution(
VexConsensus Consensus,
ImmutableArray<VexConsensusDecisionTelemetry> DecisionLog);
/// <summary>
/// Telemetry record for consensus decision auditing.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensusDecisionTelemetry(
string ProviderId,
string DocumentDigest,
VexClaimStatus Status,
bool Included,
double Weight,
string? Reason,
VexJustification? Justification,
string? Detail);
using System.Collections.Immutable;
using System.Globalization;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Resolves VEX consensus from multiple claims using weighted voting.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed class VexConsensusResolver
{
private readonly IVexConsensusPolicy _policy;
public VexConsensusResolver(IVexConsensusPolicy policy)
{
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
}
public VexConsensusResolution Resolve(VexConsensusRequest request)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var orderedClaims = request.Claims
.OrderBy(static claim => claim.ProviderId, StringComparer.Ordinal)
.ThenBy(static claim => claim.Document.Digest, StringComparer.Ordinal)
.ThenBy(static claim => claim.Document.SourceUri.ToString(), StringComparer.Ordinal)
.ToArray();
var decisions = ImmutableArray.CreateBuilder<VexConsensusDecisionTelemetry>(orderedClaims.Length);
var acceptedSources = new List<VexConsensusSource>(orderedClaims.Length);
var conflicts = new List<VexConsensusConflict>();
var conflictKeys = new HashSet<string>(StringComparer.Ordinal);
var weightByStatus = new Dictionary<VexClaimStatus, double>();
foreach (var claim in orderedClaims)
{
request.Providers.TryGetValue(claim.ProviderId, out var provider);
string? rejectionReason = null;
double weight = 0;
var included = false;
if (provider is null)
{
rejectionReason = "provider_not_registered";
}
else
{
var ceiling = request.WeightCeiling <= 0 || double.IsNaN(request.WeightCeiling) || double.IsInfinity(request.WeightCeiling)
? VexConsensusPolicyOptions.DefaultWeightCeiling
: Math.Clamp(request.WeightCeiling, 0.1, VexConsensusPolicyOptions.MaxSupportedCeiling);
weight = NormalizeWeight(_policy.GetProviderWeight(provider), ceiling);
if (weight <= 0)
{
rejectionReason = "weight_not_positive";
}
else if (!_policy.IsClaimEligible(claim, provider, out rejectionReason))
{
rejectionReason ??= "rejected_by_policy";
}
else
{
included = true;
TrackStatusWeight(weightByStatus, claim.Status, weight);
acceptedSources.Add(new VexConsensusSource(
claim.ProviderId,
claim.Status,
claim.Document.Digest,
weight,
claim.Justification,
claim.Detail,
claim.Confidence));
}
}
if (!included)
{
var conflict = new VexConsensusConflict(
claim.ProviderId,
claim.Status,
claim.Document.Digest,
claim.Justification,
claim.Detail,
rejectionReason);
if (conflictKeys.Add(CreateConflictKey(conflict.ProviderId, conflict.DocumentDigest)))
{
conflicts.Add(conflict);
}
}
decisions.Add(new VexConsensusDecisionTelemetry(
claim.ProviderId,
claim.Document.Digest,
claim.Status,
included,
weight,
rejectionReason,
claim.Justification,
claim.Detail));
}
var consensusStatus = DetermineConsensusStatus(weightByStatus);
var summary = BuildSummary(weightByStatus, consensusStatus);
var consensus = new VexConsensus(
request.VulnerabilityId,
request.Product,
consensusStatus,
request.CalculatedAt,
acceptedSources,
AttachConflictDetails(conflicts, acceptedSources, consensusStatus, conflictKeys),
request.Signals,
_policy.Version,
summary,
request.PolicyRevisionId,
request.PolicyDigest);
return new VexConsensusResolution(consensus, decisions.ToImmutable());
}
private static Dictionary<VexClaimStatus, double> TrackStatusWeight(
Dictionary<VexClaimStatus, double> accumulator,
VexClaimStatus status,
double weight)
{
if (accumulator.TryGetValue(status, out var current))
{
accumulator[status] = current + weight;
}
else
{
accumulator[status] = weight;
}
return accumulator;
}
private static double NormalizeWeight(double weight, double ceiling)
{
if (double.IsNaN(weight) || double.IsInfinity(weight) || weight <= 0)
{
return 0;
}
if (weight >= ceiling)
{
return ceiling;
}
return weight;
}
private static VexConsensusStatus DetermineConsensusStatus(
IReadOnlyDictionary<VexClaimStatus, double> weights)
{
if (weights.Count == 0)
{
return VexConsensusStatus.UnderInvestigation;
}
var ordered = weights
.OrderByDescending(static pair => pair.Value)
.ThenBy(static pair => pair.Key)
.ToArray();
var topStatus = ordered[0].Key;
var topWeight = ordered[0].Value;
var totalWeight = ordered.Sum(static pair => pair.Value);
var remainder = totalWeight - topWeight;
if (topWeight <= 0)
{
return VexConsensusStatus.UnderInvestigation;
}
if (topWeight > remainder)
{
return topStatus switch
{
VexClaimStatus.Affected => VexConsensusStatus.Affected,
VexClaimStatus.Fixed => VexConsensusStatus.Fixed,
VexClaimStatus.NotAffected => VexConsensusStatus.NotAffected,
_ => VexConsensusStatus.UnderInvestigation,
};
}
return VexConsensusStatus.UnderInvestigation;
}
private static string BuildSummary(
IReadOnlyDictionary<VexClaimStatus, double> weights,
VexConsensusStatus status)
{
if (weights.Count == 0)
{
return "No eligible claims met policy requirements.";
}
var breakdown = string.Join(
", ",
weights
.OrderByDescending(static pair => pair.Value)
.ThenBy(static pair => pair.Key)
.Select(pair => $"{FormatStatus(pair.Key)}={pair.Value.ToString("0.###", CultureInfo.InvariantCulture)}"));
if (status == VexConsensusStatus.UnderInvestigation)
{
return $"No majority consensus; weighted breakdown {breakdown}.";
}
return $"{FormatStatus(status)} determined via weighted majority; breakdown {breakdown}.";
}
private static List<VexConsensusConflict> AttachConflictDetails(
List<VexConsensusConflict> conflicts,
IEnumerable<VexConsensusSource> acceptedSources,
VexConsensusStatus status,
HashSet<string> conflictKeys)
{
var consensusClaimStatus = status switch
{
VexConsensusStatus.Affected => VexClaimStatus.Affected,
VexConsensusStatus.NotAffected => VexClaimStatus.NotAffected,
VexConsensusStatus.Fixed => VexClaimStatus.Fixed,
VexConsensusStatus.UnderInvestigation => (VexClaimStatus?)null,
VexConsensusStatus.Divergent => (VexClaimStatus?)null,
_ => null,
};
foreach (var source in acceptedSources)
{
if (consensusClaimStatus is null || source.Status != consensusClaimStatus.Value)
{
var conflict = new VexConsensusConflict(
source.ProviderId,
source.Status,
source.DocumentDigest,
source.Justification,
source.Detail,
consensusClaimStatus is null ? "no_majority" : "status_conflict");
if (conflictKeys.Add(CreateConflictKey(conflict.ProviderId, conflict.DocumentDigest)))
{
conflicts.Add(conflict);
}
}
}
return conflicts;
}
private static string FormatStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => "affected",
VexClaimStatus.NotAffected => "not_affected",
VexClaimStatus.Fixed => "fixed",
VexClaimStatus.UnderInvestigation => "under_investigation",
_ => status.ToString().ToLowerInvariant(),
};
private static string CreateConflictKey(string providerId, string documentDigest)
=> $"{providerId}|{documentDigest}";
private static string FormatStatus(VexConsensusStatus status)
=> status switch
{
VexConsensusStatus.Affected => "affected",
VexConsensusStatus.NotAffected => "not_affected",
VexConsensusStatus.Fixed => "fixed",
VexConsensusStatus.UnderInvestigation => "under_investigation",
VexConsensusStatus.Divergent => "divergent",
_ => status.ToString().ToLowerInvariant(),
};
}
/// <summary>
/// Request model for consensus resolution.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// </remarks>
#pragma warning disable EXCITITOR001 // Using obsolete VexConsensusPolicyOptions
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensusRequest(
string VulnerabilityId,
VexProduct Product,
IReadOnlyList<VexClaim> Claims,
IReadOnlyDictionary<string, VexProvider> Providers,
DateTimeOffset CalculatedAt,
double WeightCeiling = VexConsensusPolicyOptions.DefaultWeightCeiling,
VexSignalSnapshot? Signals = null,
string? PolicyRevisionId = null,
string? PolicyDigest = null);
#pragma warning restore EXCITITOR001
/// <summary>
/// Result of consensus resolution including decision log.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensusResolution(
VexConsensus Consensus,
ImmutableArray<VexConsensusDecisionTelemetry> DecisionLog);
/// <summary>
/// Telemetry record for consensus decision auditing.
/// </summary>
/// <remarks>
/// DEPRECATED: Consensus logic is being removed per AOC-19 contract.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
public sealed record VexConsensusDecisionTelemetry(
string ProviderId,
string DocumentDigest,
VexClaimStatus Status,
bool Included,
double Weight,
string? Reason,
VexJustification? Justification,
string? Detail);

View File

@@ -2,112 +2,112 @@ using System.Collections.Immutable;
using System.Linq;
using System.Runtime.Serialization;
using System.Text;
namespace StellaOps.Excititor.Core;
public sealed record VexExportManifest
{
public VexExportManifest(
string exportId,
VexQuerySignature querySignature,
VexExportFormat format,
DateTimeOffset createdAt,
VexContentAddress artifact,
int claimCount,
IEnumerable<string> sourceProviders,
bool fromCache = false,
string? consensusRevision = null,
string? policyRevisionId = null,
string? policyDigest = null,
VexContentAddress? consensusDigest = null,
namespace StellaOps.Excititor.Core;
public sealed record VexExportManifest
{
public VexExportManifest(
string exportId,
VexQuerySignature querySignature,
VexExportFormat format,
DateTimeOffset createdAt,
VexContentAddress artifact,
int claimCount,
IEnumerable<string> sourceProviders,
bool fromCache = false,
string? consensusRevision = null,
string? policyRevisionId = null,
string? policyDigest = null,
VexContentAddress? consensusDigest = null,
VexContentAddress? scoreDigest = null,
IEnumerable<VexQuietProvenance>? quietProvenance = null,
VexAttestationMetadata? attestation = null,
long sizeBytes = 0)
{
if (string.IsNullOrWhiteSpace(exportId))
{
throw new ArgumentException("Export id must be provided.", nameof(exportId));
}
if (claimCount < 0)
{
throw new ArgumentOutOfRangeException(nameof(claimCount), "Claim count cannot be negative.");
}
if (sizeBytes < 0)
{
throw new ArgumentOutOfRangeException(nameof(sizeBytes), "Export size cannot be negative.");
}
ExportId = exportId.Trim();
QuerySignature = querySignature ?? throw new ArgumentNullException(nameof(querySignature));
Format = format;
CreatedAt = createdAt;
Artifact = artifact ?? throw new ArgumentNullException(nameof(artifact));
ClaimCount = claimCount;
FromCache = fromCache;
SourceProviders = NormalizeProviders(sourceProviders);
ConsensusRevision = string.IsNullOrWhiteSpace(consensusRevision) ? null : consensusRevision.Trim();
PolicyRevisionId = string.IsNullOrWhiteSpace(policyRevisionId) ? null : policyRevisionId.Trim();
{
if (string.IsNullOrWhiteSpace(exportId))
{
throw new ArgumentException("Export id must be provided.", nameof(exportId));
}
if (claimCount < 0)
{
throw new ArgumentOutOfRangeException(nameof(claimCount), "Claim count cannot be negative.");
}
if (sizeBytes < 0)
{
throw new ArgumentOutOfRangeException(nameof(sizeBytes), "Export size cannot be negative.");
}
ExportId = exportId.Trim();
QuerySignature = querySignature ?? throw new ArgumentNullException(nameof(querySignature));
Format = format;
CreatedAt = createdAt;
Artifact = artifact ?? throw new ArgumentNullException(nameof(artifact));
ClaimCount = claimCount;
FromCache = fromCache;
SourceProviders = NormalizeProviders(sourceProviders);
ConsensusRevision = string.IsNullOrWhiteSpace(consensusRevision) ? null : consensusRevision.Trim();
PolicyRevisionId = string.IsNullOrWhiteSpace(policyRevisionId) ? null : policyRevisionId.Trim();
PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim();
ConsensusDigest = consensusDigest;
ScoreDigest = scoreDigest;
QuietProvenance = NormalizeQuietProvenance(quietProvenance);
Attestation = attestation;
SizeBytes = sizeBytes;
}
public string ExportId { get; }
public VexQuerySignature QuerySignature { get; }
public VexExportFormat Format { get; }
public DateTimeOffset CreatedAt { get; }
public VexContentAddress Artifact { get; }
public int ClaimCount { get; }
public bool FromCache { get; }
public ImmutableArray<string> SourceProviders { get; }
public string? ConsensusRevision { get; }
public string? PolicyRevisionId { get; }
public string? PolicyDigest { get; }
public VexContentAddress? ConsensusDigest { get; }
}
public string ExportId { get; }
public VexQuerySignature QuerySignature { get; }
public VexExportFormat Format { get; }
public DateTimeOffset CreatedAt { get; }
public VexContentAddress Artifact { get; }
public int ClaimCount { get; }
public bool FromCache { get; }
public ImmutableArray<string> SourceProviders { get; }
public string? ConsensusRevision { get; }
public string? PolicyRevisionId { get; }
public string? PolicyDigest { get; }
public VexContentAddress? ConsensusDigest { get; }
public VexContentAddress? ScoreDigest { get; }
public ImmutableArray<VexQuietProvenance> QuietProvenance { get; }
public VexAttestationMetadata? Attestation { get; }
public long SizeBytes { get; }
public long SizeBytes { get; }
private static ImmutableArray<string> NormalizeProviders(IEnumerable<string> providers)
{
if (providers is null)
{
throw new ArgumentNullException(nameof(providers));
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var provider in providers)
{
if (string.IsNullOrWhiteSpace(provider))
{
continue;
}
set.Add(provider.Trim());
}
{
if (providers is null)
{
throw new ArgumentNullException(nameof(providers));
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var provider in providers)
{
if (string.IsNullOrWhiteSpace(provider))
{
continue;
}
set.Add(provider.Trim());
}
return set.Count == 0
? ImmutableArray<string>.Empty
: set.ToImmutableArray();
@@ -126,154 +126,154 @@ public sealed record VexExportManifest
.ToImmutableArray();
}
}
public sealed record VexContentAddress
{
public VexContentAddress(string algorithm, string digest)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
throw new ArgumentException("Content algorithm must be provided.", nameof(algorithm));
}
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Content digest must be provided.", nameof(digest));
}
Algorithm = algorithm.Trim();
Digest = digest.Trim();
}
public string Algorithm { get; }
public string Digest { get; }
public string ToUri() => $"{Algorithm}:{Digest}";
public override string ToString() => ToUri();
}
public sealed record VexAttestationMetadata
{
public VexAttestationMetadata(
string predicateType,
VexRekorReference? rekor = null,
string? envelopeDigest = null,
DateTimeOffset? signedAt = null)
{
if (string.IsNullOrWhiteSpace(predicateType))
{
throw new ArgumentException("Predicate type must be provided.", nameof(predicateType));
}
PredicateType = predicateType.Trim();
Rekor = rekor;
EnvelopeDigest = string.IsNullOrWhiteSpace(envelopeDigest) ? null : envelopeDigest.Trim();
SignedAt = signedAt;
}
public string PredicateType { get; }
public VexRekorReference? Rekor { get; }
public string? EnvelopeDigest { get; }
public DateTimeOffset? SignedAt { get; }
}
public sealed record VexRekorReference
{
public VexRekorReference(string apiVersion, string location, string? logIndex = null, Uri? inclusionProofUri = null)
{
if (string.IsNullOrWhiteSpace(apiVersion))
{
throw new ArgumentException("Rekor API version must be provided.", nameof(apiVersion));
}
if (string.IsNullOrWhiteSpace(location))
{
throw new ArgumentException("Rekor location must be provided.", nameof(location));
}
ApiVersion = apiVersion.Trim();
Location = location.Trim();
LogIndex = string.IsNullOrWhiteSpace(logIndex) ? null : logIndex.Trim();
InclusionProofUri = inclusionProofUri;
}
public string ApiVersion { get; }
public string Location { get; }
public string? LogIndex { get; }
public Uri? InclusionProofUri { get; }
}
public sealed partial record VexQuerySignature
{
public VexQuerySignature(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Query signature must be provided.", nameof(value));
}
Value = value.Trim();
}
public string Value { get; }
public static VexQuerySignature FromFilters(IEnumerable<KeyValuePair<string, string>> filters)
{
if (filters is null)
{
throw new ArgumentNullException(nameof(filters));
}
var builder = ImmutableArray.CreateBuilder<KeyValuePair<string, string>>();
foreach (var pair in filters)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
var key = pair.Key.Trim();
var value = pair.Value?.Trim() ?? string.Empty;
builder.Add(new KeyValuePair<string, string>(key, value));
}
if (builder.Count == 0)
{
throw new ArgumentException("At least one filter is required to build a query signature.", nameof(filters));
}
var ordered = builder
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ThenBy(static pair => pair.Value, StringComparer.Ordinal)
.ToImmutableArray();
var sb = new StringBuilder();
for (var i = 0; i < ordered.Length; i++)
{
if (i > 0)
{
sb.Append('&');
}
sb.Append(ordered[i].Key);
sb.Append('=');
sb.Append(ordered[i].Value);
}
return new VexQuerySignature(sb.ToString());
}
public override string ToString() => Value;
}
public sealed record VexContentAddress
{
public VexContentAddress(string algorithm, string digest)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
throw new ArgumentException("Content algorithm must be provided.", nameof(algorithm));
}
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Content digest must be provided.", nameof(digest));
}
Algorithm = algorithm.Trim();
Digest = digest.Trim();
}
public string Algorithm { get; }
public string Digest { get; }
public string ToUri() => $"{Algorithm}:{Digest}";
public override string ToString() => ToUri();
}
public sealed record VexAttestationMetadata
{
public VexAttestationMetadata(
string predicateType,
VexRekorReference? rekor = null,
string? envelopeDigest = null,
DateTimeOffset? signedAt = null)
{
if (string.IsNullOrWhiteSpace(predicateType))
{
throw new ArgumentException("Predicate type must be provided.", nameof(predicateType));
}
PredicateType = predicateType.Trim();
Rekor = rekor;
EnvelopeDigest = string.IsNullOrWhiteSpace(envelopeDigest) ? null : envelopeDigest.Trim();
SignedAt = signedAt;
}
public string PredicateType { get; }
public VexRekorReference? Rekor { get; }
public string? EnvelopeDigest { get; }
public DateTimeOffset? SignedAt { get; }
}
public sealed record VexRekorReference
{
public VexRekorReference(string apiVersion, string location, string? logIndex = null, Uri? inclusionProofUri = null)
{
if (string.IsNullOrWhiteSpace(apiVersion))
{
throw new ArgumentException("Rekor API version must be provided.", nameof(apiVersion));
}
if (string.IsNullOrWhiteSpace(location))
{
throw new ArgumentException("Rekor location must be provided.", nameof(location));
}
ApiVersion = apiVersion.Trim();
Location = location.Trim();
LogIndex = string.IsNullOrWhiteSpace(logIndex) ? null : logIndex.Trim();
InclusionProofUri = inclusionProofUri;
}
public string ApiVersion { get; }
public string Location { get; }
public string? LogIndex { get; }
public Uri? InclusionProofUri { get; }
}
public sealed partial record VexQuerySignature
{
public VexQuerySignature(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Query signature must be provided.", nameof(value));
}
Value = value.Trim();
}
public string Value { get; }
public static VexQuerySignature FromFilters(IEnumerable<KeyValuePair<string, string>> filters)
{
if (filters is null)
{
throw new ArgumentNullException(nameof(filters));
}
var builder = ImmutableArray.CreateBuilder<KeyValuePair<string, string>>();
foreach (var pair in filters)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
var key = pair.Key.Trim();
var value = pair.Value?.Trim() ?? string.Empty;
builder.Add(new KeyValuePair<string, string>(key, value));
}
if (builder.Count == 0)
{
throw new ArgumentException("At least one filter is required to build a query signature.", nameof(filters));
}
var ordered = builder
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ThenBy(static pair => pair.Value, StringComparer.Ordinal)
.ToImmutableArray();
var sb = new StringBuilder();
for (var i = 0; i < ordered.Length; i++)
{
if (i > 0)
{
sb.Append('&');
}
sb.Append(ordered[i].Key);
sb.Append('=');
sb.Append(ordered[i].Value);
}
return new VexQuerySignature(sb.ToString());
}
public override string ToString() => Value;
}
[DataContract]
public enum VexExportFormat
{

View File

@@ -1,30 +1,30 @@
using System;
using System.Collections.Immutable;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Core;
public interface IVexExporter
{
VexExportFormat Format { get; }
VexContentAddress Digest(VexExportRequest request);
ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken);
}
public sealed record VexExportRequest(
VexQuery Query,
ImmutableArray<VexConsensus> Consensus,
ImmutableArray<VexClaim> Claims,
DateTimeOffset GeneratedAt);
public sealed record VexExportResult(
VexContentAddress Digest,
long BytesWritten,
ImmutableDictionary<string, string> Metadata);
using System;
using System.Collections.Immutable;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Core;
public interface IVexExporter
{
VexExportFormat Format { get; }
VexContentAddress Digest(VexExportRequest request);
ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken);
}
public sealed record VexExportRequest(
VexQuery Query,
ImmutableArray<VexConsensus> Consensus,
ImmutableArray<VexClaim> Claims,
DateTimeOffset GeneratedAt);
public sealed record VexExportResult(
VexContentAddress Digest,
long BytesWritten,
ImmutableDictionary<string, string> Metadata);

View File

@@ -1,28 +1,28 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Normalizer contract for translating raw connector documents into canonical claims.
/// </summary>
public interface IVexNormalizer
{
string Format { get; }
bool CanHandle(VexRawDocument document);
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, VexProvider provider, CancellationToken cancellationToken);
}
/// <summary>
/// Registry that maps formats to registered normalizers.
/// </summary>
public sealed record VexNormalizerRegistry(ImmutableArray<IVexNormalizer> Normalizers)
{
public IVexNormalizer? Resolve(VexRawDocument document)
=> Normalizers.FirstOrDefault(normalizer => normalizer.CanHandle(document));
}
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Normalizer contract for translating raw connector documents into canonical claims.
/// </summary>
public interface IVexNormalizer
{
string Format { get; }
bool CanHandle(VexRawDocument document);
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, VexProvider provider, CancellationToken cancellationToken);
}
/// <summary>
/// Registry that maps formats to registered normalizers.
/// </summary>
public sealed record VexNormalizerRegistry(ImmutableArray<IVexNormalizer> Normalizers)
{
public IVexNormalizer? Resolve(VexRawDocument document)
=> Normalizers.FirstOrDefault(normalizer => normalizer.CanHandle(document));
}

View File

@@ -1,206 +1,206 @@
using System.Collections.Immutable;
using System.Runtime.Serialization;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Metadata describing a VEX provider (vendor, distro, hub, platform).
/// </summary>
public sealed record VexProvider
{
public VexProvider(
string id,
string displayName,
VexProviderKind kind,
IEnumerable<Uri>? baseUris = null,
VexProviderDiscovery? discovery = null,
VexProviderTrust? trust = null,
bool enabled = true)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Provider id must be non-empty.", nameof(id));
}
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentException("Provider display name must be non-empty.", nameof(displayName));
}
Id = id.Trim();
DisplayName = displayName.Trim();
Kind = kind;
BaseUris = NormalizeUris(baseUris);
Discovery = discovery ?? VexProviderDiscovery.Empty;
Trust = trust ?? VexProviderTrust.Default;
Enabled = enabled;
}
public string Id { get; }
public string DisplayName { get; }
public VexProviderKind Kind { get; }
public ImmutableArray<Uri> BaseUris { get; }
public VexProviderDiscovery Discovery { get; }
public VexProviderTrust Trust { get; }
public bool Enabled { get; }
private static ImmutableArray<Uri> NormalizeUris(IEnumerable<Uri>? baseUris)
{
if (baseUris is null)
{
return ImmutableArray<Uri>.Empty;
}
var distinct = new HashSet<string>(StringComparer.Ordinal);
var builder = ImmutableArray.CreateBuilder<Uri>();
foreach (var uri in baseUris)
{
if (uri is null)
{
continue;
}
var canonical = uri.ToString();
if (distinct.Add(canonical))
{
builder.Add(uri);
}
}
if (builder.Count == 0)
{
return ImmutableArray<Uri>.Empty;
}
return builder
.OrderBy(static x => x.ToString(), StringComparer.Ordinal)
.ToImmutableArray();
}
}
public sealed record VexProviderDiscovery
{
public static readonly VexProviderDiscovery Empty = new(null, null);
public VexProviderDiscovery(Uri? wellKnownMetadata, Uri? rolieService)
{
WellKnownMetadata = wellKnownMetadata;
RolIeService = rolieService;
}
public Uri? WellKnownMetadata { get; }
public Uri? RolIeService { get; }
}
public sealed record VexProviderTrust
{
public static readonly VexProviderTrust Default = new(1.0, null, ImmutableArray<string>.Empty);
public VexProviderTrust(
double weight,
VexCosignTrust? cosign,
IEnumerable<string>? pgpFingerprints = null)
{
Weight = NormalizeWeight(weight);
Cosign = cosign;
PgpFingerprints = NormalizeFingerprints(pgpFingerprints);
}
public double Weight { get; }
public VexCosignTrust? Cosign { get; }
public ImmutableArray<string> PgpFingerprints { get; }
private static double NormalizeWeight(double weight)
{
if (double.IsNaN(weight) || double.IsInfinity(weight))
{
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite number.");
}
if (weight <= 0)
{
return 0.0;
}
if (weight >= 1.0)
{
return 1.0;
}
return weight;
}
private static ImmutableArray<string> NormalizeFingerprints(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
set.Add(value.Trim());
}
return set.Count == 0
? ImmutableArray<string>.Empty
: set.ToImmutableArray();
}
}
public sealed record VexCosignTrust
{
public VexCosignTrust(string issuer, string identityPattern)
{
if (string.IsNullOrWhiteSpace(issuer))
{
throw new ArgumentException("Issuer must be provided for cosign trust metadata.", nameof(issuer));
}
if (string.IsNullOrWhiteSpace(identityPattern))
{
throw new ArgumentException("Identity pattern must be provided for cosign trust metadata.", nameof(identityPattern));
}
Issuer = issuer.Trim();
IdentityPattern = identityPattern.Trim();
}
public string Issuer { get; }
public string IdentityPattern { get; }
}
[DataContract]
public enum VexProviderKind
{
[EnumMember(Value = "vendor")]
Vendor,
[EnumMember(Value = "distro")]
Distro,
[EnumMember(Value = "hub")]
Hub,
[EnumMember(Value = "platform")]
Platform,
[EnumMember(Value = "attestation")]
Attestation,
}
using System.Collections.Immutable;
using System.Runtime.Serialization;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Metadata describing a VEX provider (vendor, distro, hub, platform).
/// </summary>
public sealed record VexProvider
{
public VexProvider(
string id,
string displayName,
VexProviderKind kind,
IEnumerable<Uri>? baseUris = null,
VexProviderDiscovery? discovery = null,
VexProviderTrust? trust = null,
bool enabled = true)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Provider id must be non-empty.", nameof(id));
}
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentException("Provider display name must be non-empty.", nameof(displayName));
}
Id = id.Trim();
DisplayName = displayName.Trim();
Kind = kind;
BaseUris = NormalizeUris(baseUris);
Discovery = discovery ?? VexProviderDiscovery.Empty;
Trust = trust ?? VexProviderTrust.Default;
Enabled = enabled;
}
public string Id { get; }
public string DisplayName { get; }
public VexProviderKind Kind { get; }
public ImmutableArray<Uri> BaseUris { get; }
public VexProviderDiscovery Discovery { get; }
public VexProviderTrust Trust { get; }
public bool Enabled { get; }
private static ImmutableArray<Uri> NormalizeUris(IEnumerable<Uri>? baseUris)
{
if (baseUris is null)
{
return ImmutableArray<Uri>.Empty;
}
var distinct = new HashSet<string>(StringComparer.Ordinal);
var builder = ImmutableArray.CreateBuilder<Uri>();
foreach (var uri in baseUris)
{
if (uri is null)
{
continue;
}
var canonical = uri.ToString();
if (distinct.Add(canonical))
{
builder.Add(uri);
}
}
if (builder.Count == 0)
{
return ImmutableArray<Uri>.Empty;
}
return builder
.OrderBy(static x => x.ToString(), StringComparer.Ordinal)
.ToImmutableArray();
}
}
public sealed record VexProviderDiscovery
{
public static readonly VexProviderDiscovery Empty = new(null, null);
public VexProviderDiscovery(Uri? wellKnownMetadata, Uri? rolieService)
{
WellKnownMetadata = wellKnownMetadata;
RolIeService = rolieService;
}
public Uri? WellKnownMetadata { get; }
public Uri? RolIeService { get; }
}
public sealed record VexProviderTrust
{
public static readonly VexProviderTrust Default = new(1.0, null, ImmutableArray<string>.Empty);
public VexProviderTrust(
double weight,
VexCosignTrust? cosign,
IEnumerable<string>? pgpFingerprints = null)
{
Weight = NormalizeWeight(weight);
Cosign = cosign;
PgpFingerprints = NormalizeFingerprints(pgpFingerprints);
}
public double Weight { get; }
public VexCosignTrust? Cosign { get; }
public ImmutableArray<string> PgpFingerprints { get; }
private static double NormalizeWeight(double weight)
{
if (double.IsNaN(weight) || double.IsInfinity(weight))
{
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite number.");
}
if (weight <= 0)
{
return 0.0;
}
if (weight >= 1.0)
{
return 1.0;
}
return weight;
}
private static ImmutableArray<string> NormalizeFingerprints(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
set.Add(value.Trim());
}
return set.Count == 0
? ImmutableArray<string>.Empty
: set.ToImmutableArray();
}
}
public sealed record VexCosignTrust
{
public VexCosignTrust(string issuer, string identityPattern)
{
if (string.IsNullOrWhiteSpace(issuer))
{
throw new ArgumentException("Issuer must be provided for cosign trust metadata.", nameof(issuer));
}
if (string.IsNullOrWhiteSpace(identityPattern))
{
throw new ArgumentException("Identity pattern must be provided for cosign trust metadata.", nameof(identityPattern));
}
Issuer = issuer.Trim();
IdentityPattern = identityPattern.Trim();
}
public string Issuer { get; }
public string IdentityPattern { get; }
}
[DataContract]
public enum VexProviderKind
{
[EnumMember(Value = "vendor")]
Vendor,
[EnumMember(Value = "distro")]
Distro,
[EnumMember(Value = "hub")]
Hub,
[EnumMember(Value = "platform")]
Platform,
[EnumMember(Value = "attestation")]
Attestation,
}

View File

@@ -1,143 +1,143 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Linq;
namespace StellaOps.Excititor.Core;
public sealed record VexQuery(
ImmutableArray<VexQueryFilter> Filters,
ImmutableArray<VexQuerySort> Sort,
int? Limit = null,
int? Offset = null,
string? View = null)
{
public static VexQuery Empty { get; } = new(
ImmutableArray<VexQueryFilter>.Empty,
ImmutableArray<VexQuerySort>.Empty);
public static VexQuery Create(
IEnumerable<VexQueryFilter>? filters = null,
IEnumerable<VexQuerySort>? sort = null,
int? limit = null,
int? offset = null,
string? view = null)
{
var normalizedFilters = NormalizeFilters(filters);
var normalizedSort = NormalizeSort(sort);
return new VexQuery(normalizedFilters, normalizedSort, NormalizeBound(limit), NormalizeBound(offset), NormalizeView(view));
}
public VexQuery WithFilters(IEnumerable<VexQueryFilter> filters)
=> this with { Filters = NormalizeFilters(filters) };
public VexQuery WithSort(IEnumerable<VexQuerySort> sort)
=> this with { Sort = NormalizeSort(sort) };
public VexQuery WithBounds(int? limit = null, int? offset = null)
=> this with { Limit = NormalizeBound(limit), Offset = NormalizeBound(offset) };
public VexQuery WithView(string? view)
=> this with { View = NormalizeView(view) };
private static ImmutableArray<VexQueryFilter> NormalizeFilters(IEnumerable<VexQueryFilter>? filters)
{
if (filters is null)
{
return ImmutableArray<VexQueryFilter>.Empty;
}
return filters
.Where(filter => !string.IsNullOrWhiteSpace(filter.Key))
.Select(filter => new VexQueryFilter(filter.Key.Trim(), filter.Value?.Trim() ?? string.Empty))
.OrderBy(filter => filter.Key, StringComparer.Ordinal)
.ThenBy(filter => filter.Value, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<VexQuerySort> NormalizeSort(IEnumerable<VexQuerySort>? sort)
{
if (sort is null)
{
return ImmutableArray<VexQuerySort>.Empty;
}
return sort
.Where(s => !string.IsNullOrWhiteSpace(s.Field))
.Select(s => new VexQuerySort(s.Field.Trim(), s.Descending))
.OrderBy(s => s.Field, StringComparer.Ordinal)
.ThenBy(s => s.Descending)
.ToImmutableArray();
}
private static int? NormalizeBound(int? value)
{
if (value is null)
{
return null;
}
if (value.Value < 0)
{
return 0;
}
return value.Value;
}
private static string? NormalizeView(string? view)
=> string.IsNullOrWhiteSpace(view) ? null : view.Trim();
}
public sealed record VexQueryFilter(string Key, string Value);
public sealed record VexQuerySort(string Field, bool Descending);
public sealed partial record VexQuerySignature
{
public static VexQuerySignature FromQuery(VexQuery query)
{
if (query is null)
{
throw new ArgumentNullException(nameof(query));
}
var components = new List<string>(query.Filters.Length + query.Sort.Length + 3);
components.AddRange(query.Filters.Select(filter => $"{filter.Key}={filter.Value}"));
components.AddRange(query.Sort.Select(sort => sort.Descending ? $"sort=-{sort.Field}" : $"sort=+{sort.Field}"));
if (query.Limit is not null)
{
components.Add($"limit={query.Limit.Value.ToString(CultureInfo.InvariantCulture)}");
}
if (query.Offset is not null)
{
components.Add($"offset={query.Offset.Value.ToString(CultureInfo.InvariantCulture)}");
}
if (!string.IsNullOrWhiteSpace(query.View))
{
components.Add($"view={query.View}");
}
return new VexQuerySignature(string.Join('&', components));
}
public VexContentAddress ComputeHash()
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(Value);
var digest = sha.ComputeHash(bytes);
var builder = new StringBuilder(digest.Length * 2);
foreach (var b in digest)
{
_ = builder.Append(b.ToString("x2", CultureInfo.InvariantCulture));
}
return new VexContentAddress("sha256", builder.ToString());
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Linq;
namespace StellaOps.Excititor.Core;
public sealed record VexQuery(
ImmutableArray<VexQueryFilter> Filters,
ImmutableArray<VexQuerySort> Sort,
int? Limit = null,
int? Offset = null,
string? View = null)
{
public static VexQuery Empty { get; } = new(
ImmutableArray<VexQueryFilter>.Empty,
ImmutableArray<VexQuerySort>.Empty);
public static VexQuery Create(
IEnumerable<VexQueryFilter>? filters = null,
IEnumerable<VexQuerySort>? sort = null,
int? limit = null,
int? offset = null,
string? view = null)
{
var normalizedFilters = NormalizeFilters(filters);
var normalizedSort = NormalizeSort(sort);
return new VexQuery(normalizedFilters, normalizedSort, NormalizeBound(limit), NormalizeBound(offset), NormalizeView(view));
}
public VexQuery WithFilters(IEnumerable<VexQueryFilter> filters)
=> this with { Filters = NormalizeFilters(filters) };
public VexQuery WithSort(IEnumerable<VexQuerySort> sort)
=> this with { Sort = NormalizeSort(sort) };
public VexQuery WithBounds(int? limit = null, int? offset = null)
=> this with { Limit = NormalizeBound(limit), Offset = NormalizeBound(offset) };
public VexQuery WithView(string? view)
=> this with { View = NormalizeView(view) };
private static ImmutableArray<VexQueryFilter> NormalizeFilters(IEnumerable<VexQueryFilter>? filters)
{
if (filters is null)
{
return ImmutableArray<VexQueryFilter>.Empty;
}
return filters
.Where(filter => !string.IsNullOrWhiteSpace(filter.Key))
.Select(filter => new VexQueryFilter(filter.Key.Trim(), filter.Value?.Trim() ?? string.Empty))
.OrderBy(filter => filter.Key, StringComparer.Ordinal)
.ThenBy(filter => filter.Value, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<VexQuerySort> NormalizeSort(IEnumerable<VexQuerySort>? sort)
{
if (sort is null)
{
return ImmutableArray<VexQuerySort>.Empty;
}
return sort
.Where(s => !string.IsNullOrWhiteSpace(s.Field))
.Select(s => new VexQuerySort(s.Field.Trim(), s.Descending))
.OrderBy(s => s.Field, StringComparer.Ordinal)
.ThenBy(s => s.Descending)
.ToImmutableArray();
}
private static int? NormalizeBound(int? value)
{
if (value is null)
{
return null;
}
if (value.Value < 0)
{
return 0;
}
return value.Value;
}
private static string? NormalizeView(string? view)
=> string.IsNullOrWhiteSpace(view) ? null : view.Trim();
}
public sealed record VexQueryFilter(string Key, string Value);
public sealed record VexQuerySort(string Field, bool Descending);
public sealed partial record VexQuerySignature
{
public static VexQuerySignature FromQuery(VexQuery query)
{
if (query is null)
{
throw new ArgumentNullException(nameof(query));
}
var components = new List<string>(query.Filters.Length + query.Sort.Length + 3);
components.AddRange(query.Filters.Select(filter => $"{filter.Key}={filter.Value}"));
components.AddRange(query.Sort.Select(sort => sort.Descending ? $"sort=-{sort.Field}" : $"sort=+{sort.Field}"));
if (query.Limit is not null)
{
components.Add($"limit={query.Limit.Value.ToString(CultureInfo.InvariantCulture)}");
}
if (query.Offset is not null)
{
components.Add($"offset={query.Offset.Value.ToString(CultureInfo.InvariantCulture)}");
}
if (!string.IsNullOrWhiteSpace(query.View))
{
components.Add($"view={query.View}");
}
return new VexQuerySignature(string.Join('&', components));
}
public VexContentAddress ComputeHash()
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(Value);
var digest = sha.ComputeHash(bytes);
var builder = new StringBuilder(digest.Length * 2);
foreach (var b in digest)
{
_ = builder.Append(b.ToString("x2", CultureInfo.InvariantCulture));
}
return new VexContentAddress("sha256", builder.ToString());
}
}

View File

@@ -1,158 +1,158 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Excititor.Core;
public sealed record VexScoreEnvelope
{
public VexScoreEnvelope(
DateTimeOffset generatedAt,
string policyRevisionId,
string? policyDigest,
double alpha,
double beta,
double weightCeiling,
ImmutableArray<VexScoreEntry> entries)
{
if (string.IsNullOrWhiteSpace(policyRevisionId))
{
throw new ArgumentException("Policy revision id must be provided.", nameof(policyRevisionId));
}
if (double.IsNaN(alpha) || double.IsInfinity(alpha) || alpha < 0)
{
throw new ArgumentOutOfRangeException(nameof(alpha), "Alpha must be a finite, non-negative number.");
}
if (double.IsNaN(beta) || double.IsInfinity(beta) || beta < 0)
{
throw new ArgumentOutOfRangeException(nameof(beta), "Beta must be a finite, non-negative number.");
}
if (double.IsNaN(weightCeiling) || double.IsInfinity(weightCeiling) || weightCeiling <= 0)
{
throw new ArgumentOutOfRangeException(nameof(weightCeiling), "Weight ceiling must be a finite number greater than zero.");
}
GeneratedAt = generatedAt;
PolicyRevisionId = policyRevisionId.Trim();
PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim();
Alpha = alpha;
Beta = beta;
WeightCeiling = weightCeiling;
Entries = entries;
}
public VexScoreEnvelope(
DateTimeOffset generatedAt,
string policyRevisionId,
string? policyDigest,
double alpha,
double beta,
double weightCeiling,
IEnumerable<VexScoreEntry> entries)
: this(
generatedAt,
policyRevisionId,
policyDigest,
alpha,
beta,
weightCeiling,
NormalizeEntries(entries))
{
}
public DateTimeOffset GeneratedAt { get; }
public string PolicyRevisionId { get; }
public string? PolicyDigest { get; }
public double Alpha { get; }
public double Beta { get; }
public double WeightCeiling { get; }
public ImmutableArray<VexScoreEntry> Entries { get; }
private static ImmutableArray<VexScoreEntry> NormalizeEntries(IEnumerable<VexScoreEntry> entries)
{
if (entries is null)
{
throw new ArgumentNullException(nameof(entries));
}
return entries
.OrderBy(static entry => entry.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(static entry => entry.ProductKey, StringComparer.Ordinal)
.ToImmutableArray();
}
}
public sealed record VexScoreEntry
{
public VexScoreEntry(
string vulnerabilityId,
string productKey,
VexConsensusStatus status,
DateTimeOffset calculatedAt,
VexSignalSnapshot? signals,
double? score)
{
VulnerabilityId = ValidateVulnerability(vulnerabilityId);
ProductKey = ValidateProduct(productKey);
Status = status;
CalculatedAt = calculatedAt;
Signals = signals;
Score = ValidateScore(score);
}
public string VulnerabilityId { get; }
public string ProductKey { get; }
public VexConsensusStatus Status { get; }
public DateTimeOffset CalculatedAt { get; }
public VexSignalSnapshot? Signals { get; }
public double? Score { get; }
private static string ValidateVulnerability(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(value));
}
return value.Trim();
}
private static string ValidateProduct(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Product key must be provided.", nameof(value));
}
return value.Trim();
}
private static double? ValidateScore(double? score)
{
if (score is null)
{
return null;
}
if (double.IsNaN(score.Value) || double.IsInfinity(score.Value) || score.Value < 0)
{
throw new ArgumentOutOfRangeException(nameof(score), "Score must be a finite, non-negative number.");
}
return score;
}
}
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Excititor.Core;
public sealed record VexScoreEnvelope
{
public VexScoreEnvelope(
DateTimeOffset generatedAt,
string policyRevisionId,
string? policyDigest,
double alpha,
double beta,
double weightCeiling,
ImmutableArray<VexScoreEntry> entries)
{
if (string.IsNullOrWhiteSpace(policyRevisionId))
{
throw new ArgumentException("Policy revision id must be provided.", nameof(policyRevisionId));
}
if (double.IsNaN(alpha) || double.IsInfinity(alpha) || alpha < 0)
{
throw new ArgumentOutOfRangeException(nameof(alpha), "Alpha must be a finite, non-negative number.");
}
if (double.IsNaN(beta) || double.IsInfinity(beta) || beta < 0)
{
throw new ArgumentOutOfRangeException(nameof(beta), "Beta must be a finite, non-negative number.");
}
if (double.IsNaN(weightCeiling) || double.IsInfinity(weightCeiling) || weightCeiling <= 0)
{
throw new ArgumentOutOfRangeException(nameof(weightCeiling), "Weight ceiling must be a finite number greater than zero.");
}
GeneratedAt = generatedAt;
PolicyRevisionId = policyRevisionId.Trim();
PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim();
Alpha = alpha;
Beta = beta;
WeightCeiling = weightCeiling;
Entries = entries;
}
public VexScoreEnvelope(
DateTimeOffset generatedAt,
string policyRevisionId,
string? policyDigest,
double alpha,
double beta,
double weightCeiling,
IEnumerable<VexScoreEntry> entries)
: this(
generatedAt,
policyRevisionId,
policyDigest,
alpha,
beta,
weightCeiling,
NormalizeEntries(entries))
{
}
public DateTimeOffset GeneratedAt { get; }
public string PolicyRevisionId { get; }
public string? PolicyDigest { get; }
public double Alpha { get; }
public double Beta { get; }
public double WeightCeiling { get; }
public ImmutableArray<VexScoreEntry> Entries { get; }
private static ImmutableArray<VexScoreEntry> NormalizeEntries(IEnumerable<VexScoreEntry> entries)
{
if (entries is null)
{
throw new ArgumentNullException(nameof(entries));
}
return entries
.OrderBy(static entry => entry.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(static entry => entry.ProductKey, StringComparer.Ordinal)
.ToImmutableArray();
}
}
public sealed record VexScoreEntry
{
public VexScoreEntry(
string vulnerabilityId,
string productKey,
VexConsensusStatus status,
DateTimeOffset calculatedAt,
VexSignalSnapshot? signals,
double? score)
{
VulnerabilityId = ValidateVulnerability(vulnerabilityId);
ProductKey = ValidateProduct(productKey);
Status = status;
CalculatedAt = calculatedAt;
Signals = signals;
Score = ValidateScore(score);
}
public string VulnerabilityId { get; }
public string ProductKey { get; }
public VexConsensusStatus Status { get; }
public DateTimeOffset CalculatedAt { get; }
public VexSignalSnapshot? Signals { get; }
public double? Score { get; }
private static string ValidateVulnerability(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(value));
}
return value.Trim();
}
private static string ValidateProduct(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Product key must be provided.", nameof(value));
}
return value.Trim();
}
private static double? ValidateScore(double? score)
{
if (score is null)
{
return null;
}
if (double.IsNaN(score.Value) || double.IsInfinity(score.Value) || score.Value < 0)
{
throw new ArgumentOutOfRangeException(nameof(score), "Score must be a finite, non-negative number.");
}
return score;
}
}

View File

@@ -1,64 +1,64 @@
namespace StellaOps.Excititor.Core;
public sealed record VexSignalSnapshot
{
public VexSignalSnapshot(
VexSeveritySignal? severity = null,
bool? kev = null,
double? epss = null)
{
if (epss is { } epssValue)
{
if (double.IsNaN(epssValue) || double.IsInfinity(epssValue) || epssValue < 0 || epssValue > 1)
{
throw new ArgumentOutOfRangeException(nameof(epss), "EPSS probability must be between 0 and 1.");
}
}
Severity = severity;
Kev = kev;
Epss = epss;
}
public VexSeveritySignal? Severity { get; }
public bool? Kev { get; }
public double? Epss { get; }
}
public sealed record VexSeveritySignal
{
public VexSeveritySignal(
string scheme,
double? score = null,
string? label = null,
string? vector = null)
{
if (string.IsNullOrWhiteSpace(scheme))
{
throw new ArgumentException("Severity scheme must be provided.", nameof(scheme));
}
if (score is { } scoreValue)
{
if (double.IsNaN(scoreValue) || double.IsInfinity(scoreValue) || scoreValue < 0)
{
throw new ArgumentOutOfRangeException(nameof(score), "Severity score must be a finite, non-negative number.");
}
}
Scheme = scheme.Trim();
Score = score;
Label = string.IsNullOrWhiteSpace(label) ? null : label.Trim();
Vector = string.IsNullOrWhiteSpace(vector) ? null : vector.Trim();
}
public string Scheme { get; }
public double? Score { get; }
public string? Label { get; }
public string? Vector { get; }
}
namespace StellaOps.Excititor.Core;
public sealed record VexSignalSnapshot
{
public VexSignalSnapshot(
VexSeveritySignal? severity = null,
bool? kev = null,
double? epss = null)
{
if (epss is { } epssValue)
{
if (double.IsNaN(epssValue) || double.IsInfinity(epssValue) || epssValue < 0 || epssValue > 1)
{
throw new ArgumentOutOfRangeException(nameof(epss), "EPSS probability must be between 0 and 1.");
}
}
Severity = severity;
Kev = kev;
Epss = epss;
}
public VexSeveritySignal? Severity { get; }
public bool? Kev { get; }
public double? Epss { get; }
}
public sealed record VexSeveritySignal
{
public VexSeveritySignal(
string scheme,
double? score = null,
string? label = null,
string? vector = null)
{
if (string.IsNullOrWhiteSpace(scheme))
{
throw new ArgumentException("Severity scheme must be provided.", nameof(scheme));
}
if (score is { } scoreValue)
{
if (double.IsNaN(scoreValue) || double.IsInfinity(scoreValue) || scoreValue < 0)
{
throw new ArgumentOutOfRangeException(nameof(score), "Severity score must be a finite, non-negative number.");
}
}
Scheme = scheme.Trim();
Score = score;
Label = string.IsNullOrWhiteSpace(label) ? null : label.Trim();
Vector = string.IsNullOrWhiteSpace(vector) ? null : vector.Trim();
}
public string Scheme { get; }
public double? Score { get; }
public string? Label { get; }
public string? Vector { get; }
}

View File

@@ -1,17 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Signature verifier implementation that trusts ingress sources without performing verification.
/// Useful for offline development flows and ingestion pipelines that perform verification upstream.
/// </summary>
public sealed class NoopVexSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
return ValueTask.FromResult<VexSignatureMetadata?>(null);
}
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Signature verifier implementation that trusts ingress sources without performing verification.
/// Useful for offline development flows and ingestion pipelines that perform verification upstream.
/// </summary>
public sealed class NoopVexSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
return ValueTask.FromResult<VexSignatureMetadata?>(null);
}
}

View File

@@ -1,138 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public sealed class FileSystemArtifactStoreOptions
{
public string RootPath { get; set; } = ".";
public bool OverwriteExisting { get; set; } = false;
}
public sealed class FileSystemArtifactStore : IVexArtifactStore
{
private readonly IFileSystem _fileSystem;
private readonly FileSystemArtifactStoreOptions _options;
private readonly ILogger<FileSystemArtifactStore> _logger;
public FileSystemArtifactStore(
IOptions<FileSystemArtifactStoreOptions> options,
ILogger<FileSystemArtifactStore> logger,
IFileSystem? fileSystem = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_fileSystem = fileSystem ?? new FileSystem();
if (string.IsNullOrWhiteSpace(_options.RootPath))
{
throw new ArgumentException("RootPath must be provided for FileSystemArtifactStore.", nameof(options));
}
var root = _fileSystem.Path.GetFullPath(_options.RootPath);
_fileSystem.Directory.CreateDirectory(root);
_options.RootPath = root;
}
public async ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifact);
var relativePath = BuildArtifactPath(artifact.ContentAddress, artifact.Format);
var destination = _fileSystem.Path.Combine(_options.RootPath, relativePath);
var directory = _fileSystem.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(directory))
{
_fileSystem.Directory.CreateDirectory(directory);
}
if (_fileSystem.File.Exists(destination) && !_options.OverwriteExisting)
{
_logger.LogInformation("Artifact {Digest} already exists at {Path}; skipping write.", artifact.ContentAddress.ToUri(), destination);
}
else
{
await using var stream = _fileSystem.File.Create(destination);
await stream.WriteAsync(artifact.Content, cancellationToken).ConfigureAwait(false);
}
var location = destination.Replace(_options.RootPath, string.Empty).TrimStart(_fileSystem.Path.DirectorySeparatorChar, _fileSystem.Path.AltDirectorySeparatorChar);
return new VexStoredArtifact(
artifact.ContentAddress,
location,
artifact.Content.Length,
artifact.Metadata);
}
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
var path = MaterializePath(contentAddress);
if (path is not null && _fileSystem.File.Exists(path))
{
_fileSystem.File.Delete(path);
}
return ValueTask.CompletedTask;
}
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
var path = MaterializePath(contentAddress);
if (path is null || !_fileSystem.File.Exists(path))
{
return ValueTask.FromResult<Stream?>(null);
}
Stream stream = _fileSystem.File.OpenRead(path);
return ValueTask.FromResult<Stream?>(stream);
}
private static string BuildArtifactPath(VexContentAddress address, VexExportFormat format)
{
var formatSegment = format.ToString().ToLowerInvariant();
var safeDigest = address.Digest.Replace(':', '_');
var extension = GetExtension(format);
return Path.Combine(formatSegment, safeDigest + extension);
}
private string? MaterializePath(VexContentAddress address)
{
ArgumentNullException.ThrowIfNull(address);
var sanitized = address.Digest.Replace(':', '_');
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
var candidate = _fileSystem.Path.Combine(_options.RootPath, format.ToString().ToLowerInvariant(), sanitized + GetExtension(format));
if (_fileSystem.File.Exists(candidate))
{
return candidate;
}
}
// fallback: direct root search with common extensions
foreach (var extension in new[] { ".json", ".jsonl" })
{
var candidate = _fileSystem.Path.Combine(_options.RootPath, sanitized + extension);
if (_fileSystem.File.Exists(candidate))
{
return candidate;
}
}
return null;
}
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public sealed class FileSystemArtifactStoreOptions
{
public string RootPath { get; set; } = ".";
public bool OverwriteExisting { get; set; } = false;
}
public sealed class FileSystemArtifactStore : IVexArtifactStore
{
private readonly IFileSystem _fileSystem;
private readonly FileSystemArtifactStoreOptions _options;
private readonly ILogger<FileSystemArtifactStore> _logger;
public FileSystemArtifactStore(
IOptions<FileSystemArtifactStoreOptions> options,
ILogger<FileSystemArtifactStore> logger,
IFileSystem? fileSystem = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_fileSystem = fileSystem ?? new FileSystem();
if (string.IsNullOrWhiteSpace(_options.RootPath))
{
throw new ArgumentException("RootPath must be provided for FileSystemArtifactStore.", nameof(options));
}
var root = _fileSystem.Path.GetFullPath(_options.RootPath);
_fileSystem.Directory.CreateDirectory(root);
_options.RootPath = root;
}
public async ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifact);
var relativePath = BuildArtifactPath(artifact.ContentAddress, artifact.Format);
var destination = _fileSystem.Path.Combine(_options.RootPath, relativePath);
var directory = _fileSystem.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(directory))
{
_fileSystem.Directory.CreateDirectory(directory);
}
if (_fileSystem.File.Exists(destination) && !_options.OverwriteExisting)
{
_logger.LogInformation("Artifact {Digest} already exists at {Path}; skipping write.", artifact.ContentAddress.ToUri(), destination);
}
else
{
await using var stream = _fileSystem.File.Create(destination);
await stream.WriteAsync(artifact.Content, cancellationToken).ConfigureAwait(false);
}
var location = destination.Replace(_options.RootPath, string.Empty).TrimStart(_fileSystem.Path.DirectorySeparatorChar, _fileSystem.Path.AltDirectorySeparatorChar);
return new VexStoredArtifact(
artifact.ContentAddress,
location,
artifact.Content.Length,
artifact.Metadata);
}
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
var path = MaterializePath(contentAddress);
if (path is not null && _fileSystem.File.Exists(path))
{
_fileSystem.File.Delete(path);
}
return ValueTask.CompletedTask;
}
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
var path = MaterializePath(contentAddress);
if (path is null || !_fileSystem.File.Exists(path))
{
return ValueTask.FromResult<Stream?>(null);
}
Stream stream = _fileSystem.File.OpenRead(path);
return ValueTask.FromResult<Stream?>(stream);
}
private static string BuildArtifactPath(VexContentAddress address, VexExportFormat format)
{
var formatSegment = format.ToString().ToLowerInvariant();
var safeDigest = address.Digest.Replace(':', '_');
var extension = GetExtension(format);
return Path.Combine(formatSegment, safeDigest + extension);
}
private string? MaterializePath(VexContentAddress address)
{
ArgumentNullException.ThrowIfNull(address);
var sanitized = address.Digest.Replace(':', '_');
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
var candidate = _fileSystem.Path.Combine(_options.RootPath, format.ToString().ToLowerInvariant(), sanitized + GetExtension(format));
if (_fileSystem.File.Exists(candidate))
{
return candidate;
}
}
// fallback: direct root search with common extensions
foreach (var extension in new[] { ".json", ".jsonl" })
{
var candidate = _fileSystem.Path.Combine(_options.RootPath, sanitized + extension);
if (_fileSystem.File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static string GetExtension(VexExportFormat format)
=> format switch
{
@@ -144,17 +144,17 @@ public sealed class FileSystemArtifactStore : IVexArtifactStore
_ => ".bin",
};
}
public static class FileSystemArtifactStoreServiceCollectionExtensions
{
public static IServiceCollection AddVexFileSystemArtifactStore(this IServiceCollection services, Action<FileSystemArtifactStoreOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
services.AddSingleton<IVexArtifactStore, FileSystemArtifactStore>();
return services;
}
}
public static class FileSystemArtifactStoreServiceCollectionExtensions
{
public static IServiceCollection AddVexFileSystemArtifactStore(this IServiceCollection services, Action<FileSystemArtifactStoreOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
services.AddSingleton<IVexArtifactStore, FileSystemArtifactStore>();
return services;
}
}

View File

@@ -1,28 +1,28 @@
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public sealed record VexExportArtifact(
VexContentAddress ContentAddress,
VexExportFormat Format,
ReadOnlyMemory<byte> Content,
IReadOnlyDictionary<string, string> Metadata);
public sealed record VexStoredArtifact(
VexContentAddress ContentAddress,
string Location,
long SizeBytes,
IReadOnlyDictionary<string, string> Metadata);
public interface IVexArtifactStore
{
ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken);
ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken);
ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken);
}
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public sealed record VexExportArtifact(
VexContentAddress ContentAddress,
VexExportFormat Format,
ReadOnlyMemory<byte> Content,
IReadOnlyDictionary<string, string> Metadata);
public sealed record VexStoredArtifact(
VexContentAddress ContentAddress,
string Location,
long SizeBytes,
IReadOnlyDictionary<string, string> Metadata);
public interface IVexArtifactStore
{
ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken);
ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken);
ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken);
}

View File

@@ -1,218 +1,218 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.IO.Abstractions;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public sealed class OfflineBundleArtifactStoreOptions
{
public string RootPath { get; set; } = ".";
public string ArtifactsFolder { get; set; } = "artifacts";
public string BundlesFolder { get; set; } = "bundles";
public string ManifestFileName { get; set; } = "offline-manifest.json";
}
public sealed class OfflineBundleArtifactStore : IVexArtifactStore
{
private readonly IFileSystem _fileSystem;
private readonly OfflineBundleArtifactStoreOptions _options;
private readonly ILogger<OfflineBundleArtifactStore> _logger;
private readonly JsonSerializerOptions _serializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
public OfflineBundleArtifactStore(
IOptions<OfflineBundleArtifactStoreOptions> options,
ILogger<OfflineBundleArtifactStore> logger,
IFileSystem? fileSystem = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_fileSystem = fileSystem ?? new FileSystem();
if (string.IsNullOrWhiteSpace(_options.RootPath))
{
throw new ArgumentException("RootPath must be provided for OfflineBundleArtifactStore.", nameof(options));
}
var root = _fileSystem.Path.GetFullPath(_options.RootPath);
_fileSystem.Directory.CreateDirectory(root);
_options.RootPath = root;
_fileSystem.Directory.CreateDirectory(GetArtifactsRoot());
_fileSystem.Directory.CreateDirectory(GetBundlesRoot());
}
public async ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifact);
EnforceDigestMatch(artifact);
var artifactRelativePath = BuildArtifactRelativePath(artifact);
var artifactFullPath = _fileSystem.Path.Combine(_options.RootPath, artifactRelativePath);
var artifactDirectory = _fileSystem.Path.GetDirectoryName(artifactFullPath);
if (!string.IsNullOrEmpty(artifactDirectory))
{
_fileSystem.Directory.CreateDirectory(artifactDirectory);
}
await using (var stream = _fileSystem.File.Create(artifactFullPath))
{
await stream.WriteAsync(artifact.Content, cancellationToken).ConfigureAwait(false);
}
WriteOfflineBundle(artifactRelativePath, artifact, cancellationToken);
await UpdateManifestAsync(artifactRelativePath, artifact, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Stored offline artifact {Digest} at {Path}", artifact.ContentAddress.ToUri(), artifactRelativePath);
return new VexStoredArtifact(
artifact.ContentAddress,
artifactRelativePath,
artifact.Content.Length,
artifact.Metadata);
}
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
var sanitized = contentAddress.Digest.Replace(':', '_');
var artifactsRoot = GetArtifactsRoot();
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
var extension = GetExtension(format);
var path = _fileSystem.Path.Combine(artifactsRoot, format.ToString().ToLowerInvariant(), sanitized + extension);
if (_fileSystem.File.Exists(path))
{
_fileSystem.File.Delete(path);
}
var bundlePath = _fileSystem.Path.Combine(GetBundlesRoot(), sanitized + ".zip");
if (_fileSystem.File.Exists(bundlePath))
{
_fileSystem.File.Delete(bundlePath);
}
}
return ValueTask.CompletedTask;
}
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
var artifactsRoot = GetArtifactsRoot();
var sanitized = contentAddress.Digest.Replace(':', '_');
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
var candidate = _fileSystem.Path.Combine(artifactsRoot, format.ToString().ToLowerInvariant(), sanitized + GetExtension(format));
if (_fileSystem.File.Exists(candidate))
{
return ValueTask.FromResult<Stream?>(_fileSystem.File.OpenRead(candidate));
}
}
return ValueTask.FromResult<Stream?>(null);
}
private void EnforceDigestMatch(VexExportArtifact artifact)
{
if (!artifact.ContentAddress.Algorithm.Equals("sha256", StringComparison.OrdinalIgnoreCase))
{
return;
}
using var sha = SHA256.Create();
var computed = "sha256:" + Convert.ToHexString(sha.ComputeHash(artifact.Content.ToArray())).ToLowerInvariant();
if (!string.Equals(computed, artifact.ContentAddress.ToUri(), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Artifact content digest mismatch. Expected {artifact.ContentAddress.ToUri()} but computed {computed}.");
}
}
private string BuildArtifactRelativePath(VexExportArtifact artifact)
{
var sanitized = artifact.ContentAddress.Digest.Replace(':', '_');
var folder = _fileSystem.Path.Combine(_options.ArtifactsFolder, artifact.Format.ToString().ToLowerInvariant());
return _fileSystem.Path.Combine(folder, sanitized + GetExtension(artifact.Format));
}
private void WriteOfflineBundle(string artifactRelativePath, VexExportArtifact artifact, CancellationToken cancellationToken)
{
var zipPath = _fileSystem.Path.Combine(GetBundlesRoot(), artifact.ContentAddress.Digest.Replace(':', '_') + ".zip");
using var zipStream = _fileSystem.File.Create(zipPath);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create);
var entry = archive.CreateEntry(artifactRelativePath, CompressionLevel.Optimal);
using (var entryStream = entry.Open())
{
entryStream.Write(artifact.Content.Span);
}
// embed metadata file
var metadataEntry = archive.CreateEntry("metadata.json", CompressionLevel.Optimal);
using var metadataStream = new StreamWriter(metadataEntry.Open());
var metadata = new Dictionary<string, object?>
{
["digest"] = artifact.ContentAddress.ToUri(),
["format"] = artifact.Format.ToString().ToLowerInvariant(),
["sizeBytes"] = artifact.Content.Length,
["metadata"] = artifact.Metadata,
};
metadataStream.Write(JsonSerializer.Serialize(metadata, _serializerOptions));
}
private async Task UpdateManifestAsync(string artifactRelativePath, VexExportArtifact artifact, CancellationToken cancellationToken)
{
var manifestPath = _fileSystem.Path.Combine(_options.RootPath, _options.ManifestFileName);
var records = new List<ManifestEntry>();
if (_fileSystem.File.Exists(manifestPath))
{
await using var existingStream = _fileSystem.File.OpenRead(manifestPath);
var existing = await JsonSerializer.DeserializeAsync<ManifestDocument>(existingStream, _serializerOptions, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
records.AddRange(existing.Artifacts);
}
}
records.RemoveAll(x => string.Equals(x.Digest, artifact.ContentAddress.ToUri(), StringComparison.OrdinalIgnoreCase));
records.Add(new ManifestEntry(
artifact.ContentAddress.ToUri(),
artifact.Format.ToString().ToLowerInvariant(),
artifactRelativePath.Replace("\\", "/"),
artifact.Content.Length,
artifact.Metadata));
records.Sort(static (a, b) => string.CompareOrdinal(a.Digest, b.Digest));
var doc = new ManifestDocument(records.ToImmutableArray());
await using var stream = _fileSystem.File.Create(manifestPath);
await JsonSerializer.SerializeAsync(stream, doc, _serializerOptions, cancellationToken).ConfigureAwait(false);
}
private string GetArtifactsRoot() => _fileSystem.Path.Combine(_options.RootPath, _options.ArtifactsFolder);
private string GetBundlesRoot() => _fileSystem.Path.Combine(_options.RootPath, _options.BundlesFolder);
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.IO.Abstractions;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public sealed class OfflineBundleArtifactStoreOptions
{
public string RootPath { get; set; } = ".";
public string ArtifactsFolder { get; set; } = "artifacts";
public string BundlesFolder { get; set; } = "bundles";
public string ManifestFileName { get; set; } = "offline-manifest.json";
}
public sealed class OfflineBundleArtifactStore : IVexArtifactStore
{
private readonly IFileSystem _fileSystem;
private readonly OfflineBundleArtifactStoreOptions _options;
private readonly ILogger<OfflineBundleArtifactStore> _logger;
private readonly JsonSerializerOptions _serializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
public OfflineBundleArtifactStore(
IOptions<OfflineBundleArtifactStoreOptions> options,
ILogger<OfflineBundleArtifactStore> logger,
IFileSystem? fileSystem = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_fileSystem = fileSystem ?? new FileSystem();
if (string.IsNullOrWhiteSpace(_options.RootPath))
{
throw new ArgumentException("RootPath must be provided for OfflineBundleArtifactStore.", nameof(options));
}
var root = _fileSystem.Path.GetFullPath(_options.RootPath);
_fileSystem.Directory.CreateDirectory(root);
_options.RootPath = root;
_fileSystem.Directory.CreateDirectory(GetArtifactsRoot());
_fileSystem.Directory.CreateDirectory(GetBundlesRoot());
}
public async ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifact);
EnforceDigestMatch(artifact);
var artifactRelativePath = BuildArtifactRelativePath(artifact);
var artifactFullPath = _fileSystem.Path.Combine(_options.RootPath, artifactRelativePath);
var artifactDirectory = _fileSystem.Path.GetDirectoryName(artifactFullPath);
if (!string.IsNullOrEmpty(artifactDirectory))
{
_fileSystem.Directory.CreateDirectory(artifactDirectory);
}
await using (var stream = _fileSystem.File.Create(artifactFullPath))
{
await stream.WriteAsync(artifact.Content, cancellationToken).ConfigureAwait(false);
}
WriteOfflineBundle(artifactRelativePath, artifact, cancellationToken);
await UpdateManifestAsync(artifactRelativePath, artifact, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Stored offline artifact {Digest} at {Path}", artifact.ContentAddress.ToUri(), artifactRelativePath);
return new VexStoredArtifact(
artifact.ContentAddress,
artifactRelativePath,
artifact.Content.Length,
artifact.Metadata);
}
public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
var sanitized = contentAddress.Digest.Replace(':', '_');
var artifactsRoot = GetArtifactsRoot();
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
var extension = GetExtension(format);
var path = _fileSystem.Path.Combine(artifactsRoot, format.ToString().ToLowerInvariant(), sanitized + extension);
if (_fileSystem.File.Exists(path))
{
_fileSystem.File.Delete(path);
}
var bundlePath = _fileSystem.Path.Combine(GetBundlesRoot(), sanitized + ".zip");
if (_fileSystem.File.Exists(bundlePath))
{
_fileSystem.File.Delete(bundlePath);
}
}
return ValueTask.CompletedTask;
}
public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
var artifactsRoot = GetArtifactsRoot();
var sanitized = contentAddress.Digest.Replace(':', '_');
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
var candidate = _fileSystem.Path.Combine(artifactsRoot, format.ToString().ToLowerInvariant(), sanitized + GetExtension(format));
if (_fileSystem.File.Exists(candidate))
{
return ValueTask.FromResult<Stream?>(_fileSystem.File.OpenRead(candidate));
}
}
return ValueTask.FromResult<Stream?>(null);
}
private void EnforceDigestMatch(VexExportArtifact artifact)
{
if (!artifact.ContentAddress.Algorithm.Equals("sha256", StringComparison.OrdinalIgnoreCase))
{
return;
}
using var sha = SHA256.Create();
var computed = "sha256:" + Convert.ToHexString(sha.ComputeHash(artifact.Content.ToArray())).ToLowerInvariant();
if (!string.Equals(computed, artifact.ContentAddress.ToUri(), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Artifact content digest mismatch. Expected {artifact.ContentAddress.ToUri()} but computed {computed}.");
}
}
private string BuildArtifactRelativePath(VexExportArtifact artifact)
{
var sanitized = artifact.ContentAddress.Digest.Replace(':', '_');
var folder = _fileSystem.Path.Combine(_options.ArtifactsFolder, artifact.Format.ToString().ToLowerInvariant());
return _fileSystem.Path.Combine(folder, sanitized + GetExtension(artifact.Format));
}
private void WriteOfflineBundle(string artifactRelativePath, VexExportArtifact artifact, CancellationToken cancellationToken)
{
var zipPath = _fileSystem.Path.Combine(GetBundlesRoot(), artifact.ContentAddress.Digest.Replace(':', '_') + ".zip");
using var zipStream = _fileSystem.File.Create(zipPath);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create);
var entry = archive.CreateEntry(artifactRelativePath, CompressionLevel.Optimal);
using (var entryStream = entry.Open())
{
entryStream.Write(artifact.Content.Span);
}
// embed metadata file
var metadataEntry = archive.CreateEntry("metadata.json", CompressionLevel.Optimal);
using var metadataStream = new StreamWriter(metadataEntry.Open());
var metadata = new Dictionary<string, object?>
{
["digest"] = artifact.ContentAddress.ToUri(),
["format"] = artifact.Format.ToString().ToLowerInvariant(),
["sizeBytes"] = artifact.Content.Length,
["metadata"] = artifact.Metadata,
};
metadataStream.Write(JsonSerializer.Serialize(metadata, _serializerOptions));
}
private async Task UpdateManifestAsync(string artifactRelativePath, VexExportArtifact artifact, CancellationToken cancellationToken)
{
var manifestPath = _fileSystem.Path.Combine(_options.RootPath, _options.ManifestFileName);
var records = new List<ManifestEntry>();
if (_fileSystem.File.Exists(manifestPath))
{
await using var existingStream = _fileSystem.File.OpenRead(manifestPath);
var existing = await JsonSerializer.DeserializeAsync<ManifestDocument>(existingStream, _serializerOptions, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
records.AddRange(existing.Artifacts);
}
}
records.RemoveAll(x => string.Equals(x.Digest, artifact.ContentAddress.ToUri(), StringComparison.OrdinalIgnoreCase));
records.Add(new ManifestEntry(
artifact.ContentAddress.ToUri(),
artifact.Format.ToString().ToLowerInvariant(),
artifactRelativePath.Replace("\\", "/"),
artifact.Content.Length,
artifact.Metadata));
records.Sort(static (a, b) => string.CompareOrdinal(a.Digest, b.Digest));
var doc = new ManifestDocument(records.ToImmutableArray());
await using var stream = _fileSystem.File.Create(manifestPath);
await JsonSerializer.SerializeAsync(stream, doc, _serializerOptions, cancellationToken).ConfigureAwait(false);
}
private string GetArtifactsRoot() => _fileSystem.Path.Combine(_options.RootPath, _options.ArtifactsFolder);
private string GetBundlesRoot() => _fileSystem.Path.Combine(_options.RootPath, _options.BundlesFolder);
private static string GetExtension(VexExportFormat format)
=> format switch
{
@@ -224,21 +224,21 @@ public sealed class OfflineBundleArtifactStore : IVexArtifactStore
_ => ".bin",
};
private sealed record ManifestDocument(ImmutableArray<ManifestEntry> Artifacts);
private sealed record ManifestEntry(string Digest, string Format, string Path, long SizeBytes, IReadOnlyDictionary<string, string> Metadata);
}
public static class OfflineBundleArtifactStoreServiceCollectionExtensions
{
public static IServiceCollection AddVexOfflineBundleArtifactStore(this IServiceCollection services, Action<OfflineBundleArtifactStoreOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
services.AddSingleton<IVexArtifactStore, OfflineBundleArtifactStore>();
return services;
}
}
private sealed record ManifestDocument(ImmutableArray<ManifestEntry> Artifacts);
private sealed record ManifestEntry(string Digest, string Format, string Path, long SizeBytes, IReadOnlyDictionary<string, string> Metadata);
}
public static class OfflineBundleArtifactStoreServiceCollectionExtensions
{
public static IServiceCollection AddVexOfflineBundleArtifactStore(this IServiceCollection services, Action<OfflineBundleArtifactStoreOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
services.AddSingleton<IVexArtifactStore, OfflineBundleArtifactStore>();
return services;
}
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Excititor.Export.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Excititor.Export.Tests")]

View File

@@ -1,144 +1,144 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public sealed class S3ArtifactStoreOptions
{
public string BucketName { get; set; } = string.Empty;
public string? Prefix { get; set; }
= null;
public bool OverwriteExisting { get; set; }
= true;
}
public interface IS3ArtifactClient
{
Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken);
Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken);
Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken);
Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken);
}
public sealed class S3ArtifactStore : IVexArtifactStore
{
private readonly IS3ArtifactClient _client;
private readonly S3ArtifactStoreOptions _options;
private readonly ILogger<S3ArtifactStore> _logger;
public S3ArtifactStore(
IS3ArtifactClient client,
IOptions<S3ArtifactStoreOptions> options,
ILogger<S3ArtifactStore> logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (string.IsNullOrWhiteSpace(_options.BucketName))
{
throw new ArgumentException("BucketName must be provided for S3ArtifactStore.", nameof(options));
}
}
public async ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifact);
var key = BuildObjectKey(artifact.ContentAddress, artifact.Format);
if (!_options.OverwriteExisting)
{
var exists = await _client.ObjectExistsAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
if (exists)
{
_logger.LogInformation("S3 object {Bucket}/{Key} already exists; skipping upload.", _options.BucketName, key);
return new VexStoredArtifact(artifact.ContentAddress, key, artifact.Content.Length, artifact.Metadata);
}
}
using var contentStream = new MemoryStream(artifact.Content.ToArray());
await _client.PutObjectAsync(
_options.BucketName,
key,
contentStream,
BuildObjectMetadata(artifact),
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Uploaded export artifact {Digest} to {Bucket}/{Key}", artifact.ContentAddress.ToUri(), _options.BucketName, key);
return new VexStoredArtifact(
artifact.ContentAddress,
key,
artifact.Content.Length,
artifact.Metadata);
}
public async ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
foreach (var key in BuildCandidateKeys(contentAddress))
{
await _client.DeleteObjectAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation("Deleted export artifact {Digest} from {Bucket}", contentAddress.ToUri(), _options.BucketName);
}
public async ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
foreach (var key in BuildCandidateKeys(contentAddress))
{
var stream = await _client.GetObjectAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
if (stream is not null)
{
return stream;
}
}
return null;
}
private string BuildObjectKey(VexContentAddress address, VexExportFormat format)
{
var sanitizedDigest = address.Digest.Replace(':', '_');
var prefix = string.IsNullOrWhiteSpace(_options.Prefix) ? string.Empty : _options.Prefix.TrimEnd('/') + "/";
var formatSegment = format.ToString().ToLowerInvariant();
return $"{prefix}{formatSegment}/{sanitizedDigest}{GetExtension(format)}";
}
private IEnumerable<string> BuildCandidateKeys(VexContentAddress address)
{
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
yield return BuildObjectKey(address, format);
}
if (!string.IsNullOrWhiteSpace(_options.Prefix))
{
yield return $"{_options.Prefix.TrimEnd('/')}/{address.Digest.Replace(':', '_')}";
}
yield return address.Digest.Replace(':', '_');
}
private static IDictionary<string, string> BuildObjectMetadata(VexExportArtifact artifact)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["vex-format"] = artifact.Format.ToString().ToLowerInvariant(),
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public sealed class S3ArtifactStoreOptions
{
public string BucketName { get; set; } = string.Empty;
public string? Prefix { get; set; }
= null;
public bool OverwriteExisting { get; set; }
= true;
}
public interface IS3ArtifactClient
{
Task<bool> ObjectExistsAsync(string bucketName, string key, CancellationToken cancellationToken);
Task PutObjectAsync(string bucketName, string key, Stream content, IDictionary<string, string> metadata, CancellationToken cancellationToken);
Task<Stream?> GetObjectAsync(string bucketName, string key, CancellationToken cancellationToken);
Task DeleteObjectAsync(string bucketName, string key, CancellationToken cancellationToken);
}
public sealed class S3ArtifactStore : IVexArtifactStore
{
private readonly IS3ArtifactClient _client;
private readonly S3ArtifactStoreOptions _options;
private readonly ILogger<S3ArtifactStore> _logger;
public S3ArtifactStore(
IS3ArtifactClient client,
IOptions<S3ArtifactStoreOptions> options,
ILogger<S3ArtifactStore> logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (string.IsNullOrWhiteSpace(_options.BucketName))
{
throw new ArgumentException("BucketName must be provided for S3ArtifactStore.", nameof(options));
}
}
public async ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(artifact);
var key = BuildObjectKey(artifact.ContentAddress, artifact.Format);
if (!_options.OverwriteExisting)
{
var exists = await _client.ObjectExistsAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
if (exists)
{
_logger.LogInformation("S3 object {Bucket}/{Key} already exists; skipping upload.", _options.BucketName, key);
return new VexStoredArtifact(artifact.ContentAddress, key, artifact.Content.Length, artifact.Metadata);
}
}
using var contentStream = new MemoryStream(artifact.Content.ToArray());
await _client.PutObjectAsync(
_options.BucketName,
key,
contentStream,
BuildObjectMetadata(artifact),
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Uploaded export artifact {Digest} to {Bucket}/{Key}", artifact.ContentAddress.ToUri(), _options.BucketName, key);
return new VexStoredArtifact(
artifact.ContentAddress,
key,
artifact.Content.Length,
artifact.Metadata);
}
public async ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
foreach (var key in BuildCandidateKeys(contentAddress))
{
await _client.DeleteObjectAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation("Deleted export artifact {Digest} from {Bucket}", contentAddress.ToUri(), _options.BucketName);
}
public async ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(contentAddress);
foreach (var key in BuildCandidateKeys(contentAddress))
{
var stream = await _client.GetObjectAsync(_options.BucketName, key, cancellationToken).ConfigureAwait(false);
if (stream is not null)
{
return stream;
}
}
return null;
}
private string BuildObjectKey(VexContentAddress address, VexExportFormat format)
{
var sanitizedDigest = address.Digest.Replace(':', '_');
var prefix = string.IsNullOrWhiteSpace(_options.Prefix) ? string.Empty : _options.Prefix.TrimEnd('/') + "/";
var formatSegment = format.ToString().ToLowerInvariant();
return $"{prefix}{formatSegment}/{sanitizedDigest}{GetExtension(format)}";
}
private IEnumerable<string> BuildCandidateKeys(VexContentAddress address)
{
foreach (VexExportFormat format in Enum.GetValues(typeof(VexExportFormat)))
{
yield return BuildObjectKey(address, format);
}
if (!string.IsNullOrWhiteSpace(_options.Prefix))
{
yield return $"{_options.Prefix.TrimEnd('/')}/{address.Digest.Replace(':', '_')}";
}
yield return address.Digest.Replace(':', '_');
}
private static IDictionary<string, string> BuildObjectMetadata(VexExportArtifact artifact)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["vex-format"] = artifact.Format.ToString().ToLowerInvariant(),
["vex-digest"] = artifact.ContentAddress.ToUri(),
["content-type"] = artifact.Format switch
{
@@ -150,15 +150,15 @@ public sealed class S3ArtifactStore : IVexArtifactStore
_ => "application/octet-stream",
},
};
foreach (var kvp in artifact.Metadata)
{
metadata[$"meta-{kvp.Key}"] = kvp.Value;
}
return metadata;
}
foreach (var kvp in artifact.Metadata)
{
metadata[$"meta-{kvp.Key}"] = kvp.Value;
}
return metadata;
}
private static string GetExtension(VexExportFormat format)
=> format switch
{
@@ -170,14 +170,14 @@ public sealed class S3ArtifactStore : IVexArtifactStore
_ => ".bin",
};
}
public static class S3ArtifactStoreServiceCollectionExtensions
{
public static IServiceCollection AddVexS3ArtifactStore(this IServiceCollection services, Action<S3ArtifactStoreOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
services.Configure(configure);
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>();
return services;
}
}
public static class S3ArtifactStoreServiceCollectionExtensions
{
public static IServiceCollection AddVexS3ArtifactStore(this IServiceCollection services, Action<S3ArtifactStoreOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
services.Configure(configure);
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>();
return services;
}
}

View File

@@ -1,54 +1,54 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Export;
public interface IVexExportCacheService
{
ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken);
ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken);
ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken);
}
internal sealed class VexExportCacheService : IVexExportCacheService
{
private readonly IVexCacheIndex _cacheIndex;
private readonly IVexCacheMaintenance _maintenance;
private readonly ILogger<VexExportCacheService> _logger;
public VexExportCacheService(
IVexCacheIndex cacheIndex,
IVexCacheMaintenance maintenance,
ILogger<VexExportCacheService> logger)
{
_cacheIndex = cacheIndex ?? throw new ArgumentNullException(nameof(cacheIndex));
_maintenance = maintenance ?? throw new ArgumentNullException(nameof(maintenance));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(signature);
await _cacheIndex.RemoveAsync(signature, format, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Invalidated export cache entry {Signature} ({Format})", signature.Value, format);
}
public ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
=> _maintenance.RemoveExpiredAsync(asOf, cancellationToken);
public ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken)
=> _maintenance.RemoveMissingManifestReferencesAsync(cancellationToken);
}
public static class VexExportCacheServiceCollectionExtensions
{
public static IServiceCollection AddVexExportCacheServices(this IServiceCollection services)
{
services.AddSingleton<IVexExportCacheService, VexExportCacheService>();
return services;
}
}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Export;
public interface IVexExportCacheService
{
ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken);
ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken);
ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken);
}
internal sealed class VexExportCacheService : IVexExportCacheService
{
private readonly IVexCacheIndex _cacheIndex;
private readonly IVexCacheMaintenance _maintenance;
private readonly ILogger<VexExportCacheService> _logger;
public VexExportCacheService(
IVexCacheIndex cacheIndex,
IVexCacheMaintenance maintenance,
ILogger<VexExportCacheService> logger)
{
_cacheIndex = cacheIndex ?? throw new ArgumentNullException(nameof(cacheIndex));
_maintenance = maintenance ?? throw new ArgumentNullException(nameof(maintenance));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InvalidateAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(signature);
await _cacheIndex.RemoveAsync(signature, format, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Invalidated export cache entry {Signature} ({Format})", signature.Value, format);
}
public ValueTask<int> PruneExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
=> _maintenance.RemoveExpiredAsync(asOf, cancellationToken);
public ValueTask<int> PruneDanglingAsync(CancellationToken cancellationToken)
=> _maintenance.RemoveMissingManifestReferencesAsync(cancellationToken);
}
public static class VexExportCacheServiceCollectionExtensions
{
public static IServiceCollection AddVexExportCacheServices(this IServiceCollection services)
{
services.AddSingleton<IVexExportCacheService, VexExportCacheService>();
return services;
}
}

View File

@@ -1,10 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CSAF;
public static class CsafFormatsServiceCollectionExtensions
{
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CSAF;
public static class CsafFormatsServiceCollectionExtensions
{
public static IServiceCollection AddCsafNormalizer(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

View File

@@ -1,242 +1,242 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CycloneDX;
internal static class CycloneDxComponentReconciler
{
public static CycloneDxReconciliationResult Reconcile(IEnumerable<VexClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var catalog = new ComponentCatalog();
var diagnostics = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
var componentRefs = new Dictionary<(string VulnerabilityId, string ProductKey), string>();
foreach (var claim in claims)
{
if (claim is null)
{
continue;
}
var component = catalog.GetOrAdd(claim.Product, claim.ProviderId, diagnostics);
componentRefs[(claim.VulnerabilityId, claim.Product.Key)] = component.BomRef;
}
var components = catalog.Build();
var orderedDiagnostics = diagnostics.Count == 0
? ImmutableDictionary<string, string>.Empty
: diagnostics.ToImmutableDictionary(
static pair => pair.Key,
pair => string.Join(",", pair.Value.OrderBy(static value => value, StringComparer.Ordinal)),
StringComparer.Ordinal);
return new CycloneDxReconciliationResult(
components,
componentRefs.ToImmutableDictionary(),
orderedDiagnostics);
}
private sealed class ComponentCatalog
{
private readonly Dictionary<string, MutableComponent> _components = new(StringComparer.Ordinal);
private readonly HashSet<string> _bomRefs = new(StringComparer.Ordinal);
public MutableComponent GetOrAdd(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
{
if (_components.TryGetValue(product.Key, out var existing))
{
existing.Update(product, providerId, diagnostics);
return existing;
}
var bomRef = GenerateBomRef(product);
var component = new MutableComponent(product.Key, bomRef);
component.Update(product, providerId, diagnostics);
_components[product.Key] = component;
return component;
}
public ImmutableArray<CycloneDxComponentEntry> Build()
=> _components.Values
.Select(static component => component.ToEntry())
.OrderBy(static entry => entry.BomRef, StringComparer.Ordinal)
.ToImmutableArray();
private string GenerateBomRef(VexProduct product)
{
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var normalized = product.Purl!.Trim();
if (_bomRefs.Add(normalized))
{
return normalized;
}
}
var baseRef = Sanitize(product.Key);
if (_bomRefs.Add(baseRef))
{
return baseRef;
}
var hash = ComputeShortHash(product.Key + product.Name);
var candidate = FormattableString.Invariant($"{baseRef}-{hash}");
while (!_bomRefs.Add(candidate))
{
candidate = FormattableString.Invariant($"{candidate}-{hash}");
}
return candidate;
}
private static string Sanitize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var sanitized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(sanitized) ? "component" : sanitized;
}
private static string ComputeShortHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
return Convert.ToHexString(hash[..6]).ToLowerInvariant();
}
}
private sealed class MutableComponent
{
public MutableComponent(string key, string bomRef)
{
ProductKey = key;
BomRef = bomRef;
}
public string ProductKey { get; }
public string BomRef { get; }
private string? _name;
private string? _version;
private string? _purl;
private string? _cpe;
private readonly SortedDictionary<string, string> _properties = new(StringComparer.Ordinal);
public void Update(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
{
if (!string.IsNullOrWhiteSpace(product.Name) && ShouldReplace(_name, product.Name))
{
_name = product.Name;
}
if (!string.IsNullOrWhiteSpace(product.Version) && ShouldReplace(_version, product.Version))
{
_version = product.Version;
}
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var trimmed = product.Purl!.Trim();
if (string.IsNullOrWhiteSpace(_purl))
{
_purl = trimmed;
}
else if (!string.Equals(_purl, trimmed, StringComparison.OrdinalIgnoreCase))
{
AddDiagnostic(diagnostics, "purl_conflict", FormattableString.Invariant($"{ProductKey}:{_purl}->{trimmed}"));
}
}
else
{
AddDiagnostic(diagnostics, "missing_purl", FormattableString.Invariant($"{ProductKey}:{providerId}"));
}
if (!string.IsNullOrWhiteSpace(product.Cpe))
{
_cpe = product.Cpe;
}
if (product.ComponentIdentifiers.Length > 0)
{
_properties["stellaops/componentIdentifiers"] = string.Join(';', product.ComponentIdentifiers.OrderBy(static identifier => identifier, StringComparer.OrdinalIgnoreCase));
}
}
public CycloneDxComponentEntry ToEntry()
{
ImmutableArray<CycloneDxProperty>? properties = _properties.Count == 0
? null
: _properties.Select(static pair => new CycloneDxProperty(pair.Key, pair.Value)).ToImmutableArray();
return new CycloneDxComponentEntry(
BomRef,
_name ?? ProductKey,
_version,
_purl,
_cpe,
properties);
}
private static bool ShouldReplace(string? existing, string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
if (string.IsNullOrWhiteSpace(existing))
{
return true;
}
return candidate.Length > existing.Length;
}
private static void AddDiagnostic(IDictionary<string, SortedSet<string>> diagnostics, string key, string value)
{
if (!diagnostics.TryGetValue(key, out var set))
{
set = new SortedSet<string>(StringComparer.Ordinal);
diagnostics[key] = set;
}
set.Add(value);
}
}
}
internal sealed record CycloneDxReconciliationResult(
ImmutableArray<CycloneDxComponentEntry> Components,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> ComponentRefs,
ImmutableDictionary<string, string> Diagnostics);
internal sealed record CycloneDxComponentEntry(
[property: JsonPropertyName("bom-ref")] string BomRef,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Version,
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe,
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
internal sealed record CycloneDxProperty(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("value")] string Value);
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CycloneDX;
internal static class CycloneDxComponentReconciler
{
public static CycloneDxReconciliationResult Reconcile(IEnumerable<VexClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var catalog = new ComponentCatalog();
var diagnostics = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
var componentRefs = new Dictionary<(string VulnerabilityId, string ProductKey), string>();
foreach (var claim in claims)
{
if (claim is null)
{
continue;
}
var component = catalog.GetOrAdd(claim.Product, claim.ProviderId, diagnostics);
componentRefs[(claim.VulnerabilityId, claim.Product.Key)] = component.BomRef;
}
var components = catalog.Build();
var orderedDiagnostics = diagnostics.Count == 0
? ImmutableDictionary<string, string>.Empty
: diagnostics.ToImmutableDictionary(
static pair => pair.Key,
pair => string.Join(",", pair.Value.OrderBy(static value => value, StringComparer.Ordinal)),
StringComparer.Ordinal);
return new CycloneDxReconciliationResult(
components,
componentRefs.ToImmutableDictionary(),
orderedDiagnostics);
}
private sealed class ComponentCatalog
{
private readonly Dictionary<string, MutableComponent> _components = new(StringComparer.Ordinal);
private readonly HashSet<string> _bomRefs = new(StringComparer.Ordinal);
public MutableComponent GetOrAdd(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
{
if (_components.TryGetValue(product.Key, out var existing))
{
existing.Update(product, providerId, diagnostics);
return existing;
}
var bomRef = GenerateBomRef(product);
var component = new MutableComponent(product.Key, bomRef);
component.Update(product, providerId, diagnostics);
_components[product.Key] = component;
return component;
}
public ImmutableArray<CycloneDxComponentEntry> Build()
=> _components.Values
.Select(static component => component.ToEntry())
.OrderBy(static entry => entry.BomRef, StringComparer.Ordinal)
.ToImmutableArray();
private string GenerateBomRef(VexProduct product)
{
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var normalized = product.Purl!.Trim();
if (_bomRefs.Add(normalized))
{
return normalized;
}
}
var baseRef = Sanitize(product.Key);
if (_bomRefs.Add(baseRef))
{
return baseRef;
}
var hash = ComputeShortHash(product.Key + product.Name);
var candidate = FormattableString.Invariant($"{baseRef}-{hash}");
while (!_bomRefs.Add(candidate))
{
candidate = FormattableString.Invariant($"{candidate}-{hash}");
}
return candidate;
}
private static string Sanitize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var sanitized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(sanitized) ? "component" : sanitized;
}
private static string ComputeShortHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
return Convert.ToHexString(hash[..6]).ToLowerInvariant();
}
}
private sealed class MutableComponent
{
public MutableComponent(string key, string bomRef)
{
ProductKey = key;
BomRef = bomRef;
}
public string ProductKey { get; }
public string BomRef { get; }
private string? _name;
private string? _version;
private string? _purl;
private string? _cpe;
private readonly SortedDictionary<string, string> _properties = new(StringComparer.Ordinal);
public void Update(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
{
if (!string.IsNullOrWhiteSpace(product.Name) && ShouldReplace(_name, product.Name))
{
_name = product.Name;
}
if (!string.IsNullOrWhiteSpace(product.Version) && ShouldReplace(_version, product.Version))
{
_version = product.Version;
}
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var trimmed = product.Purl!.Trim();
if (string.IsNullOrWhiteSpace(_purl))
{
_purl = trimmed;
}
else if (!string.Equals(_purl, trimmed, StringComparison.OrdinalIgnoreCase))
{
AddDiagnostic(diagnostics, "purl_conflict", FormattableString.Invariant($"{ProductKey}:{_purl}->{trimmed}"));
}
}
else
{
AddDiagnostic(diagnostics, "missing_purl", FormattableString.Invariant($"{ProductKey}:{providerId}"));
}
if (!string.IsNullOrWhiteSpace(product.Cpe))
{
_cpe = product.Cpe;
}
if (product.ComponentIdentifiers.Length > 0)
{
_properties["stellaops/componentIdentifiers"] = string.Join(';', product.ComponentIdentifiers.OrderBy(static identifier => identifier, StringComparer.OrdinalIgnoreCase));
}
}
public CycloneDxComponentEntry ToEntry()
{
ImmutableArray<CycloneDxProperty>? properties = _properties.Count == 0
? null
: _properties.Select(static pair => new CycloneDxProperty(pair.Key, pair.Value)).ToImmutableArray();
return new CycloneDxComponentEntry(
BomRef,
_name ?? ProductKey,
_version,
_purl,
_cpe,
properties);
}
private static bool ShouldReplace(string? existing, string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
if (string.IsNullOrWhiteSpace(existing))
{
return true;
}
return candidate.Length > existing.Length;
}
private static void AddDiagnostic(IDictionary<string, SortedSet<string>> diagnostics, string key, string value)
{
if (!diagnostics.TryGetValue(key, out var set))
{
set = new SortedSet<string>(StringComparer.Ordinal);
diagnostics[key] = set;
}
set.Add(value);
}
}
}
internal sealed record CycloneDxReconciliationResult(
ImmutableArray<CycloneDxComponentEntry> Components,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> ComponentRefs,
ImmutableDictionary<string, string> Diagnostics);
internal sealed record CycloneDxComponentEntry(
[property: JsonPropertyName("bom-ref")] string BomRef,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Version,
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe,
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
internal sealed record CycloneDxProperty(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("value")] string Value);

View File

@@ -1,228 +1,228 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CycloneDX;
/// <summary>
/// Serialises normalized VEX claims into CycloneDX VEX documents with reconciled component references.
/// </summary>
public sealed class CycloneDxExporter : IVexExporter
{
public VexExportFormat Format => VexExportFormat.CycloneDx;
public VexContentAddress Digest(VexExportRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var document = BuildDocument(request, out _);
var json = VexCanonicalJsonSerializer.Serialize(document);
return ComputeDigest(json);
}
public async ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(output);
var document = BuildDocument(request, out var metadata);
var json = VexCanonicalJsonSerializer.Serialize(document);
var digest = ComputeDigest(json);
var buffer = Encoding.UTF8.GetBytes(json);
await output.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
return new VexExportResult(digest, buffer.LongLength, metadata);
}
private CycloneDxExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
{
var signature = VexQuerySignature.FromQuery(request.Query);
var signatureHash = signature.ComputeHash();
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
var reconciliation = CycloneDxComponentReconciler.Reconcile(request.Claims);
var vulnerabilityEntries = BuildVulnerabilities(request.Claims, reconciliation.ComponentRefs);
var missingJustifications = request.Claims
.Where(static claim => claim.Status == VexClaimStatus.NotAffected && claim.Justification is null)
.Select(static claim => FormattableString.Invariant($"{claim.VulnerabilityId}:{claim.Product.Key}"))
.Distinct(StringComparer.Ordinal)
.OrderBy(static key => key, StringComparer.Ordinal)
.ToImmutableArray();
var properties = ImmutableArray.Create(new CycloneDxProperty("stellaops/querySignature", signature.Value));
metadata = BuildMetadata(signature, reconciliation.Diagnostics, generatedAt, vulnerabilityEntries.Length, reconciliation.Components.Length, missingJustifications);
var document = new CycloneDxExportDocument(
BomFormat: "CycloneDX",
SpecVersion: "1.6",
SerialNumber: FormattableString.Invariant($"urn:uuid:{BuildDeterministicGuid(signatureHash.Digest)}"),
Version: 1,
Metadata: new CycloneDxMetadata(generatedAt),
Components: reconciliation.Components,
Vulnerabilities: vulnerabilityEntries,
Properties: properties);
return document;
}
private static ImmutableArray<CycloneDxVulnerabilityEntry> BuildVulnerabilities(
ImmutableArray<VexClaim> claims,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> componentRefs)
{
var entries = ImmutableArray.CreateBuilder<CycloneDxVulnerabilityEntry>();
foreach (var claim in claims)
{
if (!componentRefs.TryGetValue((claim.VulnerabilityId, claim.Product.Key), out var componentRef))
{
continue;
}
var analysis = new CycloneDxAnalysis(
State: MapStatus(claim.Status),
Justification: claim.Justification?.ToString().ToLowerInvariant(),
Responses: null);
var affects = ImmutableArray.Create(new CycloneDxAffectEntry(componentRef));
var properties = ImmutableArray.Create(
new CycloneDxProperty("stellaops/providerId", claim.ProviderId),
new CycloneDxProperty("stellaops/documentDigest", claim.Document.Digest));
var vulnerabilityId = claim.VulnerabilityId;
var bomRef = FormattableString.Invariant($"{vulnerabilityId}#{Normalize(componentRef)}");
entries.Add(new CycloneDxVulnerabilityEntry(
Id: vulnerabilityId,
BomRef: bomRef,
Description: claim.Detail,
Analysis: analysis,
Affects: affects,
Properties: properties));
}
return entries
.ToImmutable()
.OrderBy(static entry => entry.Id, StringComparer.Ordinal)
.ThenBy(static entry => entry.BomRef, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var normalized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(normalized) ? "component" : normalized;
}
private static string MapStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => "affected",
VexClaimStatus.NotAffected => "not_affected",
VexClaimStatus.Fixed => "resolved",
VexClaimStatus.UnderInvestigation => "under_investigation",
_ => "unknown",
};
private static ImmutableDictionary<string, string> BuildMetadata(
VexQuerySignature signature,
ImmutableDictionary<string, string> diagnostics,
string generatedAt,
int vulnerabilityCount,
int componentCount,
ImmutableArray<string> missingJustifications)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["cyclonedx.querySignature"] = signature.Value;
builder["cyclonedx.generatedAt"] = generatedAt;
builder["cyclonedx.vulnerabilityCount"] = vulnerabilityCount.ToString(CultureInfo.InvariantCulture);
builder["cyclonedx.componentCount"] = componentCount.ToString(CultureInfo.InvariantCulture);
foreach (var diagnostic in diagnostics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
builder[$"cyclonedx.{diagnostic.Key}"] = diagnostic.Value;
}
if (!missingJustifications.IsDefaultOrEmpty && missingJustifications.Length > 0)
{
builder["policy.justification_missing"] = string.Join(",", missingJustifications);
}
return builder.ToImmutable();
}
private static string BuildDeterministicGuid(string digest)
{
if (string.IsNullOrWhiteSpace(digest) || digest.Length < 32)
{
return Guid.NewGuid().ToString();
}
var hex = digest[..32];
var bytes = Enumerable.Range(0, hex.Length / 2)
.Select(i => byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture))
.ToArray();
return new Guid(bytes).ToString();
}
private static VexContentAddress ComputeDigest(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return new VexContentAddress("sha256", digest);
}
}
internal sealed record CycloneDxExportDocument(
[property: JsonPropertyName("bomFormat")] string BomFormat,
[property: JsonPropertyName("specVersion")] string SpecVersion,
[property: JsonPropertyName("serialNumber")] string SerialNumber,
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("metadata")] CycloneDxMetadata Metadata,
[property: JsonPropertyName("components")] ImmutableArray<CycloneDxComponentEntry> Components,
[property: JsonPropertyName("vulnerabilities")] ImmutableArray<CycloneDxVulnerabilityEntry> Vulnerabilities,
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
internal sealed record CycloneDxMetadata(
[property: JsonPropertyName("timestamp")] string Timestamp);
internal sealed record CycloneDxVulnerabilityEntry(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("bom-ref")] string BomRef,
[property: JsonPropertyName("description"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Description,
[property: JsonPropertyName("analysis")] CycloneDxAnalysis Analysis,
[property: JsonPropertyName("affects")] ImmutableArray<CycloneDxAffectEntry> Affects,
[property: JsonPropertyName("properties")] ImmutableArray<CycloneDxProperty> Properties);
internal sealed record CycloneDxAnalysis(
[property: JsonPropertyName("state")] string State,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("response"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? Responses);
internal sealed record CycloneDxAffectEntry(
[property: JsonPropertyName("ref")] string Reference);
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CycloneDX;
/// <summary>
/// Serialises normalized VEX claims into CycloneDX VEX documents with reconciled component references.
/// </summary>
public sealed class CycloneDxExporter : IVexExporter
{
public VexExportFormat Format => VexExportFormat.CycloneDx;
public VexContentAddress Digest(VexExportRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var document = BuildDocument(request, out _);
var json = VexCanonicalJsonSerializer.Serialize(document);
return ComputeDigest(json);
}
public async ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(output);
var document = BuildDocument(request, out var metadata);
var json = VexCanonicalJsonSerializer.Serialize(document);
var digest = ComputeDigest(json);
var buffer = Encoding.UTF8.GetBytes(json);
await output.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
return new VexExportResult(digest, buffer.LongLength, metadata);
}
private CycloneDxExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
{
var signature = VexQuerySignature.FromQuery(request.Query);
var signatureHash = signature.ComputeHash();
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
var reconciliation = CycloneDxComponentReconciler.Reconcile(request.Claims);
var vulnerabilityEntries = BuildVulnerabilities(request.Claims, reconciliation.ComponentRefs);
var missingJustifications = request.Claims
.Where(static claim => claim.Status == VexClaimStatus.NotAffected && claim.Justification is null)
.Select(static claim => FormattableString.Invariant($"{claim.VulnerabilityId}:{claim.Product.Key}"))
.Distinct(StringComparer.Ordinal)
.OrderBy(static key => key, StringComparer.Ordinal)
.ToImmutableArray();
var properties = ImmutableArray.Create(new CycloneDxProperty("stellaops/querySignature", signature.Value));
metadata = BuildMetadata(signature, reconciliation.Diagnostics, generatedAt, vulnerabilityEntries.Length, reconciliation.Components.Length, missingJustifications);
var document = new CycloneDxExportDocument(
BomFormat: "CycloneDX",
SpecVersion: "1.6",
SerialNumber: FormattableString.Invariant($"urn:uuid:{BuildDeterministicGuid(signatureHash.Digest)}"),
Version: 1,
Metadata: new CycloneDxMetadata(generatedAt),
Components: reconciliation.Components,
Vulnerabilities: vulnerabilityEntries,
Properties: properties);
return document;
}
private static ImmutableArray<CycloneDxVulnerabilityEntry> BuildVulnerabilities(
ImmutableArray<VexClaim> claims,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> componentRefs)
{
var entries = ImmutableArray.CreateBuilder<CycloneDxVulnerabilityEntry>();
foreach (var claim in claims)
{
if (!componentRefs.TryGetValue((claim.VulnerabilityId, claim.Product.Key), out var componentRef))
{
continue;
}
var analysis = new CycloneDxAnalysis(
State: MapStatus(claim.Status),
Justification: claim.Justification?.ToString().ToLowerInvariant(),
Responses: null);
var affects = ImmutableArray.Create(new CycloneDxAffectEntry(componentRef));
var properties = ImmutableArray.Create(
new CycloneDxProperty("stellaops/providerId", claim.ProviderId),
new CycloneDxProperty("stellaops/documentDigest", claim.Document.Digest));
var vulnerabilityId = claim.VulnerabilityId;
var bomRef = FormattableString.Invariant($"{vulnerabilityId}#{Normalize(componentRef)}");
entries.Add(new CycloneDxVulnerabilityEntry(
Id: vulnerabilityId,
BomRef: bomRef,
Description: claim.Detail,
Analysis: analysis,
Affects: affects,
Properties: properties));
}
return entries
.ToImmutable()
.OrderBy(static entry => entry.Id, StringComparer.Ordinal)
.ThenBy(static entry => entry.BomRef, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var normalized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(normalized) ? "component" : normalized;
}
private static string MapStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => "affected",
VexClaimStatus.NotAffected => "not_affected",
VexClaimStatus.Fixed => "resolved",
VexClaimStatus.UnderInvestigation => "under_investigation",
_ => "unknown",
};
private static ImmutableDictionary<string, string> BuildMetadata(
VexQuerySignature signature,
ImmutableDictionary<string, string> diagnostics,
string generatedAt,
int vulnerabilityCount,
int componentCount,
ImmutableArray<string> missingJustifications)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["cyclonedx.querySignature"] = signature.Value;
builder["cyclonedx.generatedAt"] = generatedAt;
builder["cyclonedx.vulnerabilityCount"] = vulnerabilityCount.ToString(CultureInfo.InvariantCulture);
builder["cyclonedx.componentCount"] = componentCount.ToString(CultureInfo.InvariantCulture);
foreach (var diagnostic in diagnostics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
builder[$"cyclonedx.{diagnostic.Key}"] = diagnostic.Value;
}
if (!missingJustifications.IsDefaultOrEmpty && missingJustifications.Length > 0)
{
builder["policy.justification_missing"] = string.Join(",", missingJustifications);
}
return builder.ToImmutable();
}
private static string BuildDeterministicGuid(string digest)
{
if (string.IsNullOrWhiteSpace(digest) || digest.Length < 32)
{
return Guid.NewGuid().ToString();
}
var hex = digest[..32];
var bytes = Enumerable.Range(0, hex.Length / 2)
.Select(i => byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture))
.ToArray();
return new Guid(bytes).ToString();
}
private static VexContentAddress ComputeDigest(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return new VexContentAddress("sha256", digest);
}
}
internal sealed record CycloneDxExportDocument(
[property: JsonPropertyName("bomFormat")] string BomFormat,
[property: JsonPropertyName("specVersion")] string SpecVersion,
[property: JsonPropertyName("serialNumber")] string SerialNumber,
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("metadata")] CycloneDxMetadata Metadata,
[property: JsonPropertyName("components")] ImmutableArray<CycloneDxComponentEntry> Components,
[property: JsonPropertyName("vulnerabilities")] ImmutableArray<CycloneDxVulnerabilityEntry> Vulnerabilities,
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
internal sealed record CycloneDxMetadata(
[property: JsonPropertyName("timestamp")] string Timestamp);
internal sealed record CycloneDxVulnerabilityEntry(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("bom-ref")] string BomRef,
[property: JsonPropertyName("description"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Description,
[property: JsonPropertyName("analysis")] CycloneDxAnalysis Analysis,
[property: JsonPropertyName("affects")] ImmutableArray<CycloneDxAffectEntry> Affects,
[property: JsonPropertyName("properties")] ImmutableArray<CycloneDxProperty> Properties);
internal sealed record CycloneDxAnalysis(
[property: JsonPropertyName("state")] string State,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("response"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? Responses);
internal sealed record CycloneDxAffectEntry(
[property: JsonPropertyName("ref")] string Reference);

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