Restore vendor connector internals and configure offline packages
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		| @@ -64,12 +64,13 @@ public class StandardClientProvisioningStoreTests | ||||
|         var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Succeeded); | ||||
|         var document = Assert.Contains("signer", store.Documents); | ||||
|         Assert.Equal("attestor signer", document.Value.Properties[AuthorityClientMetadataKeys.Audiences]); | ||||
|         Assert.True(store.Documents.TryGetValue("signer", out var document)); | ||||
|         Assert.NotNull(document); | ||||
|         Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]); | ||||
|  | ||||
|         var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None); | ||||
|         Assert.NotNull(descriptor); | ||||
|         Assert.Equal(new[] { "attestor", "signer" }, descriptor!.Audiences.OrderBy(value => value, StringComparer.Ordinal)); | ||||
|         Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
| @@ -101,8 +102,9 @@ public class StandardClientProvisioningStoreTests | ||||
|  | ||||
|         await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); | ||||
|  | ||||
|         var document = Assert.Contains("mtls-client", store.Documents).Value; | ||||
|         var binding = Assert.Single(document.CertificateBindings); | ||||
|         Assert.True(store.Documents.TryGetValue("mtls-client", out var document)); | ||||
|         Assert.NotNull(document); | ||||
|         var binding = Assert.Single(document!.CertificateBindings); | ||||
|         Assert.Equal("AABBCCDD", binding.Thumbprint); | ||||
|         Assert.Equal("01ff", binding.SerialNumber); | ||||
|         Assert.Equal("CN=mtls-client", binding.Subject); | ||||
|   | ||||
| @@ -54,11 +54,11 @@ internal sealed record BootstrapClientRequest | ||||
|  | ||||
|     public IReadOnlyCollection<BootstrapClientCertificateBinding>? CertificateBindings { get; init; } | ||||
| } | ||||
|  | ||||
| internal sealed record BootstrapInviteRequest | ||||
| { | ||||
|     public string Type { get; init; } = BootstrapInviteTypes.User; | ||||
|  | ||||
|  | ||||
| internal sealed record BootstrapInviteRequest | ||||
| { | ||||
|     public string Type { get; init; } = BootstrapInviteTypes.User; | ||||
|  | ||||
|     public string? Token { get; init; } | ||||
|  | ||||
|     public string? Provider { get; init; } | ||||
| @@ -91,31 +91,8 @@ internal sealed record BootstrapClientCertificateBinding | ||||
|     public string? Label { get; init; } | ||||
| } | ||||
|  | ||||
| internal static class BootstrapInviteTypes | ||||
| { | ||||
|     public const string User = "user"; | ||||
|     public const string Client = "client"; | ||||
| } | ||||
|  | ||||
| internal sealed record BootstrapInviteRequest | ||||
| { | ||||
|     public string Type { get; init; } = BootstrapInviteTypes.User; | ||||
|  | ||||
|     public string? Token { get; init; } | ||||
|  | ||||
|     public string? Provider { get; init; } | ||||
|  | ||||
|     public string? Target { get; init; } | ||||
|  | ||||
|     public DateTimeOffset? ExpiresAt { get; init; } | ||||
|  | ||||
|     public string? IssuedBy { get; init; } | ||||
|  | ||||
|     public IReadOnlyDictionary<string, string?>? Metadata { get; init; } | ||||
| } | ||||
|  | ||||
| internal static class BootstrapInviteTypes | ||||
| { | ||||
|     public const string User = "user"; | ||||
|     public const string Client = "client"; | ||||
| } | ||||
| internal static class BootstrapInviteTypes | ||||
| { | ||||
|     public const string User = "user"; | ||||
|     public const string Client = "client"; | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,104 @@ | ||||
| using System.Net; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Configuration; | ||||
|  | ||||
| public sealed class CertBundOptions | ||||
| { | ||||
|     public const string HttpClientName = "concelier.source.certbund"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// RSS feed providing the latest CERT-Bund advisories. | ||||
|     /// </summary> | ||||
|     public Uri FeedUri { get; set; } = new("https://wid.cert-bund.de/content/public/securityAdvisory/rss"); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Portal endpoint used to bootstrap session cookies (required for the SPA JSON API). | ||||
|     /// </summary> | ||||
|     public Uri PortalBootstrapUri { get; set; } = new("https://wid.cert-bund.de/portal/"); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Detail API endpoint template; advisory identifier is appended as the <c>name</c> query parameter. | ||||
|     /// </summary> | ||||
|     public Uri DetailApiUri { get; set; } = new("https://wid.cert-bund.de/portal/api/securityadvisory"); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional timeout override for feed/detail requests. | ||||
|     /// </summary> | ||||
|     public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Delay applied between successive detail fetches to respect upstream politeness. | ||||
|     /// </summary> | ||||
|     public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Backoff recorded in source state when a fetch attempt fails. | ||||
|     /// </summary> | ||||
|     public TimeSpan FailureBackoff { get; set; } = TimeSpan.FromMinutes(5); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Maximum number of advisories to enqueue per fetch iteration. | ||||
|     /// </summary> | ||||
|     public int MaxAdvisoriesPerFetch { get; set; } = 50; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Maximum number of advisory identifiers remembered to prevent re-processing. | ||||
|     /// </summary> | ||||
|     public int MaxKnownAdvisories { get; set; } = 512; | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         if (FeedUri is null || !FeedUri.IsAbsoluteUri) | ||||
|         { | ||||
|             throw new InvalidOperationException("CERT-Bund feed URI must be an absolute URI."); | ||||
|         } | ||||
|  | ||||
|         if (PortalBootstrapUri is null || !PortalBootstrapUri.IsAbsoluteUri) | ||||
|         { | ||||
|             throw new InvalidOperationException("CERT-Bund portal bootstrap URI must be an absolute URI."); | ||||
|         } | ||||
|  | ||||
|         if (DetailApiUri is null || !DetailApiUri.IsAbsoluteUri) | ||||
|         { | ||||
|             throw new InvalidOperationException("CERT-Bund detail API URI must be an absolute URI."); | ||||
|         } | ||||
|  | ||||
|         if (RequestTimeout <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(RequestTimeout)} must be positive."); | ||||
|         } | ||||
|  | ||||
|         if (RequestDelay < TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(RequestDelay)} cannot be negative."); | ||||
|         } | ||||
|  | ||||
|         if (FailureBackoff <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(FailureBackoff)} must be positive."); | ||||
|         } | ||||
|  | ||||
|         if (MaxAdvisoriesPerFetch <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(MaxAdvisoriesPerFetch)} must be greater than zero."); | ||||
|         } | ||||
|  | ||||
|         if (MaxKnownAdvisories <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(MaxKnownAdvisories)} must be greater than zero."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public Uri BuildDetailUri(string advisoryId) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(advisoryId)) | ||||
|         { | ||||
|             throw new ArgumentException("Advisory identifier must be provided.", nameof(advisoryId)); | ||||
|         } | ||||
|  | ||||
|         var builder = new UriBuilder(DetailApiUri); | ||||
|         var queryPrefix = string.IsNullOrEmpty(builder.Query) ? string.Empty : builder.Query.TrimStart('?') + "&"; | ||||
|         builder.Query = $"{queryPrefix}name={Uri.EscapeDataString(advisoryId)}"; | ||||
|         return builder.Uri; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| public sealed record CertBundAdvisoryDto | ||||
| { | ||||
|     [JsonPropertyName("advisoryId")] | ||||
|     public string AdvisoryId { get; init; } = string.Empty; | ||||
|  | ||||
|     [JsonPropertyName("title")] | ||||
|     public string Title { get; init; } = string.Empty; | ||||
|  | ||||
|     [JsonPropertyName("summary")] | ||||
|     public string? Summary { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("contentHtml")] | ||||
|     public string ContentHtml { get; init; } = string.Empty; | ||||
|  | ||||
|     [JsonPropertyName("severity")] | ||||
|     public string? Severity { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("language")] | ||||
|     public string Language { get; init; } = "de"; | ||||
|  | ||||
|     [JsonPropertyName("published")] | ||||
|     public DateTimeOffset? Published { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("modified")] | ||||
|     public DateTimeOffset? Modified { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("portalUri")] | ||||
|     public Uri PortalUri { get; init; } = new("https://wid.cert-bund.de/"); | ||||
|  | ||||
|     [JsonPropertyName("detailUri")] | ||||
|     public Uri DetailUri { get; init; } = new("https://wid.cert-bund.de/"); | ||||
|  | ||||
|     [JsonPropertyName("cveIds")] | ||||
|     public IReadOnlyList<string> CveIds { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     [JsonPropertyName("products")] | ||||
|     public IReadOnlyList<CertBundProductDto> Products { get; init; } = Array.Empty<CertBundProductDto>(); | ||||
|  | ||||
|     [JsonPropertyName("references")] | ||||
|     public IReadOnlyList<CertBundReferenceDto> References { get; init; } = Array.Empty<CertBundReferenceDto>(); | ||||
| } | ||||
|  | ||||
| public sealed record CertBundProductDto | ||||
| { | ||||
|     [JsonPropertyName("vendor")] | ||||
|     public string? Vendor { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("name")] | ||||
|     public string? Name { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("versions")] | ||||
|     public string? Versions { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record CertBundReferenceDto | ||||
| { | ||||
|     [JsonPropertyName("url")] | ||||
|     public string Url { get; init; } = string.Empty; | ||||
|  | ||||
|     [JsonPropertyName("label")] | ||||
|     public string? Label { get; init; } | ||||
| } | ||||
| @@ -0,0 +1,118 @@ | ||||
| using System; | ||||
| using System.Linq; | ||||
| using MongoDB.Bson; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| internal sealed record CertBundCursor( | ||||
|     IReadOnlyCollection<Guid> PendingDocuments, | ||||
|     IReadOnlyCollection<Guid> PendingMappings, | ||||
|     IReadOnlyCollection<string> KnownAdvisories, | ||||
|     DateTimeOffset? LastPublished, | ||||
|     DateTimeOffset? LastFetchAt) | ||||
| { | ||||
|     private static readonly IReadOnlyCollection<Guid> EmptyGuids = Array.Empty<Guid>(); | ||||
|     private static readonly IReadOnlyCollection<string> EmptyStrings = Array.Empty<string>(); | ||||
|  | ||||
|     public static CertBundCursor Empty { get; } = new(EmptyGuids, EmptyGuids, EmptyStrings, null, null); | ||||
|  | ||||
|     public CertBundCursor WithPendingDocuments(IEnumerable<Guid> documents) | ||||
|         => this with { PendingDocuments = Distinct(documents) }; | ||||
|  | ||||
|     public CertBundCursor WithPendingMappings(IEnumerable<Guid> mappings) | ||||
|         => this with { PendingMappings = Distinct(mappings) }; | ||||
|  | ||||
|     public CertBundCursor WithKnownAdvisories(IEnumerable<string> advisories) | ||||
|         => this with { KnownAdvisories = advisories?.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() ?? EmptyStrings }; | ||||
|  | ||||
|     public CertBundCursor WithLastPublished(DateTimeOffset? published) | ||||
|         => this with { LastPublished = published }; | ||||
|  | ||||
|     public CertBundCursor WithLastFetch(DateTimeOffset? timestamp) | ||||
|         => this with { LastFetchAt = timestamp }; | ||||
|  | ||||
|     public BsonDocument ToBsonDocument() | ||||
|     { | ||||
|         var document = new BsonDocument | ||||
|         { | ||||
|             ["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())), | ||||
|             ["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())), | ||||
|             ["knownAdvisories"] = new BsonArray(KnownAdvisories), | ||||
|         }; | ||||
|  | ||||
|         if (LastPublished.HasValue) | ||||
|         { | ||||
|             document["lastPublished"] = LastPublished.Value.UtcDateTime; | ||||
|         } | ||||
|  | ||||
|         if (LastFetchAt.HasValue) | ||||
|         { | ||||
|             document["lastFetchAt"] = LastFetchAt.Value.UtcDateTime; | ||||
|         } | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     public static CertBundCursor FromBson(BsonDocument? document) | ||||
|     { | ||||
|         if (document is null || document.ElementCount == 0) | ||||
|         { | ||||
|             return Empty; | ||||
|         } | ||||
|  | ||||
|         var pendingDocuments = ReadGuidArray(document, "pendingDocuments"); | ||||
|         var pendingMappings = ReadGuidArray(document, "pendingMappings"); | ||||
|         var knownAdvisories = ReadStringArray(document, "knownAdvisories"); | ||||
|         var lastPublished = document.TryGetValue("lastPublished", out var publishedValue) | ||||
|             ? ParseDate(publishedValue) | ||||
|             : null; | ||||
|         var lastFetch = document.TryGetValue("lastFetchAt", out var fetchValue) | ||||
|             ? ParseDate(fetchValue) | ||||
|             : null; | ||||
|  | ||||
|         return new CertBundCursor(pendingDocuments, pendingMappings, knownAdvisories, lastPublished, lastFetch); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyCollection<Guid> Distinct(IEnumerable<Guid>? values) | ||||
|         => values?.Distinct().ToArray() ?? EmptyGuids; | ||||
|  | ||||
|     private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field) | ||||
|     { | ||||
|         if (!document.TryGetValue(field, out var value) || value is not BsonArray array) | ||||
|         { | ||||
|             return EmptyGuids; | ||||
|         } | ||||
|  | ||||
|         var items = new List<Guid>(array.Count); | ||||
|         foreach (var element in array) | ||||
|         { | ||||
|             if (Guid.TryParse(element?.ToString(), out var id)) | ||||
|             { | ||||
|                 items.Add(id); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return items; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyCollection<string> ReadStringArray(BsonDocument document, string field) | ||||
|     { | ||||
|         if (!document.TryGetValue(field, out var value) || value is not BsonArray array) | ||||
|         { | ||||
|             return EmptyStrings; | ||||
|         } | ||||
|  | ||||
|         return array.Select(element => element?.ToString() ?? string.Empty) | ||||
|             .Where(static s => !string.IsNullOrWhiteSpace(s)) | ||||
|             .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static DateTimeOffset? ParseDate(BsonValue value) | ||||
|         => value.BsonType switch | ||||
|         { | ||||
|             BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc), | ||||
|             BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(), | ||||
|             _ => null, | ||||
|         }; | ||||
| } | ||||
| @@ -0,0 +1,87 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using StellaOps.Concelier.Connector.Common.Html; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| public sealed class CertBundDetailParser | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         PropertyNameCaseInsensitive = true, | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||
|     }; | ||||
|  | ||||
|     private readonly HtmlContentSanitizer _sanitizer; | ||||
|  | ||||
|     public CertBundDetailParser(HtmlContentSanitizer sanitizer) | ||||
|         => _sanitizer = sanitizer ?? throw new ArgumentNullException(nameof(sanitizer)); | ||||
|  | ||||
|     public CertBundAdvisoryDto Parse(Uri detailUri, Uri portalUri, byte[] payload) | ||||
|     { | ||||
|         var detail = JsonSerializer.Deserialize<CertBundDetailResponse>(payload, SerializerOptions) | ||||
|             ?? throw new InvalidOperationException("CERT-Bund detail payload deserialized to null."); | ||||
|  | ||||
|         var advisoryId = detail.Name ?? throw new InvalidOperationException("CERT-Bund detail missing advisory name."); | ||||
|         var contentHtml = _sanitizer.Sanitize(detail.Description ?? string.Empty, portalUri); | ||||
|  | ||||
|         return new CertBundAdvisoryDto | ||||
|         { | ||||
|             AdvisoryId = advisoryId, | ||||
|             Title = detail.Title ?? advisoryId, | ||||
|             Summary = detail.Summary, | ||||
|             ContentHtml = contentHtml, | ||||
|             Severity = detail.Severity, | ||||
|             Language = string.IsNullOrWhiteSpace(detail.Language) ? "de" : detail.Language!, | ||||
|             Published = detail.Published, | ||||
|             Modified = detail.Updated ?? detail.Published, | ||||
|             PortalUri = portalUri, | ||||
|             DetailUri = detailUri, | ||||
|             CveIds = detail.CveIds?.Where(static id => !string.IsNullOrWhiteSpace(id)) | ||||
|                 .Select(static id => id!.Trim()) | ||||
|                 .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                 .ToArray() ?? Array.Empty<string>(), | ||||
|             References = MapReferences(detail.References), | ||||
|             Products = MapProducts(detail.Products), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<CertBundReferenceDto> MapReferences(CertBundDetailReference[]? references) | ||||
|     { | ||||
|         if (references is null || references.Length == 0) | ||||
|         { | ||||
|             return Array.Empty<CertBundReferenceDto>(); | ||||
|         } | ||||
|  | ||||
|         return references | ||||
|             .Where(static reference => !string.IsNullOrWhiteSpace(reference.Url)) | ||||
|             .Select(reference => new CertBundReferenceDto | ||||
|             { | ||||
|                 Url = reference.Url!, | ||||
|                 Label = reference.Label, | ||||
|             }) | ||||
|             .DistinctBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<CertBundProductDto> MapProducts(CertBundDetailProduct[]? products) | ||||
|     { | ||||
|         if (products is null || products.Length == 0) | ||||
|         { | ||||
|             return Array.Empty<CertBundProductDto>(); | ||||
|         } | ||||
|  | ||||
|         return products | ||||
|             .Where(static product => !string.IsNullOrWhiteSpace(product.Vendor) || !string.IsNullOrWhiteSpace(product.Name)) | ||||
|             .Select(product => new CertBundProductDto | ||||
|             { | ||||
|                 Vendor = product.Vendor, | ||||
|                 Name = product.Name, | ||||
|                 Versions = product.Versions, | ||||
|             }) | ||||
|             .ToArray(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,60 @@ | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| internal sealed record CertBundDetailResponse | ||||
| { | ||||
|     [JsonPropertyName("name")] | ||||
|     public string? Name { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("title")] | ||||
|     public string? Title { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("summary")] | ||||
|     public string? Summary { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("description")] | ||||
|     public string? Description { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("severity")] | ||||
|     public string? Severity { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("language")] | ||||
|     public string? Language { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("published")] | ||||
|     public DateTimeOffset? Published { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("updated")] | ||||
|     public DateTimeOffset? Updated { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("cveIds")] | ||||
|     public string[]? CveIds { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("references")] | ||||
|     public CertBundDetailReference[]? References { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("products")] | ||||
|     public CertBundDetailProduct[]? Products { get; init; } | ||||
| } | ||||
|  | ||||
| internal sealed record CertBundDetailReference | ||||
| { | ||||
|     [JsonPropertyName("url")] | ||||
|     public string? Url { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("label")] | ||||
|     public string? Label { get; init; } | ||||
| } | ||||
|  | ||||
| internal sealed record CertBundDetailProduct | ||||
| { | ||||
|     [JsonPropertyName("vendor")] | ||||
|     public string? Vendor { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("name")] | ||||
|     public string? Name { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("versions")] | ||||
|     public string? Versions { get; init; } | ||||
| } | ||||
| @@ -0,0 +1,191 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics.Metrics; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| /// <summary> | ||||
| /// Emits OpenTelemetry counters and histograms for the CERT-Bund connector. | ||||
| /// </summary> | ||||
| public sealed class CertBundDiagnostics : IDisposable | ||||
| { | ||||
|     private const string MeterName = "StellaOps.Concelier.Connector.CertBund"; | ||||
|     private const string MeterVersion = "1.0.0"; | ||||
|  | ||||
|     private readonly Meter _meter; | ||||
|     private readonly Counter<long> _feedFetchAttempts; | ||||
|     private readonly Counter<long> _feedFetchSuccess; | ||||
|     private readonly Counter<long> _feedFetchFailures; | ||||
|     private readonly Histogram<long> _feedItemCount; | ||||
|     private readonly Histogram<long> _feedEnqueuedCount; | ||||
|     private readonly Histogram<double> _feedCoverageDays; | ||||
|     private readonly Counter<long> _detailFetchAttempts; | ||||
|     private readonly Counter<long> _detailFetchSuccess; | ||||
|     private readonly Counter<long> _detailFetchNotModified; | ||||
|     private readonly Counter<long> _detailFetchFailures; | ||||
|     private readonly Counter<long> _parseSuccess; | ||||
|     private readonly Counter<long> _parseFailures; | ||||
|     private readonly Histogram<long> _parseProductCount; | ||||
|     private readonly Histogram<long> _parseCveCount; | ||||
|     private readonly Counter<long> _mapSuccess; | ||||
|     private readonly Counter<long> _mapFailures; | ||||
|     private readonly Histogram<long> _mapPackageCount; | ||||
|     private readonly Histogram<long> _mapAliasCount; | ||||
|  | ||||
|     public CertBundDiagnostics() | ||||
|     { | ||||
|         _meter = new Meter(MeterName, MeterVersion); | ||||
|         _feedFetchAttempts = _meter.CreateCounter<long>( | ||||
|             name: "certbund.feed.fetch.attempts", | ||||
|             unit: "operations", | ||||
|             description: "Number of RSS feed load attempts."); | ||||
|         _feedFetchSuccess = _meter.CreateCounter<long>( | ||||
|             name: "certbund.feed.fetch.success", | ||||
|             unit: "operations", | ||||
|             description: "Number of successful RSS feed loads."); | ||||
|         _feedFetchFailures = _meter.CreateCounter<long>( | ||||
|             name: "certbund.feed.fetch.failures", | ||||
|             unit: "operations", | ||||
|             description: "Number of RSS feed load failures."); | ||||
|         _feedItemCount = _meter.CreateHistogram<long>( | ||||
|             name: "certbund.feed.items.count", | ||||
|             unit: "items", | ||||
|             description: "Distribution of RSS item counts per fetch."); | ||||
|         _feedEnqueuedCount = _meter.CreateHistogram<long>( | ||||
|             name: "certbund.feed.enqueued.count", | ||||
|             unit: "documents", | ||||
|             description: "Distribution of advisory documents enqueued per fetch."); | ||||
|         _feedCoverageDays = _meter.CreateHistogram<double>( | ||||
|             name: "certbund.feed.coverage.days", | ||||
|             unit: "days", | ||||
|             description: "Coverage window in days between fetch time and the oldest published advisory in the feed."); | ||||
|         _detailFetchAttempts = _meter.CreateCounter<long>( | ||||
|             name: "certbund.detail.fetch.attempts", | ||||
|             unit: "operations", | ||||
|             description: "Number of detail fetch attempts."); | ||||
|         _detailFetchSuccess = _meter.CreateCounter<long>( | ||||
|             name: "certbund.detail.fetch.success", | ||||
|             unit: "operations", | ||||
|             description: "Number of detail fetches that persisted a document."); | ||||
|         _detailFetchNotModified = _meter.CreateCounter<long>( | ||||
|             name: "certbund.detail.fetch.not_modified", | ||||
|             unit: "operations", | ||||
|             description: "Number of detail fetches returning HTTP 304."); | ||||
|         _detailFetchFailures = _meter.CreateCounter<long>( | ||||
|             name: "certbund.detail.fetch.failures", | ||||
|             unit: "operations", | ||||
|             description: "Number of detail fetches that failed."); | ||||
|         _parseSuccess = _meter.CreateCounter<long>( | ||||
|             name: "certbund.parse.success", | ||||
|             unit: "documents", | ||||
|             description: "Number of documents parsed into CERT-Bund DTOs."); | ||||
|         _parseFailures = _meter.CreateCounter<long>( | ||||
|             name: "certbund.parse.failures", | ||||
|             unit: "documents", | ||||
|             description: "Number of documents that failed to parse."); | ||||
|         _parseProductCount = _meter.CreateHistogram<long>( | ||||
|             name: "certbund.parse.products.count", | ||||
|             unit: "products", | ||||
|             description: "Distribution of product entries captured per advisory."); | ||||
|         _parseCveCount = _meter.CreateHistogram<long>( | ||||
|             name: "certbund.parse.cve.count", | ||||
|             unit: "aliases", | ||||
|             description: "Distribution of CVE identifiers captured per advisory."); | ||||
|         _mapSuccess = _meter.CreateCounter<long>( | ||||
|             name: "certbund.map.success", | ||||
|             unit: "advisories", | ||||
|             description: "Number of canonical advisories emitted by the mapper."); | ||||
|         _mapFailures = _meter.CreateCounter<long>( | ||||
|             name: "certbund.map.failures", | ||||
|             unit: "advisories", | ||||
|             description: "Number of mapping failures."); | ||||
|         _mapPackageCount = _meter.CreateHistogram<long>( | ||||
|             name: "certbund.map.affected.count", | ||||
|             unit: "packages", | ||||
|             description: "Distribution of affected packages emitted per advisory."); | ||||
|         _mapAliasCount = _meter.CreateHistogram<long>( | ||||
|             name: "certbund.map.aliases.count", | ||||
|             unit: "aliases", | ||||
|             description: "Distribution of alias counts per advisory."); | ||||
|     } | ||||
|  | ||||
|     public void FeedFetchAttempt() => _feedFetchAttempts.Add(1); | ||||
|  | ||||
|     public void FeedFetchSuccess(int itemCount) | ||||
|     { | ||||
|         _feedFetchSuccess.Add(1); | ||||
|         if (itemCount >= 0) | ||||
|         { | ||||
|             _feedItemCount.Record(itemCount); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void FeedFetchFailure(string reason = "error") | ||||
|         => _feedFetchFailures.Add(1, ReasonTag(reason)); | ||||
|  | ||||
|     public void RecordFeedCoverage(double? coverageDays) | ||||
|     { | ||||
|         if (coverageDays is { } days && days >= 0) | ||||
|         { | ||||
|             _feedCoverageDays.Record(days); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void DetailFetchAttempt() => _detailFetchAttempts.Add(1); | ||||
|  | ||||
|     public void DetailFetchSuccess() => _detailFetchSuccess.Add(1); | ||||
|  | ||||
|     public void DetailFetchNotModified() => _detailFetchNotModified.Add(1); | ||||
|  | ||||
|     public void DetailFetchFailure(string reason = "error") | ||||
|         => _detailFetchFailures.Add(1, ReasonTag(reason)); | ||||
|  | ||||
|     public void DetailFetchEnqueued(int count) | ||||
|     { | ||||
|         if (count >= 0) | ||||
|         { | ||||
|             _feedEnqueuedCount.Record(count); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void ParseSuccess(int productCount, int cveCount) | ||||
|     { | ||||
|         _parseSuccess.Add(1); | ||||
|  | ||||
|         if (productCount >= 0) | ||||
|         { | ||||
|             _parseProductCount.Record(productCount); | ||||
|         } | ||||
|  | ||||
|         if (cveCount >= 0) | ||||
|         { | ||||
|             _parseCveCount.Record(cveCount); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void ParseFailure(string reason = "error") | ||||
|         => _parseFailures.Add(1, ReasonTag(reason)); | ||||
|  | ||||
|     public void MapSuccess(int affectedPackages, int aliasCount) | ||||
|     { | ||||
|         _mapSuccess.Add(1); | ||||
|  | ||||
|         if (affectedPackages >= 0) | ||||
|         { | ||||
|             _mapPackageCount.Record(affectedPackages); | ||||
|         } | ||||
|  | ||||
|         if (aliasCount >= 0) | ||||
|         { | ||||
|             _mapAliasCount.Record(aliasCount); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void MapFailure(string reason = "error") | ||||
|         => _mapFailures.Add(1, ReasonTag(reason)); | ||||
|  | ||||
|     private static KeyValuePair<string, object?> ReasonTag(string reason) | ||||
|         => new("reason", string.IsNullOrWhiteSpace(reason) ? "unknown" : reason.ToLowerInvariant()); | ||||
|  | ||||
|     public void Dispose() => _meter.Dispose(); | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| internal static class CertBundDocumentMetadata | ||||
| { | ||||
|     public static Dictionary<string, string> CreateMetadata(CertBundFeedItem item) | ||||
|     { | ||||
|         var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) | ||||
|         { | ||||
|             ["certbund.advisoryId"] = item.AdvisoryId, | ||||
|             ["certbund.portalUri"] = item.PortalUri.ToString(), | ||||
|             ["certbund.published"] = item.Published.ToString("O"), | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(item.Category)) | ||||
|         { | ||||
|             metadata["certbund.category"] = item.Category!; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(item.Title)) | ||||
|         { | ||||
|             metadata["certbund.title"] = item.Title!; | ||||
|         } | ||||
|  | ||||
|         return metadata; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,143 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Net.Http; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Xml.Linq; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Concelier.Connector.CertBund.Configuration; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| public sealed class CertBundFeedClient | ||||
| { | ||||
|     private readonly IHttpClientFactory _httpClientFactory; | ||||
|     private readonly CertBundOptions _options; | ||||
|     private readonly ILogger<CertBundFeedClient> _logger; | ||||
|     private readonly SemaphoreSlim _bootstrapSemaphore = new(1, 1); | ||||
|     private volatile bool _bootstrapped; | ||||
|  | ||||
|     public CertBundFeedClient( | ||||
|         IHttpClientFactory httpClientFactory, | ||||
|         IOptions<CertBundOptions> options, | ||||
|         ILogger<CertBundFeedClient> logger) | ||||
|     { | ||||
|         _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); | ||||
|         _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _options.Validate(); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<IReadOnlyList<CertBundFeedItem>> LoadAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient(CertBundOptions.HttpClientName); | ||||
|         await EnsureSessionAsync(client, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         using var request = new HttpRequestMessage(HttpMethod.Get, _options.FeedUri); | ||||
|         request.Headers.TryAddWithoutValidation("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8"); | ||||
|         using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var document = XDocument.Load(stream); | ||||
|  | ||||
|         var items = new List<CertBundFeedItem>(); | ||||
|         foreach (var element in document.Descendants("item")) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var linkValue = element.Element("link")?.Value?.Trim(); | ||||
|             if (string.IsNullOrWhiteSpace(linkValue) || !Uri.TryCreate(linkValue, UriKind.Absolute, out var portalUri)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var advisoryId = TryExtractNameParameter(portalUri); | ||||
|             if (string.IsNullOrWhiteSpace(advisoryId)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var detailUri = _options.BuildDetailUri(advisoryId); | ||||
|             var pubDateText = element.Element("pubDate")?.Value; | ||||
|             var published = ParseDate(pubDateText); | ||||
|             var title = element.Element("title")?.Value?.Trim(); | ||||
|             var category = element.Element("category")?.Value?.Trim(); | ||||
|  | ||||
|             items.Add(new CertBundFeedItem(advisoryId, detailUri, portalUri, published, title, category)); | ||||
|         } | ||||
|  | ||||
|         return items; | ||||
|     } | ||||
|  | ||||
|     private async Task EnsureSessionAsync(HttpClient client, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_bootstrapped) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await _bootstrapSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             if (_bootstrapped) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             using var request = new HttpRequestMessage(HttpMethod.Get, _options.PortalBootstrapUri); | ||||
|             request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); | ||||
|             using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); | ||||
|             response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|             _bootstrapped = true; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _bootstrapSemaphore.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string? TryExtractNameParameter(Uri portalUri) | ||||
|     { | ||||
|         if (portalUri is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var query = portalUri.Query; | ||||
|         if (string.IsNullOrEmpty(query)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var trimmed = query.TrimStart('?'); | ||||
|         foreach (var pair in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries)) | ||||
|         { | ||||
|             var separatorIndex = pair.IndexOf('='); | ||||
|             if (separatorIndex <= 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var key = pair[..separatorIndex].Trim(); | ||||
|             if (!key.Equals("name", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var value = pair[(separatorIndex + 1)..]; | ||||
|             return Uri.UnescapeDataString(value); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static DateTimeOffset ParseDate(string? value) | ||||
|         => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) | ||||
|             ? parsed | ||||
|             : DateTimeOffset.UtcNow; | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| using System; | ||||
|  | ||||
| public sealed record CertBundFeedItem( | ||||
|     string AdvisoryId, | ||||
|     Uri DetailUri, | ||||
|     Uri PortalUri, | ||||
|     DateTimeOffset Published, | ||||
|     string? Title, | ||||
|     string? Category); | ||||
| @@ -0,0 +1,168 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using StellaOps.Concelier.Models; | ||||
| using StellaOps.Concelier.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertBund.Internal; | ||||
|  | ||||
| internal static class CertBundMapper | ||||
| { | ||||
|     public static Advisory Map(CertBundAdvisoryDto dto, DocumentRecord document, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(dto); | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|  | ||||
|         var aliases = BuildAliases(dto); | ||||
|         var references = BuildReferences(dto, recordedAt); | ||||
|         var packages = BuildPackages(dto, recordedAt); | ||||
|         var provenance = new AdvisoryProvenance( | ||||
|             CertBundConnectorPlugin.SourceName, | ||||
|             "advisory", | ||||
|             dto.AdvisoryId, | ||||
|             recordedAt, | ||||
|             new[] { ProvenanceFieldMasks.Advisory }); | ||||
|  | ||||
|         return new Advisory( | ||||
|             advisoryKey: dto.AdvisoryId, | ||||
|             title: dto.Title, | ||||
|             summary: dto.Summary, | ||||
|             language: dto.Language?.ToLowerInvariant() ?? "de", | ||||
|             published: dto.Published, | ||||
|             modified: dto.Modified, | ||||
|             severity: MapSeverity(dto.Severity), | ||||
|             exploitKnown: false, | ||||
|             aliases: aliases, | ||||
|             references: references, | ||||
|             affectedPackages: packages, | ||||
|             cvssMetrics: Array.Empty<CvssMetric>(), | ||||
|             provenance: new[] { provenance }); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> BuildAliases(CertBundAdvisoryDto dto) | ||||
|     { | ||||
|         var aliases = new List<string>(capacity: 4) { dto.AdvisoryId }; | ||||
|         foreach (var cve in dto.CveIds) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(cve)) | ||||
|             { | ||||
|                 aliases.Add(cve); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return aliases | ||||
|             .Where(static alias => !string.IsNullOrWhiteSpace(alias)) | ||||
|             .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|             .OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AdvisoryReference> BuildReferences(CertBundAdvisoryDto dto, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         var references = new List<AdvisoryReference> | ||||
|         { | ||||
|             new(dto.DetailUri.ToString(), "details", "cert-bund", null, new AdvisoryProvenance( | ||||
|                 CertBundConnectorPlugin.SourceName, | ||||
|                 "reference", | ||||
|                 dto.DetailUri.ToString(), | ||||
|                 recordedAt, | ||||
|                 new[] { ProvenanceFieldMasks.References })) | ||||
|         }; | ||||
|  | ||||
|         foreach (var reference in dto.References) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(reference.Url)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             references.Add(new AdvisoryReference( | ||||
|                 reference.Url, | ||||
|                 kind: "reference", | ||||
|                 sourceTag: "cert-bund", | ||||
|                 summary: reference.Label, | ||||
|                 provenance: new AdvisoryProvenance( | ||||
|                     CertBundConnectorPlugin.SourceName, | ||||
|                     "reference", | ||||
|                     reference.Url, | ||||
|                     recordedAt, | ||||
|                     new[] { ProvenanceFieldMasks.References }))); | ||||
|         } | ||||
|  | ||||
|         return references | ||||
|             .DistinctBy(static reference => reference.Url, StringComparer.Ordinal) | ||||
|             .OrderBy(static reference => reference.Url, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AffectedPackage> BuildPackages(CertBundAdvisoryDto dto, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (dto.Products.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<AffectedPackage>(); | ||||
|         } | ||||
|  | ||||
|         var packages = new List<AffectedPackage>(dto.Products.Count); | ||||
|         foreach (var product in dto.Products) | ||||
|         { | ||||
|             var vendor = Validation.TrimToNull(product.Vendor) ?? "Unspecified"; | ||||
|             var name = Validation.TrimToNull(product.Name); | ||||
|             var identifier = name is null ? vendor : $"{vendor} {name}"; | ||||
|  | ||||
|             var provenance = new AdvisoryProvenance( | ||||
|                 CertBundConnectorPlugin.SourceName, | ||||
|                 "package", | ||||
|                 identifier, | ||||
|                 recordedAt, | ||||
|                 new[] { ProvenanceFieldMasks.AffectedPackages }); | ||||
|  | ||||
|             var ranges = string.IsNullOrWhiteSpace(product.Versions) | ||||
|                 ? Array.Empty<AffectedVersionRange>() | ||||
|                 : new[] | ||||
|                     { | ||||
|                         new AffectedVersionRange( | ||||
|                             rangeKind: "string", | ||||
|                             introducedVersion: null, | ||||
|                             fixedVersion: null, | ||||
|                             lastAffectedVersion: null, | ||||
|                             rangeExpression: product.Versions, | ||||
|                             provenance: new AdvisoryProvenance( | ||||
|                                 CertBundConnectorPlugin.SourceName, | ||||
|                                 "package-range", | ||||
|                                 product.Versions, | ||||
|                                 recordedAt, | ||||
|                                 new[] { ProvenanceFieldMasks.VersionRanges })) | ||||
|                     }; | ||||
|  | ||||
|             packages.Add(new AffectedPackage( | ||||
|                 AffectedPackageTypes.Vendor, | ||||
|                 identifier, | ||||
|                 platform: null, | ||||
|                 versionRanges: ranges, | ||||
|                 statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|                 provenance: new[] { provenance }, | ||||
|                 normalizedVersions: Array.Empty<NormalizedVersionRule>())); | ||||
|         } | ||||
|  | ||||
|         return packages | ||||
|             .DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase) | ||||
|             .OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static string? MapSeverity(string? severity) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(severity)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return severity.ToLowerInvariant() switch | ||||
|         { | ||||
|             "hoch" or "high" => "high", | ||||
|             "mittel" or "medium" => "medium", | ||||
|             "gering" or "low" => "low", | ||||
|             _ => severity.ToLowerInvariant(), | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -1,162 +1,187 @@ | ||||
| using MongoDB.Bson; | ||||
| using StellaOps.Concelier.Connector.Common.Cursors; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertCc.Internal; | ||||
|  | ||||
| internal sealed record CertCcCursor( | ||||
|     TimeWindowCursorState SummaryState, | ||||
|     IReadOnlyCollection<Guid> PendingSummaries, | ||||
|     IReadOnlyCollection<string> PendingNotes, | ||||
|     IReadOnlyCollection<Guid> PendingDocuments, | ||||
|     IReadOnlyCollection<Guid> PendingMappings, | ||||
|     DateTimeOffset? LastRun) | ||||
| { | ||||
|     private static readonly Guid[] EmptyGuidArray = Array.Empty<Guid>(); | ||||
|     private static readonly string[] EmptyStringArray = Array.Empty<string>(); | ||||
|  | ||||
|     public static CertCcCursor Empty { get; } = new( | ||||
|         TimeWindowCursorState.Empty, | ||||
|         EmptyGuidArray, | ||||
|         EmptyStringArray, | ||||
|         EmptyGuidArray, | ||||
|         EmptyGuidArray, | ||||
|         null); | ||||
|  | ||||
|     public BsonDocument ToBsonDocument() | ||||
|     { | ||||
|         var document = new BsonDocument(); | ||||
|  | ||||
|         var summary = new BsonDocument(); | ||||
|         SummaryState.WriteTo(summary, "start", "end"); | ||||
|         document["summary"] = summary; | ||||
|  | ||||
|         document["pendingSummaries"] = new BsonArray(PendingSummaries.Select(static id => id.ToString())); | ||||
|         document["pendingNotes"] = new BsonArray(PendingNotes.Select(static note => note)); | ||||
|         document["pendingDocuments"] = new BsonArray(PendingDocuments.Select(static id => id.ToString())); | ||||
|         document["pendingMappings"] = new BsonArray(PendingMappings.Select(static id => id.ToString())); | ||||
|  | ||||
|         if (LastRun.HasValue) | ||||
|         { | ||||
|             document["lastRun"] = LastRun.Value.UtcDateTime; | ||||
|         } | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     public static CertCcCursor FromBson(BsonDocument? document) | ||||
|     { | ||||
|         if (document is null || document.ElementCount == 0) | ||||
|         { | ||||
|             return Empty; | ||||
|         } | ||||
|  | ||||
|         TimeWindowCursorState summaryState = TimeWindowCursorState.Empty; | ||||
|         if (document.TryGetValue("summary", out var summaryValue) && summaryValue is BsonDocument summaryDocument) | ||||
|         { | ||||
|             summaryState = TimeWindowCursorState.FromBsonDocument(summaryDocument, "start", "end"); | ||||
|         } | ||||
|  | ||||
|         var pendingSummaries = ReadGuidArray(document, "pendingSummaries"); | ||||
|         var pendingNotes = ReadStringArray(document, "pendingNotes"); | ||||
|         var pendingDocuments = ReadGuidArray(document, "pendingDocuments"); | ||||
|         var pendingMappings = ReadGuidArray(document, "pendingMappings"); | ||||
|  | ||||
|         DateTimeOffset? lastRun = null; | ||||
|         if (document.TryGetValue("lastRun", out var lastRunValue)) | ||||
|         { | ||||
|             lastRun = lastRunValue.BsonType switch | ||||
|             { | ||||
|                 BsonType.DateTime => DateTime.SpecifyKind(lastRunValue.ToUniversalTime(), DateTimeKind.Utc), | ||||
|                 BsonType.String when DateTimeOffset.TryParse(lastRunValue.AsString, out var parsed) => parsed.ToUniversalTime(), | ||||
|                 _ => null, | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         return new CertCcCursor(summaryState, pendingSummaries, pendingNotes, pendingDocuments, pendingMappings, lastRun); | ||||
|     } | ||||
|  | ||||
|     public CertCcCursor WithSummaryState(TimeWindowCursorState state) | ||||
|         => this with { SummaryState = state ?? TimeWindowCursorState.Empty }; | ||||
|  | ||||
|     public CertCcCursor WithPendingSummaries(IEnumerable<Guid>? ids) | ||||
|         => this with { PendingSummaries = NormalizeGuidSet(ids) }; | ||||
|  | ||||
|     public CertCcCursor WithPendingNotes(IEnumerable<string>? notes) | ||||
|         => this with { PendingNotes = NormalizeStringSet(notes) }; | ||||
|  | ||||
|     public CertCcCursor WithPendingDocuments(IEnumerable<Guid>? ids) | ||||
|         => this with { PendingDocuments = NormalizeGuidSet(ids) }; | ||||
|  | ||||
|     public CertCcCursor WithPendingMappings(IEnumerable<Guid>? ids) | ||||
|         => this with { PendingMappings = NormalizeGuidSet(ids) }; | ||||
|  | ||||
|     public CertCcCursor WithLastRun(DateTimeOffset? timestamp) | ||||
|         => this with { LastRun = timestamp }; | ||||
|  | ||||
|     private static Guid[] ReadGuidArray(BsonDocument document, string field) | ||||
|     { | ||||
|         if (!document.TryGetValue(field, out var value) || value is not BsonArray array || array.Count == 0) | ||||
|         { | ||||
|             return EmptyGuidArray; | ||||
|         } | ||||
|  | ||||
|         var results = new List<Guid>(array.Count); | ||||
|         foreach (var element in array) | ||||
|         { | ||||
|             if (element is BsonString bsonString && Guid.TryParse(bsonString.AsString, out var parsed)) | ||||
|             { | ||||
|                 results.Add(parsed); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (element is BsonBinaryData binary && binary.GuidRepresentation != GuidRepresentation.Unspecified) | ||||
|             { | ||||
|                 results.Add(binary.ToGuid()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results.Count == 0 ? EmptyGuidArray : results.Distinct().ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static string[] ReadStringArray(BsonDocument document, string field) | ||||
|     { | ||||
|         if (!document.TryGetValue(field, out var value) || value is not BsonArray array || array.Count == 0) | ||||
|         { | ||||
|             return EmptyStringArray; | ||||
|         } | ||||
|  | ||||
|         var results = new List<string>(array.Count); | ||||
|         foreach (var element in array) | ||||
|         { | ||||
|             switch (element) | ||||
|             { | ||||
|                 case BsonString bsonString when !string.IsNullOrWhiteSpace(bsonString.AsString): | ||||
|                     results.Add(bsonString.AsString.Trim()); | ||||
|                     break; | ||||
|                 case BsonDocument bsonDocument when bsonDocument.TryGetValue("value", out var inner) && inner.IsString: | ||||
|                     results.Add(inner.AsString.Trim()); | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results.Count == 0 | ||||
|             ? EmptyStringArray | ||||
|             : results | ||||
|                 .Where(static value => !string.IsNullOrWhiteSpace(value)) | ||||
|                 .Select(static value => value.Trim()) | ||||
|                 .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                 .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static Guid[] NormalizeGuidSet(IEnumerable<Guid>? ids) | ||||
|         => ids?.Where(static id => id != Guid.Empty).Distinct().ToArray() ?? EmptyGuidArray; | ||||
|  | ||||
|     private static string[] NormalizeStringSet(IEnumerable<string>? values) | ||||
|         => values is null | ||||
|             ? EmptyStringArray | ||||
|             : values | ||||
|                 .Where(static value => !string.IsNullOrWhiteSpace(value)) | ||||
|                 .Select(static value => value.Trim()) | ||||
|                 .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                 .ToArray(); | ||||
| } | ||||
| using MongoDB.Bson; | ||||
| using StellaOps.Concelier.Connector.Common.Cursors; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.CertCc.Internal; | ||||
|  | ||||
| internal sealed record CertCcCursor( | ||||
|     TimeWindowCursorState SummaryState, | ||||
|     IReadOnlyCollection<Guid> PendingSummaries, | ||||
|     IReadOnlyCollection<string> PendingNotes, | ||||
|     IReadOnlyCollection<Guid> PendingDocuments, | ||||
|     IReadOnlyCollection<Guid> PendingMappings, | ||||
|     DateTimeOffset? LastRun) | ||||
| { | ||||
|     private static readonly Guid[] EmptyGuidArray = Array.Empty<Guid>(); | ||||
|     private static readonly string[] EmptyStringArray = Array.Empty<string>(); | ||||
|  | ||||
|     public static CertCcCursor Empty { get; } = new( | ||||
|         TimeWindowCursorState.Empty, | ||||
|         EmptyGuidArray, | ||||
|         EmptyStringArray, | ||||
|         EmptyGuidArray, | ||||
|         EmptyGuidArray, | ||||
|         null); | ||||
|  | ||||
|     public BsonDocument ToBsonDocument() | ||||
|     { | ||||
|         var document = new BsonDocument(); | ||||
|  | ||||
|         var summary = new BsonDocument(); | ||||
|         SummaryState.WriteTo(summary, "start", "end"); | ||||
|         document["summary"] = summary; | ||||
|  | ||||
|         document["pendingSummaries"] = new BsonArray(PendingSummaries.Select(static id => id.ToString())); | ||||
|         document["pendingNotes"] = new BsonArray(PendingNotes.Select(static note => note)); | ||||
|         document["pendingDocuments"] = new BsonArray(PendingDocuments.Select(static id => id.ToString())); | ||||
|         document["pendingMappings"] = new BsonArray(PendingMappings.Select(static id => id.ToString())); | ||||
|  | ||||
|         if (LastRun.HasValue) | ||||
|         { | ||||
|             document["lastRun"] = LastRun.Value.UtcDateTime; | ||||
|         } | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     public static CertCcCursor FromBson(BsonDocument? document) | ||||
|     { | ||||
|         if (document is null || document.ElementCount == 0) | ||||
|         { | ||||
|             return Empty; | ||||
|         } | ||||
|  | ||||
|         TimeWindowCursorState summaryState = TimeWindowCursorState.Empty; | ||||
|         if (document.TryGetValue("summary", out var summaryValue) && summaryValue is BsonDocument summaryDocument) | ||||
|         { | ||||
|             summaryState = TimeWindowCursorState.FromBsonDocument(summaryDocument, "start", "end"); | ||||
|         } | ||||
|  | ||||
|         var pendingSummaries = ReadGuidArray(document, "pendingSummaries"); | ||||
|         var pendingNotes = ReadStringArray(document, "pendingNotes"); | ||||
|         var pendingDocuments = ReadGuidArray(document, "pendingDocuments"); | ||||
|         var pendingMappings = ReadGuidArray(document, "pendingMappings"); | ||||
|  | ||||
|         DateTimeOffset? lastRun = null; | ||||
|         if (document.TryGetValue("lastRun", out var lastRunValue)) | ||||
|         { | ||||
|             lastRun = lastRunValue.BsonType switch | ||||
|             { | ||||
|                 BsonType.DateTime => DateTime.SpecifyKind(lastRunValue.ToUniversalTime(), DateTimeKind.Utc), | ||||
|                 BsonType.String when DateTimeOffset.TryParse(lastRunValue.AsString, out var parsed) => parsed.ToUniversalTime(), | ||||
|                 _ => null, | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         return new CertCcCursor(summaryState, pendingSummaries, pendingNotes, pendingDocuments, pendingMappings, lastRun); | ||||
|     } | ||||
|  | ||||
|     public CertCcCursor WithSummaryState(TimeWindowCursorState state) | ||||
|         => this with { SummaryState = state ?? TimeWindowCursorState.Empty }; | ||||
|  | ||||
|     public CertCcCursor WithPendingSummaries(IEnumerable<Guid>? ids) | ||||
|         => this with { PendingSummaries = NormalizeGuidSet(ids) }; | ||||
|  | ||||
|     public CertCcCursor WithPendingNotes(IEnumerable<string>? notes) | ||||
|         => this with { PendingNotes = NormalizeStringSet(notes) }; | ||||
|  | ||||
|     public CertCcCursor WithPendingDocuments(IEnumerable<Guid>? ids) | ||||
|         => this with { PendingDocuments = NormalizeGuidSet(ids) }; | ||||
|  | ||||
|     public CertCcCursor WithPendingMappings(IEnumerable<Guid>? ids) | ||||
|         => this with { PendingMappings = NormalizeGuidSet(ids) }; | ||||
|  | ||||
|     public CertCcCursor WithLastRun(DateTimeOffset? timestamp) | ||||
|         => this with { LastRun = timestamp }; | ||||
|  | ||||
|     private static Guid[] ReadGuidArray(BsonDocument document, string field) | ||||
|     { | ||||
|         if (!document.TryGetValue(field, out var value) || value is not BsonArray array || array.Count == 0) | ||||
|         { | ||||
|             return EmptyGuidArray; | ||||
|         } | ||||
|  | ||||
|         var results = new List<Guid>(array.Count); | ||||
|         foreach (var element in array) | ||||
|         { | ||||
|             if (TryReadGuid(element, out var parsed)) | ||||
|             { | ||||
|                 results.Add(parsed); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results.Count == 0 ? EmptyGuidArray : results.Distinct().ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static string[] ReadStringArray(BsonDocument document, string field) | ||||
|     { | ||||
|         if (!document.TryGetValue(field, out var value) || value is not BsonArray array || array.Count == 0) | ||||
|         { | ||||
|             return EmptyStringArray; | ||||
|         } | ||||
|  | ||||
|         var results = new List<string>(array.Count); | ||||
|         foreach (var element in array) | ||||
|         { | ||||
|             switch (element) | ||||
|             { | ||||
|                 case BsonString bsonString when !string.IsNullOrWhiteSpace(bsonString.AsString): | ||||
|                     results.Add(bsonString.AsString.Trim()); | ||||
|                     break; | ||||
|                 case BsonDocument bsonDocument when bsonDocument.TryGetValue("value", out var inner) && inner.IsString: | ||||
|                     results.Add(inner.AsString.Trim()); | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results.Count == 0 | ||||
|             ? EmptyStringArray | ||||
|             : results | ||||
|                 .Where(static value => !string.IsNullOrWhiteSpace(value)) | ||||
|                 .Select(static value => value.Trim()) | ||||
|                 .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                 .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static bool TryReadGuid(BsonValue value, out Guid guid) | ||||
|     { | ||||
|         if (value is BsonString bsonString && Guid.TryParse(bsonString.AsString, out guid)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (value is BsonBinaryData binary) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 guid = binary.ToGuid(); | ||||
|                 return true; | ||||
|             } | ||||
|             catch (FormatException) | ||||
|             { | ||||
|                 // ignore and fall back to byte array parsing | ||||
|             } | ||||
|  | ||||
|             var bytes = binary.AsByteArray; | ||||
|             if (bytes.Length == 16) | ||||
|             { | ||||
|                 guid = new Guid(bytes); | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         guid = default; | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private static Guid[] NormalizeGuidSet(IEnumerable<Guid>? ids) | ||||
|         => ids?.Where(static id => id != Guid.Empty).Distinct().ToArray() ?? EmptyGuidArray; | ||||
|  | ||||
|     private static string[] NormalizeStringSet(IEnumerable<string>? values) | ||||
|         => values is null | ||||
|             ? EmptyStringArray | ||||
|             : values | ||||
|                 .Where(static value => !string.IsNullOrWhiteSpace(value)) | ||||
|                 .Select(static value => value.Trim()) | ||||
|                 .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|                 .ToArray(); | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,124 @@ | ||||
| using System.Globalization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Configuration; | ||||
|  | ||||
| public sealed class CiscoOptions | ||||
| { | ||||
|     public const string HttpClientName = "concelier.source.vndr.cisco"; | ||||
|     public const string AuthHttpClientName = "concelier.source.vndr.cisco.auth"; | ||||
|  | ||||
|     public Uri BaseUri { get; set; } = new("https://api.cisco.com/security/advisories/v2/", UriKind.Absolute); | ||||
|  | ||||
|     public Uri TokenEndpoint { get; set; } = new("https://id.cisco.com/oauth2/default/v1/token", UriKind.Absolute); | ||||
|  | ||||
|     public string ClientId { get; set; } = string.Empty; | ||||
|  | ||||
|     public string ClientSecret { get; set; } = string.Empty; | ||||
|  | ||||
|     public int PageSize { get; set; } = 100; | ||||
|  | ||||
|     public int MaxPagesPerFetch { get; set; } = 5; | ||||
|  | ||||
|     public int MaxAdvisoriesPerFetch { get; set; } = 200; | ||||
|  | ||||
|     public TimeSpan InitialBackfillWindow { get; set; } = TimeSpan.FromDays(30); | ||||
|  | ||||
|     public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250); | ||||
|  | ||||
|     public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); | ||||
|  | ||||
|     public TimeSpan FailureBackoff { get; set; } = TimeSpan.FromMinutes(5); | ||||
|  | ||||
|     public TimeSpan TokenRefreshSkew { get; set; } = TimeSpan.FromMinutes(1); | ||||
|  | ||||
|     public string LastModifiedPathTemplate { get; set; } = "advisories/lastmodified/{0}"; | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         if (BaseUri is null || !BaseUri.IsAbsoluteUri) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco BaseUri must be an absolute URI."); | ||||
|         } | ||||
|  | ||||
|         if (TokenEndpoint is null || !TokenEndpoint.IsAbsoluteUri) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco TokenEndpoint must be an absolute URI."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(ClientId)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco clientId must be configured."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(ClientSecret)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco clientSecret must be configured."); | ||||
|         } | ||||
|  | ||||
|         if (PageSize is < 1 or > 100) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco PageSize must be between 1 and 100."); | ||||
|         } | ||||
|  | ||||
|         if (MaxPagesPerFetch <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco MaxPagesPerFetch must be greater than zero."); | ||||
|         } | ||||
|  | ||||
|         if (MaxAdvisoriesPerFetch <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco MaxAdvisoriesPerFetch must be greater than zero."); | ||||
|         } | ||||
|  | ||||
|         if (InitialBackfillWindow <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco InitialBackfillWindow must be positive."); | ||||
|         } | ||||
|  | ||||
|         if (RequestDelay < TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco RequestDelay cannot be negative."); | ||||
|         } | ||||
|  | ||||
|         if (RequestTimeout <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco RequestTimeout must be positive."); | ||||
|         } | ||||
|  | ||||
|         if (FailureBackoff <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco FailureBackoff must be positive."); | ||||
|         } | ||||
|  | ||||
|         if (TokenRefreshSkew < TimeSpan.FromSeconds(5)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco TokenRefreshSkew must be at least 5 seconds."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(LastModifiedPathTemplate)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco LastModifiedPathTemplate must be configured."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public Uri BuildLastModifiedUri(DateOnly date, int pageIndex, int pageSize) | ||||
|     { | ||||
|         if (pageIndex < 1) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(pageIndex), pageIndex, "Page index must be >= 1."); | ||||
|         } | ||||
|  | ||||
|         if (pageSize is < 1 or > 100) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be between 1 and 100."); | ||||
|         } | ||||
|  | ||||
|         var path = string.Format(CultureInfo.InvariantCulture, LastModifiedPathTemplate, date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); | ||||
|         var builder = new UriBuilder(BaseUri); | ||||
|         var basePath = builder.Path.TrimEnd('/'); | ||||
|         builder.Path = $"{basePath}/{path}".Replace("//", "/", StringComparison.Ordinal); | ||||
|         var query = $"pageIndex={pageIndex.ToString(CultureInfo.InvariantCulture)}&pageSize={pageSize.ToString(CultureInfo.InvariantCulture)}"; | ||||
|         builder.Query = string.IsNullOrEmpty(builder.Query) ? query : builder.Query.TrimStart('?') + "&" + query; | ||||
|         return builder.Uri; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,145 @@ | ||||
| using System.Net.Http.Headers; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Concelier.Connector.Vndr.Cisco.Configuration; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| internal sealed class CiscoAccessTokenProvider : IDisposable | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         PropertyNameCaseInsensitive = true, | ||||
|     }; | ||||
|  | ||||
|     private readonly IHttpClientFactory _httpClientFactory; | ||||
|     private readonly IOptionsMonitor<CiscoOptions> _options; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly ILogger<CiscoAccessTokenProvider> _logger; | ||||
|     private readonly SemaphoreSlim _refreshLock = new(1, 1); | ||||
|  | ||||
|     private volatile AccessToken? _cached; | ||||
|     private bool _disposed; | ||||
|  | ||||
|     public CiscoAccessTokenProvider( | ||||
|         IHttpClientFactory httpClientFactory, | ||||
|         IOptionsMonitor<CiscoOptions> options, | ||||
|         TimeProvider? timeProvider, | ||||
|         ILogger<CiscoAccessTokenProvider> logger) | ||||
|     { | ||||
|         _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); | ||||
|         _options = options ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<string> GetTokenAsync(CancellationToken cancellationToken) | ||||
|         => await GetTokenInternalAsync(forceRefresh: false, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|     public void Invalidate() | ||||
|         => _cached = null; | ||||
|  | ||||
|     private async Task<string> GetTokenInternalAsync(bool forceRefresh, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ThrowIfDisposed(); | ||||
|  | ||||
|         var options = _options.CurrentValue; | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|         var cached = _cached; | ||||
|         if (!forceRefresh && cached is not null && now < cached.ExpiresAt - options.TokenRefreshSkew) | ||||
|         { | ||||
|             return cached.Value; | ||||
|         } | ||||
|  | ||||
|         await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             cached = _cached; | ||||
|             now = _timeProvider.GetUtcNow(); | ||||
|             if (!forceRefresh && cached is not null && now < cached.ExpiresAt - options.TokenRefreshSkew) | ||||
|             { | ||||
|                 return cached.Value; | ||||
|             } | ||||
|  | ||||
|             var fresh = await RequestTokenAsync(options, cancellationToken).ConfigureAwait(false); | ||||
|             _cached = fresh; | ||||
|             return fresh.Value; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _refreshLock.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task<AccessToken> RequestTokenAsync(CiscoOptions options, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient(CiscoOptions.AuthHttpClientName); | ||||
|         client.Timeout = options.RequestTimeout; | ||||
|  | ||||
|         using var request = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint); | ||||
|         request.Headers.Accept.Clear(); | ||||
|         request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); | ||||
|  | ||||
|         var content = new FormUrlEncodedContent(new Dictionary<string, string> | ||||
|         { | ||||
|             ["grant_type"] = "client_credentials", | ||||
|             ["client_id"] = options.ClientId, | ||||
|             ["client_secret"] = options.ClientSecret, | ||||
|         }); | ||||
|  | ||||
|         request.Content = content; | ||||
|  | ||||
|         using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         if (!response.IsSuccessStatusCode) | ||||
|         { | ||||
|             var preview = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||
|             var message = $"Cisco OAuth token request failed with status {(int)response.StatusCode} {response.StatusCode}."; | ||||
|             _logger.LogError("Cisco openVuln token request failed: {Message}; response={Preview}", message, preview); | ||||
|             throw new HttpRequestException(message); | ||||
|         } | ||||
|  | ||||
|         await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var payload = await JsonSerializer.DeserializeAsync<TokenResponse>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false); | ||||
|         if (payload is null || string.IsNullOrWhiteSpace(payload.AccessToken)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco OAuth token response did not include an access token."); | ||||
|         } | ||||
|  | ||||
|         var expiresIn = payload.ExpiresIn > 0 ? TimeSpan.FromSeconds(payload.ExpiresIn) : TimeSpan.FromHours(1); | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|         var expiresAt = now + expiresIn; | ||||
|         _logger.LogInformation("Cisco openVuln token issued; expires in {ExpiresIn}", expiresIn); | ||||
|         return new AccessToken(payload.AccessToken, expiresAt); | ||||
|     } | ||||
|  | ||||
|     public async Task<string> RefreshAsync(CancellationToken cancellationToken) | ||||
|         => await GetTokenInternalAsync(forceRefresh: true, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|     private void ThrowIfDisposed() | ||||
|     { | ||||
|         if (_disposed) | ||||
|         { | ||||
|             throw new ObjectDisposedException(nameof(CiscoAccessTokenProvider)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void Dispose() | ||||
|     { | ||||
|         if (_disposed) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         _refreshLock.Dispose(); | ||||
|         _disposed = true; | ||||
|     } | ||||
|  | ||||
|     private sealed record AccessToken(string Value, DateTimeOffset ExpiresAt); | ||||
|  | ||||
|     private sealed record TokenResponse( | ||||
|         [property: JsonPropertyName("access_token")] string AccessToken, | ||||
|         [property: JsonPropertyName("expires_in")] int ExpiresIn, | ||||
|         [property: JsonPropertyName("token_type")] string? TokenType); | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| public sealed record CiscoAdvisoryDto( | ||||
|     string AdvisoryId, | ||||
|     string Title, | ||||
|     string? Summary, | ||||
|     string? Severity, | ||||
|     DateTimeOffset? Published, | ||||
|     DateTimeOffset? Updated, | ||||
|     string? PublicationUrl, | ||||
|     string? CsafUrl, | ||||
|     string? CvrfUrl, | ||||
|     double? CvssBaseScore, | ||||
|     IReadOnlyList<string> Cves, | ||||
|     IReadOnlyList<string> BugIds, | ||||
|     IReadOnlyList<CiscoAffectedProductDto> Products); | ||||
|  | ||||
| public sealed record CiscoAffectedProductDto( | ||||
|     string Name, | ||||
|     string? ProductId, | ||||
|     string? Version, | ||||
|     IReadOnlyCollection<string> Statuses); | ||||
| @@ -0,0 +1,64 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Net.Http; | ||||
| using System.Text; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Concelier.Connector.Common.Fetch; | ||||
| using StellaOps.Concelier.Connector.Vndr.Cisco.Configuration; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| public interface ICiscoCsafClient | ||||
| { | ||||
|     Task<string?> TryFetchAsync(string? url, CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public class CiscoCsafClient : ICiscoCsafClient | ||||
| { | ||||
|     private static readonly string[] AcceptHeaders = { "application/json", "application/csaf+json", "application/vnd.cisco.csaf+json" }; | ||||
|  | ||||
|     private readonly SourceFetchService _fetchService; | ||||
|     private readonly ILogger<CiscoCsafClient> _logger; | ||||
|  | ||||
|     public CiscoCsafClient(SourceFetchService fetchService, ILogger<CiscoCsafClient> logger) | ||||
|     { | ||||
|         _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public virtual async Task<string?> TryFetchAsync(string? url, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(url)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) | ||||
|         { | ||||
|             _logger.LogWarning("Cisco CSAF URL '{Url}' is not a valid absolute URI.", url); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var request = new SourceFetchRequest(CiscoOptions.HttpClientName, VndrCiscoConnectorPlugin.SourceName, uri) | ||||
|             { | ||||
|                 AcceptHeaders = AcceptHeaders, | ||||
|             }; | ||||
|  | ||||
|             var result = await _fetchService.FetchContentAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|             if (!result.IsSuccess || result.Content is null) | ||||
|             { | ||||
|                 _logger.LogWarning("Cisco CSAF download returned status {Status} for {Url}", result.StatusCode, url); | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             return System.Text.Encoding.UTF8.GetString(result.Content); | ||||
|         } | ||||
|         catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException or InvalidOperationException) | ||||
|         { | ||||
|             _logger.LogWarning(ex, "Cisco CSAF download failed for {Url}", url); | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| internal sealed record CiscoCsafData( | ||||
|     IReadOnlyDictionary<string, CiscoCsafProduct> Products, | ||||
|     IReadOnlyDictionary<string, IReadOnlyCollection<string>> ProductStatuses); | ||||
|  | ||||
| internal sealed record CiscoCsafProduct(string ProductId, string Name); | ||||
| @@ -0,0 +1,123 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| internal static class CiscoCsafParser | ||||
| { | ||||
|     public static CiscoCsafData Parse(string content) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(content)) | ||||
|         { | ||||
|             return new CiscoCsafData( | ||||
|                 Products: new Dictionary<string, CiscoCsafProduct>(0, StringComparer.OrdinalIgnoreCase), | ||||
|                 ProductStatuses: new Dictionary<string, IReadOnlyCollection<string>>(0, StringComparer.OrdinalIgnoreCase)); | ||||
|         } | ||||
|  | ||||
|         using var document = JsonDocument.Parse(content); | ||||
|         var root = document.RootElement; | ||||
|  | ||||
|         var products = ParseProducts(root); | ||||
|         var statuses = ParseStatuses(root); | ||||
|  | ||||
|         return new CiscoCsafData(products, statuses); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, CiscoCsafProduct> ParseProducts(JsonElement root) | ||||
|     { | ||||
|         var dictionary = new Dictionary<string, CiscoCsafProduct>(StringComparer.OrdinalIgnoreCase); | ||||
|         if (!root.TryGetProperty("product_tree", out var productTree)) | ||||
|         { | ||||
|             return dictionary; | ||||
|         } | ||||
|  | ||||
|         if (productTree.TryGetProperty("full_product_names", out var fullProductNames) | ||||
|             && fullProductNames.ValueKind == JsonValueKind.Array) | ||||
|         { | ||||
|             foreach (var entry in fullProductNames.EnumerateArray()) | ||||
|             { | ||||
|                 var productId = entry.TryGetProperty("product_id", out var idElement) && idElement.ValueKind == JsonValueKind.String | ||||
|                     ? idElement.GetString() | ||||
|                     : null; | ||||
|  | ||||
|                 if (string.IsNullOrWhiteSpace(productId)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var name = entry.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String | ||||
|                     ? nameElement.GetString() | ||||
|                     : null; | ||||
|  | ||||
|                 if (string.IsNullOrWhiteSpace(name)) | ||||
|                 { | ||||
|                     name = productId; | ||||
|                 } | ||||
|  | ||||
|                 dictionary[productId] = new CiscoCsafProduct(productId, name); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return dictionary; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> ParseStatuses(JsonElement root) | ||||
|     { | ||||
|         var map = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities) | ||||
|             || vulnerabilities.ValueKind != JsonValueKind.Array) | ||||
|         { | ||||
|             return map.ToDictionary( | ||||
|                 static kvp => kvp.Key, | ||||
|                 static kvp => (IReadOnlyCollection<string>)kvp.Value.ToArray(), | ||||
|                 StringComparer.OrdinalIgnoreCase); | ||||
|         } | ||||
|  | ||||
|         foreach (var vulnerability in vulnerabilities.EnumerateArray()) | ||||
|         { | ||||
|             if (!vulnerability.TryGetProperty("product_status", out var productStatus) | ||||
|                 || productStatus.ValueKind != JsonValueKind.Object) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             foreach (var property in productStatus.EnumerateObject()) | ||||
|             { | ||||
|                 var statusLabel = property.Name; | ||||
|                 if (property.Value.ValueKind != JsonValueKind.Array) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 foreach (var productIdElement in property.Value.EnumerateArray()) | ||||
|                 { | ||||
|                     if (productIdElement.ValueKind != JsonValueKind.String) | ||||
|                     { | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var productId = productIdElement.GetString(); | ||||
|                     if (string.IsNullOrWhiteSpace(productId)) | ||||
|                     { | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     if (!map.TryGetValue(productId, out var set)) | ||||
|                     { | ||||
|                         set = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|                         map[productId] = set; | ||||
|                     } | ||||
|  | ||||
|                     set.Add(statusLabel); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return map.ToDictionary( | ||||
|             static kvp => kvp.Key, | ||||
|             static kvp => (IReadOnlyCollection<string>)kvp.Value.ToArray(), | ||||
|             StringComparer.OrdinalIgnoreCase); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,101 @@ | ||||
| using MongoDB.Bson; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| internal sealed record CiscoCursor( | ||||
|     DateTimeOffset? LastModified, | ||||
|     string? LastAdvisoryId, | ||||
|     IReadOnlyCollection<Guid> PendingDocuments, | ||||
|     IReadOnlyCollection<Guid> PendingMappings) | ||||
| { | ||||
|     private static readonly IReadOnlyCollection<Guid> EmptyGuidCollection = Array.Empty<Guid>(); | ||||
|  | ||||
|     public static CiscoCursor Empty { get; } = new(null, null, EmptyGuidCollection, EmptyGuidCollection); | ||||
|  | ||||
|     public BsonDocument ToBson() | ||||
|     { | ||||
|         var document = new BsonDocument | ||||
|         { | ||||
|             ["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())), | ||||
|             ["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())), | ||||
|         }; | ||||
|  | ||||
|         if (LastModified.HasValue) | ||||
|         { | ||||
|             document["lastModified"] = LastModified.Value.UtcDateTime; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(LastAdvisoryId)) | ||||
|         { | ||||
|             document["lastAdvisoryId"] = LastAdvisoryId; | ||||
|         } | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     public static CiscoCursor FromBson(BsonDocument? document) | ||||
|     { | ||||
|         if (document is null || document.ElementCount == 0) | ||||
|         { | ||||
|             return Empty; | ||||
|         } | ||||
|  | ||||
|         DateTimeOffset? lastModified = null; | ||||
|         if (document.TryGetValue("lastModified", out var lastModifiedValue)) | ||||
|         { | ||||
|             lastModified = lastModifiedValue.BsonType switch | ||||
|             { | ||||
|                 BsonType.DateTime => DateTime.SpecifyKind(lastModifiedValue.ToUniversalTime(), DateTimeKind.Utc), | ||||
|                 BsonType.String when DateTimeOffset.TryParse(lastModifiedValue.AsString, out var parsed) => parsed.ToUniversalTime(), | ||||
|                 _ => null, | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         string? lastAdvisoryId = null; | ||||
|         if (document.TryGetValue("lastAdvisoryId", out var idValue) && idValue.BsonType == BsonType.String) | ||||
|         { | ||||
|             var value = idValue.AsString.Trim(); | ||||
|             if (value.Length > 0) | ||||
|             { | ||||
|                 lastAdvisoryId = value; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var pendingDocuments = ReadGuidArray(document, "pendingDocuments"); | ||||
|         var pendingMappings = ReadGuidArray(document, "pendingMappings"); | ||||
|  | ||||
|         return new CiscoCursor(lastModified, lastAdvisoryId, pendingDocuments, pendingMappings); | ||||
|     } | ||||
|  | ||||
|     public CiscoCursor WithCheckpoint(DateTimeOffset lastModified, string advisoryId) | ||||
|         => this with | ||||
|         { | ||||
|             LastModified = lastModified.ToUniversalTime(), | ||||
|             LastAdvisoryId = string.IsNullOrWhiteSpace(advisoryId) ? null : advisoryId.Trim(), | ||||
|         }; | ||||
|  | ||||
|     public CiscoCursor WithPendingDocuments(IEnumerable<Guid>? documents) | ||||
|         => this with { PendingDocuments = documents?.Distinct().ToArray() ?? EmptyGuidCollection }; | ||||
|  | ||||
|     public CiscoCursor WithPendingMappings(IEnumerable<Guid>? mappings) | ||||
|         => this with { PendingMappings = mappings?.Distinct().ToArray() ?? EmptyGuidCollection }; | ||||
|  | ||||
|     private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string key) | ||||
|     { | ||||
|         if (!document.TryGetValue(key, out var value) || value is not BsonArray array) | ||||
|         { | ||||
|             return EmptyGuidCollection; | ||||
|         } | ||||
|  | ||||
|         var results = new List<Guid>(array.Count); | ||||
|         foreach (var element in array) | ||||
|         { | ||||
|             if (Guid.TryParse(element.ToString(), out var guid)) | ||||
|             { | ||||
|                 results.Add(guid); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,82 @@ | ||||
| using System.Diagnostics.Metrics; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| public sealed class CiscoDiagnostics : IDisposable | ||||
| { | ||||
|     public const string MeterName = "StellaOps.Concelier.Connector.Vndr.Cisco"; | ||||
|     private const string MeterVersion = "1.0.0"; | ||||
|  | ||||
|     private readonly Meter _meter; | ||||
|     private readonly Counter<long> _fetchDocuments; | ||||
|     private readonly Counter<long> _fetchFailures; | ||||
|     private readonly Counter<long> _fetchUnchanged; | ||||
|     private readonly Counter<long> _parseSuccess; | ||||
|     private readonly Counter<long> _parseFailures; | ||||
|     private readonly Counter<long> _mapSuccess; | ||||
|     private readonly Counter<long> _mapFailures; | ||||
|     private readonly Histogram<long> _mapAffected; | ||||
|  | ||||
|     public CiscoDiagnostics() | ||||
|     { | ||||
|         _meter = new Meter(MeterName, MeterVersion); | ||||
|         _fetchDocuments = _meter.CreateCounter<long>( | ||||
|             name: "cisco.fetch.documents", | ||||
|             unit: "documents", | ||||
|             description: "Number of Cisco advisories fetched."); | ||||
|         _fetchFailures = _meter.CreateCounter<long>( | ||||
|             name: "cisco.fetch.failures", | ||||
|             unit: "operations", | ||||
|             description: "Number of Cisco fetch failures."); | ||||
|         _fetchUnchanged = _meter.CreateCounter<long>( | ||||
|             name: "cisco.fetch.unchanged", | ||||
|             unit: "documents", | ||||
|             description: "Number of Cisco advisories skipped because they were unchanged."); | ||||
|         _parseSuccess = _meter.CreateCounter<long>( | ||||
|             name: "cisco.parse.success", | ||||
|             unit: "documents", | ||||
|             description: "Number of Cisco documents parsed successfully."); | ||||
|         _parseFailures = _meter.CreateCounter<long>( | ||||
|             name: "cisco.parse.failures", | ||||
|             unit: "documents", | ||||
|             description: "Number of Cisco documents that failed to parse."); | ||||
|         _mapSuccess = _meter.CreateCounter<long>( | ||||
|             name: "cisco.map.success", | ||||
|             unit: "documents", | ||||
|             description: "Number of Cisco advisories mapped successfully."); | ||||
|         _mapFailures = _meter.CreateCounter<long>( | ||||
|             name: "cisco.map.failures", | ||||
|             unit: "documents", | ||||
|             description: "Number of Cisco advisories that failed to map to canonical form."); | ||||
|         _mapAffected = _meter.CreateHistogram<long>( | ||||
|             name: "cisco.map.affected.packages", | ||||
|             unit: "packages", | ||||
|             description: "Distribution of affected package counts emitted per Cisco advisory."); | ||||
|     } | ||||
|  | ||||
|     public Meter Meter => _meter; | ||||
|  | ||||
|     public void FetchDocument() => _fetchDocuments.Add(1); | ||||
|  | ||||
|     public void FetchFailure() => _fetchFailures.Add(1); | ||||
|  | ||||
|     public void FetchUnchanged() => _fetchUnchanged.Add(1); | ||||
|  | ||||
|     public void ParseSuccess() => _parseSuccess.Add(1); | ||||
|  | ||||
|     public void ParseFailure() => _parseFailures.Add(1); | ||||
|  | ||||
|     public void MapSuccess() => _mapSuccess.Add(1); | ||||
|  | ||||
|     public void MapFailure() => _mapFailures.Add(1); | ||||
|  | ||||
|     public void MapAffected(int count) | ||||
|     { | ||||
|         if (count >= 0) | ||||
|         { | ||||
|             _mapAffected.Record(count); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void Dispose() => _meter.Dispose(); | ||||
| } | ||||
| @@ -0,0 +1,190 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Concelier.Models; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| public class CiscoDtoFactory | ||||
| { | ||||
|     private readonly ICiscoCsafClient _csafClient; | ||||
|     private readonly ILogger<CiscoDtoFactory> _logger; | ||||
|  | ||||
|     public CiscoDtoFactory(ICiscoCsafClient csafClient, ILogger<CiscoDtoFactory> logger) | ||||
|     { | ||||
|         _csafClient = csafClient ?? throw new ArgumentNullException(nameof(csafClient)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<CiscoAdvisoryDto> CreateAsync(CiscoRawAdvisory raw, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(raw); | ||||
|  | ||||
|         var advisoryId = raw.AdvisoryId?.Trim(); | ||||
|         if (string.IsNullOrWhiteSpace(advisoryId)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Cisco advisory is missing advisoryId."); | ||||
|         } | ||||
|  | ||||
|         var title = string.IsNullOrWhiteSpace(raw.AdvisoryTitle) ? advisoryId : raw.AdvisoryTitle!.Trim(); | ||||
|         var severity = SeverityNormalization.Normalize(raw.Sir); | ||||
|         var published = ParseDate(raw.FirstPublished); | ||||
|         var updated = ParseDate(raw.LastUpdated); | ||||
|  | ||||
|         CiscoCsafData? csafData = null; | ||||
|         if (!string.IsNullOrWhiteSpace(raw.CsafUrl)) | ||||
|         { | ||||
|             var csafContent = await _csafClient.TryFetchAsync(raw.CsafUrl, cancellationToken).ConfigureAwait(false); | ||||
|             if (!string.IsNullOrWhiteSpace(csafContent)) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     csafData = CiscoCsafParser.Parse(csafContent!); | ||||
|                 } | ||||
|                 catch (JsonException ex) | ||||
|                 { | ||||
|                     _logger.LogWarning(ex, "Cisco CSAF payload parsing failed for {AdvisoryId}", advisoryId); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var products = BuildProducts(raw, csafData); | ||||
|         var cves = NormalizeList(raw.Cves); | ||||
|         var bugIds = NormalizeList(raw.BugIds); | ||||
|         var cvss = ParseDouble(raw.CvssBaseScore); | ||||
|  | ||||
|         return new CiscoAdvisoryDto( | ||||
|             AdvisoryId: advisoryId, | ||||
|             Title: title, | ||||
|             Summary: string.IsNullOrWhiteSpace(raw.Summary) ? null : raw.Summary!.Trim(), | ||||
|             Severity: severity, | ||||
|             Published: published, | ||||
|             Updated: updated, | ||||
|             PublicationUrl: NormalizeUrl(raw.PublicationUrl), | ||||
|             CsafUrl: NormalizeUrl(raw.CsafUrl), | ||||
|             CvrfUrl: NormalizeUrl(raw.CvrfUrl), | ||||
|             CvssBaseScore: cvss, | ||||
|             Cves: cves, | ||||
|             BugIds: bugIds, | ||||
|             Products: products); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<CiscoAffectedProductDto> BuildProducts(CiscoRawAdvisory raw, CiscoCsafData? csafData) | ||||
|     { | ||||
|         var map = new Dictionary<string, CiscoAffectedProductDto>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         if (csafData is not null) | ||||
|         { | ||||
|             foreach (var entry in csafData.ProductStatuses) | ||||
|             { | ||||
|                 var productId = entry.Key; | ||||
|                 var name = csafData.Products.TryGetValue(productId, out var product) | ||||
|                     ? product.Name | ||||
|                     : productId; | ||||
|  | ||||
|                 var statuses = NormalizeStatuses(entry.Value); | ||||
|                 map[name] = new CiscoAffectedProductDto( | ||||
|                     Name: name, | ||||
|                     ProductId: productId, | ||||
|                     Version: raw.Version?.Trim(), | ||||
|                     Statuses: statuses); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var rawProducts = NormalizeList(raw.ProductNames); | ||||
|         foreach (var productName in rawProducts) | ||||
|         { | ||||
|             if (map.ContainsKey(productName)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             map[productName] = new CiscoAffectedProductDto( | ||||
|                 Name: productName, | ||||
|                 ProductId: null, | ||||
|                 Version: raw.Version?.Trim(), | ||||
|                 Statuses: new[] { AffectedPackageStatusCatalog.KnownAffected }); | ||||
|         } | ||||
|  | ||||
|         return map.Count == 0 | ||||
|             ? Array.Empty<CiscoAffectedProductDto>() | ||||
|             : map.Values | ||||
|                 .OrderBy(static p => p.Name, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ThenBy(static p => p.ProductId, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyCollection<string> NormalizeStatuses(IEnumerable<string> statuses) | ||||
|     { | ||||
|         var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|         foreach (var status in statuses) | ||||
|         { | ||||
|             if (AffectedPackageStatusCatalog.TryNormalize(status, out var normalized)) | ||||
|             { | ||||
|                 set.Add(normalized); | ||||
|             } | ||||
|             else if (!string.IsNullOrWhiteSpace(status)) | ||||
|             { | ||||
|                 set.Add(status.Trim().ToLowerInvariant()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (set.Count == 0) | ||||
|         { | ||||
|             set.Add(AffectedPackageStatusCatalog.KnownAffected); | ||||
|         } | ||||
|  | ||||
|         return set; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> NormalizeList(IEnumerable<string>? items) | ||||
|     { | ||||
|         if (items is null) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         var set = new SortedSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|         foreach (var item in items) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(item)) | ||||
|             { | ||||
|                 set.Add(item.Trim()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return set.Count == 0 ? Array.Empty<string>() : set.ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static double? ParseDouble(string? value) | ||||
|         => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) | ||||
|             ? parsed | ||||
|             : null; | ||||
|  | ||||
|     private static DateTimeOffset? ParseDate(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) | ||||
|         { | ||||
|             return parsed.ToUniversalTime(); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static string? NormalizeUrl(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri) ? uri.ToString() : null; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,263 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using StellaOps.Concelier.Models; | ||||
| using StellaOps.Concelier.Connector.Common.Packages; | ||||
| using StellaOps.Concelier.Storage.Mongo.Documents; | ||||
| using StellaOps.Concelier.Storage.Mongo.Dtos; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| public static class CiscoMapper | ||||
| { | ||||
|     public static Advisory Map(CiscoAdvisoryDto dto, DocumentRecord document, DtoRecord dtoRecord) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(dto); | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|         ArgumentNullException.ThrowIfNull(dtoRecord); | ||||
|  | ||||
|         var recordedAt = dtoRecord.ValidatedAt.ToUniversalTime(); | ||||
|         var fetchProvenance = new AdvisoryProvenance( | ||||
|             VndrCiscoConnectorPlugin.SourceName, | ||||
|             "document", | ||||
|             document.Uri, | ||||
|             document.FetchedAt.ToUniversalTime()); | ||||
|  | ||||
|         var mapProvenance = new AdvisoryProvenance( | ||||
|             VndrCiscoConnectorPlugin.SourceName, | ||||
|             "map", | ||||
|             dto.AdvisoryId, | ||||
|             recordedAt); | ||||
|  | ||||
|         var aliases = BuildAliases(dto); | ||||
|         var references = BuildReferences(dto, recordedAt); | ||||
|         var affected = BuildAffectedPackages(dto, recordedAt); | ||||
|  | ||||
|         return new Advisory( | ||||
|             advisoryKey: dto.AdvisoryId, | ||||
|             title: dto.Title, | ||||
|             summary: dto.Summary, | ||||
|             language: "en", | ||||
|             published: dto.Published, | ||||
|             modified: dto.Updated, | ||||
|             severity: dto.Severity, | ||||
|             exploitKnown: false, | ||||
|             aliases: aliases, | ||||
|             references: references, | ||||
|             affectedPackages: affected, | ||||
|             cvssMetrics: Array.Empty<CvssMetric>(), | ||||
|             provenance: new[] { fetchProvenance, mapProvenance }); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> BuildAliases(CiscoAdvisoryDto dto) | ||||
|     { | ||||
|         var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase) | ||||
|         { | ||||
|             dto.AdvisoryId, | ||||
|         }; | ||||
|  | ||||
|         foreach (var cve in dto.Cves) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(cve)) | ||||
|             { | ||||
|                 set.Add(cve.Trim()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach (var bugId in dto.BugIds) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(bugId)) | ||||
|             { | ||||
|                 set.Add(bugId.Trim()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (dto.PublicationUrl is not null) | ||||
|         { | ||||
|             set.Add(dto.PublicationUrl); | ||||
|         } | ||||
|  | ||||
|         return set.Count == 0 | ||||
|             ? Array.Empty<string>() | ||||
|             : set.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AdvisoryReference> BuildReferences(CiscoAdvisoryDto dto, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         var list = new List<AdvisoryReference>(3); | ||||
|         AddReference(list, dto.PublicationUrl, "publication", recordedAt); | ||||
|         AddReference(list, dto.CvrfUrl, "cvrf", recordedAt); | ||||
|         AddReference(list, dto.CsafUrl, "csaf", recordedAt); | ||||
|  | ||||
|         return list.Count == 0 | ||||
|             ? Array.Empty<AdvisoryReference>() | ||||
|             : list.OrderBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase).ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static void AddReference(ICollection<AdvisoryReference> references, string? url, string kind, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(url)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var provenance = new AdvisoryProvenance( | ||||
|             VndrCiscoConnectorPlugin.SourceName, | ||||
|             $"reference:{kind}", | ||||
|             uri.ToString(), | ||||
|             recordedAt); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             references.Add(new AdvisoryReference( | ||||
|                 url: uri.ToString(), | ||||
|                 kind: kind, | ||||
|                 sourceTag: null, | ||||
|                 summary: null, | ||||
|                 provenance: provenance)); | ||||
|         } | ||||
|         catch (ArgumentException) | ||||
|         { | ||||
|             // ignore invalid URLs | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AffectedPackage> BuildAffectedPackages(CiscoAdvisoryDto dto, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (dto.Products.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<AffectedPackage>(); | ||||
|         } | ||||
|  | ||||
|         var packages = new List<AffectedPackage>(dto.Products.Count); | ||||
|         foreach (var product in dto.Products) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(product.Name)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var range = BuildVersionRange(product, recordedAt); | ||||
|             var statuses = BuildStatuses(product, recordedAt); | ||||
|             var provenance = new[] | ||||
|             { | ||||
|                 new AdvisoryProvenance( | ||||
|                     VndrCiscoConnectorPlugin.SourceName, | ||||
|                     "affected", | ||||
|                     product.ProductId ?? product.Name, | ||||
|                     recordedAt), | ||||
|             }; | ||||
|  | ||||
|             packages.Add(new AffectedPackage( | ||||
|                 type: AffectedPackageTypes.Vendor, | ||||
|                 identifier: product.Name, | ||||
|                 platform: null, | ||||
|                 versionRanges: range is null ? Array.Empty<AffectedVersionRange>() : new[] { range }, | ||||
|                 statuses: statuses, | ||||
|                 provenance: provenance, | ||||
|                 normalizedVersions: Array.Empty<NormalizedVersionRule>())); | ||||
|         } | ||||
|  | ||||
|         return packages.Count == 0 | ||||
|             ? Array.Empty<AffectedPackage>() | ||||
|             : packages.OrderBy(static p => p.Identifier, StringComparer.OrdinalIgnoreCase).ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static AffectedVersionRange? BuildVersionRange(CiscoAffectedProductDto product, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(product.Version)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var version = product.Version.Trim(); | ||||
|         RangePrimitives? primitives = null; | ||||
|         string rangeKind = "vendor"; | ||||
|         string? rangeExpression = version; | ||||
|  | ||||
|         if (PackageCoordinateHelper.TryParseSemVer(version, out _, out var normalized)) | ||||
|         { | ||||
|             var semver = new SemVerPrimitive( | ||||
|                 Introduced: null, | ||||
|                 IntroducedInclusive: true, | ||||
|                 Fixed: null, | ||||
|                 FixedInclusive: false, | ||||
|                 LastAffected: null, | ||||
|                 LastAffectedInclusive: true, | ||||
|                 ConstraintExpression: null, | ||||
|                 ExactValue: normalized); | ||||
|  | ||||
|             primitives = new RangePrimitives(semver, null, null, BuildVendorExtensions(product)); | ||||
|             rangeKind = "semver"; | ||||
|             rangeExpression = normalized; | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             primitives = new RangePrimitives(null, null, null, BuildVendorExtensions(product, includeVersion: true)); | ||||
|         } | ||||
|  | ||||
|         var provenance = new AdvisoryProvenance( | ||||
|             VndrCiscoConnectorPlugin.SourceName, | ||||
|             "range", | ||||
|             product.ProductId ?? product.Name, | ||||
|             recordedAt); | ||||
|  | ||||
|         return new AffectedVersionRange( | ||||
|             rangeKind: rangeKind, | ||||
|             introducedVersion: null, | ||||
|             fixedVersion: null, | ||||
|             lastAffectedVersion: null, | ||||
|             rangeExpression: rangeExpression, | ||||
|             provenance: provenance, | ||||
|             primitives: primitives); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, string>? BuildVendorExtensions(CiscoAffectedProductDto product, bool includeVersion = false) | ||||
|     { | ||||
|         var dictionary = new Dictionary<string, string>(StringComparer.Ordinal); | ||||
|         if (!string.IsNullOrWhiteSpace(product.ProductId)) | ||||
|         { | ||||
|             dictionary["cisco.productId"] = product.ProductId!; | ||||
|         } | ||||
|  | ||||
|         if (includeVersion && !string.IsNullOrWhiteSpace(product.Version)) | ||||
|         { | ||||
|             dictionary["cisco.version.raw"] = product.Version!; | ||||
|         } | ||||
|  | ||||
|         return dictionary.Count == 0 ? null : dictionary; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AffectedPackageStatus> BuildStatuses(CiscoAffectedProductDto product, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (product.Statuses is null || product.Statuses.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<AffectedPackageStatus>(); | ||||
|         } | ||||
|  | ||||
|         var list = new List<AffectedPackageStatus>(product.Statuses.Count); | ||||
|         foreach (var status in product.Statuses) | ||||
|         { | ||||
|             if (!AffectedPackageStatusCatalog.TryNormalize(status, out var normalized) | ||||
|                 || string.IsNullOrWhiteSpace(normalized)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var provenance = new AdvisoryProvenance( | ||||
|                 VndrCiscoConnectorPlugin.SourceName, | ||||
|                 "status", | ||||
|                 product.ProductId ?? product.Name, | ||||
|                 recordedAt); | ||||
|  | ||||
|             list.Add(new AffectedPackageStatus(normalized, provenance)); | ||||
|         } | ||||
|  | ||||
|         return list.Count == 0 ? Array.Empty<AffectedPackageStatus>() : list; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,101 @@ | ||||
| using System.IO; | ||||
| using System.Net; | ||||
| using System.Net.Http.Headers; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| internal sealed class CiscoOAuthMessageHandler : DelegatingHandler | ||||
| { | ||||
|     private readonly CiscoAccessTokenProvider _tokenProvider; | ||||
|     private readonly ILogger<CiscoOAuthMessageHandler> _logger; | ||||
|  | ||||
|     public CiscoOAuthMessageHandler( | ||||
|         CiscoAccessTokenProvider tokenProvider, | ||||
|         ILogger<CiscoOAuthMessageHandler> logger) | ||||
|     { | ||||
|         _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(request); | ||||
|  | ||||
|         HttpRequestMessage? retryTemplate = null; | ||||
|         try | ||||
|         { | ||||
|             retryTemplate = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             // Unable to buffer content; retry will fail if needed. | ||||
|             retryTemplate = null; | ||||
|         } | ||||
|  | ||||
|         request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await _tokenProvider.GetTokenAsync(cancellationToken).ConfigureAwait(false)); | ||||
|         var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         if (response.StatusCode != HttpStatusCode.Unauthorized) | ||||
|         { | ||||
|             return response; | ||||
|         } | ||||
|  | ||||
|         response.Dispose(); | ||||
|         _logger.LogWarning("Cisco openVuln request returned 401 Unauthorized; refreshing access token."); | ||||
|         await _tokenProvider.RefreshAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (retryTemplate is null) | ||||
|         { | ||||
|             _tokenProvider.Invalidate(); | ||||
|             throw new HttpRequestException("Cisco openVuln request returned 401 Unauthorized and could not be retried."); | ||||
|         } | ||||
|  | ||||
|         retryTemplate.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await _tokenProvider.GetTokenAsync(cancellationToken).ConfigureAwait(false)); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var retryResponse = await base.SendAsync(retryTemplate, cancellationToken).ConfigureAwait(false); | ||||
|             if (retryResponse.StatusCode == HttpStatusCode.Unauthorized) | ||||
|             { | ||||
|                 _tokenProvider.Invalidate(); | ||||
|             } | ||||
|  | ||||
|             return retryResponse; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             retryTemplate.Dispose(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static async Task<HttpRequestMessage?> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var clone = new HttpRequestMessage(request.Method, request.RequestUri) | ||||
|         { | ||||
|             Version = request.Version, | ||||
|             VersionPolicy = request.VersionPolicy, | ||||
|         }; | ||||
|  | ||||
|         foreach (var header in request.Headers) | ||||
|         { | ||||
|             clone.Headers.TryAddWithoutValidation(header.Key, header.Value); | ||||
|         } | ||||
|  | ||||
|         if (request.Content is not null) | ||||
|         { | ||||
|             using var memory = new MemoryStream(); | ||||
|             await request.Content.CopyToAsync(memory, cancellationToken).ConfigureAwait(false); | ||||
|             memory.Position = 0; | ||||
|             var buffer = memory.ToArray(); | ||||
|             var contentClone = new ByteArrayContent(buffer); | ||||
|             foreach (var header in request.Content.Headers) | ||||
|             { | ||||
|                 contentClone.Headers.TryAddWithoutValidation(header.Key, header.Value); | ||||
|             } | ||||
|  | ||||
|             clone.Content = contentClone; | ||||
|         } | ||||
|  | ||||
|         return clone; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,196 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Concelier.Connector.Common.Fetch; | ||||
| using StellaOps.Concelier.Connector.Vndr.Cisco.Configuration; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| public sealed class CiscoOpenVulnClient | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         PropertyNameCaseInsensitive = true, | ||||
|         NumberHandling = JsonNumberHandling.AllowReadingFromString, | ||||
|     }; | ||||
|  | ||||
|     private readonly SourceFetchService _fetchService; | ||||
|     private readonly IOptionsMonitor<CiscoOptions> _options; | ||||
|     private readonly ILogger<CiscoOpenVulnClient> _logger; | ||||
|     private readonly string _sourceName; | ||||
|  | ||||
|     public CiscoOpenVulnClient( | ||||
|         SourceFetchService fetchService, | ||||
|         IOptionsMonitor<CiscoOptions> options, | ||||
|         ILogger<CiscoOpenVulnClient> logger, | ||||
|         string sourceName) | ||||
|     { | ||||
|         _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService)); | ||||
|         _options = options ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _sourceName = sourceName ?? throw new ArgumentNullException(nameof(sourceName)); | ||||
|     } | ||||
|  | ||||
|     internal async Task<CiscoAdvisoryPage?> FetchAsync(DateOnly date, int pageIndex, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var options = _options.CurrentValue; | ||||
|         var requestUri = options.BuildLastModifiedUri(date, pageIndex, options.PageSize); | ||||
|         var request = new SourceFetchRequest(CiscoOptions.HttpClientName, _sourceName, requestUri) | ||||
|         { | ||||
|             AcceptHeaders = new[] { "application/json" }, | ||||
|             TimeoutOverride = options.RequestTimeout, | ||||
|         }; | ||||
|  | ||||
|         var result = await _fetchService.FetchContentAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         if (!result.IsSuccess || result.Content is null) | ||||
|         { | ||||
|             _logger.LogDebug("Cisco openVuln request returned empty payload for {Uri} (status {Status})", requestUri, result.StatusCode); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return CiscoAdvisoryPage.Parse(result.Content); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record CiscoAdvisoryPage( | ||||
|     IReadOnlyList<CiscoAdvisoryItem> Advisories, | ||||
|     CiscoPagination Pagination) | ||||
| { | ||||
|     public bool HasMore => Pagination.PageIndex < Pagination.TotalPages; | ||||
|  | ||||
|     public static CiscoAdvisoryPage Parse(byte[] content) | ||||
|     { | ||||
|         using var document = JsonDocument.Parse(content); | ||||
|         var root = document.RootElement; | ||||
|         var advisories = new List<CiscoAdvisoryItem>(); | ||||
|  | ||||
|         if (root.TryGetProperty("advisories", out var advisoriesElement) && advisoriesElement.ValueKind == JsonValueKind.Array) | ||||
|         { | ||||
|             foreach (var advisory in advisoriesElement.EnumerateArray()) | ||||
|             { | ||||
|                 if (!TryCreateItem(advisory, out var item)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 advisories.Add(item); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var pagination = CiscoPagination.FromJson(root.TryGetProperty("pagination", out var paginationElement) ? paginationElement : default); | ||||
|         return new CiscoAdvisoryPage(advisories, pagination); | ||||
|     } | ||||
|  | ||||
|     private static bool TryCreateItem(JsonElement advisory, [NotNullWhen(true)] out CiscoAdvisoryItem? item) | ||||
|     { | ||||
|         var rawJson = advisory.GetRawText(); | ||||
|         var advisoryId = GetString(advisory, "advisoryId"); | ||||
|         if (string.IsNullOrWhiteSpace(advisoryId)) | ||||
|         { | ||||
|             item = null; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var lastUpdated = ParseDate(GetString(advisory, "lastUpdated")); | ||||
|         var firstPublished = ParseDate(GetString(advisory, "firstPublished")); | ||||
|         var severity = GetString(advisory, "sir"); | ||||
|         var publicationUrl = GetString(advisory, "publicationUrl"); | ||||
|         var csafUrl = GetString(advisory, "csafUrl"); | ||||
|         var cvrfUrl = GetString(advisory, "cvrfUrl"); | ||||
|         var cvss = GetString(advisory, "cvssBaseScore"); | ||||
|  | ||||
|         var cves = ReadStringArray(advisory, "cves"); | ||||
|         var bugIds = ReadStringArray(advisory, "bugIDs"); | ||||
|         var productNames = ReadStringArray(advisory, "productNames"); | ||||
|  | ||||
|         item = new CiscoAdvisoryItem( | ||||
|             advisoryId, | ||||
|             lastUpdated, | ||||
|             firstPublished, | ||||
|             severity, | ||||
|             publicationUrl, | ||||
|             csafUrl, | ||||
|             cvrfUrl, | ||||
|             cvss, | ||||
|             cves, | ||||
|             bugIds, | ||||
|             productNames, | ||||
|             rawJson); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static string? GetString(JsonElement element, string propertyName) | ||||
|         => element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String | ||||
|             ? value.GetString() | ||||
|             : null; | ||||
|  | ||||
|     private static DateTimeOffset? ParseDate(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (DateTimeOffset.TryParse(value, out var parsed)) | ||||
|         { | ||||
|             return parsed.ToUniversalTime(); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> ReadStringArray(JsonElement element, string property) | ||||
|     { | ||||
|         if (!element.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         var results = new List<string>(); | ||||
|         foreach (var child in value.EnumerateArray()) | ||||
|         { | ||||
|             if (child.ValueKind == JsonValueKind.String) | ||||
|             { | ||||
|                 var text = child.GetString(); | ||||
|                 if (!string.IsNullOrWhiteSpace(text)) | ||||
|                 { | ||||
|                     results.Add(text.Trim()); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record CiscoAdvisoryItem( | ||||
|     string AdvisoryId, | ||||
|     DateTimeOffset? LastUpdated, | ||||
|     DateTimeOffset? FirstPublished, | ||||
|     string? Severity, | ||||
|     string? PublicationUrl, | ||||
|     string? CsafUrl, | ||||
|     string? CvrfUrl, | ||||
|     string? CvssBaseScore, | ||||
|     IReadOnlyList<string> Cves, | ||||
|     IReadOnlyList<string> BugIds, | ||||
|     IReadOnlyList<string> ProductNames, | ||||
|     string RawJson) | ||||
| { | ||||
|     public byte[] GetRawBytes() => Encoding.UTF8.GetBytes(RawJson); | ||||
| } | ||||
|  | ||||
| internal sealed record CiscoPagination(int PageIndex, int PageSize, int TotalPages, int TotalRecords) | ||||
| { | ||||
|     public static CiscoPagination FromJson(JsonElement element) | ||||
|     { | ||||
|         var pageIndex = element.TryGetProperty("pageIndex", out var index) && index.TryGetInt32(out var parsedIndex) ? parsedIndex : 1; | ||||
|         var pageSize = element.TryGetProperty("pageSize", out var size) && size.TryGetInt32(out var parsedSize) ? parsedSize : 0; | ||||
|         var totalPages = element.TryGetProperty("totalPages", out var pages) && pages.TryGetInt32(out var parsedPages) ? parsedPages : pageIndex; | ||||
|         var totalRecords = element.TryGetProperty("totalRecords", out var records) && records.TryGetInt32(out var parsedRecords) ? parsedRecords : 0; | ||||
|         return new CiscoPagination(pageIndex, pageSize, totalPages, totalRecords); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,64 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal; | ||||
|  | ||||
| public class CiscoRawAdvisory | ||||
| { | ||||
|     [JsonPropertyName("advisoryId")] | ||||
|     public string? AdvisoryId { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("advisoryTitle")] | ||||
|     public string? AdvisoryTitle { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("publicationUrl")] | ||||
|     public string? PublicationUrl { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("cvrfUrl")] | ||||
|     public string? CvrfUrl { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("csafUrl")] | ||||
|     public string? CsafUrl { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("summary")] | ||||
|     public string? Summary { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("sir")] | ||||
|     public string? Sir { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("firstPublished")] | ||||
|     public string? FirstPublished { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("lastUpdated")] | ||||
|     public string? LastUpdated { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("productNames")] | ||||
|     public List<string>? ProductNames { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("version")] | ||||
|     public string? Version { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("iosRelease")] | ||||
|     public string? IosRelease { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("cves")] | ||||
|     public List<string>? Cves { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("bugIDs")] | ||||
|     public List<string>? BugIds { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("cvssBaseScore")] | ||||
|     public string? CvssBaseScore { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("cvssTemporalScore")] | ||||
|     public string? CvssTemporalScore { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("cvssEnvironmentalScore")] | ||||
|     public string? CvssEnvironmentalScore { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("cvssBaseScoreVersion2")] | ||||
|     public string? CvssBaseScoreV2 { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("status")] | ||||
|     public string? Status { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,132 @@ | ||||
| using System.Globalization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Configuration; | ||||
|  | ||||
| public sealed class MsrcOptions | ||||
| { | ||||
|     public const string HttpClientName = "concelier.source.vndr.msrc"; | ||||
|     public const string TokenClientName = "concelier.source.vndr.msrc.token"; | ||||
|  | ||||
|     public Uri BaseUri { get; set; } = new("https://api.msrc.microsoft.com/sug/v2.0/", UriKind.Absolute); | ||||
|  | ||||
|     public string Locale { get; set; } = "en-US"; | ||||
|  | ||||
|     public string ApiVersion { get; set; } = "2024-08-01"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Azure AD tenant identifier used for client credential flow. | ||||
|     /// </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 client secret used for token acquisition. | ||||
|     /// </summary> | ||||
|     public string ClientSecret { get; set; } = string.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Scope requested during client-credential token acquisition. | ||||
|     /// </summary> | ||||
|     public string Scope { get; set; } = "api://api.msrc.microsoft.com/.default"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Maximum advisories to fetch per cycle. | ||||
|     /// </summary> | ||||
|     public int MaxAdvisoriesPerFetch { get; set; } = 200; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Page size used when iterating the MSRC API. | ||||
|     /// </summary> | ||||
|     public int PageSize { get; set; } = 100; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Overlap window added when resuming from the last modified cursor. | ||||
|     /// </summary> | ||||
|     public TimeSpan CursorOverlap { get; set; } = TimeSpan.FromMinutes(10); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// When enabled the connector downloads the CVRF artefact referenced by each advisory. | ||||
|     /// </summary> | ||||
|     public bool DownloadCvrf { get; set; } = false; | ||||
|  | ||||
|     public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250); | ||||
|  | ||||
|     public TimeSpan FailureBackoff { get; set; } = TimeSpan.FromMinutes(5); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional lower bound for the initial sync if the cursor is empty. | ||||
|     /// </summary> | ||||
|     public DateTimeOffset? InitialLastModified { get; set; } = DateTimeOffset.UtcNow.AddDays(-30); | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         if (BaseUri is null || !BaseUri.IsAbsoluteUri) | ||||
|         { | ||||
|             throw new InvalidOperationException("MSRC base URI must be absolute."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(Locale)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Locale must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(Locale) && !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("API version must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (!Guid.TryParse(TenantId, out _)) | ||||
|         { | ||||
|             throw new InvalidOperationException("TenantId must be a valid GUID."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(ClientId)) | ||||
|         { | ||||
|             throw new InvalidOperationException("ClientId must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(ClientSecret)) | ||||
|         { | ||||
|             throw new InvalidOperationException("ClientSecret must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(Scope)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Scope must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (MaxAdvisoriesPerFetch <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(MaxAdvisoriesPerFetch)} must be greater than zero."); | ||||
|         } | ||||
|  | ||||
|         if (PageSize <= 0 || PageSize > 500) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(PageSize)} must be between 1 and 500."); | ||||
|         } | ||||
|  | ||||
|         if (CursorOverlap < TimeSpan.Zero || CursorOverlap > TimeSpan.FromHours(6)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(CursorOverlap)} must be within 0-6 hours."); | ||||
|         } | ||||
|  | ||||
|         if (RequestDelay < TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(RequestDelay)} cannot be negative."); | ||||
|         } | ||||
|  | ||||
|         if (FailureBackoff <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException($"{nameof(FailureBackoff)} must be positive."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,49 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| public sealed record MsrcAdvisoryDto | ||||
| { | ||||
|     public string AdvisoryId { get; init; } = string.Empty; | ||||
|  | ||||
|     public string Title { get; init; } = string.Empty; | ||||
|  | ||||
|     public string? Description { get; init; } | ||||
|  | ||||
|     public string? Severity { get; init; } | ||||
|  | ||||
|     public DateTimeOffset? ReleaseDate { get; init; } | ||||
|  | ||||
|     public DateTimeOffset? LastModifiedDate { get; init; } | ||||
|  | ||||
|     public IReadOnlyList<string> CveIds { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     public IReadOnlyList<string> KbIds { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     public IReadOnlyList<MsrcAdvisoryThreat> Threats { get; init; } = Array.Empty<MsrcAdvisoryThreat>(); | ||||
|  | ||||
|     public IReadOnlyList<MsrcAdvisoryRemediation> Remediations { get; init; } = Array.Empty<MsrcAdvisoryRemediation>(); | ||||
|  | ||||
|     public IReadOnlyList<MsrcAdvisoryProduct> Products { get; init; } = Array.Empty<MsrcAdvisoryProduct>(); | ||||
|  | ||||
|     public double? CvssBaseScore { get; init; } | ||||
|  | ||||
|     public string? CvssVector { get; init; } | ||||
|  | ||||
|     public string? ReleaseNoteUrl { get; init; } | ||||
|  | ||||
|     public string? CvrfUrl { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record MsrcAdvisoryThreat(string Type, string? Description, string? Severity); | ||||
|  | ||||
| public sealed record MsrcAdvisoryRemediation(string Type, string? Description, string? Url, string? Kb); | ||||
|  | ||||
| public sealed record MsrcAdvisoryProduct( | ||||
|     string Identifier, | ||||
|     string? ProductName, | ||||
|     string? Platform, | ||||
|     string? Architecture, | ||||
|     string? BuildNumber, | ||||
|     string? Cpe); | ||||
| @@ -0,0 +1,138 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Json; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Concelier.Connector.Vndr.Msrc.Configuration; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| public sealed class MsrcApiClient | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         PropertyNameCaseInsensitive = true, | ||||
|         WriteIndented = false, | ||||
|     }; | ||||
|  | ||||
|     private readonly IHttpClientFactory _httpClientFactory; | ||||
|     private readonly IMsrcTokenProvider _tokenProvider; | ||||
|     private readonly MsrcOptions _options; | ||||
|     private readonly ILogger<MsrcApiClient> _logger; | ||||
|  | ||||
|     public MsrcApiClient( | ||||
|         IHttpClientFactory httpClientFactory, | ||||
|         IMsrcTokenProvider tokenProvider, | ||||
|         IOptions<MsrcOptions> options, | ||||
|         ILogger<MsrcApiClient> logger) | ||||
|     { | ||||
|         _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); | ||||
|         _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); | ||||
|         _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<IReadOnlyList<MsrcVulnerabilitySummary>> FetchSummariesAsync(DateTimeOffset fromInclusive, DateTimeOffset toExclusive, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var client = await CreateAuthenticatedClientAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var results = new List<MsrcVulnerabilitySummary>(); | ||||
|         var requestUri = BuildSummaryUri(fromInclusive, toExclusive); | ||||
|  | ||||
|         while (requestUri is not null) | ||||
|         { | ||||
|             using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | ||||
|             using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             if (!response.IsSuccessStatusCode) | ||||
|             { | ||||
|                 var preview = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||
|                 throw new HttpRequestException($"MSRC summary fetch failed with {(int)response.StatusCode}. Body: {preview}"); | ||||
|             } | ||||
|  | ||||
|             var payload = await response.Content.ReadFromJsonAsync<MsrcSummaryResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false) | ||||
|                 ?? new MsrcSummaryResponse(); | ||||
|  | ||||
|             results.AddRange(payload.Value); | ||||
|  | ||||
|             if (string.IsNullOrWhiteSpace(payload.NextLink)) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             requestUri = new Uri(payload.NextLink, UriKind.Absolute); | ||||
|         } | ||||
|  | ||||
|         return results; | ||||
|     } | ||||
|  | ||||
|     public Uri BuildDetailUri(string vulnerabilityId) | ||||
|     { | ||||
|         var uri = CreateDetailUriInternal(vulnerabilityId); | ||||
|         return uri; | ||||
|     } | ||||
|  | ||||
|     public async Task<byte[]> FetchDetailAsync(string vulnerabilityId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var client = await CreateAuthenticatedClientAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var uri = CreateDetailUriInternal(vulnerabilityId); | ||||
|  | ||||
|         using var request = new HttpRequestMessage(HttpMethod.Get, uri); | ||||
|         using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (!response.IsSuccessStatusCode) | ||||
|         { | ||||
|             var preview = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||
|             throw new HttpRequestException($"MSRC detail fetch failed for {vulnerabilityId} with {(int)response.StatusCode}. Body: {preview}"); | ||||
|         } | ||||
|  | ||||
|         return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private async Task<HttpClient> CreateAuthenticatedClientAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var token = await _tokenProvider.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var client = _httpClientFactory.CreateClient(MsrcOptions.HttpClientName); | ||||
|         client.DefaultRequestHeaders.Remove("Authorization"); | ||||
|         client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}"); | ||||
|         client.DefaultRequestHeaders.Remove("Accept"); | ||||
|         client.DefaultRequestHeaders.Add("Accept", "application/json"); | ||||
|         client.DefaultRequestHeaders.Remove("api-version"); | ||||
|         client.DefaultRequestHeaders.Add("api-version", _options.ApiVersion); | ||||
|         client.DefaultRequestHeaders.Remove("Accept-Language"); | ||||
|         client.DefaultRequestHeaders.Add("Accept-Language", _options.Locale); | ||||
|         return client; | ||||
|     } | ||||
|  | ||||
|     private Uri BuildSummaryUri(DateTimeOffset fromInclusive, DateTimeOffset toExclusive) | ||||
|     { | ||||
|         var builder = new StringBuilder(); | ||||
|         builder.Append(_options.BaseUri.ToString().TrimEnd('/')); | ||||
|         builder.Append("/vulnerabilities?"); | ||||
|         builder.Append("$top=").Append(_options.PageSize); | ||||
|         builder.Append("&lastModifiedStartDateTime=").Append(Uri.EscapeDataString(fromInclusive.ToUniversalTime().ToString("O"))); | ||||
|         builder.Append("&lastModifiedEndDateTime=").Append(Uri.EscapeDataString(toExclusive.ToUniversalTime().ToString("O"))); | ||||
|         builder.Append("&$orderby=lastModifiedDate"); | ||||
|         builder.Append("&locale=").Append(Uri.EscapeDataString(_options.Locale)); | ||||
|         builder.Append("&api-version=").Append(Uri.EscapeDataString(_options.ApiVersion)); | ||||
|  | ||||
|         return new Uri(builder.ToString(), UriKind.Absolute); | ||||
|     } | ||||
|  | ||||
|     private Uri CreateDetailUriInternal(string vulnerabilityId) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(vulnerabilityId)) | ||||
|         { | ||||
|             throw new ArgumentException("Vulnerability identifier must be provided.", nameof(vulnerabilityId)); | ||||
|         } | ||||
|  | ||||
|         var baseUri = _options.BaseUri.ToString().TrimEnd('/'); | ||||
|         var path = $"{baseUri}/vulnerability/{Uri.EscapeDataString(vulnerabilityId)}?api-version={Uri.EscapeDataString(_options.ApiVersion)}&locale={Uri.EscapeDataString(_options.Locale)}"; | ||||
|         return new Uri(path, UriKind.Absolute); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,87 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using MongoDB.Bson; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| internal sealed record MsrcCursor( | ||||
|     IReadOnlyCollection<Guid> PendingDocuments, | ||||
|     IReadOnlyCollection<Guid> PendingMappings, | ||||
|     DateTimeOffset? LastModifiedCursor) | ||||
| { | ||||
|     private static readonly IReadOnlyCollection<Guid> EmptyGuidSet = Array.Empty<Guid>(); | ||||
|  | ||||
|     public static MsrcCursor Empty { get; } = new(EmptyGuidSet, EmptyGuidSet, null); | ||||
|  | ||||
|     public MsrcCursor WithPendingDocuments(IEnumerable<Guid> documents) | ||||
|         => this with { PendingDocuments = Distinct(documents) }; | ||||
|  | ||||
|     public MsrcCursor WithPendingMappings(IEnumerable<Guid> mappings) | ||||
|         => this with { PendingMappings = Distinct(mappings) }; | ||||
|  | ||||
|     public MsrcCursor WithLastModifiedCursor(DateTimeOffset? timestamp) | ||||
|         => this with { LastModifiedCursor = timestamp }; | ||||
|  | ||||
|     public BsonDocument ToBsonDocument() | ||||
|     { | ||||
|         var document = new BsonDocument | ||||
|         { | ||||
|             ["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())), | ||||
|             ["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())), | ||||
|         }; | ||||
|  | ||||
|         if (LastModifiedCursor.HasValue) | ||||
|         { | ||||
|             document["lastModifiedCursor"] = LastModifiedCursor.Value.UtcDateTime; | ||||
|         } | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     public static MsrcCursor FromBson(BsonDocument? document) | ||||
|     { | ||||
|         if (document is null || document.ElementCount == 0) | ||||
|         { | ||||
|             return Empty; | ||||
|         } | ||||
|  | ||||
|         var pendingDocuments = ReadGuidArray(document, "pendingDocuments"); | ||||
|         var pendingMappings = ReadGuidArray(document, "pendingMappings"); | ||||
|         var lastModified = document.TryGetValue("lastModifiedCursor", out var value) | ||||
|             ? ParseDate(value) | ||||
|             : null; | ||||
|  | ||||
|         return new MsrcCursor(pendingDocuments, pendingMappings, lastModified); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyCollection<Guid> Distinct(IEnumerable<Guid>? values) | ||||
|         => values?.Distinct().ToArray() ?? EmptyGuidSet; | ||||
|  | ||||
|     private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field) | ||||
|     { | ||||
|         if (!document.TryGetValue(field, out var value) || value is not BsonArray array) | ||||
|         { | ||||
|             return EmptyGuidSet; | ||||
|         } | ||||
|  | ||||
|         var items = new List<Guid>(array.Count); | ||||
|         foreach (var element in array) | ||||
|         { | ||||
|             if (Guid.TryParse(element?.ToString(), out var id)) | ||||
|             { | ||||
|                 items.Add(id); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return items; | ||||
|     } | ||||
|  | ||||
|     private static DateTimeOffset? ParseDate(BsonValue value) | ||||
|         => value.BsonType switch | ||||
|         { | ||||
|             BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc), | ||||
|             BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(), | ||||
|             _ => null, | ||||
|         }; | ||||
| } | ||||
| @@ -0,0 +1,113 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| public sealed record MsrcVulnerabilityDetailDto | ||||
| { | ||||
|     [JsonPropertyName("id")] | ||||
|     public string Id { get; init; } = string.Empty; | ||||
|  | ||||
|     [JsonPropertyName("vulnerabilityId")] | ||||
|     public string VulnerabilityId { get; init; } = string.Empty; | ||||
|  | ||||
|     [JsonPropertyName("cveNumber")] | ||||
|     public string? CveNumber { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("cveNumbers")] | ||||
|     public IReadOnlyList<string> CveNumbers { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     [JsonPropertyName("title")] | ||||
|     public string Title { get; init; } = string.Empty; | ||||
|  | ||||
|     [JsonPropertyName("description")] | ||||
|     public string? Description { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("releaseDate")] | ||||
|     public DateTimeOffset? ReleaseDate { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("lastModifiedDate")] | ||||
|     public DateTimeOffset? LastModifiedDate { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("severity")] | ||||
|     public string? Severity { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("threats")] | ||||
|     public IReadOnlyList<MsrcThreatDto> Threats { get; init; } = Array.Empty<MsrcThreatDto>(); | ||||
|  | ||||
|     [JsonPropertyName("remediations")] | ||||
|     public IReadOnlyList<MsrcRemediationDto> Remediations { get; init; } = Array.Empty<MsrcRemediationDto>(); | ||||
|  | ||||
|     [JsonPropertyName("affectedProducts")] | ||||
|     public IReadOnlyList<MsrcAffectedProductDto> AffectedProducts { get; init; } = Array.Empty<MsrcAffectedProductDto>(); | ||||
|  | ||||
|     [JsonPropertyName("cvssV3")] | ||||
|     public MsrcCvssDto? Cvss { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("releaseNoteUrl")] | ||||
|     public string? ReleaseNoteUrl { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("cvrfUrl")] | ||||
|     public string? CvrfUrl { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record MsrcThreatDto | ||||
| { | ||||
|     [JsonPropertyName("type")] | ||||
|     public string? Type { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("description")] | ||||
|     public string? Description { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("severity")] | ||||
|     public string? Severity { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record MsrcRemediationDto | ||||
| { | ||||
|     [JsonPropertyName("id")] | ||||
|     public string? Id { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("type")] | ||||
|     public string? Type { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("description")] | ||||
|     public string? Description { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("url")] | ||||
|     public string? Url { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("kbNumber")] | ||||
|     public string? KbNumber { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record MsrcAffectedProductDto | ||||
| { | ||||
|     [JsonPropertyName("productId")] | ||||
|     public string? ProductId { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("productName")] | ||||
|     public string? ProductName { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("cpe")] | ||||
|     public string? Cpe { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("platform")] | ||||
|     public string? Platform { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("architecture")] | ||||
|     public string? Architecture { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("buildNumber")] | ||||
|     public string? BuildNumber { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record MsrcCvssDto | ||||
| { | ||||
|     [JsonPropertyName("baseScore")] | ||||
|     public double? BaseScore { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("vectorString")] | ||||
|     public string? VectorString { get; init; } | ||||
| } | ||||
| @@ -0,0 +1,71 @@ | ||||
| using System; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| public sealed class MsrcDetailParser | ||||
| { | ||||
|     public MsrcAdvisoryDto Parse(MsrcVulnerabilityDetailDto detail) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(detail); | ||||
|  | ||||
|         var advisoryId = string.IsNullOrWhiteSpace(detail.VulnerabilityId) ? detail.Id : detail.VulnerabilityId; | ||||
|         var cveIds = detail.CveNumbers?.Where(static c => !string.IsNullOrWhiteSpace(c)).Select(static c => c.Trim()).ToArray() | ||||
|             ?? (string.IsNullOrWhiteSpace(detail.CveNumber) ? Array.Empty<string>() : new[] { detail.CveNumber! }); | ||||
|  | ||||
|         var kbIds = detail.Remediations? | ||||
|             .Where(static remediation => !string.IsNullOrWhiteSpace(remediation.KbNumber)) | ||||
|             .Select(static remediation => remediation.KbNumber!.Trim()) | ||||
|             .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray() ?? Array.Empty<string>(); | ||||
|  | ||||
|         return new MsrcAdvisoryDto | ||||
|         { | ||||
|             AdvisoryId = advisoryId, | ||||
|             Title = string.IsNullOrWhiteSpace(detail.Title) ? advisoryId : detail.Title.Trim(), | ||||
|             Description = detail.Description, | ||||
|             Severity = detail.Severity, | ||||
|             ReleaseDate = detail.ReleaseDate, | ||||
|             LastModifiedDate = detail.LastModifiedDate, | ||||
|             CveIds = cveIds, | ||||
|             KbIds = kbIds, | ||||
|             Threats = detail.Threats?.Select(static threat => new MsrcAdvisoryThreat( | ||||
|                 threat.Type ?? "unspecified", | ||||
|                 threat.Description, | ||||
|                 threat.Severity)).ToArray() ?? Array.Empty<MsrcAdvisoryThreat>(), | ||||
|             Remediations = detail.Remediations?.Select(static remediation => new MsrcAdvisoryRemediation( | ||||
|                 remediation.Type ?? "unspecified", | ||||
|                 remediation.Description, | ||||
|                 remediation.Url, | ||||
|                 remediation.KbNumber)).ToArray() ?? Array.Empty<MsrcAdvisoryRemediation>(), | ||||
|             Products = detail.AffectedProducts?.Select(product => | ||||
|                 new MsrcAdvisoryProduct( | ||||
|                     BuildProductIdentifier(product), | ||||
|                     product.ProductName, | ||||
|                     product.Platform, | ||||
|                     product.Architecture, | ||||
|                     product.BuildNumber, | ||||
|                     product.Cpe)).ToArray() ?? Array.Empty<MsrcAdvisoryProduct>(), | ||||
|             CvssBaseScore = detail.Cvss?.BaseScore, | ||||
|             CvssVector = detail.Cvss?.VectorString, | ||||
|             ReleaseNoteUrl = detail.ReleaseNoteUrl, | ||||
|             CvrfUrl = detail.CvrfUrl, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static string BuildProductIdentifier(MsrcAffectedProductDto product) | ||||
|     { | ||||
|         var name = string.IsNullOrWhiteSpace(product.ProductName) ? product.ProductId : product.ProductName; | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             name = "Unknown Product"; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(product.BuildNumber)) | ||||
|         { | ||||
|             return $"{name} build {product.BuildNumber}"; | ||||
|         } | ||||
|  | ||||
|         return name; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,129 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics.Metrics; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| public sealed class MsrcDiagnostics : IDisposable | ||||
| { | ||||
|     private const string MeterName = "StellaOps.Concelier.Connector.Vndr.Msrc"; | ||||
|     private const string MeterVersion = "1.0.0"; | ||||
|  | ||||
|     private readonly Meter _meter; | ||||
|     private readonly Counter<long> _summaryFetchAttempts; | ||||
|     private readonly Counter<long> _summaryFetchSuccess; | ||||
|     private readonly Counter<long> _summaryFetchFailures; | ||||
|     private readonly Histogram<long> _summaryItemCount; | ||||
|     private readonly Histogram<double> _summaryWindowHours; | ||||
|     private readonly Counter<long> _detailFetchAttempts; | ||||
|     private readonly Counter<long> _detailFetchSuccess; | ||||
|     private readonly Counter<long> _detailFetchNotModified; | ||||
|     private readonly Counter<long> _detailFetchFailures; | ||||
|     private readonly Histogram<long> _detailEnqueued; | ||||
|     private readonly Counter<long> _parseSuccess; | ||||
|     private readonly Counter<long> _parseFailures; | ||||
|     private readonly Histogram<long> _parseProductCount; | ||||
|     private readonly Histogram<long> _parseKbCount; | ||||
|     private readonly Counter<long> _mapSuccess; | ||||
|     private readonly Counter<long> _mapFailures; | ||||
|     private readonly Histogram<long> _mapAliasCount; | ||||
|     private readonly Histogram<long> _mapAffectedCount; | ||||
|  | ||||
|     public MsrcDiagnostics() | ||||
|     { | ||||
|         _meter = new Meter(MeterName, MeterVersion); | ||||
|         _summaryFetchAttempts = _meter.CreateCounter<long>("msrc.summary.fetch.attempts", "operations"); | ||||
|         _summaryFetchSuccess = _meter.CreateCounter<long>("msrc.summary.fetch.success", "operations"); | ||||
|         _summaryFetchFailures = _meter.CreateCounter<long>("msrc.summary.fetch.failures", "operations"); | ||||
|         _summaryItemCount = _meter.CreateHistogram<long>("msrc.summary.items.count", "items"); | ||||
|         _summaryWindowHours = _meter.CreateHistogram<double>("msrc.summary.window.hours", "hours"); | ||||
|         _detailFetchAttempts = _meter.CreateCounter<long>("msrc.detail.fetch.attempts", "operations"); | ||||
|         _detailFetchSuccess = _meter.CreateCounter<long>("msrc.detail.fetch.success", "operations"); | ||||
|         _detailFetchNotModified = _meter.CreateCounter<long>("msrc.detail.fetch.not_modified", "operations"); | ||||
|         _detailFetchFailures = _meter.CreateCounter<long>("msrc.detail.fetch.failures", "operations"); | ||||
|         _detailEnqueued = _meter.CreateHistogram<long>("msrc.detail.enqueued.count", "documents"); | ||||
|         _parseSuccess = _meter.CreateCounter<long>("msrc.parse.success", "documents"); | ||||
|         _parseFailures = _meter.CreateCounter<long>("msrc.parse.failures", "documents"); | ||||
|         _parseProductCount = _meter.CreateHistogram<long>("msrc.parse.products.count", "products"); | ||||
|         _parseKbCount = _meter.CreateHistogram<long>("msrc.parse.kb.count", "kb"); | ||||
|         _mapSuccess = _meter.CreateCounter<long>("msrc.map.success", "advisories"); | ||||
|         _mapFailures = _meter.CreateCounter<long>("msrc.map.failures", "advisories"); | ||||
|         _mapAliasCount = _meter.CreateHistogram<long>("msrc.map.aliases.count", "aliases"); | ||||
|         _mapAffectedCount = _meter.CreateHistogram<long>("msrc.map.affected.count", "packages"); | ||||
|     } | ||||
|  | ||||
|     public void SummaryFetchAttempt() => _summaryFetchAttempts.Add(1); | ||||
|  | ||||
|     public void SummaryFetchSuccess(int count, double? windowHours) | ||||
|     { | ||||
|         _summaryFetchSuccess.Add(1); | ||||
|         if (count >= 0) | ||||
|         { | ||||
|             _summaryItemCount.Record(count); | ||||
|         } | ||||
|  | ||||
|         if (windowHours is { } value && value >= 0) | ||||
|         { | ||||
|             _summaryWindowHours.Record(value); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void SummaryFetchFailure(string reason) | ||||
|         => _summaryFetchFailures.Add(1, ReasonTag(reason)); | ||||
|  | ||||
|     public void DetailFetchAttempt() => _detailFetchAttempts.Add(1); | ||||
|  | ||||
|     public void DetailFetchSuccess() => _detailFetchSuccess.Add(1); | ||||
|  | ||||
|     public void DetailFetchNotModified() => _detailFetchNotModified.Add(1); | ||||
|  | ||||
|     public void DetailFetchFailure(string reason) | ||||
|         => _detailFetchFailures.Add(1, ReasonTag(reason)); | ||||
|  | ||||
|     public void DetailEnqueued(int count) | ||||
|     { | ||||
|         if (count >= 0) | ||||
|         { | ||||
|             _detailEnqueued.Record(count); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void ParseSuccess(int productCount, int kbCount) | ||||
|     { | ||||
|         _parseSuccess.Add(1); | ||||
|         if (productCount >= 0) | ||||
|         { | ||||
|             _parseProductCount.Record(productCount); | ||||
|         } | ||||
|  | ||||
|         if (kbCount >= 0) | ||||
|         { | ||||
|             _parseKbCount.Record(kbCount); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void ParseFailure(string reason) | ||||
|         => _parseFailures.Add(1, ReasonTag(reason)); | ||||
|  | ||||
|     public void MapSuccess(int aliasCount, int packageCount) | ||||
|     { | ||||
|         _mapSuccess.Add(1); | ||||
|         if (aliasCount >= 0) | ||||
|         { | ||||
|             _mapAliasCount.Record(aliasCount); | ||||
|         } | ||||
|  | ||||
|         if (packageCount >= 0) | ||||
|         { | ||||
|             _mapAffectedCount.Record(packageCount); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void MapFailure(string reason) | ||||
|         => _mapFailures.Add(1, ReasonTag(reason)); | ||||
|  | ||||
|     private static KeyValuePair<string, object?> ReasonTag(string reason) | ||||
|         => new("reason", string.IsNullOrWhiteSpace(reason) ? "unknown" : reason.ToLowerInvariant()); | ||||
|  | ||||
|     public void Dispose() => _meter.Dispose(); | ||||
| } | ||||
| @@ -0,0 +1,45 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| internal static class MsrcDocumentMetadata | ||||
| { | ||||
|     public static Dictionary<string, string> CreateMetadata(MsrcVulnerabilitySummary summary) | ||||
|     { | ||||
|         var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) | ||||
|         { | ||||
|             ["msrc.vulnerabilityId"] = summary.VulnerabilityId ?? summary.Id, | ||||
|             ["msrc.id"] = summary.Id, | ||||
|         }; | ||||
|  | ||||
|         if (summary.LastModifiedDate.HasValue) | ||||
|         { | ||||
|             metadata["msrc.lastModified"] = summary.LastModifiedDate.Value.ToString("O"); | ||||
|         } | ||||
|  | ||||
|         if (summary.ReleaseDate.HasValue) | ||||
|         { | ||||
|             metadata["msrc.releaseDate"] = summary.ReleaseDate.Value.ToString("O"); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(summary.CvrfUrl)) | ||||
|         { | ||||
|             metadata["msrc.cvrfUrl"] = summary.CvrfUrl!; | ||||
|         } | ||||
|  | ||||
|         if (summary.CveNumbers.Count > 0) | ||||
|         { | ||||
|             metadata["msrc.cves"] = string.Join(",", summary.CveNumbers); | ||||
|         } | ||||
|  | ||||
|         return metadata; | ||||
|     } | ||||
|  | ||||
|     public static Dictionary<string, string> CreateCvrfMetadata(MsrcVulnerabilitySummary summary) | ||||
|     { | ||||
|         var metadata = CreateMetadata(summary); | ||||
|         metadata["msrc.cvrf"] = "true"; | ||||
|         return metadata; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,239 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using StellaOps.Concelier.Models; | ||||
| using StellaOps.Concelier.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| internal static class MsrcMapper | ||||
| { | ||||
|     public static Advisory Map(MsrcAdvisoryDto dto, DocumentRecord document, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(dto); | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|  | ||||
|         var advisoryKey = dto.AdvisoryId; | ||||
|         var aliases = BuildAliases(dto); | ||||
|         var references = BuildReferences(dto, recordedAt); | ||||
|         var affectedPackages = BuildPackages(dto, recordedAt); | ||||
|         var cvssMetrics = BuildCvss(dto, recordedAt); | ||||
|  | ||||
|         var provenance = new AdvisoryProvenance( | ||||
|             source: MsrcConnectorPlugin.SourceName, | ||||
|             kind: "advisory", | ||||
|             value: advisoryKey, | ||||
|             recordedAt, | ||||
|             new[] { ProvenanceFieldMasks.Advisory }); | ||||
|  | ||||
|         return new Advisory( | ||||
|             advisoryKey: advisoryKey, | ||||
|             title: dto.Title, | ||||
|             summary: dto.Description, | ||||
|             language: "en", | ||||
|             published: dto.ReleaseDate, | ||||
|             modified: dto.LastModifiedDate, | ||||
|             severity: NormalizeSeverity(dto.Severity), | ||||
|             exploitKnown: false, | ||||
|             aliases: aliases, | ||||
|             references: references, | ||||
|             affectedPackages: affectedPackages, | ||||
|             cvssMetrics: cvssMetrics, | ||||
|             provenance: new[] { provenance }); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> BuildAliases(MsrcAdvisoryDto dto) | ||||
|     { | ||||
|         var aliases = new List<string> { dto.AdvisoryId }; | ||||
|         foreach (var cve in dto.CveIds) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(cve)) | ||||
|             { | ||||
|                 aliases.Add(cve); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach (var kb in dto.KbIds) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(kb)) | ||||
|             { | ||||
|                 aliases.Add(kb.StartsWith("KB", StringComparison.OrdinalIgnoreCase) ? kb : $"KB{kb}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return aliases | ||||
|             .Where(static alias => !string.IsNullOrWhiteSpace(alias)) | ||||
|             .Distinct(StringComparer.OrdinalIgnoreCase) | ||||
|             .OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AdvisoryReference> BuildReferences(MsrcAdvisoryDto dto, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         var references = new List<AdvisoryReference>(); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(dto.ReleaseNoteUrl)) | ||||
|         { | ||||
|             references.Add(CreateReference(dto.ReleaseNoteUrl!, "details", recordedAt)); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(dto.CvrfUrl)) | ||||
|         { | ||||
|             references.Add(CreateReference(dto.CvrfUrl!, "cvrf", recordedAt)); | ||||
|         } | ||||
|  | ||||
|         foreach (var remediation in dto.Remediations) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(remediation.Url)) | ||||
|             { | ||||
|                 references.Add(CreateReference( | ||||
|                     remediation.Url!, | ||||
|                     string.Equals(remediation.Type, "security update", StringComparison.OrdinalIgnoreCase) ? "remediation" : remediation.Type ?? "reference", | ||||
|                     recordedAt, | ||||
|                     remediation.Description)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return references | ||||
|             .DistinctBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase) | ||||
|             .OrderBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static AdvisoryReference CreateReference(string url, string kind, DateTimeOffset recordedAt, string? summary = null) | ||||
|         => new( | ||||
|             url, | ||||
|             kind: kind.ToLowerInvariant(), | ||||
|             sourceTag: "msrc", | ||||
|             summary: summary, | ||||
|             provenance: new AdvisoryProvenance( | ||||
|                 MsrcConnectorPlugin.SourceName, | ||||
|                 "reference", | ||||
|                 url, | ||||
|                 recordedAt, | ||||
|                 new[] { ProvenanceFieldMasks.References })); | ||||
|  | ||||
|     private static IReadOnlyList<AffectedPackage> BuildPackages(MsrcAdvisoryDto dto, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (dto.Products.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<AffectedPackage>(); | ||||
|         } | ||||
|  | ||||
|         var packages = new List<AffectedPackage>(dto.Products.Count); | ||||
|         foreach (var product in dto.Products) | ||||
|         { | ||||
|             var identifier = string.IsNullOrWhiteSpace(product.Identifier) ? "Unknown Product" : product.Identifier; | ||||
|             var provenance = new AdvisoryProvenance( | ||||
|                 MsrcConnectorPlugin.SourceName, | ||||
|                 "package", | ||||
|                 identifier, | ||||
|                 recordedAt, | ||||
|                 new[] { ProvenanceFieldMasks.AffectedPackages }); | ||||
|  | ||||
|             var notes = new List<string>(); | ||||
|             if (!string.IsNullOrWhiteSpace(product.Platform)) | ||||
|             { | ||||
|                 notes.Add($"platform:{product.Platform}"); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(product.Architecture)) | ||||
|             { | ||||
|                 notes.Add($"arch:{product.Architecture}"); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(product.Cpe)) | ||||
|             { | ||||
|                 notes.Add($"cpe:{product.Cpe}"); | ||||
|             } | ||||
|  | ||||
|             var range = !string.IsNullOrWhiteSpace(product.BuildNumber) | ||||
|                 ? new[] | ||||
|                 { | ||||
|                     new AffectedVersionRange( | ||||
|                         rangeKind: "custom", | ||||
|                         introducedVersion: null, | ||||
|                         fixedVersion: null, | ||||
|                         lastAffectedVersion: null, | ||||
|                         rangeExpression: $"build:{product.BuildNumber}", | ||||
|                         provenance: new AdvisoryProvenance( | ||||
|                             MsrcConnectorPlugin.SourceName, | ||||
|                             "package-range", | ||||
|                             identifier, | ||||
|                             recordedAt, | ||||
|                             new[] { ProvenanceFieldMasks.VersionRanges })), | ||||
|                 } | ||||
|                 : Array.Empty<AffectedVersionRange>(); | ||||
|  | ||||
|             var normalizedRules = !string.IsNullOrWhiteSpace(product.BuildNumber) | ||||
|                 ? new[] | ||||
|                 { | ||||
|                     new NormalizedVersionRule( | ||||
|                         scheme: "msrc.build", | ||||
|                         type: NormalizedVersionRuleTypes.Exact, | ||||
|                         value: product.BuildNumber, | ||||
|                         notes: string.Join(";", notes.Where(static n => !string.IsNullOrWhiteSpace(n)))) | ||||
|                 } | ||||
|                 : Array.Empty<NormalizedVersionRule>(); | ||||
|  | ||||
|             packages.Add(new AffectedPackage( | ||||
|                 type: AffectedPackageTypes.Vendor, | ||||
|                 identifier: identifier, | ||||
|                 platform: product.Platform, | ||||
|                 versionRanges: range, | ||||
|                 statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|                 provenance: new[] { provenance }, | ||||
|                 normalizedVersions: normalizedRules)); | ||||
|         } | ||||
|  | ||||
|         return packages | ||||
|             .DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase) | ||||
|             .OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<CvssMetric> BuildCvss(MsrcAdvisoryDto dto, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (dto.CvssBaseScore is null || string.IsNullOrWhiteSpace(dto.CvssVector)) | ||||
|         { | ||||
|             return Array.Empty<CvssMetric>(); | ||||
|         } | ||||
|  | ||||
|         var severity = CvssSeverityFromScore(dto.CvssBaseScore.Value); | ||||
|  | ||||
|         return new[] | ||||
|         { | ||||
|             new CvssMetric( | ||||
|                 version: "3.1", | ||||
|                 vector: dto.CvssVector!, | ||||
|                 baseScore: dto.CvssBaseScore.Value, | ||||
|                 baseSeverity: severity, | ||||
|                 provenance: new AdvisoryProvenance( | ||||
|                     MsrcConnectorPlugin.SourceName, | ||||
|                     "cvss", | ||||
|                     dto.AdvisoryId, | ||||
|                     recordedAt, | ||||
|                     new[] { ProvenanceFieldMasks.CvssMetrics })), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static string CvssSeverityFromScore(double score) | ||||
|         => score switch | ||||
|         { | ||||
|             < 0 => "none", | ||||
|             < 4 => "low", | ||||
|             < 7 => "medium", | ||||
|             < 9 => "high", | ||||
|             _ => "critical", | ||||
|         }; | ||||
|  | ||||
|     private static string? NormalizeSeverity(string? severity) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(severity)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return severity.Trim().ToLowerInvariant(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,47 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| public sealed record MsrcSummaryResponse | ||||
| { | ||||
|     [JsonPropertyName("value")] | ||||
|     public List<MsrcVulnerabilitySummary> Value { get; init; } = new(); | ||||
|  | ||||
|     [JsonPropertyName("@odata.nextLink")] | ||||
|     public string? NextLink { get; init; } | ||||
| } | ||||
|  | ||||
| public sealed record MsrcVulnerabilitySummary | ||||
| { | ||||
|     [JsonPropertyName("id")] | ||||
|     public string Id { get; init; } = string.Empty; | ||||
|  | ||||
|     [JsonPropertyName("vulnerabilityId")] | ||||
|     public string? VulnerabilityId { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("cveNumber")] | ||||
|     public string? CveNumber { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("cveNumbers")] | ||||
|     public IReadOnlyList<string> CveNumbers { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     [JsonPropertyName("title")] | ||||
|     public string? Title { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("description")] | ||||
|     public string? Description { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("releaseDate")] | ||||
|     public DateTimeOffset? ReleaseDate { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("lastModifiedDate")] | ||||
|     public DateTimeOffset? LastModifiedDate { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("severity")] | ||||
|     public string? Severity { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("cvrfUrl")] | ||||
|     public string? CvrfUrl { get; init; } | ||||
| } | ||||
| @@ -0,0 +1,106 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Concelier.Connector.Vndr.Msrc.Configuration; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Vndr.Msrc.Internal; | ||||
|  | ||||
| public interface IMsrcTokenProvider | ||||
| { | ||||
|     Task<string> GetAccessTokenAsync(CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public sealed class MsrcTokenProvider : IMsrcTokenProvider, IDisposable | ||||
| { | ||||
|     private readonly IHttpClientFactory _httpClientFactory; | ||||
|     private readonly MsrcOptions _options; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly ILogger<MsrcTokenProvider> _logger; | ||||
|     private readonly SemaphoreSlim _refreshLock = new(1, 1); | ||||
|  | ||||
|     private AccessToken? _currentToken; | ||||
|  | ||||
|     public MsrcTokenProvider( | ||||
|         IHttpClientFactory httpClientFactory, | ||||
|         IOptions<MsrcOptions> options, | ||||
|         TimeProvider? timeProvider, | ||||
|         ILogger<MsrcTokenProvider> logger) | ||||
|     { | ||||
|         _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); | ||||
|         _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _options.Validate(); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var token = _currentToken; | ||||
|         if (token is not null && !token.IsExpired(_timeProvider.GetUtcNow())) | ||||
|         { | ||||
|             return token.Token; | ||||
|         } | ||||
|  | ||||
|         await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); | ||||
|         try | ||||
|         { | ||||
|             token = _currentToken; | ||||
|             if (token is not null && !token.IsExpired(_timeProvider.GetUtcNow())) | ||||
|             { | ||||
|                 return token.Token; | ||||
|             } | ||||
|  | ||||
|             _logger.LogInformation("Requesting new MSRC access token"); | ||||
|             var client = _httpClientFactory.CreateClient(MsrcOptions.TokenClientName); | ||||
|             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"] = _options.Scope, | ||||
|                 }), | ||||
|             }; | ||||
|  | ||||
|             using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|             response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|             var payload = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: cancellationToken).ConfigureAwait(false) | ||||
|                 ?? throw new InvalidOperationException("AAD token response was null."); | ||||
|  | ||||
|             var expiresAt = _timeProvider.GetUtcNow().AddSeconds(payload.ExpiresIn - 60); | ||||
|             _currentToken = new AccessToken(payload.AccessToken, expiresAt); | ||||
|             return payload.AccessToken; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             _refreshLock.Release(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private Uri BuildTokenUri() | ||||
|         => new($"https://login.microsoftonline.com/{_options.TenantId}/oauth2/v2.0/token"); | ||||
|  | ||||
|     public void Dispose() => _refreshLock.Dispose(); | ||||
|  | ||||
|     private sealed record AccessToken(string Token, DateTimeOffset ExpiresAt) | ||||
|     { | ||||
|         public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt; | ||||
|     } | ||||
|  | ||||
|     private sealed record TokenResponse | ||||
|     { | ||||
|         [JsonPropertyName("access_token")] | ||||
|         public string AccessToken { get; init; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("expires_in")] | ||||
|         public int ExpiresIn { get; init; } | ||||
|     } | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Text; | ||||
| using FluentAssertions; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Text; | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Options; | ||||
| @@ -11,12 +11,13 @@ using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Cisco.CSAF; | ||||
| using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration; | ||||
| using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using System.Collections.Immutable; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
| using System.Threading; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using System.Collections.Immutable; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
| using System.Threading; | ||||
| using MongoDB.Driver; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Tests.Connectors; | ||||
|  | ||||
| @@ -159,14 +160,14 @@ public sealed class CiscoCsafConnectorTests | ||||
|     { | ||||
|         public VexConnectorState? CurrentState { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(CurrentState); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken) | ||||
|         { | ||||
|             CurrentState = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult(CurrentState); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             CurrentState = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryRawSink : IVexRawDocumentSink | ||||
|   | ||||
| @@ -10,11 +10,12 @@ using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.MSRC.CSAF; | ||||
| using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication; | ||||
| using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using Xunit; | ||||
| using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication; | ||||
| using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using Xunit; | ||||
| using MongoDB.Driver; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors; | ||||
|  | ||||
| @@ -316,14 +317,14 @@ public sealed class MsrcCsafConnectorTests | ||||
|     { | ||||
|         public VexConnectorState? State { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(State); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken) | ||||
|         { | ||||
|             State = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult(State); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             State = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class TestHttpMessageHandler : HttpMessageHandler | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -15,11 +15,12 @@ using Microsoft.Extensions.Logging.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Oracle.CSAF; | ||||
| using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration; | ||||
| using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
| using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
| using MongoDB.Driver; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.Oracle.CSAF.Tests.Connectors; | ||||
|  | ||||
| @@ -254,14 +255,14 @@ public sealed class OracleCsafConnectorTests | ||||
|     { | ||||
|         public VexConnectorState? State { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(State); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken) | ||||
|         { | ||||
|             State = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult(State); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             State = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryRawSink : IVexRawDocumentSink | ||||
|   | ||||
| @@ -10,9 +10,10 @@ using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.RedHat.CSAF.Configuration; | ||||
| using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using StellaOps.Excititor.Connectors.RedHat.CSAF.Metadata; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using MongoDB.Driver; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.RedHat.CSAF.Tests.Connectors; | ||||
|  | ||||
| @@ -258,20 +259,20 @@ public sealed class RedHatCsafConnectorTests | ||||
|     { | ||||
|         public VexConnectorState? State { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken) | ||||
|         { | ||||
|             if (State is not null && string.Equals(State.ConnectorId, connectorId, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return ValueTask.FromResult<VexConnectorState?>(State); | ||||
|             } | ||||
|  | ||||
|             return ValueTask.FromResult<VexConnectorState?>(null); | ||||
|         } | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken) | ||||
|         { | ||||
|             State = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             if (State is not null && string.Equals(State.ConnectorId, connectorId, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return ValueTask.FromResult<VexConnectorState?>(State); | ||||
|             } | ||||
|  | ||||
|             return ValueTask.FromResult<VexConnectorState?>(null); | ||||
|         } | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             State = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -14,9 +14,9 @@ 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; | ||||
|  | ||||
| internal sealed class RancherHubEventClient | ||||
| namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events; | ||||
|  | ||||
| public sealed class RancherHubEventClient | ||||
| { | ||||
|     private readonly IHttpClientFactory _httpClientFactory; | ||||
|     private readonly RancherHubTokenProvider _tokenProvider; | ||||
|   | ||||
| @@ -1,21 +1,21 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events; | ||||
|  | ||||
| internal sealed record RancherHubEventRecord( | ||||
|     string RawJson, | ||||
|     string? Id, | ||||
|     string? Type, | ||||
|     string? Channel, | ||||
|     DateTimeOffset? PublishedAt, | ||||
|     Uri? DocumentUri, | ||||
|     string? DocumentDigest, | ||||
|     string? DocumentFormat); | ||||
|  | ||||
| internal sealed record RancherHubEventBatch( | ||||
|     string? Cursor, | ||||
|     string? NextCursor, | ||||
|     ImmutableArray<RancherHubEventRecord> Events, | ||||
|     bool FromOfflineSnapshot, | ||||
|     string RawPayload); | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events; | ||||
|  | ||||
| public sealed record RancherHubEventRecord( | ||||
|     string RawJson, | ||||
|     string? Id, | ||||
|     string? Type, | ||||
|     string? Channel, | ||||
|     DateTimeOffset? PublishedAt, | ||||
|     Uri? DocumentUri, | ||||
|     string? DocumentDigest, | ||||
|     string? DocumentFormat); | ||||
|  | ||||
| public sealed record RancherHubEventBatch( | ||||
|     string? Cursor, | ||||
|     string? NextCursor, | ||||
|     ImmutableArray<RancherHubEventRecord> Events, | ||||
|     bool FromOfflineSnapshot, | ||||
|     string RawPayload); | ||||
|   | ||||
| @@ -1,344 +1,345 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| 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.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub; | ||||
|  | ||||
| public sealed class RancherHubConnector : VexConnectorBase | ||||
| { | ||||
|     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); | ||||
|         } | ||||
|  | ||||
|         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; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         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)); | ||||
|  | ||||
|         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 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 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")); | ||||
|  | ||||
|         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.Linq; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using System.Runtime.CompilerServices; | ||||
| 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.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub; | ||||
|  | ||||
| public sealed class RancherHubConnector : VexConnectorBase | ||||
| { | ||||
|     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); | ||||
|         } | ||||
|  | ||||
|         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; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         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)); | ||||
|  | ||||
|         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 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 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")); | ||||
|  | ||||
|         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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -6,15 +6,15 @@ using System.Threading.Tasks; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State; | ||||
|  | ||||
| internal sealed record RancherHubCheckpointState( | ||||
|     string? Cursor, | ||||
|     DateTimeOffset? LastPublishedAt, | ||||
|     DateTimeOffset? EffectiveSince, | ||||
|     ImmutableArray<string> Digests); | ||||
|  | ||||
| internal sealed class RancherHubCheckpointManager | ||||
| namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State; | ||||
|  | ||||
| public sealed record RancherHubCheckpointState( | ||||
|     string? Cursor, | ||||
|     DateTimeOffset? LastPublishedAt, | ||||
|     DateTimeOffset? EffectiveSince, | ||||
|     ImmutableArray<string> Digests); | ||||
|  | ||||
| public sealed class RancherHubCheckpointManager | ||||
| { | ||||
|     private const string CheckpointPrefix = "checkpoint:"; | ||||
|     private readonly IVexConnectorStateRepository _repository; | ||||
|   | ||||
| @@ -1,309 +1,310 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Ubuntu.CSAF; | ||||
| using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration; | ||||
| using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors; | ||||
|  | ||||
| public sealed class UbuntuCsafConnectorTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag() | ||||
|     { | ||||
|         var baseUri = new Uri("https://ubuntu.test/security/csaf/"); | ||||
|         var indexUri = new Uri(baseUri, "index.json"); | ||||
|         var catalogUri = new Uri(baseUri, "stable/catalog.json"); | ||||
|         var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json"); | ||||
|  | ||||
|         var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z"); | ||||
|         var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"); | ||||
|         var documentSha = ComputeSha256(documentPayload); | ||||
|  | ||||
|         var indexJson = manifest.IndexJson; | ||||
|         var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal); | ||||
|         var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123"); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler); | ||||
|         var httpFactory = new SingleClientFactory(httpClient); | ||||
|         var cache = new MemoryCache(new MemoryCacheOptions()); | ||||
|         var fileSystem = new MockFileSystem(); | ||||
|         var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System); | ||||
|  | ||||
|         var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem); | ||||
|         var stateRepository = new InMemoryConnectorStateRepository(); | ||||
|         var connector = new UbuntuCsafConnector( | ||||
|             loader, | ||||
|             httpFactory, | ||||
|             stateRepository, | ||||
|             new[] { optionsValidator }, | ||||
|             NullLogger<UbuntuCsafConnector>.Instance, | ||||
|             TimeProvider.System); | ||||
|  | ||||
|         var settings = new VexConnectorSettings(ImmutableDictionary<string, string>.Empty); | ||||
|         await connector.ValidateAsync(settings, CancellationToken.None); | ||||
|  | ||||
|         var sink = new InMemoryRawSink(); | ||||
|         var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider()); | ||||
|  | ||||
|         var documents = new List<VexRawDocument>(); | ||||
|         await foreach (var doc in connector.FetchAsync(context, CancellationToken.None)) | ||||
|         { | ||||
|             documents.Add(doc); | ||||
|         } | ||||
|  | ||||
|         documents.Should().HaveCount(1); | ||||
|         sink.Documents.Should().HaveCount(1); | ||||
|         var stored = sink.Documents.Single(); | ||||
|         stored.Digest.Should().Be($"sha256:{documentSha}"); | ||||
|         stored.Metadata.TryGetValue("ubuntu.etag", out var storedEtag).Should().BeTrue(); | ||||
|         storedEtag.Should().Be("etag-123"); | ||||
|  | ||||
|         stateRepository.CurrentState.Should().NotBeNull(); | ||||
|         stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}"); | ||||
|         stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123"); | ||||
|         stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z")); | ||||
|  | ||||
|         handler.DocumentRequestCount.Should().Be(1); | ||||
|  | ||||
|         // Second run: Expect connector to send If-None-Match and skip download via 304. | ||||
|         sink.Documents.Clear(); | ||||
|         documents.Clear(); | ||||
|  | ||||
|         await foreach (var doc in connector.FetchAsync(context, CancellationToken.None)) | ||||
|         { | ||||
|             documents.Add(doc); | ||||
|         } | ||||
|  | ||||
|         documents.Should().BeEmpty(); | ||||
|         sink.Documents.Should().BeEmpty(); | ||||
|         handler.DocumentRequestCount.Should().Be(2); | ||||
|         handler.SeenIfNoneMatch.Should().Contain("\"etag-123\""); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task FetchAsync_SkipsWhenChecksumMismatch() | ||||
|     { | ||||
|         var baseUri = new Uri("https://ubuntu.test/security/csaf/"); | ||||
|         var indexUri = new Uri(baseUri, "index.json"); | ||||
|         var catalogUri = new Uri(baseUri, "stable/catalog.json"); | ||||
|         var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json"); | ||||
|  | ||||
|         var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z"); | ||||
|         var indexJson = manifest.IndexJson; | ||||
|         var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal); | ||||
|         var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999"); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler); | ||||
|         var httpFactory = new SingleClientFactory(httpClient); | ||||
|         var cache = new MemoryCache(new MemoryCacheOptions()); | ||||
|         var fileSystem = new MockFileSystem(); | ||||
|         var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System); | ||||
|         var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem); | ||||
|         var stateRepository = new InMemoryConnectorStateRepository(); | ||||
|  | ||||
|         var connector = new UbuntuCsafConnector( | ||||
|             loader, | ||||
|             httpFactory, | ||||
|             stateRepository, | ||||
|             new[] { optionsValidator }, | ||||
|             NullLogger<UbuntuCsafConnector>.Instance, | ||||
|             TimeProvider.System); | ||||
|  | ||||
|         await connector.ValidateAsync(new VexConnectorSettings(ImmutableDictionary<string, string>.Empty), CancellationToken.None); | ||||
|  | ||||
|         var sink = new InMemoryRawSink(); | ||||
|         var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider()); | ||||
|  | ||||
|         var documents = new List<VexRawDocument>(); | ||||
|         await foreach (var doc in connector.FetchAsync(context, CancellationToken.None)) | ||||
|         { | ||||
|             documents.Add(doc); | ||||
|         } | ||||
|  | ||||
|         documents.Should().BeEmpty(); | ||||
|         sink.Documents.Should().BeEmpty(); | ||||
|         stateRepository.CurrentState.Should().NotBeNull(); | ||||
|         stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty(); | ||||
|         handler.DocumentRequestCount.Should().Be(1); | ||||
|     } | ||||
|  | ||||
|     private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp) | ||||
|     { | ||||
|         var indexJson = $$""" | ||||
|         { | ||||
|           "generated": "2025-10-18T00:00:00Z", | ||||
|           "channels": [ | ||||
|             { | ||||
|               "name": "stable", | ||||
|               "catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json", | ||||
|               "sha256": "ignore" | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
|         """; | ||||
|  | ||||
|         var catalogJson = $$""" | ||||
|         { | ||||
|           "resources": [ | ||||
|             { | ||||
|               "id": "{{advisoryId}}", | ||||
|               "type": "csaf", | ||||
|               "url": "{{advisoryUri}}", | ||||
|               "last_modified": "{{timestamp}}", | ||||
|               "hashes": { | ||||
|                 "sha256": "{{SHA256}}" | ||||
|               }, | ||||
|               "etag": "\"etag-123\"", | ||||
|               "title": "{{advisoryId}}" | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
|         """; | ||||
|  | ||||
|         return (indexJson, catalogJson); | ||||
|     } | ||||
|  | ||||
|     private static string ComputeSha256(ReadOnlySpan<byte> payload) | ||||
|     { | ||||
|         Span<byte> buffer = stackalloc byte[32]; | ||||
|         SHA256.HashData(payload, buffer); | ||||
|         return Convert.ToHexString(buffer).ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     private sealed class SingleClientFactory : IHttpClientFactory | ||||
|     { | ||||
|         private readonly HttpClient _client; | ||||
|  | ||||
|         public SingleClientFactory(HttpClient client) | ||||
|         { | ||||
|             _client = client; | ||||
|         } | ||||
|  | ||||
|         public HttpClient CreateClient(string name) => _client; | ||||
|     } | ||||
|  | ||||
|     private sealed class UbuntuTestHttpHandler : HttpMessageHandler | ||||
|     { | ||||
|         private readonly Uri _indexUri; | ||||
|         private readonly string _indexPayload; | ||||
|         private readonly Uri _catalogUri; | ||||
|         private readonly string _catalogPayload; | ||||
|         private readonly Uri _documentUri; | ||||
|         private readonly byte[] _documentPayload; | ||||
|         private readonly string _expectedEtag; | ||||
|  | ||||
|         public int DocumentRequestCount { get; private set; } | ||||
|         public List<string> SeenIfNoneMatch { get; } = new(); | ||||
|  | ||||
|         public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag) | ||||
|         { | ||||
|             _indexUri = indexUri; | ||||
|             _indexPayload = indexPayload; | ||||
|             _catalogUri = catalogUri; | ||||
|             _catalogPayload = catalogPayload; | ||||
|             _documentUri = documentUri; | ||||
|             _documentPayload = documentPayload; | ||||
|             _expectedEtag = expectedEtag; | ||||
|         } | ||||
|  | ||||
|         protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             if (request.RequestUri == _indexUri) | ||||
|             { | ||||
|                 return Task.FromResult(CreateJsonResponse(_indexPayload)); | ||||
|             } | ||||
|  | ||||
|             if (request.RequestUri == _catalogUri) | ||||
|             { | ||||
|                 return Task.FromResult(CreateJsonResponse(_catalogPayload)); | ||||
|             } | ||||
|  | ||||
|             if (request.RequestUri == _documentUri) | ||||
|             { | ||||
|                 DocumentRequestCount++; | ||||
|                 if (request.Headers.IfNoneMatch is { Count: > 0 }) | ||||
|                 { | ||||
|                     var header = request.Headers.IfNoneMatch.First().ToString(); | ||||
|                     SeenIfNoneMatch.Add(header); | ||||
|                     if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"") | ||||
|                     { | ||||
|                         return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified)); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|                 { | ||||
|                     Content = new ByteArrayContent(_documentPayload), | ||||
|                 }; | ||||
|                 response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\""); | ||||
|                 response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); | ||||
|                 return Task.FromResult(response); | ||||
|             } | ||||
|  | ||||
|             return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) | ||||
|             { | ||||
|                 Content = new StringContent($"No response configured for {request.RequestUri}"), | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         private static HttpResponseMessage CreateJsonResponse(string payload) | ||||
|             => new(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new StringContent(payload, Encoding.UTF8, "application/json"), | ||||
|             }; | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository | ||||
|     { | ||||
|         public VexConnectorState? CurrentState { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(CurrentState); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken) | ||||
|         { | ||||
|             CurrentState = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryRawSink : IVexRawDocumentSink | ||||
|     { | ||||
|         public List<VexRawDocument> Documents { get; } = new(); | ||||
|  | ||||
|         public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|         { | ||||
|             Documents.Add(document); | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class NoopSignatureVerifier : IVexSignatureVerifier | ||||
|     { | ||||
|         public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<VexSignatureMetadata?>(null); | ||||
|     } | ||||
|  | ||||
|     private sealed class NoopNormalizerRouter : IVexNormalizerRouter | ||||
|     { | ||||
|         public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty)); | ||||
|     } | ||||
| } | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Connectors.Ubuntu.CSAF; | ||||
| using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration; | ||||
| using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using System.IO.Abstractions.TestingHelpers; | ||||
| using Xunit; | ||||
| using MongoDB.Driver; | ||||
|  | ||||
| namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors; | ||||
|  | ||||
| public sealed class UbuntuCsafConnectorTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag() | ||||
|     { | ||||
|         var baseUri = new Uri("https://ubuntu.test/security/csaf/"); | ||||
|         var indexUri = new Uri(baseUri, "index.json"); | ||||
|         var catalogUri = new Uri(baseUri, "stable/catalog.json"); | ||||
|         var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json"); | ||||
|  | ||||
|         var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z"); | ||||
|         var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"); | ||||
|         var documentSha = ComputeSha256(documentPayload); | ||||
|  | ||||
|         var indexJson = manifest.IndexJson; | ||||
|         var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal); | ||||
|         var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123"); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler); | ||||
|         var httpFactory = new SingleClientFactory(httpClient); | ||||
|         var cache = new MemoryCache(new MemoryCacheOptions()); | ||||
|         var fileSystem = new MockFileSystem(); | ||||
|         var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System); | ||||
|  | ||||
|         var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem); | ||||
|         var stateRepository = new InMemoryConnectorStateRepository(); | ||||
|         var connector = new UbuntuCsafConnector( | ||||
|             loader, | ||||
|             httpFactory, | ||||
|             stateRepository, | ||||
|             new[] { optionsValidator }, | ||||
|             NullLogger<UbuntuCsafConnector>.Instance, | ||||
|             TimeProvider.System); | ||||
|  | ||||
|         var settings = new VexConnectorSettings(ImmutableDictionary<string, string>.Empty); | ||||
|         await connector.ValidateAsync(settings, CancellationToken.None); | ||||
|  | ||||
|         var sink = new InMemoryRawSink(); | ||||
|         var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider()); | ||||
|  | ||||
|         var documents = new List<VexRawDocument>(); | ||||
|         await foreach (var doc in connector.FetchAsync(context, CancellationToken.None)) | ||||
|         { | ||||
|             documents.Add(doc); | ||||
|         } | ||||
|  | ||||
|         documents.Should().HaveCount(1); | ||||
|         sink.Documents.Should().HaveCount(1); | ||||
|         var stored = sink.Documents.Single(); | ||||
|         stored.Digest.Should().Be($"sha256:{documentSha}"); | ||||
|         stored.Metadata.TryGetValue("ubuntu.etag", out var storedEtag).Should().BeTrue(); | ||||
|         storedEtag.Should().Be("etag-123"); | ||||
|  | ||||
|         stateRepository.CurrentState.Should().NotBeNull(); | ||||
|         stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}"); | ||||
|         stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123"); | ||||
|         stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z")); | ||||
|  | ||||
|         handler.DocumentRequestCount.Should().Be(1); | ||||
|  | ||||
|         // Second run: Expect connector to send If-None-Match and skip download via 304. | ||||
|         sink.Documents.Clear(); | ||||
|         documents.Clear(); | ||||
|  | ||||
|         await foreach (var doc in connector.FetchAsync(context, CancellationToken.None)) | ||||
|         { | ||||
|             documents.Add(doc); | ||||
|         } | ||||
|  | ||||
|         documents.Should().BeEmpty(); | ||||
|         sink.Documents.Should().BeEmpty(); | ||||
|         handler.DocumentRequestCount.Should().Be(2); | ||||
|         handler.SeenIfNoneMatch.Should().Contain("\"etag-123\""); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task FetchAsync_SkipsWhenChecksumMismatch() | ||||
|     { | ||||
|         var baseUri = new Uri("https://ubuntu.test/security/csaf/"); | ||||
|         var indexUri = new Uri(baseUri, "index.json"); | ||||
|         var catalogUri = new Uri(baseUri, "stable/catalog.json"); | ||||
|         var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json"); | ||||
|  | ||||
|         var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z"); | ||||
|         var indexJson = manifest.IndexJson; | ||||
|         var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal); | ||||
|         var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999"); | ||||
|  | ||||
|         var httpClient = new HttpClient(handler); | ||||
|         var httpFactory = new SingleClientFactory(httpClient); | ||||
|         var cache = new MemoryCache(new MemoryCacheOptions()); | ||||
|         var fileSystem = new MockFileSystem(); | ||||
|         var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System); | ||||
|         var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem); | ||||
|         var stateRepository = new InMemoryConnectorStateRepository(); | ||||
|  | ||||
|         var connector = new UbuntuCsafConnector( | ||||
|             loader, | ||||
|             httpFactory, | ||||
|             stateRepository, | ||||
|             new[] { optionsValidator }, | ||||
|             NullLogger<UbuntuCsafConnector>.Instance, | ||||
|             TimeProvider.System); | ||||
|  | ||||
|         await connector.ValidateAsync(new VexConnectorSettings(ImmutableDictionary<string, string>.Empty), CancellationToken.None); | ||||
|  | ||||
|         var sink = new InMemoryRawSink(); | ||||
|         var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider()); | ||||
|  | ||||
|         var documents = new List<VexRawDocument>(); | ||||
|         await foreach (var doc in connector.FetchAsync(context, CancellationToken.None)) | ||||
|         { | ||||
|             documents.Add(doc); | ||||
|         } | ||||
|  | ||||
|         documents.Should().BeEmpty(); | ||||
|         sink.Documents.Should().BeEmpty(); | ||||
|         stateRepository.CurrentState.Should().NotBeNull(); | ||||
|         stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty(); | ||||
|         handler.DocumentRequestCount.Should().Be(1); | ||||
|     } | ||||
|  | ||||
|     private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp) | ||||
|     { | ||||
|         var indexJson = """ | ||||
|         { | ||||
|           "generated": "2025-10-18T00:00:00Z", | ||||
|           "channels": [ | ||||
|             { | ||||
|               "name": "stable", | ||||
|               "catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json", | ||||
|               "sha256": "ignore" | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
|         """; | ||||
|  | ||||
|         var catalogJson = """ | ||||
|         { | ||||
|           "resources": [ | ||||
|             { | ||||
|               "id": "{{advisoryId}}", | ||||
|               "type": "csaf", | ||||
|               "url": "{{advisoryUri}}", | ||||
|               "last_modified": "{{timestamp}}", | ||||
|               "hashes": { | ||||
|                 "sha256": "{{SHA256}}" | ||||
|               }, | ||||
|               "etag": "\"etag-123\"", | ||||
|               "title": "{{advisoryId}}" | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
|         """; | ||||
|  | ||||
|         return (indexJson, catalogJson); | ||||
|     } | ||||
|  | ||||
|     private static string ComputeSha256(ReadOnlySpan<byte> payload) | ||||
|     { | ||||
|         Span<byte> buffer = stackalloc byte[32]; | ||||
|         SHA256.HashData(payload, buffer); | ||||
|         return Convert.ToHexString(buffer).ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     private sealed class SingleClientFactory : IHttpClientFactory | ||||
|     { | ||||
|         private readonly HttpClient _client; | ||||
|  | ||||
|         public SingleClientFactory(HttpClient client) | ||||
|         { | ||||
|             _client = client; | ||||
|         } | ||||
|  | ||||
|         public HttpClient CreateClient(string name) => _client; | ||||
|     } | ||||
|  | ||||
|     private sealed class UbuntuTestHttpHandler : HttpMessageHandler | ||||
|     { | ||||
|         private readonly Uri _indexUri; | ||||
|         private readonly string _indexPayload; | ||||
|         private readonly Uri _catalogUri; | ||||
|         private readonly string _catalogPayload; | ||||
|         private readonly Uri _documentUri; | ||||
|         private readonly byte[] _documentPayload; | ||||
|         private readonly string _expectedEtag; | ||||
|  | ||||
|         public int DocumentRequestCount { get; private set; } | ||||
|         public List<string> SeenIfNoneMatch { get; } = new(); | ||||
|  | ||||
|         public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag) | ||||
|         { | ||||
|             _indexUri = indexUri; | ||||
|             _indexPayload = indexPayload; | ||||
|             _catalogUri = catalogUri; | ||||
|             _catalogPayload = catalogPayload; | ||||
|             _documentUri = documentUri; | ||||
|             _documentPayload = documentPayload; | ||||
|             _expectedEtag = expectedEtag; | ||||
|         } | ||||
|  | ||||
|         protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             if (request.RequestUri == _indexUri) | ||||
|             { | ||||
|                 return Task.FromResult(CreateJsonResponse(_indexPayload)); | ||||
|             } | ||||
|  | ||||
|             if (request.RequestUri == _catalogUri) | ||||
|             { | ||||
|                 return Task.FromResult(CreateJsonResponse(_catalogPayload)); | ||||
|             } | ||||
|  | ||||
|             if (request.RequestUri == _documentUri) | ||||
|             { | ||||
|                 DocumentRequestCount++; | ||||
|                 if (request.Headers.IfNoneMatch is { Count: > 0 }) | ||||
|                 { | ||||
|                     var header = request.Headers.IfNoneMatch.First().ToString(); | ||||
|                     SeenIfNoneMatch.Add(header); | ||||
|                     if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"") | ||||
|                     { | ||||
|                         return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified)); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|                 { | ||||
|                     Content = new ByteArrayContent(_documentPayload), | ||||
|                 }; | ||||
|                 response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\""); | ||||
|                 response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); | ||||
|                 return Task.FromResult(response); | ||||
|             } | ||||
|  | ||||
|             return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) | ||||
|             { | ||||
|                 Content = new StringContent($"No response configured for {request.RequestUri}"), | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         private static HttpResponseMessage CreateJsonResponse(string payload) | ||||
|             => new(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new StringContent(payload, Encoding.UTF8, "application/json"), | ||||
|             }; | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository | ||||
|     { | ||||
|         public VexConnectorState? CurrentState { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult(CurrentState); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             CurrentState = state; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryRawSink : IVexRawDocumentSink | ||||
|     { | ||||
|         public List<VexRawDocument> Documents { get; } = new(); | ||||
|  | ||||
|         public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|         { | ||||
|             Documents.Add(document); | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class NoopSignatureVerifier : IVexSignatureVerifier | ||||
|     { | ||||
|         public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<VexSignatureMetadata?>(null); | ||||
|     } | ||||
|  | ||||
|     private sealed class NoopNormalizerRouter : IVexNormalizerRouter | ||||
|     { | ||||
|         public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty)); | ||||
|     } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,277 +1,277 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
| using System.IO; | ||||
| using System.Text; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Export; | ||||
| using StellaOps.Excititor.Policy; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Excititor.Export.Tests; | ||||
|  | ||||
| public sealed class ExportEngineTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task ExportAsync_GeneratesAndCachesManifest() | ||||
|     { | ||||
|         var store = new InMemoryExportStore(); | ||||
|         var evaluator = new StaticPolicyEvaluator("baseline/v1"); | ||||
|         var dataSource = new InMemoryExportDataSource(); | ||||
|         var exporter = new DummyExporter(VexExportFormat.Json); | ||||
|         var engine = new VexExportEngine(store, evaluator, dataSource, new[] { exporter }, NullLogger<VexExportEngine>.Instance); | ||||
|  | ||||
|         var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); | ||||
|         var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow, ForceRefresh: false); | ||||
|  | ||||
|         var manifest = await engine.ExportAsync(context, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(manifest.FromCache); | ||||
|         Assert.Equal(VexExportFormat.Json, manifest.Format); | ||||
|         Assert.Equal("baseline/v1", manifest.ConsensusRevision); | ||||
|         Assert.Equal(1, manifest.ClaimCount); | ||||
|  | ||||
|         // second call hits cache | ||||
|         var cached = await engine.ExportAsync(context, CancellationToken.None); | ||||
|         Assert.True(cached.FromCache); | ||||
|         Assert.Equal(manifest.ExportId, cached.ExportId); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ExportAsync_ForceRefreshInvalidatesCacheEntry() | ||||
|     { | ||||
|         var store = new InMemoryExportStore(); | ||||
|         var evaluator = new StaticPolicyEvaluator("baseline/v1"); | ||||
|         var dataSource = new InMemoryExportDataSource(); | ||||
|         var exporter = new DummyExporter(VexExportFormat.Json); | ||||
|         var cacheIndex = new RecordingCacheIndex(); | ||||
|         var engine = new VexExportEngine(store, evaluator, dataSource, new[] { exporter }, NullLogger<VexExportEngine>.Instance, cacheIndex); | ||||
|  | ||||
|         var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); | ||||
|         var initialContext = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow); | ||||
|         _ = await engine.ExportAsync(initialContext, CancellationToken.None); | ||||
|  | ||||
|         var refreshContext = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow.AddMinutes(1), ForceRefresh: true); | ||||
|         var refreshed = await engine.ExportAsync(refreshContext, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(refreshed.FromCache); | ||||
|         var signature = VexQuerySignature.FromQuery(refreshContext.Query); | ||||
|         Assert.True(cacheIndex.RemoveCalls.TryGetValue((signature.Value, refreshContext.Format), out var removed)); | ||||
|         Assert.True(removed); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ExportAsync_WritesArtifactsToAllStores() | ||||
|     { | ||||
|         var store = new InMemoryExportStore(); | ||||
|         var evaluator = new StaticPolicyEvaluator("baseline/v1"); | ||||
|         var dataSource = new InMemoryExportDataSource(); | ||||
|         var exporter = new DummyExporter(VexExportFormat.Json); | ||||
|         var recorder1 = new RecordingArtifactStore(); | ||||
|         var recorder2 = new RecordingArtifactStore(); | ||||
|         var engine = new VexExportEngine( | ||||
|             store, | ||||
|             evaluator, | ||||
|             dataSource, | ||||
|             new[] { exporter }, | ||||
|             NullLogger<VexExportEngine>.Instance, | ||||
|             cacheIndex: null, | ||||
|             artifactStores: new[] { recorder1, recorder2 }); | ||||
|  | ||||
|         var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); | ||||
|         var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         await engine.ExportAsync(context, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(1, recorder1.SaveCount); | ||||
|         Assert.Equal(1, recorder2.SaveCount); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ExportAsync_AttachesAttestationMetadata() | ||||
|     { | ||||
|         var store = new InMemoryExportStore(); | ||||
|         var evaluator = new StaticPolicyEvaluator("baseline/v1"); | ||||
|         var dataSource = new InMemoryExportDataSource(); | ||||
|         var exporter = new DummyExporter(VexExportFormat.Json); | ||||
|         var attestation = new RecordingAttestationClient(); | ||||
|         var engine = new VexExportEngine( | ||||
|             store, | ||||
|             evaluator, | ||||
|             dataSource, | ||||
|             new[] { exporter }, | ||||
|             NullLogger<VexExportEngine>.Instance, | ||||
|             cacheIndex: null, | ||||
|             artifactStores: null, | ||||
|             attestationClient: attestation); | ||||
|  | ||||
|         var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); | ||||
|         var requestedAt = DateTimeOffset.UtcNow; | ||||
|         var context = new VexExportRequestContext(query, VexExportFormat.Json, requestedAt); | ||||
|  | ||||
|         var manifest = await engine.ExportAsync(context, CancellationToken.None); | ||||
|  | ||||
|         Assert.NotNull(attestation.LastRequest); | ||||
|         Assert.Equal(manifest.ExportId, attestation.LastRequest!.ExportId); | ||||
|         Assert.NotNull(manifest.Attestation); | ||||
|         Assert.Equal(attestation.Response.Attestation.EnvelopeDigest, manifest.Attestation!.EnvelopeDigest); | ||||
|         Assert.Equal(attestation.Response.Attestation.PredicateType, manifest.Attestation.PredicateType); | ||||
|  | ||||
|         Assert.NotNull(store.LastSavedManifest); | ||||
|         Assert.Equal(manifest.Attestation, store.LastSavedManifest!.Attestation); | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryExportStore : IVexExportStore | ||||
|     { | ||||
|         private readonly Dictionary<string, VexExportManifest> _store = new(StringComparer.Ordinal); | ||||
|  | ||||
|         public VexExportManifest? LastSavedManifest { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             var key = CreateKey(signature.Value, format); | ||||
|             _store.TryGetValue(key, out var manifest); | ||||
|             return ValueTask.FromResult<VexExportManifest?>(manifest); | ||||
|         } | ||||
|  | ||||
|         public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             var key = CreateKey(manifest.QuerySignature.Value, manifest.Format); | ||||
|             _store[key] = manifest; | ||||
|             LastSavedManifest = manifest; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         private static string CreateKey(string signature, VexExportFormat format) | ||||
|             => FormattableString.Invariant($"{signature}|{format}"); | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingAttestationClient : IVexAttestationClient | ||||
|     { | ||||
|         public VexAttestationRequest? LastRequest { get; private set; } | ||||
|  | ||||
|         public VexAttestationResponse Response { get; } = new VexAttestationResponse( | ||||
|             new VexAttestationMetadata( | ||||
|                 predicateType: "https://stella-ops.org/attestations/vex-export", | ||||
|                 rekor: new VexRekorReference("0.2", "rekor://entry", "123"), | ||||
|                 envelopeDigest: "sha256:envelope", | ||||
|                 signedAt: DateTimeOffset.UnixEpoch), | ||||
|             ImmutableDictionary<string, string>.Empty); | ||||
|  | ||||
|         public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastRequest = request; | ||||
|             return ValueTask.FromResult(Response); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingCacheIndex : IVexCacheIndex | ||||
|     { | ||||
|         public Dictionary<(string Signature, VexExportFormat Format), bool> RemoveCalls { get; } = new(); | ||||
|  | ||||
|         public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<VexCacheEntry?>(null); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken) | ||||
|         { | ||||
|             RemoveCalls[(signature.Value, format)] = true; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingArtifactStore : IVexArtifactStore | ||||
|     { | ||||
|         public int SaveCount { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken) | ||||
|         { | ||||
|             SaveCount++; | ||||
|             return ValueTask.FromResult(new VexStoredArtifact(artifact.ContentAddress, "memory", artifact.Content.Length, artifact.Metadata)); | ||||
|         } | ||||
|  | ||||
|         public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<Stream?>(null); | ||||
|     } | ||||
|  | ||||
|     private sealed class StaticPolicyEvaluator : IVexPolicyEvaluator | ||||
|     { | ||||
|         public StaticPolicyEvaluator(string version) | ||||
|         { | ||||
|             Version = version; | ||||
|         } | ||||
|  | ||||
|         public string Version { get; } | ||||
|  | ||||
|         public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default; | ||||
|  | ||||
|         public double GetProviderWeight(VexProvider provider) => 1.0; | ||||
|  | ||||
|         public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason) | ||||
|         { | ||||
|             rejectionReason = null; | ||||
|             return true; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryExportDataSource : IVexExportDataSource | ||||
|     { | ||||
|         public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var claim = new VexClaim( | ||||
|                 "CVE-2025-0001", | ||||
|                 "vendor", | ||||
|                 new VexProduct("pkg:demo/app", "Demo"), | ||||
|                 VexClaimStatus.Affected, | ||||
|                 new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:demo", new Uri("https://example.org/demo")), | ||||
|                 DateTimeOffset.UtcNow, | ||||
|                 DateTimeOffset.UtcNow); | ||||
|  | ||||
|             var consensus = new VexConsensus( | ||||
|                 "CVE-2025-0001", | ||||
|                 claim.Product, | ||||
|                 VexConsensusStatus.Affected, | ||||
|                 DateTimeOffset.UtcNow, | ||||
|                 new[] { new VexConsensusSource("vendor", VexClaimStatus.Affected, "sha256:demo", 1.0) }, | ||||
|                 conflicts: null, | ||||
|                 policyVersion: "baseline/v1", | ||||
|                 summary: "affected"); | ||||
|  | ||||
|             return ValueTask.FromResult(new VexExportDataSet( | ||||
|                 ImmutableArray.Create(consensus), | ||||
|                 ImmutableArray.Create(claim), | ||||
|                 ImmutableArray.Create("vendor"))); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class DummyExporter : IVexExporter | ||||
|     { | ||||
|         public DummyExporter(VexExportFormat format) | ||||
|         { | ||||
|             Format = format; | ||||
|         } | ||||
|  | ||||
|         public VexExportFormat Format { get; } | ||||
|  | ||||
|         public VexContentAddress Digest(VexExportRequest request) | ||||
|             => new("sha256", "deadbeef"); | ||||
|  | ||||
|         public ValueTask<VexExportResult> SerializeAsync(VexExportRequest request, Stream output, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var bytes = System.Text.Encoding.UTF8.GetBytes("{}"); | ||||
|             output.Write(bytes); | ||||
|             return ValueTask.FromResult(new VexExportResult(new VexContentAddress("sha256", "deadbeef"), bytes.Length, ImmutableDictionary<string, string>.Empty)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
| using System.IO; | ||||
| using System.Text; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Export; | ||||
| using StellaOps.Excititor.Policy; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Excititor.Export.Tests; | ||||
|  | ||||
| public sealed class ExportEngineTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task ExportAsync_GeneratesAndCachesManifest() | ||||
|     { | ||||
|         var store = new InMemoryExportStore(); | ||||
|         var evaluator = new StaticPolicyEvaluator("baseline/v1"); | ||||
|         var dataSource = new InMemoryExportDataSource(); | ||||
|         var exporter = new DummyExporter(VexExportFormat.Json); | ||||
|         var engine = new VexExportEngine(store, evaluator, dataSource, new[] { exporter }, NullLogger<VexExportEngine>.Instance); | ||||
|  | ||||
|         var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); | ||||
|         var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow, ForceRefresh: false); | ||||
|  | ||||
|         var manifest = await engine.ExportAsync(context, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(manifest.FromCache); | ||||
|         Assert.Equal(VexExportFormat.Json, manifest.Format); | ||||
|         Assert.Equal("baseline/v1", manifest.ConsensusRevision); | ||||
|         Assert.Equal(1, manifest.ClaimCount); | ||||
|  | ||||
|         // second call hits cache | ||||
|         var cached = await engine.ExportAsync(context, CancellationToken.None); | ||||
|         Assert.True(cached.FromCache); | ||||
|         Assert.Equal(manifest.ExportId, cached.ExportId); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ExportAsync_ForceRefreshInvalidatesCacheEntry() | ||||
|     { | ||||
|         var store = new InMemoryExportStore(); | ||||
|         var evaluator = new StaticPolicyEvaluator("baseline/v1"); | ||||
|         var dataSource = new InMemoryExportDataSource(); | ||||
|         var exporter = new DummyExporter(VexExportFormat.Json); | ||||
|         var cacheIndex = new RecordingCacheIndex(); | ||||
|         var engine = new VexExportEngine(store, evaluator, dataSource, new[] { exporter }, NullLogger<VexExportEngine>.Instance, cacheIndex); | ||||
|  | ||||
|         var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); | ||||
|         var initialContext = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow); | ||||
|         _ = await engine.ExportAsync(initialContext, CancellationToken.None); | ||||
|  | ||||
|         var refreshContext = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow.AddMinutes(1), ForceRefresh: true); | ||||
|         var refreshed = await engine.ExportAsync(refreshContext, CancellationToken.None); | ||||
|  | ||||
|         Assert.False(refreshed.FromCache); | ||||
|         var signature = VexQuerySignature.FromQuery(refreshContext.Query); | ||||
|         Assert.True(cacheIndex.RemoveCalls.TryGetValue((signature.Value, refreshContext.Format), out var removed)); | ||||
|         Assert.True(removed); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ExportAsync_WritesArtifactsToAllStores() | ||||
|     { | ||||
|         var store = new InMemoryExportStore(); | ||||
|         var evaluator = new StaticPolicyEvaluator("baseline/v1"); | ||||
|         var dataSource = new InMemoryExportDataSource(); | ||||
|         var exporter = new DummyExporter(VexExportFormat.Json); | ||||
|         var recorder1 = new RecordingArtifactStore(); | ||||
|         var recorder2 = new RecordingArtifactStore(); | ||||
|         var engine = new VexExportEngine( | ||||
|             store, | ||||
|             evaluator, | ||||
|             dataSource, | ||||
|             new[] { exporter }, | ||||
|             NullLogger<VexExportEngine>.Instance, | ||||
|             cacheIndex: null, | ||||
|             artifactStores: new[] { recorder1, recorder2 }); | ||||
|  | ||||
|         var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); | ||||
|         var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         await engine.ExportAsync(context, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(1, recorder1.SaveCount); | ||||
|         Assert.Equal(1, recorder2.SaveCount); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ExportAsync_AttachesAttestationMetadata() | ||||
|     { | ||||
|         var store = new InMemoryExportStore(); | ||||
|         var evaluator = new StaticPolicyEvaluator("baseline/v1"); | ||||
|         var dataSource = new InMemoryExportDataSource(); | ||||
|         var exporter = new DummyExporter(VexExportFormat.Json); | ||||
|         var attestation = new RecordingAttestationClient(); | ||||
|         var engine = new VexExportEngine( | ||||
|             store, | ||||
|             evaluator, | ||||
|             dataSource, | ||||
|             new[] { exporter }, | ||||
|             NullLogger<VexExportEngine>.Instance, | ||||
|             cacheIndex: null, | ||||
|             artifactStores: null, | ||||
|             attestationClient: attestation); | ||||
|  | ||||
|         var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); | ||||
|         var requestedAt = DateTimeOffset.UtcNow; | ||||
|         var context = new VexExportRequestContext(query, VexExportFormat.Json, requestedAt); | ||||
|  | ||||
|         var manifest = await engine.ExportAsync(context, CancellationToken.None); | ||||
|  | ||||
|         Assert.NotNull(attestation.LastRequest); | ||||
|         Assert.Equal(manifest.ExportId, attestation.LastRequest!.ExportId); | ||||
|         Assert.NotNull(manifest.Attestation); | ||||
|         Assert.Equal(attestation.Response.Attestation.EnvelopeDigest, manifest.Attestation!.EnvelopeDigest); | ||||
|         Assert.Equal(attestation.Response.Attestation.PredicateType, manifest.Attestation.PredicateType); | ||||
|  | ||||
|         Assert.NotNull(store.LastSavedManifest); | ||||
|         Assert.Equal(manifest.Attestation, store.LastSavedManifest!.Attestation); | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryExportStore : IVexExportStore | ||||
|     { | ||||
|         private readonly Dictionary<string, VexExportManifest> _store = new(StringComparer.Ordinal); | ||||
|  | ||||
|         public VexExportManifest? LastSavedManifest { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             var key = CreateKey(signature.Value, format); | ||||
|             _store.TryGetValue(key, out var manifest); | ||||
|             return ValueTask.FromResult<VexExportManifest?>(manifest); | ||||
|         } | ||||
|  | ||||
|         public ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             var key = CreateKey(manifest.QuerySignature.Value, manifest.Format); | ||||
|             _store[key] = manifest; | ||||
|             LastSavedManifest = manifest; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|  | ||||
|         private static string CreateKey(string signature, VexExportFormat format) | ||||
|             => FormattableString.Invariant($"{signature}|{format}"); | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingAttestationClient : IVexAttestationClient | ||||
|     { | ||||
|         public VexAttestationRequest? LastRequest { get; private set; } | ||||
|  | ||||
|         public VexAttestationResponse Response { get; } = new VexAttestationResponse( | ||||
|             new VexAttestationMetadata( | ||||
|                 predicateType: "https://stella-ops.org/attestations/vex-export", | ||||
|                 rekor: new VexRekorReference("0.2", "rekor://entry", "123"), | ||||
|                 envelopeDigest: "sha256:envelope", | ||||
|                 signedAt: DateTimeOffset.UnixEpoch), | ||||
|             ImmutableDictionary<string, string>.Empty); | ||||
|  | ||||
|         public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastRequest = request; | ||||
|             return ValueTask.FromResult(Response); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingCacheIndex : IVexCacheIndex | ||||
|     { | ||||
|         public Dictionary<(string Signature, VexExportFormat Format), bool> RemoveCalls { get; } = new(); | ||||
|  | ||||
|         public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult<VexCacheEntry?>(null); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             RemoveCalls[(signature.Value, format)] = true; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingArtifactStore : IVexArtifactStore | ||||
|     { | ||||
|         public int SaveCount { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexStoredArtifact> SaveAsync(VexExportArtifact artifact, CancellationToken cancellationToken) | ||||
|         { | ||||
|             SaveCount++; | ||||
|             return ValueTask.FromResult(new VexStoredArtifact(artifact.ContentAddress, "memory", artifact.Content.Length, artifact.Metadata)); | ||||
|         } | ||||
|  | ||||
|         public ValueTask DeleteAsync(VexContentAddress contentAddress, CancellationToken cancellationToken) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask<Stream?> OpenReadAsync(VexContentAddress contentAddress, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<Stream?>(null); | ||||
|     } | ||||
|  | ||||
|     private sealed class StaticPolicyEvaluator : IVexPolicyEvaluator | ||||
|     { | ||||
|         public StaticPolicyEvaluator(string version) | ||||
|         { | ||||
|             Version = version; | ||||
|         } | ||||
|  | ||||
|         public string Version { get; } | ||||
|  | ||||
|         public VexPolicySnapshot Snapshot => VexPolicySnapshot.Default; | ||||
|  | ||||
|         public double GetProviderWeight(VexProvider provider) => 1.0; | ||||
|  | ||||
|         public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason) | ||||
|         { | ||||
|             rejectionReason = null; | ||||
|             return true; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class InMemoryExportDataSource : IVexExportDataSource | ||||
|     { | ||||
|         public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var claim = new VexClaim( | ||||
|                 "CVE-2025-0001", | ||||
|                 "vendor", | ||||
|                 new VexProduct("pkg:demo/app", "Demo"), | ||||
|                 VexClaimStatus.Affected, | ||||
|                 new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:demo", new Uri("https://example.org/demo")), | ||||
|                 DateTimeOffset.UtcNow, | ||||
|                 DateTimeOffset.UtcNow); | ||||
|  | ||||
|             var consensus = new VexConsensus( | ||||
|                 "CVE-2025-0001", | ||||
|                 claim.Product, | ||||
|                 VexConsensusStatus.Affected, | ||||
|                 DateTimeOffset.UtcNow, | ||||
|                 new[] { new VexConsensusSource("vendor", VexClaimStatus.Affected, "sha256:demo", 1.0) }, | ||||
|                 conflicts: null, | ||||
|                 policyVersion: "baseline/v1", | ||||
|                 summary: "affected"); | ||||
|  | ||||
|             return ValueTask.FromResult(new VexExportDataSet( | ||||
|                 ImmutableArray.Create(consensus), | ||||
|                 ImmutableArray.Create(claim), | ||||
|                 ImmutableArray.Create("vendor"))); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class DummyExporter : IVexExporter | ||||
|     { | ||||
|         public DummyExporter(VexExportFormat format) | ||||
|         { | ||||
|             Format = format; | ||||
|         } | ||||
|  | ||||
|         public VexExportFormat Format { get; } | ||||
|  | ||||
|         public VexContentAddress Digest(VexExportRequest request) | ||||
|             => new("sha256", "deadbeef"); | ||||
|  | ||||
|         public ValueTask<VexExportResult> SerializeAsync(VexExportRequest request, Stream output, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var bytes = System.Text.Encoding.UTF8.GetBytes("{}"); | ||||
|             output.Write(bytes); | ||||
|             return ValueTask.FromResult(new VexExportResult(new VexContentAddress("sha256", "deadbeef"), bytes.Length, ImmutableDictionary<string, string>.Empty)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,82 +1,82 @@ | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Export; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
|  | ||||
| namespace StellaOps.Excititor.Export.Tests; | ||||
|  | ||||
| public sealed class VexExportCacheServiceTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task InvalidateAsync_RemovesEntry() | ||||
|     { | ||||
|         var cacheIndex = new RecordingIndex(); | ||||
|         var maintenance = new StubMaintenance(); | ||||
|         var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance); | ||||
|  | ||||
|         var signature = new VexQuerySignature("format=json|provider=vendor"); | ||||
|         await service.InvalidateAsync(signature, VexExportFormat.Json, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(signature.Value, cacheIndex.LastSignature?.Value); | ||||
|         Assert.Equal(VexExportFormat.Json, cacheIndex.LastFormat); | ||||
|         Assert.Equal(1, cacheIndex.RemoveCalls); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task PruneExpiredAsync_ReturnsCount() | ||||
|     { | ||||
|         var cacheIndex = new RecordingIndex(); | ||||
|         var maintenance = new StubMaintenance { ExpiredCount = 3 }; | ||||
|         var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance); | ||||
|  | ||||
|         var removed = await service.PruneExpiredAsync(DateTimeOffset.UtcNow, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(3, removed); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task PruneDanglingAsync_ReturnsCount() | ||||
|     { | ||||
|         var cacheIndex = new RecordingIndex(); | ||||
|         var maintenance = new StubMaintenance { DanglingCount = 2 }; | ||||
|         var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance); | ||||
|  | ||||
|         var removed = await service.PruneDanglingAsync(CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(2, removed); | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingIndex : IVexCacheIndex | ||||
|     { | ||||
|         public VexQuerySignature? LastSignature { get; private set; } | ||||
|         public VexExportFormat LastFormat { get; private set; } | ||||
|         public int RemoveCalls { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<VexCacheEntry?>(null); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken) | ||||
|         { | ||||
|             LastSignature = signature; | ||||
|             LastFormat = format; | ||||
|             RemoveCalls++; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class StubMaintenance : IVexCacheMaintenance | ||||
|     { | ||||
|         public int ExpiredCount { get; set; } | ||||
|         public int DanglingCount { get; set; } | ||||
|  | ||||
|         public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult(ExpiredCount); | ||||
|  | ||||
|         public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult(DanglingCount); | ||||
|     } | ||||
| } | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Export; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
|  | ||||
| namespace StellaOps.Excititor.Export.Tests; | ||||
|  | ||||
| public sealed class VexExportCacheServiceTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task InvalidateAsync_RemovesEntry() | ||||
|     { | ||||
|         var cacheIndex = new RecordingIndex(); | ||||
|         var maintenance = new StubMaintenance(); | ||||
|         var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance); | ||||
|  | ||||
|         var signature = new VexQuerySignature("format=json|provider=vendor"); | ||||
|         await service.InvalidateAsync(signature, VexExportFormat.Json, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(signature.Value, cacheIndex.LastSignature?.Value); | ||||
|         Assert.Equal(VexExportFormat.Json, cacheIndex.LastFormat); | ||||
|         Assert.Equal(1, cacheIndex.RemoveCalls); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task PruneExpiredAsync_ReturnsCount() | ||||
|     { | ||||
|         var cacheIndex = new RecordingIndex(); | ||||
|         var maintenance = new StubMaintenance { ExpiredCount = 3 }; | ||||
|         var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance); | ||||
|  | ||||
|         var removed = await service.PruneExpiredAsync(DateTimeOffset.UtcNow, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(3, removed); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task PruneDanglingAsync_ReturnsCount() | ||||
|     { | ||||
|         var cacheIndex = new RecordingIndex(); | ||||
|         var maintenance = new StubMaintenance { DanglingCount = 2 }; | ||||
|         var service = new VexExportCacheService(cacheIndex, maintenance, NullLogger<VexExportCacheService>.Instance); | ||||
|  | ||||
|         var removed = await service.PruneDanglingAsync(CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(2, removed); | ||||
|     } | ||||
|  | ||||
|     private sealed class RecordingIndex : IVexCacheIndex | ||||
|     { | ||||
|         public VexQuerySignature? LastSignature { get; private set; } | ||||
|         public VexExportFormat LastFormat { get; private set; } | ||||
|         public int RemoveCalls { get; private set; } | ||||
|  | ||||
|         public ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult<VexCacheEntry?>(null); | ||||
|  | ||||
|         public ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.CompletedTask; | ||||
|  | ||||
|         public ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             LastSignature = signature; | ||||
|             LastFormat = format; | ||||
|             RemoveCalls++; | ||||
|             return ValueTask.CompletedTask; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class StubMaintenance : IVexCacheMaintenance | ||||
|     { | ||||
|         public int ExpiredCount { get; set; } | ||||
|         public int DanglingCount { get; set; } | ||||
|  | ||||
|         public ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult(ExpiredCount); | ||||
|  | ||||
|         public ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => ValueTask.FromResult(DanglingCount); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,209 +1,244 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Policy; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
|  | ||||
| namespace StellaOps.Excititor.Export; | ||||
|  | ||||
| public interface IExportEngine | ||||
| { | ||||
|     ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public sealed record VexExportRequestContext( | ||||
|     VexQuery Query, | ||||
|     VexExportFormat Format, | ||||
|     DateTimeOffset RequestedAt, | ||||
|     bool ForceRefresh = false); | ||||
|  | ||||
| public interface IVexExportDataSource | ||||
| { | ||||
|     ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public sealed record VexExportDataSet( | ||||
|     ImmutableArray<VexConsensus> Consensus, | ||||
|     ImmutableArray<VexClaim> Claims, | ||||
|     ImmutableArray<string> SourceProviders); | ||||
|  | ||||
| public sealed class VexExportEngine : IExportEngine | ||||
| { | ||||
|     private readonly IVexExportStore _exportStore; | ||||
|     private readonly IVexPolicyEvaluator _policyEvaluator; | ||||
|     private readonly IVexExportDataSource _dataSource; | ||||
|     private readonly IReadOnlyDictionary<VexExportFormat, IVexExporter> _exporters; | ||||
|     private readonly ILogger<VexExportEngine> _logger; | ||||
|     private readonly IVexCacheIndex? _cacheIndex; | ||||
|     private readonly IReadOnlyList<IVexArtifactStore> _artifactStores; | ||||
|     private readonly IVexAttestationClient? _attestationClient; | ||||
|  | ||||
|     public VexExportEngine( | ||||
|         IVexExportStore exportStore, | ||||
|         IVexPolicyEvaluator policyEvaluator, | ||||
|         IVexExportDataSource dataSource, | ||||
|         IEnumerable<IVexExporter> exporters, | ||||
|         ILogger<VexExportEngine> logger, | ||||
|         IVexCacheIndex? cacheIndex = null, | ||||
|         IEnumerable<IVexArtifactStore>? artifactStores = null, | ||||
|         IVexAttestationClient? attestationClient = null) | ||||
|     { | ||||
|         _exportStore = exportStore ?? throw new ArgumentNullException(nameof(exportStore)); | ||||
|         _policyEvaluator = policyEvaluator ?? throw new ArgumentNullException(nameof(policyEvaluator)); | ||||
|         _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _cacheIndex = cacheIndex; | ||||
|         _artifactStores = artifactStores?.ToArray() ?? Array.Empty<IVexArtifactStore>(); | ||||
|         _attestationClient = attestationClient; | ||||
|  | ||||
|         if (exporters is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(exporters)); | ||||
|         } | ||||
|  | ||||
|         _exporters = exporters.ToDictionary(x => x.Format); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|         var signature = VexQuerySignature.FromQuery(context.Query); | ||||
|  | ||||
|         if (!context.ForceRefresh) | ||||
|         { | ||||
|             var cached = await _exportStore.FindAsync(signature, context.Format, cancellationToken).ConfigureAwait(false); | ||||
|             if (cached is not null) | ||||
|             { | ||||
|                 _logger.LogInformation("Reusing cached export for {Signature} ({Format})", signature.Value, context.Format); | ||||
|                 return new VexExportManifest( | ||||
|                     cached.ExportId, | ||||
|                     cached.QuerySignature, | ||||
|                     cached.Format, | ||||
|                     cached.CreatedAt, | ||||
|                     cached.Artifact, | ||||
|                     cached.ClaimCount, | ||||
|                     cached.SourceProviders, | ||||
|                     fromCache: true, | ||||
|                     cached.ConsensusRevision, | ||||
|                     cached.Attestation, | ||||
|                     cached.SizeBytes); | ||||
|             } | ||||
|         } | ||||
|         else if (_cacheIndex is not null) | ||||
|         { | ||||
|             await _cacheIndex.RemoveAsync(signature, context.Format, cancellationToken).ConfigureAwait(false); | ||||
|             _logger.LogInformation("Force refresh requested; invalidated cache entry for {Signature} ({Format})", signature.Value, context.Format); | ||||
|         } | ||||
|  | ||||
|         var dataset = await _dataSource.FetchAsync(context.Query, cancellationToken).ConfigureAwait(false); | ||||
|         var exporter = ResolveExporter(context.Format); | ||||
|  | ||||
|         var exportRequest = new VexExportRequest( | ||||
|             context.Query, | ||||
|             dataset.Consensus, | ||||
|             dataset.Claims, | ||||
|             context.RequestedAt); | ||||
|  | ||||
|         var digest = exporter.Digest(exportRequest); | ||||
|         var exportId = FormattableString.Invariant($"exports/{context.RequestedAt:yyyyMMddTHHmmssfffZ}/{digest.Digest}"); | ||||
|  | ||||
|         await using var buffer = new MemoryStream(); | ||||
|         var result = await exporter.SerializeAsync(exportRequest, buffer, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (_artifactStores.Count > 0) | ||||
|         { | ||||
|             var writtenBytes = buffer.ToArray(); | ||||
|             try | ||||
|             { | ||||
|                 var artifact = new VexExportArtifact( | ||||
|                     result.Digest, | ||||
|                     context.Format, | ||||
|                     writtenBytes, | ||||
|                     result.Metadata); | ||||
|  | ||||
|                 foreach (var store in _artifactStores) | ||||
|                 { | ||||
|                     await store.SaveAsync(artifact, cancellationToken).ConfigureAwait(false); | ||||
|                 } | ||||
|  | ||||
|                 _logger.LogInformation("Stored export artifact {Digest} via {StoreCount} store(s)", result.Digest.ToUri(), _artifactStores.Count); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Failed to store export artifact {Digest}", result.Digest.ToUri()); | ||||
|                 throw; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         VexAttestationMetadata? attestationMetadata = null; | ||||
|         if (_attestationClient is not null) | ||||
|         { | ||||
|             var attestationRequest = new VexAttestationRequest( | ||||
|                 exportId, | ||||
|                 signature, | ||||
|                 digest, | ||||
|                 context.Format, | ||||
|                 context.RequestedAt, | ||||
|                 dataset.SourceProviders, | ||||
|                 result.Metadata); | ||||
|  | ||||
|             var response = await _attestationClient.SignAsync(attestationRequest, cancellationToken).ConfigureAwait(false); | ||||
|             attestationMetadata = response.Attestation; | ||||
|  | ||||
|             if (!response.Diagnostics.IsEmpty) | ||||
|             { | ||||
|                 foreach (var diagnostic in response.Diagnostics) | ||||
|                 { | ||||
|                     _logger.LogDebug( | ||||
|                         "Attestation diagnostic {Key}={Value} for export {ExportId}", | ||||
|                         diagnostic.Key, | ||||
|                         diagnostic.Value, | ||||
|                         exportId); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             _logger.LogInformation("Attestation generated for export {ExportId}", exportId); | ||||
|         } | ||||
|  | ||||
|         var manifest = new VexExportManifest( | ||||
|             exportId, | ||||
|             signature, | ||||
|             context.Format, | ||||
|             context.RequestedAt, | ||||
|             digest, | ||||
|             dataset.Claims.Length, | ||||
|             dataset.SourceProviders, | ||||
|             fromCache: false, | ||||
|             consensusRevision: _policyEvaluator.Version, | ||||
|             attestation: attestationMetadata, | ||||
|             sizeBytes: result.BytesWritten); | ||||
|  | ||||
|         await _exportStore.SaveAsync(manifest, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         _logger.LogInformation( | ||||
|             "Export generated for {Signature} ({Format}) size={SizeBytes} bytes", | ||||
|             signature.Value, | ||||
|             context.Format, | ||||
|             result.BytesWritten); | ||||
|  | ||||
|         return manifest; | ||||
|     } | ||||
|  | ||||
|     private IVexExporter ResolveExporter(VexExportFormat format) | ||||
|         => _exporters.TryGetValue(format, out var exporter) | ||||
|             ? exporter | ||||
|             : throw new InvalidOperationException($"No exporter registered for format '{format}'."); | ||||
| } | ||||
|  | ||||
| public static class VexExportServiceCollectionExtensions | ||||
| { | ||||
|     public static IServiceCollection AddVexExportEngine(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddSingleton<IExportEngine, VexExportEngine>(); | ||||
|         services.AddVexExportCacheServices(); | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Policy; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
|  | ||||
| namespace StellaOps.Excititor.Export; | ||||
|  | ||||
| public interface IExportEngine | ||||
| { | ||||
|     ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public sealed record VexExportRequestContext( | ||||
|     VexQuery Query, | ||||
|     VexExportFormat Format, | ||||
|     DateTimeOffset RequestedAt, | ||||
|     bool ForceRefresh = false); | ||||
|  | ||||
| public interface IVexExportDataSource | ||||
| { | ||||
|     ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public sealed record VexExportDataSet( | ||||
|     ImmutableArray<VexConsensus> Consensus, | ||||
|     ImmutableArray<VexClaim> Claims, | ||||
|     ImmutableArray<string> SourceProviders); | ||||
|  | ||||
| public sealed class VexExportEngine : IExportEngine | ||||
| { | ||||
|     private readonly IVexExportStore _exportStore; | ||||
|     private readonly IVexPolicyEvaluator _policyEvaluator; | ||||
|     private readonly IVexExportDataSource _dataSource; | ||||
|     private readonly IReadOnlyDictionary<VexExportFormat, IVexExporter> _exporters; | ||||
|     private readonly ILogger<VexExportEngine> _logger; | ||||
|     private readonly IVexCacheIndex? _cacheIndex; | ||||
|     private readonly IReadOnlyList<IVexArtifactStore> _artifactStores; | ||||
|     private readonly IVexAttestationClient? _attestationClient; | ||||
|  | ||||
|     public VexExportEngine( | ||||
|         IVexExportStore exportStore, | ||||
|         IVexPolicyEvaluator policyEvaluator, | ||||
|         IVexExportDataSource dataSource, | ||||
|         IEnumerable<IVexExporter> exporters, | ||||
|         ILogger<VexExportEngine> logger, | ||||
|         IVexCacheIndex? cacheIndex = null, | ||||
|         IEnumerable<IVexArtifactStore>? artifactStores = null, | ||||
|         IVexAttestationClient? attestationClient = null) | ||||
|     { | ||||
|         _exportStore = exportStore ?? throw new ArgumentNullException(nameof(exportStore)); | ||||
|         _policyEvaluator = policyEvaluator ?? throw new ArgumentNullException(nameof(policyEvaluator)); | ||||
|         _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _cacheIndex = cacheIndex; | ||||
|         _artifactStores = artifactStores?.ToArray() ?? Array.Empty<IVexArtifactStore>(); | ||||
|         _attestationClient = attestationClient; | ||||
|  | ||||
|         if (exporters is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(exporters)); | ||||
|         } | ||||
|  | ||||
|         _exporters = exporters.ToDictionary(x => x.Format); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<VexExportManifest> ExportAsync(VexExportRequestContext context, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|         var signature = VexQuerySignature.FromQuery(context.Query); | ||||
|  | ||||
|         if (!context.ForceRefresh) | ||||
|         { | ||||
|             var cached = await _exportStore.FindAsync(signature, context.Format, cancellationToken).ConfigureAwait(false); | ||||
|             if (cached is not null) | ||||
|             { | ||||
|                 _logger.LogInformation("Reusing cached export for {Signature} ({Format})", signature.Value, context.Format); | ||||
|                 return new VexExportManifest( | ||||
|                     cached.ExportId, | ||||
|                     cached.QuerySignature, | ||||
|                     cached.Format, | ||||
|                     cached.CreatedAt, | ||||
|                     cached.Artifact, | ||||
|                     cached.ClaimCount, | ||||
|                     cached.SourceProviders, | ||||
|                     fromCache: true, | ||||
|                     cached.ConsensusRevision, | ||||
|                     cached.PolicyRevisionId, | ||||
|                     cached.PolicyDigest, | ||||
|                     cached.ConsensusDigest, | ||||
|                     cached.ScoreDigest, | ||||
|                     cached.Attestation, | ||||
|                     cached.SizeBytes); | ||||
|             } | ||||
|         } | ||||
|         else if (_cacheIndex is not null) | ||||
|         { | ||||
|             await _cacheIndex.RemoveAsync(signature, context.Format, cancellationToken).ConfigureAwait(false); | ||||
|             _logger.LogInformation("Force refresh requested; invalidated cache entry for {Signature} ({Format})", signature.Value, context.Format); | ||||
|         } | ||||
|  | ||||
|         var dataset = await _dataSource.FetchAsync(context.Query, cancellationToken).ConfigureAwait(false); | ||||
|         var exporter = ResolveExporter(context.Format); | ||||
|         var policySnapshot = _policyEvaluator.Snapshot; | ||||
|  | ||||
|         var exportRequest = new VexExportRequest( | ||||
|             context.Query, | ||||
|             dataset.Consensus, | ||||
|             dataset.Claims, | ||||
|             context.RequestedAt); | ||||
|  | ||||
|         var digest = exporter.Digest(exportRequest); | ||||
|         var exportId = FormattableString.Invariant($"exports/{context.RequestedAt:yyyyMMddTHHmmssfffZ}/{digest.Digest}"); | ||||
|  | ||||
|         await using var buffer = new MemoryStream(); | ||||
|         var result = await exporter.SerializeAsync(exportRequest, buffer, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (_artifactStores.Count > 0) | ||||
|         { | ||||
|             var writtenBytes = buffer.ToArray(); | ||||
|             try | ||||
|             { | ||||
|                 var artifact = new VexExportArtifact( | ||||
|                     result.Digest, | ||||
|                     context.Format, | ||||
|                     writtenBytes, | ||||
|                     result.Metadata); | ||||
|  | ||||
|                 foreach (var store in _artifactStores) | ||||
|                 { | ||||
|                     await store.SaveAsync(artifact, cancellationToken).ConfigureAwait(false); | ||||
|                 } | ||||
|  | ||||
|                 _logger.LogInformation("Stored export artifact {Digest} via {StoreCount} store(s)", result.Digest.ToUri(), _artifactStores.Count); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Failed to store export artifact {Digest}", result.Digest.ToUri()); | ||||
|                 throw; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         VexAttestationMetadata? attestationMetadata = null; | ||||
|         if (_attestationClient is not null) | ||||
|         { | ||||
|             var attestationRequest = new VexAttestationRequest( | ||||
|                 exportId, | ||||
|                 signature, | ||||
|                 digest, | ||||
|                 context.Format, | ||||
|                 context.RequestedAt, | ||||
|                 dataset.SourceProviders, | ||||
|                 result.Metadata); | ||||
|  | ||||
|             var response = await _attestationClient.SignAsync(attestationRequest, cancellationToken).ConfigureAwait(false); | ||||
|             attestationMetadata = response.Attestation; | ||||
|  | ||||
|             if (!response.Diagnostics.IsEmpty) | ||||
|             { | ||||
|                 foreach (var diagnostic in response.Diagnostics) | ||||
|                 { | ||||
|                     _logger.LogDebug( | ||||
|                         "Attestation diagnostic {Key}={Value} for export {ExportId}", | ||||
|                         diagnostic.Key, | ||||
|                         diagnostic.Value, | ||||
|                         exportId); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             _logger.LogInformation("Attestation generated for export {ExportId}", exportId); | ||||
|         } | ||||
|  | ||||
|         var consensusDigestAddress = TryGetContentAddress(result.Metadata, "consensusDigest"); | ||||
|         var scoreDigestAddress = TryGetContentAddress(result.Metadata, "scoreDigest"); | ||||
|  | ||||
|         var manifest = new VexExportManifest( | ||||
|             exportId, | ||||
|             signature, | ||||
|             context.Format, | ||||
|             context.RequestedAt, | ||||
|             digest, | ||||
|             dataset.Claims.Length, | ||||
|             dataset.SourceProviders, | ||||
|             fromCache: false, | ||||
|             consensusRevision: policySnapshot.Version, | ||||
|             policyRevisionId: policySnapshot.RevisionId, | ||||
|             policyDigest: policySnapshot.Digest, | ||||
|             consensusDigest: consensusDigestAddress, | ||||
|             scoreDigest: scoreDigestAddress, | ||||
|             attestation: attestationMetadata, | ||||
|             sizeBytes: result.BytesWritten); | ||||
|  | ||||
|         await _exportStore.SaveAsync(manifest, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         _logger.LogInformation( | ||||
|             "Export generated for {Signature} ({Format}) size={SizeBytes} bytes", | ||||
|             signature.Value, | ||||
|             context.Format, | ||||
|             result.BytesWritten); | ||||
|  | ||||
|         return manifest; | ||||
|     } | ||||
|  | ||||
|     private static VexContentAddress? TryGetContentAddress(IReadOnlyDictionary<string, string> metadata, string key) | ||||
|     { | ||||
|         if (metadata is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var parts = value.Split(':', 2, StringSplitOptions.TrimEntries); | ||||
|         if (parts.Length != 2) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return new VexContentAddress(parts[0], parts[1]); | ||||
|     } | ||||
|  | ||||
|     private IVexExporter ResolveExporter(VexExportFormat format) | ||||
|         => _exporters.TryGetValue(format, out var exporter) | ||||
|             ? exporter | ||||
|             : throw new InvalidOperationException($"No exporter registered for format '{format}'."); | ||||
| } | ||||
|  | ||||
| public static class VexExportServiceCollectionExtensions | ||||
| { | ||||
|     public static IServiceCollection AddVexExportEngine(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddSingleton<IExportEngine, VexExportEngine>(); | ||||
|         services.AddVexExportCacheServices(); | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,116 +1,119 @@ | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Plugin; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
|  | ||||
| namespace StellaOps.Excititor.Worker.Scheduling; | ||||
|  | ||||
| internal sealed class DefaultVexProviderRunner : IVexProviderRunner | ||||
| { | ||||
|     private readonly IServiceProvider _serviceProvider; | ||||
|     private readonly PluginCatalog _pluginCatalog; | ||||
|     private readonly ILogger<DefaultVexProviderRunner> _logger; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|  | ||||
|     public DefaultVexProviderRunner( | ||||
|         IServiceProvider serviceProvider, | ||||
|         PluginCatalog pluginCatalog, | ||||
|         ILogger<DefaultVexProviderRunner> logger, | ||||
|         TimeProvider timeProvider) | ||||
|     { | ||||
|         _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); | ||||
|         _pluginCatalog = pluginCatalog ?? throw new ArgumentNullException(nameof(pluginCatalog)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask RunAsync(string providerId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(providerId); | ||||
|  | ||||
|         using var scope = _serviceProvider.CreateScope(); | ||||
|         var availablePlugins = _pluginCatalog.GetAvailableConnectorPlugins(scope.ServiceProvider); | ||||
|         var matched = availablePlugins.FirstOrDefault(plugin => | ||||
|             string.Equals(plugin.Name, providerId, StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|         if (matched is not null) | ||||
|         { | ||||
|             _logger.LogInformation( | ||||
|                 "Connector plugin {PluginName} ({ProviderId}) is available. Execution hooks will be added in subsequent tasks.", | ||||
|                 matched.Name, | ||||
|                 providerId); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             _logger.LogInformation("No legacy connector plugin registered for provider {ProviderId}; falling back to DI-managed connectors.", providerId); | ||||
|         } | ||||
|  | ||||
|         var connectors = scope.ServiceProvider.GetServices<IVexConnector>(); | ||||
|         var connector = connectors.FirstOrDefault(c => string.Equals(c.Id, providerId, StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|         if (connector is null) | ||||
|         { | ||||
|             _logger.LogWarning("No IVexConnector implementation registered for provider {ProviderId}; skipping run.", providerId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await ExecuteConnectorAsync(scope.ServiceProvider, connector, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private async Task ExecuteConnectorAsync(IServiceProvider scopeProvider, IVexConnector connector, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var rawStore = scopeProvider.GetRequiredService<IVexRawStore>(); | ||||
|         var claimStore = scopeProvider.GetRequiredService<IVexClaimStore>(); | ||||
|         var providerStore = scopeProvider.GetRequiredService<IVexProviderStore>(); | ||||
|         var normalizerRouter = scopeProvider.GetRequiredService<IVexNormalizerRouter>(); | ||||
|         var signatureVerifier = scopeProvider.GetRequiredService<IVexSignatureVerifier>(); | ||||
|         var sessionProvider = scopeProvider.GetRequiredService<IVexMongoSessionProvider>(); | ||||
|         var session = await sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var descriptor = connector switch | ||||
|         { | ||||
|             VexConnectorBase baseConnector => baseConnector.Descriptor, | ||||
|             _ => new VexConnectorDescriptor(connector.Id, VexProviderKind.Vendor, connector.Id) | ||||
|         }; | ||||
|  | ||||
|         var provider = await providerStore.FindAsync(descriptor.Id, cancellationToken, session).ConfigureAwait(false) | ||||
|             ?? new VexProvider(descriptor.Id, descriptor.DisplayName, descriptor.Kind); | ||||
|  | ||||
|         await providerStore.SaveAsync(provider, cancellationToken, session).ConfigureAwait(false); | ||||
|  | ||||
|         await connector.ValidateAsync(VexConnectorSettings.Empty, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var context = new VexConnectorContext( | ||||
|             Since: null, | ||||
|             Settings: VexConnectorSettings.Empty, | ||||
|             RawSink: rawStore, | ||||
|             SignatureVerifier: signatureVerifier, | ||||
|             Normalizers: normalizerRouter, | ||||
|             Services: scopeProvider); | ||||
|  | ||||
|         var documentCount = 0; | ||||
|         var claimCount = 0; | ||||
|  | ||||
|         await foreach (var document in connector.FetchAsync(context, cancellationToken)) | ||||
|         { | ||||
|             documentCount++; | ||||
|  | ||||
|             var batch = await normalizerRouter.NormalizeAsync(document, cancellationToken).ConfigureAwait(false); | ||||
|             if (!batch.Claims.IsDefaultOrEmpty && batch.Claims.Length > 0) | ||||
|             { | ||||
|                 claimCount += batch.Claims.Length; | ||||
|                 await claimStore.AppendAsync(batch.Claims, _timeProvider.GetUtcNow(), cancellationToken, session).ConfigureAwait(false); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         _logger.LogInformation( | ||||
|             "Connector {ConnectorId} persisted {DocumentCount} document(s) and {ClaimCount} claim(s) this run.", | ||||
|             connector.Id, | ||||
|             documentCount, | ||||
|             claimCount); | ||||
|     } | ||||
| } | ||||
| using System; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Plugin; | ||||
| using StellaOps.Excititor.Connectors.Abstractions; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Storage.Mongo; | ||||
|  | ||||
| namespace StellaOps.Excititor.Worker.Scheduling; | ||||
|  | ||||
| internal sealed class DefaultVexProviderRunner : IVexProviderRunner | ||||
| { | ||||
|     private readonly IServiceProvider _serviceProvider; | ||||
|     private readonly PluginCatalog _pluginCatalog; | ||||
|     private readonly ILogger<DefaultVexProviderRunner> _logger; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|  | ||||
|     public DefaultVexProviderRunner( | ||||
|         IServiceProvider serviceProvider, | ||||
|         PluginCatalog pluginCatalog, | ||||
|         ILogger<DefaultVexProviderRunner> logger, | ||||
|         TimeProvider timeProvider) | ||||
|     { | ||||
|         _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); | ||||
|         _pluginCatalog = pluginCatalog ?? throw new ArgumentNullException(nameof(pluginCatalog)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask RunAsync(VexWorkerSchedule schedule, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(schedule); | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(schedule.ProviderId); | ||||
|  | ||||
|         using var scope = _serviceProvider.CreateScope(); | ||||
|         var availablePlugins = _pluginCatalog.GetAvailableConnectorPlugins(scope.ServiceProvider); | ||||
|         var matched = availablePlugins.FirstOrDefault(plugin => | ||||
|             string.Equals(plugin.Name, schedule.ProviderId, StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|         if (matched is not null) | ||||
|         { | ||||
|             _logger.LogInformation( | ||||
|                 "Connector plugin {PluginName} ({ProviderId}) is available. Execution hooks will be added in subsequent tasks.", | ||||
|                 matched.Name, | ||||
|                 schedule.ProviderId); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             _logger.LogInformation("No legacy connector plugin registered for provider {ProviderId}; falling back to DI-managed connectors.", schedule.ProviderId); | ||||
|         } | ||||
|  | ||||
|         var connectors = scope.ServiceProvider.GetServices<IVexConnector>(); | ||||
|         var connector = connectors.FirstOrDefault(c => string.Equals(c.Id, schedule.ProviderId, StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|         if (connector is null) | ||||
|         { | ||||
|             _logger.LogWarning("No IVexConnector implementation registered for provider {ProviderId}; skipping run.", schedule.ProviderId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         await ExecuteConnectorAsync(scope.ServiceProvider, connector, schedule.Settings, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private async Task ExecuteConnectorAsync(IServiceProvider scopeProvider, IVexConnector connector, VexConnectorSettings settings, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var effectiveSettings = settings ?? VexConnectorSettings.Empty; | ||||
|         var rawStore = scopeProvider.GetRequiredService<IVexRawStore>(); | ||||
|         var claimStore = scopeProvider.GetRequiredService<IVexClaimStore>(); | ||||
|         var providerStore = scopeProvider.GetRequiredService<IVexProviderStore>(); | ||||
|         var normalizerRouter = scopeProvider.GetRequiredService<IVexNormalizerRouter>(); | ||||
|         var signatureVerifier = scopeProvider.GetRequiredService<IVexSignatureVerifier>(); | ||||
|         var sessionProvider = scopeProvider.GetRequiredService<IVexMongoSessionProvider>(); | ||||
|         var session = await sessionProvider.StartSessionAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var descriptor = connector switch | ||||
|         { | ||||
|             VexConnectorBase baseConnector => baseConnector.Descriptor, | ||||
|             _ => new VexConnectorDescriptor(connector.Id, VexProviderKind.Vendor, connector.Id) | ||||
|         }; | ||||
|  | ||||
|         var provider = await providerStore.FindAsync(descriptor.Id, cancellationToken, session).ConfigureAwait(false) | ||||
|             ?? new VexProvider(descriptor.Id, descriptor.DisplayName, descriptor.Kind); | ||||
|  | ||||
|         await providerStore.SaveAsync(provider, cancellationToken, session).ConfigureAwait(false); | ||||
|  | ||||
|         await connector.ValidateAsync(effectiveSettings, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var context = new VexConnectorContext( | ||||
|             Since: null, | ||||
|             Settings: effectiveSettings, | ||||
|             RawSink: rawStore, | ||||
|             SignatureVerifier: signatureVerifier, | ||||
|             Normalizers: normalizerRouter, | ||||
|             Services: scopeProvider); | ||||
|  | ||||
|         var documentCount = 0; | ||||
|         var claimCount = 0; | ||||
|  | ||||
|         await foreach (var document in connector.FetchAsync(context, cancellationToken)) | ||||
|         { | ||||
|             documentCount++; | ||||
|  | ||||
|             var batch = await normalizerRouter.NormalizeAsync(document, cancellationToken).ConfigureAwait(false); | ||||
|             if (!batch.Claims.IsDefaultOrEmpty && batch.Claims.Length > 0) | ||||
|             { | ||||
|                 claimCount += batch.Claims.Length; | ||||
|                 await claimStore.AppendAsync(batch.Claims, _timeProvider.GetUtcNow(), cancellationToken, session).ConfigureAwait(false); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         _logger.LogInformation( | ||||
|             "Connector {ConnectorId} persisted {DocumentCount} document(s) and {ClaimCount} claim(s) this run.", | ||||
|             connector.Id, | ||||
|             documentCount, | ||||
|             claimCount); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,204 +1,205 @@ | ||||
| using System.Text; | ||||
| using StellaOps.Zastava.Core.Contracts; | ||||
| using StellaOps.Zastava.Core.Hashing; | ||||
| using StellaOps.Zastava.Core.Serialization; | ||||
|  | ||||
| namespace StellaOps.Zastava.Core.Tests.Serialization; | ||||
|  | ||||
| public sealed class ZastavaCanonicalJsonSerializerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Serialize_RuntimeEventEnvelope_ProducesDeterministicOrdering() | ||||
|     { | ||||
|         var runtimeEvent = new RuntimeEvent | ||||
|         { | ||||
|             EventId = "evt-123", | ||||
|             When = DateTimeOffset.Parse("2025-10-19T12:34:56Z"), | ||||
|             Kind = RuntimeEventKind.ContainerStart, | ||||
|             Tenant = "tenant-01", | ||||
|             Node = "node-a", | ||||
|             Runtime = new RuntimeEngine | ||||
|             { | ||||
|                 Engine = "containerd", | ||||
|                 Version = "1.7.19" | ||||
|             }, | ||||
|             Workload = new RuntimeWorkload | ||||
|             { | ||||
|                 Platform = "kubernetes", | ||||
|                 Namespace = "payments", | ||||
|                 Pod = "api-7c9fbbd8b7-ktd84", | ||||
|                 Container = "api", | ||||
|                 ContainerId = "containerd://abc", | ||||
|                 ImageRef = "ghcr.io/acme/api@sha256:abcd", | ||||
|                 Owner = new RuntimeWorkloadOwner | ||||
|                 { | ||||
|                     Kind = "Deployment", | ||||
|                     Name = "api" | ||||
|                 } | ||||
|             }, | ||||
|             Process = new RuntimeProcess | ||||
|             { | ||||
|                 Pid = 12345, | ||||
|                 Entrypoint = new[] { "/entrypoint.sh", "--serve" }, | ||||
|                 EntryTrace = new[] | ||||
|                 { | ||||
|                     new RuntimeEntryTrace | ||||
|                     { | ||||
|                         File = "/entrypoint.sh", | ||||
|                         Line = 3, | ||||
|                         Op = "exec", | ||||
|                         Target = "/usr/bin/python3" | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             LoadedLibraries = new[] | ||||
|             { | ||||
|                 new RuntimeLoadedLibrary | ||||
|                 { | ||||
|                     Path = "/lib/x86_64-linux-gnu/libssl.so.3", | ||||
|                     Inode = 123456, | ||||
|                     Sha256 = "abc123" | ||||
|                 } | ||||
|             }, | ||||
|             Posture = new RuntimePosture | ||||
|             { | ||||
|                 ImageSigned = true, | ||||
|                 SbomReferrer = "present", | ||||
|                 Attestation = new RuntimeAttestation | ||||
|                 { | ||||
|                     Uuid = "rekor-uuid", | ||||
|                     Verified = true | ||||
|                 } | ||||
|             }, | ||||
|             Delta = new RuntimeDelta | ||||
|             { | ||||
|                 BaselineImageDigest = "sha256:abcd", | ||||
|                 ChangedFiles = new[] { "/opt/app/server.py" }, | ||||
|                 NewBinaries = new[] | ||||
|                 { | ||||
|                     new RuntimeNewBinary | ||||
|                     { | ||||
|                         Path = "/usr/local/bin/helper", | ||||
|                         Sha256 = "def456" | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             Evidence = new[] | ||||
|             { | ||||
|                 new RuntimeEvidence | ||||
|                 { | ||||
|                     Signal = "procfs.maps", | ||||
|                     Value = "/lib/.../libssl.so.3@0x7f..." | ||||
|                 } | ||||
|             }, | ||||
|             Annotations = new Dictionary<string, string> | ||||
|             { | ||||
|                 ["source"] = "unit-test" | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var envelope = RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent); | ||||
|         var json = ZastavaCanonicalJsonSerializer.Serialize(envelope); | ||||
|  | ||||
|         var expectedOrder = new[] | ||||
|         { | ||||
|             "\"schemaVersion\"", | ||||
|             "\"event\"", | ||||
|             "\"eventId\"", | ||||
|             "\"when\"", | ||||
|             "\"kind\"", | ||||
|             "\"tenant\"", | ||||
|             "\"node\"", | ||||
|             "\"runtime\"", | ||||
|             "\"engine\"", | ||||
|             "\"version\"", | ||||
|             "\"workload\"", | ||||
|             "\"platform\"", | ||||
|             "\"namespace\"", | ||||
|             "\"pod\"", | ||||
|             "\"container\"", | ||||
|             "\"containerId\"", | ||||
|             "\"imageRef\"", | ||||
|             "\"owner\"", | ||||
|             "\"kind\"", | ||||
|             "\"name\"", | ||||
|             "\"process\"", | ||||
|             "\"pid\"", | ||||
|             "\"entrypoint\"", | ||||
|             "\"entryTrace\"", | ||||
|             "\"loadedLibs\"", | ||||
|             "\"posture\"", | ||||
|             "\"imageSigned\"", | ||||
|             "\"sbomReferrer\"", | ||||
|             "\"attestation\"", | ||||
|             "\"uuid\"", | ||||
|             "\"verified\"", | ||||
|             "\"delta\"", | ||||
|             "\"baselineImageDigest\"", | ||||
|             "\"changedFiles\"", | ||||
|             "\"newBinaries\"", | ||||
|             "\"path\"", | ||||
|             "\"sha256\"", | ||||
|             "\"evidence\"", | ||||
|             "\"signal\"", | ||||
|             "\"value\"", | ||||
|             "\"annotations\"", | ||||
|             "\"source\"" | ||||
|         }; | ||||
|  | ||||
|         var cursor = -1; | ||||
|         foreach (var token in expectedOrder) | ||||
|         { | ||||
|             var position = json.IndexOf(token, cursor + 1, StringComparison.Ordinal); | ||||
|             Assert.True(position > cursor, $"Property token {token} not found in the expected order."); | ||||
|             cursor = position; | ||||
|         } | ||||
|  | ||||
|         Assert.DoesNotContain("  ", json, StringComparison.Ordinal); | ||||
|         Assert.StartsWith("{\"schemaVersion\"", json, StringComparison.Ordinal); | ||||
|         Assert.EndsWith("}}", json, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void ComputeMultihash_ProducesStableBase64UrlDigest() | ||||
|     { | ||||
|         var decision = AdmissionDecisionEnvelope.Create( | ||||
|             new AdmissionDecision | ||||
|             { | ||||
|                 AdmissionId = "admission-123", | ||||
|                 Namespace = "payments", | ||||
|                 PodSpecDigest = "sha256:deadbeef", | ||||
|                 Images = new[] | ||||
|                 { | ||||
|                     new AdmissionImageVerdict | ||||
|                     { | ||||
|                         Name = "ghcr.io/acme/api:1.2.3", | ||||
|                         Resolved = "ghcr.io/acme/api@sha256:abcd", | ||||
|                         Signed = true, | ||||
|                         HasSbomReferrers = true, | ||||
|                         PolicyVerdict = PolicyVerdict.Pass, | ||||
|                         Reasons = Array.Empty<string>(), | ||||
|                         Rekor = new AdmissionRekorEvidence | ||||
|                         { | ||||
|                             Uuid = "xyz", | ||||
|                             Verified = true | ||||
|                         } | ||||
|                     } | ||||
|                 }, | ||||
|                 Decision = AdmissionDecisionOutcome.Allow, | ||||
|                 TtlSeconds = 300 | ||||
|             }, | ||||
|             ZastavaContractVersions.AdmissionDecision); | ||||
|  | ||||
|         var canonicalJson = ZastavaCanonicalJsonSerializer.Serialize(decision); | ||||
|         var expectedDigestBytes = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson)); | ||||
|         var expected = $"sha256-{Convert.ToBase64String(expectedDigestBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; | ||||
|  | ||||
|         var hash = ZastavaHashing.ComputeMultihash(decision); | ||||
|  | ||||
|         Assert.Equal(expected, hash); | ||||
|  | ||||
|         var sha512 = ZastavaHashing.ComputeMultihash(Encoding.UTF8.GetBytes(canonicalJson), "sha512"); | ||||
|         Assert.StartsWith("sha512-", sha512, StringComparison.Ordinal); | ||||
|     } | ||||
| } | ||||
| using System.Text; | ||||
| using System.Security.Cryptography; | ||||
| using StellaOps.Zastava.Core.Contracts; | ||||
| using StellaOps.Zastava.Core.Hashing; | ||||
| using StellaOps.Zastava.Core.Serialization; | ||||
|  | ||||
| namespace StellaOps.Zastava.Core.Tests.Serialization; | ||||
|  | ||||
| public sealed class ZastavaCanonicalJsonSerializerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Serialize_RuntimeEventEnvelope_ProducesDeterministicOrdering() | ||||
|     { | ||||
|         var runtimeEvent = new RuntimeEvent | ||||
|         { | ||||
|             EventId = "evt-123", | ||||
|             When = DateTimeOffset.Parse("2025-10-19T12:34:56Z"), | ||||
|             Kind = RuntimeEventKind.ContainerStart, | ||||
|             Tenant = "tenant-01", | ||||
|             Node = "node-a", | ||||
|             Runtime = new RuntimeEngine | ||||
|             { | ||||
|                 Engine = "containerd", | ||||
|                 Version = "1.7.19" | ||||
|             }, | ||||
|             Workload = new RuntimeWorkload | ||||
|             { | ||||
|                 Platform = "kubernetes", | ||||
|                 Namespace = "payments", | ||||
|                 Pod = "api-7c9fbbd8b7-ktd84", | ||||
|                 Container = "api", | ||||
|                 ContainerId = "containerd://abc", | ||||
|                 ImageRef = "ghcr.io/acme/api@sha256:abcd", | ||||
|                 Owner = new RuntimeWorkloadOwner | ||||
|                 { | ||||
|                     Kind = "Deployment", | ||||
|                     Name = "api" | ||||
|                 } | ||||
|             }, | ||||
|             Process = new RuntimeProcess | ||||
|             { | ||||
|                 Pid = 12345, | ||||
|                 Entrypoint = new[] { "/entrypoint.sh", "--serve" }, | ||||
|                 EntryTrace = new[] | ||||
|                 { | ||||
|                     new RuntimeEntryTrace | ||||
|                     { | ||||
|                         File = "/entrypoint.sh", | ||||
|                         Line = 3, | ||||
|                         Op = "exec", | ||||
|                         Target = "/usr/bin/python3" | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             LoadedLibraries = new[] | ||||
|             { | ||||
|                 new RuntimeLoadedLibrary | ||||
|                 { | ||||
|                     Path = "/lib/x86_64-linux-gnu/libssl.so.3", | ||||
|                     Inode = 123456, | ||||
|                     Sha256 = "abc123" | ||||
|                 } | ||||
|             }, | ||||
|             Posture = new RuntimePosture | ||||
|             { | ||||
|                 ImageSigned = true, | ||||
|                 SbomReferrer = "present", | ||||
|                 Attestation = new RuntimeAttestation | ||||
|                 { | ||||
|                     Uuid = "rekor-uuid", | ||||
|                     Verified = true | ||||
|                 } | ||||
|             }, | ||||
|             Delta = new RuntimeDelta | ||||
|             { | ||||
|                 BaselineImageDigest = "sha256:abcd", | ||||
|                 ChangedFiles = new[] { "/opt/app/server.py" }, | ||||
|                 NewBinaries = new[] | ||||
|                 { | ||||
|                     new RuntimeNewBinary | ||||
|                     { | ||||
|                         Path = "/usr/local/bin/helper", | ||||
|                         Sha256 = "def456" | ||||
|                     } | ||||
|                 } | ||||
|             }, | ||||
|             Evidence = new[] | ||||
|             { | ||||
|                 new RuntimeEvidence | ||||
|                 { | ||||
|                     Signal = "procfs.maps", | ||||
|                     Value = "/lib/.../libssl.so.3@0x7f..." | ||||
|                 } | ||||
|             }, | ||||
|             Annotations = new Dictionary<string, string> | ||||
|             { | ||||
|                 ["source"] = "unit-test" | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var envelope = RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent); | ||||
|         var json = ZastavaCanonicalJsonSerializer.Serialize(envelope); | ||||
|  | ||||
|         var expectedOrder = new[] | ||||
|         { | ||||
|             "\"schemaVersion\"", | ||||
|             "\"event\"", | ||||
|             "\"eventId\"", | ||||
|             "\"when\"", | ||||
|             "\"kind\"", | ||||
|             "\"tenant\"", | ||||
|             "\"node\"", | ||||
|             "\"runtime\"", | ||||
|             "\"engine\"", | ||||
|             "\"version\"", | ||||
|             "\"workload\"", | ||||
|             "\"platform\"", | ||||
|             "\"namespace\"", | ||||
|             "\"pod\"", | ||||
|             "\"container\"", | ||||
|             "\"containerId\"", | ||||
|             "\"imageRef\"", | ||||
|             "\"owner\"", | ||||
|             "\"kind\"", | ||||
|             "\"name\"", | ||||
|             "\"process\"", | ||||
|             "\"pid\"", | ||||
|             "\"entrypoint\"", | ||||
|             "\"entryTrace\"", | ||||
|             "\"loadedLibs\"", | ||||
|             "\"posture\"", | ||||
|             "\"imageSigned\"", | ||||
|             "\"sbomReferrer\"", | ||||
|             "\"attestation\"", | ||||
|             "\"uuid\"", | ||||
|             "\"verified\"", | ||||
|             "\"delta\"", | ||||
|             "\"baselineImageDigest\"", | ||||
|             "\"changedFiles\"", | ||||
|             "\"newBinaries\"", | ||||
|             "\"path\"", | ||||
|             "\"sha256\"", | ||||
|             "\"evidence\"", | ||||
|             "\"signal\"", | ||||
|             "\"value\"", | ||||
|             "\"annotations\"", | ||||
|             "\"source\"" | ||||
|         }; | ||||
|  | ||||
|         var cursor = -1; | ||||
|         foreach (var token in expectedOrder) | ||||
|         { | ||||
|             var position = json.IndexOf(token, cursor + 1, StringComparison.Ordinal); | ||||
|             Assert.True(position > cursor, $"Property token {token} not found in the expected order."); | ||||
|             cursor = position; | ||||
|         } | ||||
|  | ||||
|         Assert.DoesNotContain("  ", json, StringComparison.Ordinal); | ||||
|         Assert.StartsWith("{\"schemaVersion\"", json, StringComparison.Ordinal); | ||||
|         Assert.EndsWith("}}", json, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void ComputeMultihash_ProducesStableBase64UrlDigest() | ||||
|     { | ||||
|         var decision = AdmissionDecisionEnvelope.Create( | ||||
|             new AdmissionDecision | ||||
|             { | ||||
|                 AdmissionId = "admission-123", | ||||
|                 Namespace = "payments", | ||||
|                 PodSpecDigest = "sha256:deadbeef", | ||||
|                 Images = new[] | ||||
|                 { | ||||
|                     new AdmissionImageVerdict | ||||
|                     { | ||||
|                         Name = "ghcr.io/acme/api:1.2.3", | ||||
|                         Resolved = "ghcr.io/acme/api@sha256:abcd", | ||||
|                         Signed = true, | ||||
|                         HasSbomReferrers = true, | ||||
|                         PolicyVerdict = PolicyVerdict.Pass, | ||||
|                         Reasons = Array.Empty<string>(), | ||||
|                         Rekor = new AdmissionRekorEvidence | ||||
|                         { | ||||
|                             Uuid = "xyz", | ||||
|                             Verified = true | ||||
|                         } | ||||
|                     } | ||||
|                 }, | ||||
|                 Decision = AdmissionDecisionOutcome.Allow, | ||||
|                 TtlSeconds = 300 | ||||
|             }, | ||||
|             ZastavaContractVersions.AdmissionDecision); | ||||
|  | ||||
|         var canonicalJson = ZastavaCanonicalJsonSerializer.Serialize(decision); | ||||
|         var expectedDigestBytes = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson)); | ||||
|         var expected = $"sha256-{Convert.ToBase64String(expectedDigestBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; | ||||
|  | ||||
|         var hash = ZastavaHashing.ComputeMultihash(decision); | ||||
|  | ||||
|         Assert.Equal(expected, hash); | ||||
|  | ||||
|         var sha512 = ZastavaHashing.ComputeMultihash(Encoding.UTF8.GetBytes(canonicalJson), "sha512"); | ||||
|         Assert.StartsWith("sha512-", sha512, StringComparison.Ordinal); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -104,8 +104,8 @@ public static class ZastavaContractVersions | ||||
|         /// <summary> | ||||
|         /// Canonical string representation (schema@vMajor.Minor). | ||||
|         /// </summary> | ||||
|         public override string ToString() | ||||
|             => $"{Schema}@v{Version.ToString(2, CultureInfo.InvariantCulture)}"; | ||||
|         public override string ToString() | ||||
|             => $"{Schema}@v{Version.ToString(2)}"; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Determines whether a remote contract is compatible with the local definition. | ||||
|   | ||||
| @@ -1,4 +1,13 @@ | ||||
| namespace StellaOps.Zastava.Core.Serialization; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text.Encodings.Web; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Text.Json.Serialization.Metadata; | ||||
| using StellaOps.Zastava.Core.Contracts; | ||||
|  | ||||
| namespace StellaOps.Zastava.Core.Serialization; | ||||
|  | ||||
| /// <summary> | ||||
| /// Deterministic serializer used for runtime/admission contracts. | ||||
|   | ||||
		Reference in New Issue
	
	Block a user