diff --git a/NuGet.config b/NuGet.config
new file mode 100644
index 00000000..0e524b46
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/local-nuget/Microsoft.Extensions.Http.Polly.9.0.0-preview.6.24328.4.nupkg b/local-nuget/Microsoft.Extensions.Http.Polly.9.0.0-preview.6.24328.4.nupkg
new file mode 100644
index 00000000..290eb382
Binary files /dev/null and b/local-nuget/Microsoft.Extensions.Http.Polly.9.0.0-preview.6.24328.4.nupkg differ
diff --git a/local-nuget/Mongo2Go.4.1.0.nupkg b/local-nuget/Mongo2Go.4.1.0.nupkg
new file mode 100644
index 00000000..a9378128
Binary files /dev/null and b/local-nuget/Mongo2Go.4.1.0.nupkg differ
diff --git a/src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardClientProvisioningStoreTests.cs b/src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardClientProvisioningStoreTests.cs
index a7017fef..c65e6867 100644
--- a/src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardClientProvisioningStoreTests.cs
+++ b/src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardClientProvisioningStoreTests.cs
@@ -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);
diff --git a/src/StellaOps.Authority/StellaOps.Authority/Bootstrap/BootstrapRequests.cs b/src/StellaOps.Authority/StellaOps.Authority/Bootstrap/BootstrapRequests.cs
index 1bcfb431..ff64b999 100644
--- a/src/StellaOps.Authority/StellaOps.Authority/Bootstrap/BootstrapRequests.cs
+++ b/src/StellaOps.Authority/StellaOps.Authority/Bootstrap/BootstrapRequests.cs
@@ -54,11 +54,11 @@ internal sealed record BootstrapClientRequest
public IReadOnlyCollection? 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? 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";
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Configuration/CertBundOptions.cs b/src/StellaOps.Concelier.Connector.CertBund/Configuration/CertBundOptions.cs
new file mode 100644
index 00000000..f2fc1082
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Configuration/CertBundOptions.cs
@@ -0,0 +1,104 @@
+using System.Net;
+
+namespace StellaOps.Concelier.Connector.CertBund.Configuration;
+
+public sealed class CertBundOptions
+{
+ public const string HttpClientName = "concelier.source.certbund";
+
+ ///
+ /// RSS feed providing the latest CERT-Bund advisories.
+ ///
+ public Uri FeedUri { get; set; } = new("https://wid.cert-bund.de/content/public/securityAdvisory/rss");
+
+ ///
+ /// Portal endpoint used to bootstrap session cookies (required for the SPA JSON API).
+ ///
+ public Uri PortalBootstrapUri { get; set; } = new("https://wid.cert-bund.de/portal/");
+
+ ///
+ /// Detail API endpoint template; advisory identifier is appended as the name query parameter.
+ ///
+ public Uri DetailApiUri { get; set; } = new("https://wid.cert-bund.de/portal/api/securityadvisory");
+
+ ///
+ /// Optional timeout override for feed/detail requests.
+ ///
+ public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Delay applied between successive detail fetches to respect upstream politeness.
+ ///
+ public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
+
+ ///
+ /// Backoff recorded in source state when a fetch attempt fails.
+ ///
+ public TimeSpan FailureBackoff { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Maximum number of advisories to enqueue per fetch iteration.
+ ///
+ public int MaxAdvisoriesPerFetch { get; set; } = 50;
+
+ ///
+ /// Maximum number of advisory identifiers remembered to prevent re-processing.
+ ///
+ 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;
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundAdvisoryDto.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundAdvisoryDto.cs
new file mode 100644
index 00000000..6ec62c7e
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundAdvisoryDto.cs
@@ -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 CveIds { get; init; } = Array.Empty();
+
+ [JsonPropertyName("products")]
+ public IReadOnlyList Products { get; init; } = Array.Empty();
+
+ [JsonPropertyName("references")]
+ public IReadOnlyList References { get; init; } = Array.Empty();
+}
+
+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; }
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundCursor.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundCursor.cs
new file mode 100644
index 00000000..2bd39ad5
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundCursor.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Linq;
+using MongoDB.Bson;
+
+namespace StellaOps.Concelier.Connector.CertBund.Internal;
+
+internal sealed record CertBundCursor(
+ IReadOnlyCollection PendingDocuments,
+ IReadOnlyCollection PendingMappings,
+ IReadOnlyCollection KnownAdvisories,
+ DateTimeOffset? LastPublished,
+ DateTimeOffset? LastFetchAt)
+{
+ private static readonly IReadOnlyCollection EmptyGuids = Array.Empty();
+ private static readonly IReadOnlyCollection EmptyStrings = Array.Empty();
+
+ public static CertBundCursor Empty { get; } = new(EmptyGuids, EmptyGuids, EmptyStrings, null, null);
+
+ public CertBundCursor WithPendingDocuments(IEnumerable documents)
+ => this with { PendingDocuments = Distinct(documents) };
+
+ public CertBundCursor WithPendingMappings(IEnumerable mappings)
+ => this with { PendingMappings = Distinct(mappings) };
+
+ public CertBundCursor WithKnownAdvisories(IEnumerable 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 Distinct(IEnumerable? values)
+ => values?.Distinct().ToArray() ?? EmptyGuids;
+
+ private static IReadOnlyCollection ReadGuidArray(BsonDocument document, string field)
+ {
+ if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
+ {
+ return EmptyGuids;
+ }
+
+ var items = new List(array.Count);
+ foreach (var element in array)
+ {
+ if (Guid.TryParse(element?.ToString(), out var id))
+ {
+ items.Add(id);
+ }
+ }
+
+ return items;
+ }
+
+ private static IReadOnlyCollection 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,
+ };
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDetailParser.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDetailParser.cs
new file mode 100644
index 00000000..0f713fdb
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDetailParser.cs
@@ -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(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(),
+ References = MapReferences(detail.References),
+ Products = MapProducts(detail.Products),
+ };
+ }
+
+ private static IReadOnlyList MapReferences(CertBundDetailReference[]? references)
+ {
+ if (references is null || references.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ 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 MapProducts(CertBundDetailProduct[]? products)
+ {
+ if (products is null || products.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ 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();
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDetailResponse.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDetailResponse.cs
new file mode 100644
index 00000000..92676484
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDetailResponse.cs
@@ -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; }
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDiagnostics.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDiagnostics.cs
new file mode 100644
index 00000000..03c0cf68
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDiagnostics.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Metrics;
+
+namespace StellaOps.Concelier.Connector.CertBund.Internal;
+
+///
+/// Emits OpenTelemetry counters and histograms for the CERT-Bund connector.
+///
+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 _feedFetchAttempts;
+ private readonly Counter _feedFetchSuccess;
+ private readonly Counter _feedFetchFailures;
+ private readonly Histogram _feedItemCount;
+ private readonly Histogram _feedEnqueuedCount;
+ private readonly Histogram _feedCoverageDays;
+ private readonly Counter _detailFetchAttempts;
+ private readonly Counter _detailFetchSuccess;
+ private readonly Counter _detailFetchNotModified;
+ private readonly Counter _detailFetchFailures;
+ private readonly Counter _parseSuccess;
+ private readonly Counter _parseFailures;
+ private readonly Histogram _parseProductCount;
+ private readonly Histogram _parseCveCount;
+ private readonly Counter _mapSuccess;
+ private readonly Counter _mapFailures;
+ private readonly Histogram _mapPackageCount;
+ private readonly Histogram _mapAliasCount;
+
+ public CertBundDiagnostics()
+ {
+ _meter = new Meter(MeterName, MeterVersion);
+ _feedFetchAttempts = _meter.CreateCounter(
+ name: "certbund.feed.fetch.attempts",
+ unit: "operations",
+ description: "Number of RSS feed load attempts.");
+ _feedFetchSuccess = _meter.CreateCounter(
+ name: "certbund.feed.fetch.success",
+ unit: "operations",
+ description: "Number of successful RSS feed loads.");
+ _feedFetchFailures = _meter.CreateCounter(
+ name: "certbund.feed.fetch.failures",
+ unit: "operations",
+ description: "Number of RSS feed load failures.");
+ _feedItemCount = _meter.CreateHistogram(
+ name: "certbund.feed.items.count",
+ unit: "items",
+ description: "Distribution of RSS item counts per fetch.");
+ _feedEnqueuedCount = _meter.CreateHistogram(
+ name: "certbund.feed.enqueued.count",
+ unit: "documents",
+ description: "Distribution of advisory documents enqueued per fetch.");
+ _feedCoverageDays = _meter.CreateHistogram(
+ 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(
+ name: "certbund.detail.fetch.attempts",
+ unit: "operations",
+ description: "Number of detail fetch attempts.");
+ _detailFetchSuccess = _meter.CreateCounter(
+ name: "certbund.detail.fetch.success",
+ unit: "operations",
+ description: "Number of detail fetches that persisted a document.");
+ _detailFetchNotModified = _meter.CreateCounter(
+ name: "certbund.detail.fetch.not_modified",
+ unit: "operations",
+ description: "Number of detail fetches returning HTTP 304.");
+ _detailFetchFailures = _meter.CreateCounter(
+ name: "certbund.detail.fetch.failures",
+ unit: "operations",
+ description: "Number of detail fetches that failed.");
+ _parseSuccess = _meter.CreateCounter(
+ name: "certbund.parse.success",
+ unit: "documents",
+ description: "Number of documents parsed into CERT-Bund DTOs.");
+ _parseFailures = _meter.CreateCounter(
+ name: "certbund.parse.failures",
+ unit: "documents",
+ description: "Number of documents that failed to parse.");
+ _parseProductCount = _meter.CreateHistogram(
+ name: "certbund.parse.products.count",
+ unit: "products",
+ description: "Distribution of product entries captured per advisory.");
+ _parseCveCount = _meter.CreateHistogram(
+ name: "certbund.parse.cve.count",
+ unit: "aliases",
+ description: "Distribution of CVE identifiers captured per advisory.");
+ _mapSuccess = _meter.CreateCounter(
+ name: "certbund.map.success",
+ unit: "advisories",
+ description: "Number of canonical advisories emitted by the mapper.");
+ _mapFailures = _meter.CreateCounter(
+ name: "certbund.map.failures",
+ unit: "advisories",
+ description: "Number of mapping failures.");
+ _mapPackageCount = _meter.CreateHistogram(
+ name: "certbund.map.affected.count",
+ unit: "packages",
+ description: "Distribution of affected packages emitted per advisory.");
+ _mapAliasCount = _meter.CreateHistogram(
+ 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 ReasonTag(string reason)
+ => new("reason", string.IsNullOrWhiteSpace(reason) ? "unknown" : reason.ToLowerInvariant());
+
+ public void Dispose() => _meter.Dispose();
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDocumentMetadata.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDocumentMetadata.cs
new file mode 100644
index 00000000..26149696
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundDocumentMetadata.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+
+namespace StellaOps.Concelier.Connector.CertBund.Internal;
+
+internal static class CertBundDocumentMetadata
+{
+ public static Dictionary CreateMetadata(CertBundFeedItem item)
+ {
+ var metadata = new Dictionary(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;
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundFeedClient.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundFeedClient.cs
new file mode 100644
index 00000000..9641e051
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundFeedClient.cs
@@ -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 _logger;
+ private readonly SemaphoreSlim _bootstrapSemaphore = new(1, 1);
+ private volatile bool _bootstrapped;
+
+ public CertBundFeedClient(
+ IHttpClientFactory httpClientFactory,
+ IOptions options,
+ ILogger 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> 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();
+ 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;
+}
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundFeedItem.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundFeedItem.cs
new file mode 100644
index 00000000..40eab8f0
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundFeedItem.cs
@@ -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);
diff --git a/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundMapper.cs b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundMapper.cs
new file mode 100644
index 00000000..540e9660
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.CertBund/Internal/CertBundMapper.cs
@@ -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(),
+ provenance: new[] { provenance });
+ }
+
+ private static IReadOnlyList BuildAliases(CertBundAdvisoryDto dto)
+ {
+ var aliases = new List(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 BuildReferences(CertBundAdvisoryDto dto, DateTimeOffset recordedAt)
+ {
+ var references = new List
+ {
+ 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 BuildPackages(CertBundAdvisoryDto dto, DateTimeOffset recordedAt)
+ {
+ if (dto.Products.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var packages = new List(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()
+ : 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(),
+ provenance: new[] { provenance },
+ normalizedVersions: Array.Empty()));
+ }
+
+ 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(),
+ };
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.CertCc/Internal/CertCcCursor.cs b/src/StellaOps.Concelier.Connector.CertCc/Internal/CertCcCursor.cs
index 219e89ff..3ca3489c 100644
--- a/src/StellaOps.Concelier.Connector.CertCc/Internal/CertCcCursor.cs
+++ b/src/StellaOps.Concelier.Connector.CertCc/Internal/CertCcCursor.cs
@@ -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 PendingSummaries,
- IReadOnlyCollection PendingNotes,
- IReadOnlyCollection PendingDocuments,
- IReadOnlyCollection PendingMappings,
- DateTimeOffset? LastRun)
-{
- private static readonly Guid[] EmptyGuidArray = Array.Empty();
- private static readonly string[] EmptyStringArray = Array.Empty();
-
- 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? ids)
- => this with { PendingSummaries = NormalizeGuidSet(ids) };
-
- public CertCcCursor WithPendingNotes(IEnumerable? notes)
- => this with { PendingNotes = NormalizeStringSet(notes) };
-
- public CertCcCursor WithPendingDocuments(IEnumerable? ids)
- => this with { PendingDocuments = NormalizeGuidSet(ids) };
-
- public CertCcCursor WithPendingMappings(IEnumerable? 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(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(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? ids)
- => ids?.Where(static id => id != Guid.Empty).Distinct().ToArray() ?? EmptyGuidArray;
-
- private static string[] NormalizeStringSet(IEnumerable? 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 PendingSummaries,
+ IReadOnlyCollection PendingNotes,
+ IReadOnlyCollection PendingDocuments,
+ IReadOnlyCollection PendingMappings,
+ DateTimeOffset? LastRun)
+{
+ private static readonly Guid[] EmptyGuidArray = Array.Empty();
+ private static readonly string[] EmptyStringArray = Array.Empty();
+
+ 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? ids)
+ => this with { PendingSummaries = NormalizeGuidSet(ids) };
+
+ public CertCcCursor WithPendingNotes(IEnumerable? notes)
+ => this with { PendingNotes = NormalizeStringSet(notes) };
+
+ public CertCcCursor WithPendingDocuments(IEnumerable? ids)
+ => this with { PendingDocuments = NormalizeGuidSet(ids) };
+
+ public CertCcCursor WithPendingMappings(IEnumerable? 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(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(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? ids)
+ => ids?.Where(static id => id != Guid.Empty).Distinct().ToArray() ?? EmptyGuidArray;
+
+ private static string[] NormalizeStringSet(IEnumerable? values)
+ => values is null
+ ? EmptyStringArray
+ : values
+ .Where(static value => !string.IsNullOrWhiteSpace(value))
+ .Select(static value => value.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Configuration/CiscoOptions.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Configuration/CiscoOptions.cs
new file mode 100644
index 00000000..fd1768e3
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Configuration/CiscoOptions.cs
@@ -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;
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoAccessTokenProvider.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoAccessTokenProvider.cs
new file mode 100644
index 00000000..86cecf49
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoAccessTokenProvider.cs
@@ -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 _options;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _refreshLock = new(1, 1);
+
+ private volatile AccessToken? _cached;
+ private bool _disposed;
+
+ public CiscoAccessTokenProvider(
+ IHttpClientFactory httpClientFactory,
+ IOptionsMonitor options,
+ TimeProvider? timeProvider,
+ ILogger 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 GetTokenAsync(CancellationToken cancellationToken)
+ => await GetTokenInternalAsync(forceRefresh: false, cancellationToken).ConfigureAwait(false);
+
+ public void Invalidate()
+ => _cached = null;
+
+ private async Task 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 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
+ {
+ ["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(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 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);
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoAdvisoryDto.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoAdvisoryDto.cs
new file mode 100644
index 00000000..c5f14419
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoAdvisoryDto.cs
@@ -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 Cves,
+ IReadOnlyList BugIds,
+ IReadOnlyList Products);
+
+public sealed record CiscoAffectedProductDto(
+ string Name,
+ string? ProductId,
+ string? Version,
+ IReadOnlyCollection Statuses);
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafClient.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafClient.cs
new file mode 100644
index 00000000..9f8d2870
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafClient.cs
@@ -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 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 _logger;
+
+ public CiscoCsafClient(SourceFetchService fetchService, ILogger logger)
+ {
+ _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public virtual async Task 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;
+ }
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafData.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafData.cs
new file mode 100644
index 00000000..cc1e87d9
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafData.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal;
+
+internal sealed record CiscoCsafData(
+ IReadOnlyDictionary Products,
+ IReadOnlyDictionary> ProductStatuses);
+
+internal sealed record CiscoCsafProduct(string ProductId, string Name);
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafParser.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafParser.cs
new file mode 100644
index 00000000..be956e3e
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCsafParser.cs
@@ -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(0, StringComparer.OrdinalIgnoreCase),
+ ProductStatuses: new Dictionary>(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 ParseProducts(JsonElement root)
+ {
+ var dictionary = new Dictionary(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> ParseStatuses(JsonElement root)
+ {
+ var map = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities)
+ || vulnerabilities.ValueKind != JsonValueKind.Array)
+ {
+ return map.ToDictionary(
+ static kvp => kvp.Key,
+ static kvp => (IReadOnlyCollection)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(StringComparer.OrdinalIgnoreCase);
+ map[productId] = set;
+ }
+
+ set.Add(statusLabel);
+ }
+ }
+ }
+
+ return map.ToDictionary(
+ static kvp => kvp.Key,
+ static kvp => (IReadOnlyCollection)kvp.Value.ToArray(),
+ StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCursor.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCursor.cs
new file mode 100644
index 00000000..528cc28e
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoCursor.cs
@@ -0,0 +1,101 @@
+using MongoDB.Bson;
+
+namespace StellaOps.Concelier.Connector.Vndr.Cisco.Internal;
+
+internal sealed record CiscoCursor(
+ DateTimeOffset? LastModified,
+ string? LastAdvisoryId,
+ IReadOnlyCollection PendingDocuments,
+ IReadOnlyCollection PendingMappings)
+{
+ private static readonly IReadOnlyCollection EmptyGuidCollection = Array.Empty();
+
+ 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? documents)
+ => this with { PendingDocuments = documents?.Distinct().ToArray() ?? EmptyGuidCollection };
+
+ public CiscoCursor WithPendingMappings(IEnumerable? mappings)
+ => this with { PendingMappings = mappings?.Distinct().ToArray() ?? EmptyGuidCollection };
+
+ private static IReadOnlyCollection ReadGuidArray(BsonDocument document, string key)
+ {
+ if (!document.TryGetValue(key, out var value) || value is not BsonArray array)
+ {
+ return EmptyGuidCollection;
+ }
+
+ var results = new List(array.Count);
+ foreach (var element in array)
+ {
+ if (Guid.TryParse(element.ToString(), out var guid))
+ {
+ results.Add(guid);
+ }
+ }
+
+ return results;
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoDiagnostics.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoDiagnostics.cs
new file mode 100644
index 00000000..8c421a98
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoDiagnostics.cs
@@ -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 _fetchDocuments;
+ private readonly Counter _fetchFailures;
+ private readonly Counter _fetchUnchanged;
+ private readonly Counter _parseSuccess;
+ private readonly Counter _parseFailures;
+ private readonly Counter _mapSuccess;
+ private readonly Counter _mapFailures;
+ private readonly Histogram _mapAffected;
+
+ public CiscoDiagnostics()
+ {
+ _meter = new Meter(MeterName, MeterVersion);
+ _fetchDocuments = _meter.CreateCounter(
+ name: "cisco.fetch.documents",
+ unit: "documents",
+ description: "Number of Cisco advisories fetched.");
+ _fetchFailures = _meter.CreateCounter(
+ name: "cisco.fetch.failures",
+ unit: "operations",
+ description: "Number of Cisco fetch failures.");
+ _fetchUnchanged = _meter.CreateCounter(
+ name: "cisco.fetch.unchanged",
+ unit: "documents",
+ description: "Number of Cisco advisories skipped because they were unchanged.");
+ _parseSuccess = _meter.CreateCounter(
+ name: "cisco.parse.success",
+ unit: "documents",
+ description: "Number of Cisco documents parsed successfully.");
+ _parseFailures = _meter.CreateCounter(
+ name: "cisco.parse.failures",
+ unit: "documents",
+ description: "Number of Cisco documents that failed to parse.");
+ _mapSuccess = _meter.CreateCounter(
+ name: "cisco.map.success",
+ unit: "documents",
+ description: "Number of Cisco advisories mapped successfully.");
+ _mapFailures = _meter.CreateCounter(
+ name: "cisco.map.failures",
+ unit: "documents",
+ description: "Number of Cisco advisories that failed to map to canonical form.");
+ _mapAffected = _meter.CreateHistogram(
+ 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();
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoDtoFactory.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoDtoFactory.cs
new file mode 100644
index 00000000..e2d879b8
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoDtoFactory.cs
@@ -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 _logger;
+
+ public CiscoDtoFactory(ICiscoCsafClient csafClient, ILogger logger)
+ {
+ _csafClient = csafClient ?? throw new ArgumentNullException(nameof(csafClient));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task 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 BuildProducts(CiscoRawAdvisory raw, CiscoCsafData? csafData)
+ {
+ var map = new Dictionary(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()
+ : map.Values
+ .OrderBy(static p => p.Name, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(static p => p.ProductId, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ private static IReadOnlyCollection NormalizeStatuses(IEnumerable statuses)
+ {
+ var set = new SortedSet(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 NormalizeList(IEnumerable? items)
+ {
+ if (items is null)
+ {
+ return Array.Empty();
+ }
+
+ var set = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var item in items)
+ {
+ if (!string.IsNullOrWhiteSpace(item))
+ {
+ set.Add(item.Trim());
+ }
+ }
+
+ return set.Count == 0 ? Array.Empty() : 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;
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoMapper.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoMapper.cs
new file mode 100644
index 00000000..5477b59b
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoMapper.cs
@@ -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(),
+ provenance: new[] { fetchProvenance, mapProvenance });
+ }
+
+ private static IReadOnlyList BuildAliases(CiscoAdvisoryDto dto)
+ {
+ var set = new HashSet(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()
+ : set.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToArray();
+ }
+
+ private static IReadOnlyList BuildReferences(CiscoAdvisoryDto dto, DateTimeOffset recordedAt)
+ {
+ var list = new List(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()
+ : list.OrderBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase).ToArray();
+ }
+
+ private static void AddReference(ICollection 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 BuildAffectedPackages(CiscoAdvisoryDto dto, DateTimeOffset recordedAt)
+ {
+ if (dto.Products.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var packages = new List(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() : new[] { range },
+ statuses: statuses,
+ provenance: provenance,
+ normalizedVersions: Array.Empty()));
+ }
+
+ return packages.Count == 0
+ ? Array.Empty()
+ : 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? BuildVendorExtensions(CiscoAffectedProductDto product, bool includeVersion = false)
+ {
+ var dictionary = new Dictionary(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 BuildStatuses(CiscoAffectedProductDto product, DateTimeOffset recordedAt)
+ {
+ if (product.Statuses is null || product.Statuses.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var list = new List(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() : list;
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoOAuthMessageHandler.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoOAuthMessageHandler.cs
new file mode 100644
index 00000000..7fdecae1
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoOAuthMessageHandler.cs
@@ -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 _logger;
+
+ public CiscoOAuthMessageHandler(
+ CiscoAccessTokenProvider tokenProvider,
+ ILogger logger)
+ {
+ _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ protected override async Task 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 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;
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoOpenVulnClient.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoOpenVulnClient.cs
new file mode 100644
index 00000000..38bdb5ac
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoOpenVulnClient.cs
@@ -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 _options;
+ private readonly ILogger _logger;
+ private readonly string _sourceName;
+
+ public CiscoOpenVulnClient(
+ SourceFetchService fetchService,
+ IOptionsMonitor options,
+ ILogger 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 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 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();
+
+ 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 ReadStringArray(JsonElement element, string property)
+ {
+ if (!element.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array)
+ {
+ return Array.Empty();
+ }
+
+ var results = new List();
+ 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 Cves,
+ IReadOnlyList BugIds,
+ IReadOnlyList 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);
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoRawAdvisory.cs b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoRawAdvisory.cs
new file mode 100644
index 00000000..4c0dffc5
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Cisco/Internal/CiscoRawAdvisory.cs
@@ -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? ProductNames { get; set; }
+
+ [JsonPropertyName("version")]
+ public string? Version { get; set; }
+
+ [JsonPropertyName("iosRelease")]
+ public string? IosRelease { get; set; }
+
+ [JsonPropertyName("cves")]
+ public List? Cves { get; set; }
+
+ [JsonPropertyName("bugIDs")]
+ public List? 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; }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Msrc/Configuration/MsrcOptions.cs b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Configuration/MsrcOptions.cs
new file mode 100644
index 00000000..2bd43c38
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Configuration/MsrcOptions.cs
@@ -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";
+
+ ///
+ /// Azure AD tenant identifier used for client credential flow.
+ ///
+ public string TenantId { get; set; } = string.Empty;
+
+ ///
+ /// Azure AD application (client) identifier.
+ ///
+ public string ClientId { get; set; } = string.Empty;
+
+ ///
+ /// Azure AD client secret used for token acquisition.
+ ///
+ public string ClientSecret { get; set; } = string.Empty;
+
+ ///
+ /// Scope requested during client-credential token acquisition.
+ ///
+ public string Scope { get; set; } = "api://api.msrc.microsoft.com/.default";
+
+ ///
+ /// Maximum advisories to fetch per cycle.
+ ///
+ public int MaxAdvisoriesPerFetch { get; set; } = 200;
+
+ ///
+ /// Page size used when iterating the MSRC API.
+ ///
+ public int PageSize { get; set; } = 100;
+
+ ///
+ /// Overlap window added when resuming from the last modified cursor.
+ ///
+ public TimeSpan CursorOverlap { get; set; } = TimeSpan.FromMinutes(10);
+
+ ///
+ /// When enabled the connector downloads the CVRF artefact referenced by each advisory.
+ ///
+ public bool DownloadCvrf { get; set; } = false;
+
+ public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
+
+ public TimeSpan FailureBackoff { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Optional lower bound for the initial sync if the cursor is empty.
+ ///
+ 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.");
+ }
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcAdvisoryDto.cs b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcAdvisoryDto.cs
new file mode 100644
index 00000000..34315b47
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcAdvisoryDto.cs
@@ -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 CveIds { get; init; } = Array.Empty();
+
+ public IReadOnlyList KbIds { get; init; } = Array.Empty();
+
+ public IReadOnlyList Threats { get; init; } = Array.Empty();
+
+ public IReadOnlyList Remediations { get; init; } = Array.Empty();
+
+ public IReadOnlyList Products { get; init; } = Array.Empty();
+
+ 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);
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcApiClient.cs b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcApiClient.cs
new file mode 100644
index 00000000..de1bc678
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcApiClient.cs
@@ -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 _logger;
+
+ public MsrcApiClient(
+ IHttpClientFactory httpClientFactory,
+ IMsrcTokenProvider tokenProvider,
+ IOptions options,
+ ILogger 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> FetchSummariesAsync(DateTimeOffset fromInclusive, DateTimeOffset toExclusive, CancellationToken cancellationToken)
+ {
+ var client = await CreateAuthenticatedClientAsync(cancellationToken).ConfigureAwait(false);
+
+ var results = new List();
+ 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(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 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 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);
+ }
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcCursor.cs b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcCursor.cs
new file mode 100644
index 00000000..7166dd40
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcCursor.cs
@@ -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 PendingDocuments,
+ IReadOnlyCollection PendingMappings,
+ DateTimeOffset? LastModifiedCursor)
+{
+ private static readonly IReadOnlyCollection EmptyGuidSet = Array.Empty();
+
+ public static MsrcCursor Empty { get; } = new(EmptyGuidSet, EmptyGuidSet, null);
+
+ public MsrcCursor WithPendingDocuments(IEnumerable documents)
+ => this with { PendingDocuments = Distinct(documents) };
+
+ public MsrcCursor WithPendingMappings(IEnumerable 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 Distinct(IEnumerable? values)
+ => values?.Distinct().ToArray() ?? EmptyGuidSet;
+
+ private static IReadOnlyCollection ReadGuidArray(BsonDocument document, string field)
+ {
+ if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
+ {
+ return EmptyGuidSet;
+ }
+
+ var items = new List(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,
+ };
+}
diff --git a/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcDetailDto.cs b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcDetailDto.cs
new file mode 100644
index 00000000..8e108b8d
--- /dev/null
+++ b/src/StellaOps.Concelier.Connector.Vndr.Msrc/Internal/MsrcDetailDto.cs
@@ -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 CveNumbers { get; init; } = Array.Empty();
+
+ [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 Threats { get; init; } = Array.Empty();
+
+ [JsonPropertyName("remediations")]
+ public IReadOnlyList Remediations { get; init; } = Array.Empty();
+
+ [JsonPropertyName("affectedProducts")]
+ public IReadOnlyList AffectedProducts { get; init; } = Array.Empty