Rename Concelier Source modules to Connector

This commit is contained in:
master
2025-10-18 20:11:18 +03:00
parent 89ede53cc3
commit 052da7a7d0
789 changed files with 1489 additions and 1489 deletions

View File

@@ -0,0 +1,38 @@
# AGENTS
## Role
Create a dedicated CVE connector when we need raw CVE stream ingestion outside of NVD/OSV/National feeds (e.g., CVE JSON 5 API or CNA disclosures).
## Scope
- Determine whether this connector should consume the official CVE JSON 5 API, CNA disclosures, or another stream.
- Implement fetch/windowing aligned with CVE publication cadence; manage cursors for incremental backfills.
- Parse CVE payloads into DTOs capturing descriptions, affected vendors/products, references, and metrics.
- Map CVEs into canonical `Advisory` records (aliases, references, affected packages, range primitives).
- Deliver deterministic fixtures/tests for fetch/parse/map lifecycle.
## Participants
- `Source.Common` (HTTP/fetch utilities, DTO storage).
- `Storage.Mongo` (raw/document/DTO/advisory stores & source state).
- `Concelier.Models` (canonical data model).
- `Concelier.Testing` (integration fixtures, snapshot helpers).
## Interfaces & Contracts
- Job kinds: `cve:fetch`, `cve:parse`, `cve:map`.
- Persist upstream metadata (e.g., `If-Modified-Since`, `cveMetadataDate`) for incremental fetching.
- Aliases must include primary CVE ID along with CNA-specific identifiers when available.
## In/Out of scope
In scope:
- Core pipeline for CVE ingestion with provenance/range primitives.
Out of scope:
- Downstream impact scoring or enrichment (handled by other teams).
## Observability & Security Expectations
- Log fetch batch sizes, update timestamps, and mapping counts.
- Handle rate limits politely with exponential backoff.
- Sanitize and validate payloads before persistence.
## Tests
- Add `StellaOps.Concelier.Connector.Cve.Tests` with canned CVE JSON fixtures covering fetch/parse/map.
- Snapshot canonical advisories; include env flag for fixture regeneration.
- Ensure deterministic ordering and timestamp handling.

View File

@@ -0,0 +1,126 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
namespace StellaOps.Concelier.Connector.Cve.Configuration;
public sealed class CveOptions
{
public static string HttpClientName => "source.cve";
public Uri BaseEndpoint { get; set; } = new("https://cveawg.mitre.org/api/", UriKind.Absolute);
/// <summary>
/// CVE Services requires an organisation identifier for authenticated requests.
/// </summary>
public string ApiOrg { get; set; } = string.Empty;
/// <summary>
/// CVE Services user identifier. Typically the username registered with the CVE Program.
/// </summary>
public string ApiUser { get; set; } = string.Empty;
/// <summary>
/// API key issued by the CVE Program for the configured organisation/user pair.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Optional path containing seed CVE JSON documents used when live credentials are unavailable.
/// </summary>
public string? SeedDirectory { get; set; }
/// <summary>
/// Results fetched per page when querying CVE Services. Valid range 1-500.
/// </summary>
public int PageSize { get; set; } = 200;
/// <summary>
/// Maximum number of pages to fetch in a single run. Guards against runaway backfills.
/// </summary>
public int MaxPagesPerFetch { get; set; } = 5;
/// <summary>
/// Sliding look-back window when no previous cursor is available.
/// </summary>
public TimeSpan InitialBackfill { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Delay between paginated requests to respect API throttling guidance.
/// </summary>
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Backoff applied when the connector encounters an unrecoverable failure.
/// </summary>
public TimeSpan FailureBackoff { get; set; } = TimeSpan.FromMinutes(10);
[MemberNotNull(nameof(BaseEndpoint))]
public void Validate()
{
if (BaseEndpoint is null || !BaseEndpoint.IsAbsoluteUri)
{
throw new InvalidOperationException("BaseEndpoint must be an absolute URI.");
}
var hasCredentials = !string.IsNullOrWhiteSpace(ApiOrg)
&& !string.IsNullOrWhiteSpace(ApiUser)
&& !string.IsNullOrWhiteSpace(ApiKey);
var hasSeedDirectory = !string.IsNullOrWhiteSpace(SeedDirectory);
if (!hasCredentials && !hasSeedDirectory)
{
throw new InvalidOperationException("Api credentials must be provided unless a SeedDirectory is configured.");
}
if (hasCredentials && string.IsNullOrWhiteSpace(ApiOrg))
{
throw new InvalidOperationException("ApiOrg must be provided.");
}
if (hasCredentials && string.IsNullOrWhiteSpace(ApiUser))
{
throw new InvalidOperationException("ApiUser must be provided.");
}
if (hasCredentials && string.IsNullOrWhiteSpace(ApiKey))
{
throw new InvalidOperationException("ApiKey must be provided.");
}
if (hasSeedDirectory && !Directory.Exists(SeedDirectory!))
{
throw new InvalidOperationException($"SeedDirectory '{SeedDirectory}' does not exist.");
}
if (PageSize is < 1 or > 500)
{
throw new InvalidOperationException("PageSize must be between 1 and 500.");
}
if (MaxPagesPerFetch <= 0)
{
throw new InvalidOperationException("MaxPagesPerFetch must be a positive integer.");
}
if (InitialBackfill < TimeSpan.Zero)
{
throw new InvalidOperationException("InitialBackfill cannot be negative.");
}
if (RequestDelay < TimeSpan.Zero)
{
throw new InvalidOperationException("RequestDelay cannot be negative.");
}
if (FailureBackoff <= TimeSpan.Zero)
{
throw new InvalidOperationException("FailureBackoff must be greater than zero.");
}
}
public bool HasCredentials()
=> !string.IsNullOrWhiteSpace(ApiOrg)
&& !string.IsNullOrWhiteSpace(ApiUser)
&& !string.IsNullOrWhiteSpace(ApiKey);
}

View File

@@ -0,0 +1,609 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Normalization.Text;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Cve.Configuration;
using StellaOps.Concelier.Connector.Cve.Internal;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Storage.Mongo.Dtos;
using StellaOps.Plugin;
namespace StellaOps.Concelier.Connector.Cve;
public sealed class CveConnector : IFeedConnector
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
WriteIndented = false,
};
private readonly SourceFetchService _fetchService;
private readonly RawDocumentStorage _rawDocumentStorage;
private readonly IDocumentStore _documentStore;
private readonly IDtoStore _dtoStore;
private readonly IAdvisoryStore _advisoryStore;
private readonly ISourceStateRepository _stateRepository;
private readonly CveOptions _options;
private readonly CveDiagnostics _diagnostics;
private readonly TimeProvider _timeProvider;
private readonly ILogger<CveConnector> _logger;
public CveConnector(
SourceFetchService fetchService,
RawDocumentStorage rawDocumentStorage,
IDocumentStore documentStore,
IDtoStore dtoStore,
IAdvisoryStore advisoryStore,
ISourceStateRepository stateRepository,
IOptions<CveOptions> options,
CveDiagnostics diagnostics,
TimeProvider? timeProvider,
ILogger<CveConnector> logger)
{
_fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService));
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
_dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore));
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
_options.Validate();
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string SourceName => CveConnectorPlugin.SourceName;
public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(services);
var now = _timeProvider.GetUtcNow();
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
if (!_options.HasCredentials())
{
if (await TrySeedFromDirectoryAsync(cursor, now, cancellationToken).ConfigureAwait(false))
{
return;
}
_logger.LogWarning("CVEs fetch skipped: no credentials configured and no seed data found at {SeedDirectory}.", _options.SeedDirectory ?? "(seed directory not configured)");
return;
}
var pendingDocuments = cursor.PendingDocuments.ToHashSet();
var pendingMappings = cursor.PendingMappings.ToHashSet();
var initialPendingDocuments = pendingDocuments.Count;
var initialPendingMappings = pendingMappings.Count;
var documentsFetched = 0;
var detailFailures = 0;
var detailUnchanged = 0;
var listSuccessCount = 0;
var listUnchangedCount = 0;
var since = cursor.CurrentWindowStart ?? cursor.LastModifiedExclusive ?? now - _options.InitialBackfill;
if (since > now)
{
since = now;
}
var windowEnd = cursor.CurrentWindowEnd ?? now;
if (windowEnd <= since)
{
windowEnd = since + TimeSpan.FromMinutes(1);
}
var page = cursor.NextPage <= 0 ? 1 : cursor.NextPage;
var pagesFetched = 0;
var hasMorePages = true;
DateTimeOffset? maxModified = cursor.LastModifiedExclusive;
while (hasMorePages && pagesFetched < _options.MaxPagesPerFetch)
{
cancellationToken.ThrowIfCancellationRequested();
var requestUri = BuildListRequestUri(since, windowEnd, page, _options.PageSize);
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["since"] = since.ToString("O"),
["until"] = windowEnd.ToString("O"),
["page"] = page.ToString(CultureInfo.InvariantCulture),
["pageSize"] = _options.PageSize.ToString(CultureInfo.InvariantCulture),
};
SourceFetchContentResult listResult;
try
{
_diagnostics.FetchAttempt();
listResult = await _fetchService.FetchContentAsync(
new SourceFetchRequest(
CveOptions.HttpClientName,
SourceName,
requestUri)
{
Metadata = metadata,
AcceptHeaders = new[] { "application/json" },
},
cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (IsAuthenticationFailure(ex))
{
_logger.LogWarning("CVEs fetch requires API credentials ({StatusCode}); falling back to seed data if available.", ex.StatusCode);
if (await TrySeedFromDirectoryAsync(cursor, now, cancellationToken).ConfigureAwait(false))
{
return;
}
_logger.LogWarning("CVEs fetch aborted: no seed data available (SeedDirectory={SeedDirectory}).", _options.SeedDirectory ?? "(seed directory not configured)");
return;
}
catch (HttpRequestException ex)
{
_diagnostics.FetchFailure();
await _stateRepository.MarkFailureAsync(SourceName, now, _options.FailureBackoff, ex.Message, cancellationToken).ConfigureAwait(false);
throw;
}
if (listResult.IsNotModified)
{
_diagnostics.FetchUnchanged();
listUnchangedCount++;
break;
}
if (!listResult.IsSuccess || listResult.Content is null)
{
_diagnostics.FetchFailure();
break;
}
_diagnostics.FetchSuccess();
listSuccessCount++;
var pageModel = CveListParser.Parse(listResult.Content, page, _options.PageSize);
if (pageModel.Items.Count == 0)
{
hasMorePages = false;
}
foreach (var item in pageModel.Items)
{
cancellationToken.ThrowIfCancellationRequested();
var detailUri = BuildDetailRequestUri(item.CveId);
var detailMetadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["cveId"] = item.CveId,
["page"] = page.ToString(CultureInfo.InvariantCulture),
["since"] = since.ToString("O"),
["until"] = windowEnd.ToString("O"),
};
SourceFetchResult detailResult;
try
{
detailResult = await _fetchService.FetchAsync(
new SourceFetchRequest(
CveOptions.HttpClientName,
SourceName,
detailUri)
{
Metadata = detailMetadata,
AcceptHeaders = new[] { "application/json" },
},
cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (IsAuthenticationFailure(ex))
{
_diagnostics.FetchFailure();
_logger.LogWarning(ex, "Failed fetching CVE record {CveId} due to authentication. Seeding if possible.", item.CveId);
if (await TrySeedFromDirectoryAsync(cursor, now, cancellationToken).ConfigureAwait(false))
{
return;
}
_logger.LogWarning("CVE record {CveId} skipped; missing credentials and no seed data available.", item.CveId);
continue;
}
catch (HttpRequestException ex)
{
_diagnostics.FetchFailure();
_logger.LogWarning(ex, "Failed fetching CVE record {CveId}", item.CveId);
continue;
}
if (detailResult.IsNotModified)
{
_diagnostics.FetchUnchanged();
detailUnchanged++;
continue;
}
if (!detailResult.IsSuccess || detailResult.Document is null)
{
_diagnostics.FetchFailure();
detailFailures++;
continue;
}
_diagnostics.FetchDocument();
if (pendingDocuments.Add(detailResult.Document.Id))
{
documentsFetched++;
}
pendingMappings.Add(detailResult.Document.Id);
}
if (pageModel.MaxModified.HasValue)
{
if (!maxModified.HasValue || pageModel.MaxModified > maxModified)
{
maxModified = pageModel.MaxModified;
}
}
hasMorePages = pageModel.HasMorePages;
page = pageModel.NextPageCandidate;
pagesFetched++;
if (hasMorePages && _options.RequestDelay > TimeSpan.Zero)
{
await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false);
}
}
var updatedCursor = cursor
.WithPendingDocuments(pendingDocuments)
.WithPendingMappings(pendingMappings);
if (hasMorePages)
{
updatedCursor = updatedCursor
.WithCurrentWindowStart(since)
.WithCurrentWindowEnd(windowEnd)
.WithNextPage(page);
}
else
{
var nextSince = maxModified ?? windowEnd;
updatedCursor = updatedCursor
.WithLastModifiedExclusive(nextSince)
.WithCurrentWindowStart(null)
.WithCurrentWindowEnd(null)
.WithNextPage(1);
}
var nextWindowStart = hasMorePages ? since : maxModified ?? windowEnd;
DateTimeOffset? nextWindowEnd = hasMorePages ? windowEnd : null;
var nextPage = hasMorePages ? page : 1;
var windowStartString = since.ToString("O");
var windowEndString = windowEnd.ToString("O");
var nextWindowStartString = nextWindowStart.ToString("O");
var nextWindowEndString = nextWindowEnd?.ToString("O") ?? "(none)";
_logger.LogInformation(
"CVEs fetch window {WindowStart}->{WindowEnd} pages={PagesFetched} listSuccess={ListSuccess} detailDocuments={DocumentsFetched} detailFailures={DetailFailures} detailUnchanged={DetailUnchanged} pendingDocuments={PendingDocumentsBefore}->{PendingDocumentsAfter} pendingMappings={PendingMappingsBefore}->{PendingMappingsAfter} hasMorePages={HasMorePages} nextWindowStart={NextWindowStart} nextWindowEnd={NextWindowEnd} nextPage={NextPage}",
windowStartString,
windowEndString,
pagesFetched,
listSuccessCount,
documentsFetched,
detailFailures,
detailUnchanged,
initialPendingDocuments,
pendingDocuments.Count,
initialPendingMappings,
pendingMappings.Count,
hasMorePages,
nextWindowStartString,
nextWindowEndString,
nextPage);
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
}
public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(services);
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
if (cursor.PendingDocuments.Count == 0)
{
return;
}
var remainingDocuments = cursor.PendingDocuments.ToList();
foreach (var documentId in cursor.PendingDocuments)
{
cancellationToken.ThrowIfCancellationRequested();
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
if (document is null)
{
remainingDocuments.Remove(documentId);
continue;
}
if (!document.GridFsId.HasValue)
{
_diagnostics.ParseFailure();
_logger.LogWarning("CVEs document {DocumentId} missing GridFS content", documentId);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
remainingDocuments.Remove(documentId);
continue;
}
byte[] rawBytes;
try
{
rawBytes = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_diagnostics.ParseFailure();
_logger.LogError(ex, "Unable to download CVE raw document {DocumentId}", documentId);
throw;
}
CveRecordDto dto;
try
{
dto = CveRecordParser.Parse(rawBytes);
}
catch (JsonException ex)
{
_diagnostics.ParseQuarantine();
_logger.LogError(ex, "Malformed CVE JSON for {DocumentId}", documentId);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
remainingDocuments.Remove(documentId);
continue;
}
var payload = BsonDocument.Parse(JsonSerializer.Serialize(dto, SerializerOptions));
var dtoRecord = new DtoRecord(
Guid.NewGuid(),
document.Id,
SourceName,
"cve/5.0",
payload,
_timeProvider.GetUtcNow());
await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);
remainingDocuments.Remove(documentId);
_diagnostics.ParseSuccess();
}
var updatedCursor = cursor
.WithPendingDocuments(remainingDocuments);
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
}
public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(services);
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
if (cursor.PendingMappings.Count == 0)
{
return;
}
var pendingMappings = cursor.PendingMappings.ToList();
foreach (var documentId in cursor.PendingMappings)
{
cancellationToken.ThrowIfCancellationRequested();
var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
if (dtoRecord is null || document is null)
{
_logger.LogWarning("Skipping CVE mapping for {DocumentId}: DTO or document missing", documentId);
pendingMappings.Remove(documentId);
continue;
}
CveRecordDto dto;
try
{
dto = JsonSerializer.Deserialize<CveRecordDto>(dtoRecord.Payload.ToJson(), SerializerOptions)
?? throw new InvalidOperationException("Deserialized DTO was null.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize CVE DTO for {DocumentId}", documentId);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
pendingMappings.Remove(documentId);
continue;
}
var recordedAt = dtoRecord.ValidatedAt;
var advisory = CveMapper.Map(dto, document, recordedAt);
await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
pendingMappings.Remove(documentId);
_diagnostics.MapSuccess(1);
}
var updatedCursor = cursor.WithPendingMappings(pendingMappings);
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
}
private async Task<bool> TrySeedFromDirectoryAsync(CveCursor cursor, DateTimeOffset now, CancellationToken cancellationToken)
{
var seedDirectory = _options.SeedDirectory;
if (string.IsNullOrWhiteSpace(seedDirectory) || !Directory.Exists(seedDirectory))
{
return false;
}
var detailFiles = Directory.EnumerateFiles(seedDirectory, "CVE-*.json", SearchOption.AllDirectories)
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (detailFiles.Length == 0)
{
return false;
}
var seeded = 0;
DateTimeOffset? maxModified = cursor.LastModifiedExclusive;
foreach (var file in detailFiles)
{
cancellationToken.ThrowIfCancellationRequested();
byte[] payload;
try
{
payload = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to read CVE seed file {File}", file);
continue;
}
CveRecordDto dto;
try
{
dto = CveRecordParser.Parse(payload);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Seed file {File} did not contain a valid CVE record", file);
continue;
}
if (string.IsNullOrWhiteSpace(dto.CveId))
{
_logger.LogWarning("Seed file {File} missing CVE identifier", file);
continue;
}
var uri = $"seed://{dto.CveId}";
var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, uri, cancellationToken).ConfigureAwait(false);
var documentId = existing?.Id ?? Guid.NewGuid();
var sha256 = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
var lastModified = dto.Modified ?? dto.Published ?? now;
ObjectId gridId = ObjectId.Empty;
try
{
if (existing?.GridFsId is ObjectId existingGrid && existingGrid != ObjectId.Empty)
{
gridId = existingGrid;
}
else
{
gridId = await _rawDocumentStorage.UploadAsync(SourceName, uri, payload, "application/json", cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to store CVE seed payload for {CveId}", dto.CveId);
continue;
}
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["seed.file"] = Path.GetFileName(file),
["seed.directory"] = seedDirectory,
};
var document = new DocumentRecord(
documentId,
SourceName,
uri,
now,
sha256,
DocumentStatuses.Mapped,
"application/json",
Headers: null,
Metadata: metadata,
Etag: null,
LastModified: lastModified,
GridFsId: gridId);
await _documentStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
var advisory = CveMapper.Map(dto, document, now);
await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
if (!maxModified.HasValue || lastModified > maxModified)
{
maxModified = lastModified;
}
seeded++;
}
if (seeded == 0)
{
return false;
}
var updatedCursor = cursor
.WithPendingDocuments(Array.Empty<Guid>())
.WithPendingMappings(Array.Empty<Guid>())
.WithLastModifiedExclusive(maxModified ?? now)
.WithCurrentWindowStart(null)
.WithCurrentWindowEnd(null)
.WithNextPage(1);
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Seeded {SeededCount} CVE advisories from {SeedDirectory}; live fetch will resume when credentials are configured.", seeded, seedDirectory);
return true;
}
private static bool IsAuthenticationFailure(HttpRequestException exception)
=> exception.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
private async Task<CveCursor> GetCursorAsync(CancellationToken cancellationToken)
{
var state = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
return state is null ? CveCursor.Empty : CveCursor.FromBson(state.Cursor);
}
private async Task UpdateCursorAsync(CveCursor cursor, CancellationToken cancellationToken)
{
await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
}
private static Uri BuildListRequestUri(DateTimeOffset since, DateTimeOffset until, int page, int pageSize)
{
var query = $"time_modified.gte={Uri.EscapeDataString(since.ToString("O"))}&time_modified.lte={Uri.EscapeDataString(until.ToString("O"))}&page={page}&size={pageSize}";
return new Uri($"cve?{query}", UriKind.Relative);
}
private static Uri BuildDetailRequestUri(string cveId)
{
var encoded = Uri.EscapeDataString(cveId);
return new Uri($"cve/{encoded}", UriKind.Relative);
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Plugin;
namespace StellaOps.Concelier.Connector.Cve;
public sealed class CveConnectorPlugin : IConnectorPlugin
{
public const string SourceName = "cve";
public string Name => SourceName;
public bool IsAvailable(IServiceProvider services) => services is not null;
public IFeedConnector Create(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return ActivatorUtilities.CreateInstance<CveConnector>(services);
}
}

View File

@@ -0,0 +1,54 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Connector.Cve.Configuration;
namespace StellaOps.Concelier.Connector.Cve;
public sealed class CveDependencyInjectionRoutine : IDependencyInjectionRoutine
{
private const string ConfigurationSection = "concelier:sources:cve";
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddCveConnector(options =>
{
configuration.GetSection(ConfigurationSection).Bind(options);
options.Validate();
});
services.AddTransient<CveFetchJob>();
services.AddTransient<CveParseJob>();
services.AddTransient<CveMapJob>();
services.PostConfigure<JobSchedulerOptions>(options =>
{
EnsureJob(options, CveJobKinds.Fetch, typeof(CveFetchJob));
EnsureJob(options, CveJobKinds.Parse, typeof(CveParseJob));
EnsureJob(options, CveJobKinds.Map, typeof(CveMapJob));
});
return services;
}
private static void EnsureJob(JobSchedulerOptions options, string kind, Type jobType)
{
if (options.Definitions.ContainsKey(kind))
{
return;
}
options.Definitions[kind] = new JobDefinition(
kind,
jobType,
options.DefaultTimeout,
options.DefaultLeaseDuration,
CronExpression: null,
Enabled: true);
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Cve.Configuration;
using StellaOps.Concelier.Connector.Cve.Internal;
namespace StellaOps.Concelier.Connector.Cve;
public static class CveServiceCollectionExtensions
{
public static IServiceCollection AddCveConnector(this IServiceCollection services, Action<CveOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.AddOptions<CveOptions>()
.Configure(configure)
.PostConfigure(static opts => opts.Validate());
services.AddSourceHttpClient(CveOptions.HttpClientName, (sp, clientOptions) =>
{
var options = sp.GetRequiredService<IOptions<CveOptions>>().Value;
clientOptions.BaseAddress = options.BaseEndpoint;
clientOptions.Timeout = TimeSpan.FromSeconds(30);
clientOptions.UserAgent = "StellaOps.Concelier.Cve/1.0";
clientOptions.AllowedHosts.Clear();
clientOptions.AllowedHosts.Add(options.BaseEndpoint.Host);
clientOptions.DefaultRequestHeaders["Accept"] = "application/json";
if (options.HasCredentials())
{
clientOptions.DefaultRequestHeaders["CVE-API-ORG"] = options.ApiOrg;
clientOptions.DefaultRequestHeaders["CVE-API-USER"] = options.ApiUser;
clientOptions.DefaultRequestHeaders["CVE-API-KEY"] = options.ApiKey;
}
});
services.AddSingleton<CveDiagnostics>();
services.AddTransient<CveConnector>();
return services;
}
}

View File

@@ -0,0 +1,135 @@
using System.Collections.Generic;
using System.Linq;
using MongoDB.Bson;
namespace StellaOps.Concelier.Connector.Cve.Internal;
internal sealed record CveCursor(
DateTimeOffset? LastModifiedExclusive,
DateTimeOffset? CurrentWindowStart,
DateTimeOffset? CurrentWindowEnd,
int NextPage,
IReadOnlyCollection<Guid> PendingDocuments,
IReadOnlyCollection<Guid> PendingMappings)
{
private static readonly IReadOnlyCollection<Guid> EmptyGuidList = Array.Empty<Guid>();
public static CveCursor Empty { get; } = new(
LastModifiedExclusive: null,
CurrentWindowStart: null,
CurrentWindowEnd: null,
NextPage: 1,
PendingDocuments: EmptyGuidList,
PendingMappings: EmptyGuidList);
public BsonDocument ToBsonDocument()
{
var document = new BsonDocument
{
["nextPage"] = NextPage,
["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())),
["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())),
};
if (LastModifiedExclusive.HasValue)
{
document["lastModifiedExclusive"] = LastModifiedExclusive.Value.UtcDateTime;
}
if (CurrentWindowStart.HasValue)
{
document["currentWindowStart"] = CurrentWindowStart.Value.UtcDateTime;
}
if (CurrentWindowEnd.HasValue)
{
document["currentWindowEnd"] = CurrentWindowEnd.Value.UtcDateTime;
}
return document;
}
public static CveCursor FromBson(BsonDocument? document)
{
if (document is null || document.ElementCount == 0)
{
return Empty;
}
var lastModifiedExclusive = document.TryGetValue("lastModifiedExclusive", out var lastModifiedValue)
? ParseDate(lastModifiedValue)
: null;
var currentWindowStart = document.TryGetValue("currentWindowStart", out var windowStartValue)
? ParseDate(windowStartValue)
: null;
var currentWindowEnd = document.TryGetValue("currentWindowEnd", out var windowEndValue)
? ParseDate(windowEndValue)
: null;
var nextPage = document.TryGetValue("nextPage", out var nextPageValue) && nextPageValue.IsInt32
? Math.Max(1, nextPageValue.AsInt32)
: 1;
var pendingDocuments = ReadGuidArray(document, "pendingDocuments");
var pendingMappings = ReadGuidArray(document, "pendingMappings");
return new CveCursor(
LastModifiedExclusive: lastModifiedExclusive,
CurrentWindowStart: currentWindowStart,
CurrentWindowEnd: currentWindowEnd,
NextPage: nextPage,
PendingDocuments: pendingDocuments,
PendingMappings: pendingMappings);
}
public CveCursor WithPendingDocuments(IEnumerable<Guid> ids)
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? EmptyGuidList };
public CveCursor WithPendingMappings(IEnumerable<Guid> ids)
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? EmptyGuidList };
public CveCursor WithLastModifiedExclusive(DateTimeOffset? timestamp)
=> this with { LastModifiedExclusive = timestamp };
public CveCursor WithCurrentWindowEnd(DateTimeOffset? timestamp)
=> this with { CurrentWindowEnd = timestamp };
public CveCursor WithCurrentWindowStart(DateTimeOffset? timestamp)
=> this with { CurrentWindowStart = timestamp };
public CveCursor WithNextPage(int page)
=> this with { NextPage = page < 1 ? 1 : page };
private static DateTimeOffset? ParseDate(BsonValue value)
{
return value.BsonType switch
{
BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
_ => null,
};
}
private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field)
{
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
{
return EmptyGuidList;
}
var results = new List<Guid>(array.Count);
foreach (var element in array)
{
if (element is null)
{
continue;
}
if (Guid.TryParse(element.ToString(), out var guid))
{
results.Add(guid);
}
}
return results;
}
}

View File

@@ -0,0 +1,81 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Concelier.Connector.Cve.Internal;
public sealed class CveDiagnostics : IDisposable
{
public const string MeterName = "StellaOps.Concelier.Connector.Cve";
public const string MeterVersion = "1.0.0";
private readonly Meter _meter;
private readonly Counter<long> _fetchAttempts;
private readonly Counter<long> _fetchDocuments;
private readonly Counter<long> _fetchSuccess;
private readonly Counter<long> _fetchFailures;
private readonly Counter<long> _fetchUnchanged;
private readonly Counter<long> _parseSuccess;
private readonly Counter<long> _parseFailures;
private readonly Counter<long> _parseQuarantine;
private readonly Counter<long> _mapSuccess;
public CveDiagnostics()
{
_meter = new Meter(MeterName, MeterVersion);
_fetchAttempts = _meter.CreateCounter<long>(
name: "cve.fetch.attempts",
unit: "operations",
description: "Number of CVE fetch operations attempted.");
_fetchSuccess = _meter.CreateCounter<long>(
name: "cve.fetch.success",
unit: "operations",
description: "Number of CVE fetch operations that completed successfully.");
_fetchDocuments = _meter.CreateCounter<long>(
name: "cve.fetch.documents",
unit: "documents",
description: "Count of CVE documents fetched and persisted.");
_fetchFailures = _meter.CreateCounter<long>(
name: "cve.fetch.failures",
unit: "operations",
description: "Count of CVE fetch attempts that resulted in an error.");
_fetchUnchanged = _meter.CreateCounter<long>(
name: "cve.fetch.unchanged",
unit: "operations",
description: "Count of CVE fetch attempts returning 304 Not Modified.");
_parseSuccess = _meter.CreateCounter<long>(
name: "cve.parse.success",
unit: "documents",
description: "Count of CVE documents successfully parsed into DTOs.");
_parseFailures = _meter.CreateCounter<long>(
name: "cve.parse.failures",
unit: "documents",
description: "Count of CVE documents that could not be parsed.");
_parseQuarantine = _meter.CreateCounter<long>(
name: "cve.parse.quarantine",
unit: "documents",
description: "Count of CVE documents quarantined after schema validation errors.");
_mapSuccess = _meter.CreateCounter<long>(
name: "cve.map.success",
unit: "advisories",
description: "Count of canonical advisories emitted by the CVE mapper.");
}
public void FetchAttempt() => _fetchAttempts.Add(1);
public void FetchDocument() => _fetchDocuments.Add(1);
public void FetchSuccess() => _fetchSuccess.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 ParseQuarantine() => _parseQuarantine.Add(1);
public void MapSuccess(long count) => _mapSuccess.Add(count);
public void Dispose() => _meter.Dispose();
}

View File

@@ -0,0 +1,264 @@
using System.Globalization;
using System.Text.Json;
namespace StellaOps.Concelier.Connector.Cve.Internal;
internal static class CveListParser
{
public static CveListPage Parse(ReadOnlySpan<byte> content, int currentPage, int pageSize)
{
using var document = JsonDocument.Parse(content.ToArray());
var root = document.RootElement;
var items = new List<CveListItem>();
DateTimeOffset? maxModified = null;
foreach (var element in EnumerateItemElements(root))
{
var cveId = ExtractCveId(element);
if (string.IsNullOrWhiteSpace(cveId))
{
continue;
}
var modified = ExtractModified(element);
if (modified.HasValue && (!maxModified.HasValue || modified > maxModified))
{
maxModified = modified;
}
items.Add(new CveListItem(cveId, modified));
}
var hasMore = TryDetermineHasMore(root, currentPage, pageSize, items.Count, out var nextPage);
return new CveListPage(items, maxModified, hasMore, nextPage ?? currentPage + 1);
}
private static IEnumerable<JsonElement> EnumerateItemElements(JsonElement root)
{
if (root.TryGetProperty("data", out var dataElement) && dataElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in dataElement.EnumerateArray())
{
yield return item;
}
yield break;
}
if (root.TryGetProperty("vulnerabilities", out var vulnerabilities) && vulnerabilities.ValueKind == JsonValueKind.Array)
{
foreach (var item in vulnerabilities.EnumerateArray())
{
yield return item;
}
yield break;
}
if (root.ValueKind == JsonValueKind.Array)
{
foreach (var item in root.EnumerateArray())
{
yield return item;
}
}
}
private static string? ExtractCveId(JsonElement element)
{
if (element.TryGetProperty("cveId", out var cveId) && cveId.ValueKind == JsonValueKind.String)
{
return cveId.GetString();
}
if (element.TryGetProperty("cveMetadata", out var metadata))
{
if (metadata.TryGetProperty("cveId", out var metadataId) && metadataId.ValueKind == JsonValueKind.String)
{
return metadataId.GetString();
}
}
if (element.TryGetProperty("cve", out var cve) && cve.ValueKind == JsonValueKind.Object)
{
if (cve.TryGetProperty("cveMetadata", out var nestedMeta) && nestedMeta.ValueKind == JsonValueKind.Object)
{
if (nestedMeta.TryGetProperty("cveId", out var nestedId) && nestedId.ValueKind == JsonValueKind.String)
{
return nestedId.GetString();
}
}
if (cve.TryGetProperty("id", out var cveIdElement) && cveIdElement.ValueKind == JsonValueKind.String)
{
return cveIdElement.GetString();
}
}
return null;
}
private static DateTimeOffset? ExtractModified(JsonElement element)
{
static DateTimeOffset? Parse(JsonElement candidate)
{
return candidate.ValueKind switch
{
JsonValueKind.String when DateTimeOffset.TryParse(candidate.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
=> parsed.ToUniversalTime(),
_ => null,
};
}
if (element.TryGetProperty("dateUpdated", out var dateUpdated))
{
var parsed = Parse(dateUpdated);
if (parsed.HasValue)
{
return parsed;
}
}
if (element.TryGetProperty("cveMetadata", out var metadata))
{
if (metadata.TryGetProperty("dateUpdated", out var metadataUpdated))
{
var parsed = Parse(metadataUpdated);
if (parsed.HasValue)
{
return parsed;
}
}
}
if (element.TryGetProperty("cve", out var cve) && cve.ValueKind == JsonValueKind.Object)
{
if (cve.TryGetProperty("cveMetadata", out var nestedMeta))
{
if (nestedMeta.TryGetProperty("dateUpdated", out var nestedUpdated))
{
var parsed = Parse(nestedUpdated);
if (parsed.HasValue)
{
return parsed;
}
}
}
if (cve.TryGetProperty("lastModified", out var lastModified))
{
var parsed = Parse(lastModified);
if (parsed.HasValue)
{
return parsed;
}
}
}
return null;
}
private static bool TryDetermineHasMore(JsonElement root, int currentPage, int pageSize, int itemCount, out int? nextPage)
{
nextPage = null;
if (root.TryGetProperty("pagination", out var pagination) && pagination.ValueKind == JsonValueKind.Object)
{
var totalPages = TryGetInt(pagination, "totalPages")
?? TryGetInt(pagination, "pageCount")
?? TryGetInt(pagination, "totalPagesCount");
if (totalPages.HasValue)
{
if (currentPage < totalPages.Value)
{
nextPage = currentPage + 1;
return true;
}
return false;
}
var totalCount = TryGetInt(pagination, "totalCount")
?? TryGetInt(pagination, "totalResults");
var limit = TryGetInt(pagination, "limit")
?? TryGetInt(pagination, "itemsPerPage")
?? TryGetInt(pagination, "pageSize")
?? pageSize;
if (totalCount.HasValue)
{
var processed = (currentPage - 1) * limit + itemCount;
if (processed < totalCount.Value)
{
nextPage = currentPage + 1;
return true;
}
return false;
}
if (pagination.TryGetProperty("nextPage", out var nextPageElement))
{
switch (nextPageElement.ValueKind)
{
case JsonValueKind.Number when nextPageElement.TryGetInt32(out var value):
nextPage = value;
return true;
case JsonValueKind.String when int.TryParse(nextPageElement.GetString(), out var parsed):
nextPage = parsed;
return true;
case JsonValueKind.String when !string.IsNullOrWhiteSpace(nextPageElement.GetString()):
nextPage = currentPage + 1;
return true;
}
}
}
if (root.TryGetProperty("nextPage", out var nextPageValue))
{
switch (nextPageValue.ValueKind)
{
case JsonValueKind.Number when nextPageValue.TryGetInt32(out var value):
nextPage = value;
return true;
case JsonValueKind.String when int.TryParse(nextPageValue.GetString(), out var parsed):
nextPage = parsed;
return true;
case JsonValueKind.String when !string.IsNullOrWhiteSpace(nextPageValue.GetString()):
nextPage = currentPage + 1;
return true;
}
}
if (itemCount >= pageSize)
{
nextPage = currentPage + 1;
return true;
}
return false;
}
private static int? TryGetInt(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value))
{
return null;
}
return value.ValueKind switch
{
JsonValueKind.Number when value.TryGetInt32(out var number) => number,
JsonValueKind.String when int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => null,
};
}
}
internal sealed record CveListPage(
IReadOnlyList<CveListItem> Items,
DateTimeOffset? MaxModified,
bool HasMorePages,
int NextPageCandidate);
internal sealed record CveListItem(string CveId, DateTimeOffset? DateUpdated);

View File

@@ -0,0 +1,451 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Normalization.Cvss;
using StellaOps.Concelier.Storage.Mongo.Documents;
using NuGet.Versioning;
namespace StellaOps.Concelier.Connector.Cve.Internal;
internal static class CveMapper
{
private static readonly string[] SeverityOrder =
{
"critical",
"high",
"medium",
"low",
"informational",
"none",
"unknown",
};
public static Advisory Map(CveRecordDto dto, DocumentRecord document, DateTimeOffset recordedAt)
{
ArgumentNullException.ThrowIfNull(dto);
ArgumentNullException.ThrowIfNull(document);
var fetchProvenance = new AdvisoryProvenance(CveConnectorPlugin.SourceName, "document", document.Uri, document.FetchedAt);
var mapProvenance = new AdvisoryProvenance(CveConnectorPlugin.SourceName, "mapping", dto.CveId, recordedAt);
var aliases = dto.Aliases
.Append(dto.CveId)
.Where(static alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var references = dto.References
.Select(reference => CreateReference(reference, recordedAt))
.Where(static reference => reference is not null)
.Cast<AdvisoryReference>()
.ToList();
var affected = CreateAffectedPackages(dto, recordedAt);
var cvssMetrics = CreateCvssMetrics(dto, recordedAt, document.Uri);
var severity = DetermineSeverity(cvssMetrics);
var provenance = new[]
{
fetchProvenance,
mapProvenance,
};
var title = string.IsNullOrWhiteSpace(dto.Title) ? dto.CveId : dto.Title!;
return new Advisory(
advisoryKey: dto.CveId,
title: title,
summary: dto.Summary,
language: dto.Language,
published: dto.Published,
modified: dto.Modified ?? dto.Published,
severity: severity,
exploitKnown: false,
aliases: aliases,
references: references,
affectedPackages: affected,
cvssMetrics: cvssMetrics,
provenance: provenance);
}
private static AdvisoryReference? CreateReference(CveReferenceDto dto, DateTimeOffset recordedAt)
{
if (string.IsNullOrWhiteSpace(dto.Url) || !Validation.LooksLikeHttpUrl(dto.Url))
{
return null;
}
var kind = dto.Tags.FirstOrDefault();
return new AdvisoryReference(
dto.Url,
kind,
dto.Source,
summary: null,
provenance: new AdvisoryProvenance(CveConnectorPlugin.SourceName, "reference", dto.Url, recordedAt));
}
private static IReadOnlyList<AffectedPackage> CreateAffectedPackages(CveRecordDto dto, DateTimeOffset recordedAt)
{
if (dto.Affected.Count == 0)
{
return Array.Empty<AffectedPackage>();
}
var packages = new List<AffectedPackage>(dto.Affected.Count);
foreach (var affected in dto.Affected)
{
var vendor = string.IsNullOrWhiteSpace(affected.Vendor) ? "unknown-vendor" : affected.Vendor!.Trim();
var product = string.IsNullOrWhiteSpace(affected.Product) ? "unknown-product" : affected.Product!.Trim();
var identifier = string.Equals(product, vendor, StringComparison.OrdinalIgnoreCase)
? vendor.ToLowerInvariant()
: $"{vendor}:{product}".ToLowerInvariant();
var provenance = new[]
{
new AdvisoryProvenance(CveConnectorPlugin.SourceName, "affected", identifier, recordedAt),
};
var vendorExtensions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["vendor"] = vendor,
["product"] = product,
};
if (!string.IsNullOrWhiteSpace(affected.Platform))
{
vendorExtensions["platform"] = affected.Platform!;
}
var note = BuildNormalizedNote(dto.CveId, identifier);
var (ranges, normalizedVersions) = CreateVersionArtifacts(affected, recordedAt, identifier, vendorExtensions, note);
var statuses = CreateStatuses(affected, recordedAt, identifier);
if (ranges.Count == 0)
{
var fallbackPrimitives = vendorExtensions.Count == 0
? null
: new RangePrimitives(null, null, null, vendorExtensions);
ranges.Add(new AffectedVersionRange(
rangeKind: "vendor",
introducedVersion: null,
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: null,
provenance: provenance[0],
primitives: fallbackPrimitives));
}
packages.Add(new AffectedPackage(
type: AffectedPackageTypes.Vendor,
identifier: identifier,
platform: affected.Platform,
versionRanges: ranges,
statuses: statuses,
provenance: provenance,
normalizedVersions: normalizedVersions.Count == 0
? Array.Empty<NormalizedVersionRule>()
: normalizedVersions.ToArray()));
}
return packages;
}
private static (List<AffectedVersionRange> Ranges, List<NormalizedVersionRule> Normalized) CreateVersionArtifacts(
CveAffectedDto affected,
DateTimeOffset recordedAt,
string identifier,
IReadOnlyDictionary<string, string> vendorExtensions,
string normalizedNote)
{
var ranges = new List<AffectedVersionRange>();
var normalized = new List<NormalizedVersionRule>();
foreach (var version in affected.Versions)
{
var range = BuildVersionRange(version, recordedAt, identifier, vendorExtensions);
if (range is null)
{
continue;
}
ranges.Add(range);
var rule = range.ToNormalizedVersionRule(normalizedNote);
if (rule is not null)
{
normalized.Add(rule);
}
}
return (ranges, normalized);
}
private static AffectedVersionRange? BuildVersionRange(
CveVersionDto version,
DateTimeOffset recordedAt,
string identifier,
IReadOnlyDictionary<string, string> baseVendorExtensions)
{
var vendor = new Dictionary<string, string>(baseVendorExtensions, StringComparer.OrdinalIgnoreCase);
void AddExtension(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
vendor[key] = value.Trim();
}
}
AddExtension("version", version.Version);
AddExtension("lessThan", version.LessThan);
AddExtension("lessThanOrEqual", version.LessThanOrEqual);
AddExtension("versionType", version.VersionType);
AddExtension("versionRange", version.Range);
var introduced = Normalize(version.Version);
var fixedExclusive = Normalize(version.LessThan);
var lastInclusive = Normalize(version.LessThanOrEqual);
var rangeExpression = Normalize(string.IsNullOrWhiteSpace(version.Range)
? BuildRangeExpression(version)
: version.Range);
var provenance = new AdvisoryProvenance(
CveConnectorPlugin.SourceName,
"affected-range",
identifier,
recordedAt);
var semVerPrimitive = TryBuildSemVerPrimitive(
version.VersionType,
introduced,
fixedExclusive,
lastInclusive,
rangeExpression,
out var primitive);
if (semVerPrimitive)
{
introduced = primitive!.Introduced ?? introduced;
fixedExclusive = primitive.Fixed ?? fixedExclusive;
lastInclusive = primitive.LastAffected ?? lastInclusive;
}
if (introduced is null && fixedExclusive is null && lastInclusive is null && rangeExpression is null && primitive is null && vendor.Count == baseVendorExtensions.Count)
{
return null;
}
var rangeKind = primitive is not null
? NormalizedVersionSchemes.SemVer
: (string.IsNullOrWhiteSpace(version.VersionType)
? "vendor"
: version.VersionType!.Trim().ToLowerInvariant());
var rangePrimitives = primitive is null && vendor.Count == 0
? null
: new RangePrimitives(
primitive,
Nevra: null,
Evr: null,
VendorExtensions: vendor.Count == 0 ? null : vendor);
return new AffectedVersionRange(
rangeKind: rangeKind,
introducedVersion: introduced,
fixedVersion: fixedExclusive,
lastAffectedVersion: lastInclusive,
rangeExpression: rangeExpression,
provenance: provenance,
primitives: rangePrimitives);
}
private static List<AffectedPackageStatus> CreateStatuses(CveAffectedDto affected, DateTimeOffset recordedAt, string identifier)
{
var statuses = new List<AffectedPackageStatus>();
void AddStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status))
{
return;
}
statuses.Add(new AffectedPackageStatus(
status,
new AdvisoryProvenance(CveConnectorPlugin.SourceName, "affected-status", identifier, recordedAt)));
}
AddStatus(affected.DefaultStatus);
foreach (var version in affected.Versions)
{
AddStatus(version.Status);
}
return statuses;
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) || value is "*" or "-" ? null : value.Trim();
private static string? BuildRangeExpression(CveVersionDto version)
{
var builder = new List<string>();
if (!string.IsNullOrWhiteSpace(version.Version))
{
builder.Add($"version={version.Version}");
}
if (!string.IsNullOrWhiteSpace(version.LessThan))
{
builder.Add($"< {version.LessThan}");
}
if (!string.IsNullOrWhiteSpace(version.LessThanOrEqual))
{
builder.Add($"<= {version.LessThanOrEqual}");
}
if (builder.Count == 0)
{
return null;
}
return string.Join(", ", builder);
}
private static string BuildNormalizedNote(string cveId, string identifier)
{
var baseId = string.IsNullOrWhiteSpace(cveId)
? "unknown"
: cveId.Trim().ToLowerInvariant();
return $"cve:{baseId}:{identifier}";
}
private static bool TryBuildSemVerPrimitive(
string? versionType,
string? introduced,
string? fixedExclusive,
string? lastInclusive,
string? constraintExpression,
out SemVerPrimitive? primitive)
{
primitive = null;
if (!string.Equals(versionType, "semver", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!TryNormalizeSemVer(introduced, out var normalizedIntroduced)
|| !TryNormalizeSemVer(fixedExclusive, out var normalizedFixed)
|| !TryNormalizeSemVer(lastInclusive, out var normalizedLast))
{
normalizedIntroduced = introduced;
normalizedFixed = fixedExclusive;
normalizedLast = lastInclusive;
return false;
}
if (normalizedIntroduced is null && normalizedFixed is null && normalizedLast is null)
{
return false;
}
var introducedInclusive = true;
var fixedInclusive = false;
var lastInclusiveFlag = true;
if (normalizedFixed is null && normalizedLast is null && normalizedIntroduced is not null)
{
// Exact version. Treat as introduced/fixed equality.
normalizedFixed = normalizedIntroduced;
normalizedLast = normalizedIntroduced;
}
primitive = new SemVerPrimitive(
Introduced: normalizedIntroduced,
IntroducedInclusive: introducedInclusive,
Fixed: normalizedFixed,
FixedInclusive: fixedInclusive,
LastAffected: normalizedLast,
LastAffectedInclusive: lastInclusiveFlag,
ConstraintExpression: constraintExpression,
ExactValue: normalizedFixed is not null && normalizedIntroduced is not null && normalizedFixed == normalizedIntroduced
? normalizedIntroduced
: null);
return true;
}
private static bool TryNormalizeSemVer(string? value, out string? normalized)
{
normalized = null;
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
var trimmed = value.Trim();
if (trimmed.StartsWith("v", StringComparison.OrdinalIgnoreCase) && trimmed.Length > 1)
{
trimmed = trimmed[1..];
}
if (!NuGetVersion.TryParse(trimmed, out var parsed))
{
return false;
}
normalized = parsed.ToNormalizedString();
return true;
}
private static IReadOnlyList<CvssMetric> CreateCvssMetrics(CveRecordDto dto, DateTimeOffset recordedAt, string sourceUri)
{
if (dto.Metrics.Count == 0)
{
return Array.Empty<CvssMetric>();
}
var provenance = new AdvisoryProvenance(CveConnectorPlugin.SourceName, "cvss", sourceUri, recordedAt);
var metrics = new List<CvssMetric>(dto.Metrics.Count);
foreach (var metric in dto.Metrics)
{
if (!CvssMetricNormalizer.TryNormalize(metric.Version, metric.Vector, metric.BaseScore, metric.BaseSeverity, out var normalized))
{
continue;
}
metrics.Add(new CvssMetric(
normalized.Version,
normalized.Vector,
normalized.BaseScore,
normalized.BaseSeverity,
provenance));
}
return metrics;
}
private static string? DetermineSeverity(IReadOnlyList<CvssMetric> metrics)
{
if (metrics.Count == 0)
{
return null;
}
foreach (var level in SeverityOrder)
{
if (metrics.Any(metric => string.Equals(metric.BaseSeverity, level, StringComparison.OrdinalIgnoreCase)))
{
return level;
}
}
return metrics
.Select(metric => metric.BaseSeverity)
.FirstOrDefault();
}
}

View File

@@ -0,0 +1,105 @@
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Connector.Cve.Internal;
internal sealed record CveRecordDto
{
[JsonPropertyName("cveId")]
public string CveId { get; init; } = string.Empty;
[JsonPropertyName("title")]
public string? Title { get; init; }
[JsonPropertyName("summary")]
public string? Summary { get; init; }
[JsonPropertyName("language")]
public string? Language { get; init; }
[JsonPropertyName("state")]
public string State { get; init; } = "PUBLISHED";
[JsonPropertyName("published")]
public DateTimeOffset? Published { get; init; }
[JsonPropertyName("modified")]
public DateTimeOffset? Modified { get; init; }
[JsonPropertyName("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
[JsonPropertyName("references")]
public IReadOnlyList<CveReferenceDto> References { get; init; } = Array.Empty<CveReferenceDto>();
[JsonPropertyName("affected")]
public IReadOnlyList<CveAffectedDto> Affected { get; init; } = Array.Empty<CveAffectedDto>();
[JsonPropertyName("metrics")]
public IReadOnlyList<CveCvssMetricDto> Metrics { get; init; } = Array.Empty<CveCvssMetricDto>();
}
internal sealed record CveReferenceDto
{
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
}
internal sealed record CveAffectedDto
{
[JsonPropertyName("vendor")]
public string? Vendor { get; init; }
[JsonPropertyName("product")]
public string? Product { get; init; }
[JsonPropertyName("platform")]
public string? Platform { get; init; }
[JsonPropertyName("defaultStatus")]
public string? DefaultStatus { get; init; }
[JsonPropertyName("versions")]
public IReadOnlyList<CveVersionDto> Versions { get; init; } = Array.Empty<CveVersionDto>();
}
internal sealed record CveVersionDto
{
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("lessThan")]
public string? LessThan { get; init; }
[JsonPropertyName("lessThanOrEqual")]
public string? LessThanOrEqual { get; init; }
[JsonPropertyName("versionType")]
public string? VersionType { get; init; }
[JsonPropertyName("versionRange")]
public string? Range { get; init; }
}
internal sealed record CveCvssMetricDto
{
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("vector")]
public string? Vector { get; init; }
[JsonPropertyName("baseScore")]
public double? BaseScore { get; init; }
[JsonPropertyName("baseSeverity")]
public string? BaseSeverity { get; init; }
}

View File

@@ -0,0 +1,346 @@
using System.Globalization;
using System.Linq;
using System.Text.Json;
using StellaOps.Concelier.Normalization.Text;
namespace StellaOps.Concelier.Connector.Cve.Internal;
internal static class CveRecordParser
{
public static CveRecordDto Parse(ReadOnlySpan<byte> content)
{
using var document = JsonDocument.Parse(content.ToArray());
var root = document.RootElement;
var metadata = TryGetProperty(root, "cveMetadata");
if (metadata.ValueKind != JsonValueKind.Object)
{
throw new JsonException("cveMetadata section missing.");
}
var containers = TryGetProperty(root, "containers");
var cna = TryGetProperty(containers, "cna");
var cveId = GetString(metadata, "cveId") ?? throw new JsonException("cveMetadata.cveId missing.");
var state = GetString(metadata, "state") ?? "PUBLISHED";
var published = GetDate(metadata, "datePublished");
var modified = GetDate(metadata, "dateUpdated") ?? GetDate(metadata, "dateReserved");
var description = ParseDescription(cna);
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
cveId,
};
foreach (var alias in ParseAliases(cna))
{
aliases.Add(alias);
}
var references = ParseReferences(cna);
var affected = ParseAffected(cna);
var metrics = ParseMetrics(cna);
return new CveRecordDto
{
CveId = cveId,
Title = GetString(cna, "title") ?? cveId,
Summary = description.Text,
Language = description.Language,
State = state,
Published = published,
Modified = modified,
Aliases = aliases.ToArray(),
References = references,
Affected = affected,
Metrics = metrics,
};
}
private static NormalizedDescription ParseDescription(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return DescriptionNormalizer.Normalize(Array.Empty<LocalizedText>());
}
if (!element.TryGetProperty("descriptions", out var descriptions) || descriptions.ValueKind != JsonValueKind.Array)
{
return DescriptionNormalizer.Normalize(Array.Empty<LocalizedText>());
}
var items = new List<LocalizedText>(descriptions.GetArrayLength());
foreach (var entry in descriptions.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object)
{
continue;
}
var text = GetString(entry, "value");
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
var lang = GetString(entry, "lang");
items.Add(new LocalizedText(text, lang));
}
return DescriptionNormalizer.Normalize(items);
}
private static IEnumerable<string> ParseAliases(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
yield break;
}
if (!element.TryGetProperty("aliases", out var aliases) || aliases.ValueKind != JsonValueKind.Array)
{
yield break;
}
foreach (var alias in aliases.EnumerateArray())
{
if (alias.ValueKind == JsonValueKind.String)
{
var value = alias.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
yield return value;
}
}
}
}
private static IReadOnlyList<CveReferenceDto> ParseReferences(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return Array.Empty<CveReferenceDto>();
}
if (!element.TryGetProperty("references", out var references) || references.ValueKind != JsonValueKind.Array)
{
return Array.Empty<CveReferenceDto>();
}
var list = new List<CveReferenceDto>(references.GetArrayLength());
foreach (var reference in references.EnumerateArray())
{
if (reference.ValueKind != JsonValueKind.Object)
{
continue;
}
var url = GetString(reference, "url");
if (string.IsNullOrWhiteSpace(url))
{
continue;
}
var tags = Array.Empty<string>();
if (reference.TryGetProperty("tags", out var tagsElement) && tagsElement.ValueKind == JsonValueKind.Array)
{
tags = tagsElement
.EnumerateArray()
.Where(static t => t.ValueKind == JsonValueKind.String)
.Select(static t => t.GetString()!)
.Where(static v => !string.IsNullOrWhiteSpace(v))
.ToArray();
}
var source = GetString(reference, "name") ?? GetString(reference, "source");
list.Add(new CveReferenceDto
{
Url = url,
Source = source,
Tags = tags,
});
}
return list;
}
private static IReadOnlyList<CveAffectedDto> ParseAffected(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return Array.Empty<CveAffectedDto>();
}
if (!element.TryGetProperty("affected", out var affected) || affected.ValueKind != JsonValueKind.Array)
{
return Array.Empty<CveAffectedDto>();
}
var list = new List<CveAffectedDto>(affected.GetArrayLength());
foreach (var item in affected.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
{
continue;
}
var versions = new List<CveVersionDto>();
if (item.TryGetProperty("versions", out var versionsElement) && versionsElement.ValueKind == JsonValueKind.Array)
{
foreach (var versionEntry in versionsElement.EnumerateArray())
{
if (versionEntry.ValueKind != JsonValueKind.Object)
{
continue;
}
versions.Add(new CveVersionDto
{
Status = GetString(versionEntry, "status"),
Version = GetString(versionEntry, "version"),
LessThan = GetString(versionEntry, "lessThan"),
LessThanOrEqual = GetString(versionEntry, "lessThanOrEqual"),
VersionType = GetString(versionEntry, "versionType"),
Range = GetString(versionEntry, "versionRange"),
});
}
}
list.Add(new CveAffectedDto
{
Vendor = GetString(item, "vendor") ?? GetString(item, "vendorName"),
Product = GetString(item, "product") ?? GetString(item, "productName"),
Platform = GetString(item, "platform"),
DefaultStatus = GetString(item, "defaultStatus"),
Versions = versions,
});
}
return list;
}
private static IReadOnlyList<CveCvssMetricDto> ParseMetrics(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return Array.Empty<CveCvssMetricDto>();
}
if (!element.TryGetProperty("metrics", out var metrics) || metrics.ValueKind != JsonValueKind.Array)
{
return Array.Empty<CveCvssMetricDto>();
}
var list = new List<CveCvssMetricDto>(metrics.GetArrayLength());
foreach (var metric in metrics.EnumerateArray())
{
if (metric.ValueKind != JsonValueKind.Object)
{
continue;
}
if (metric.TryGetProperty("cvssV4_0", out var cvss40) && cvss40.ValueKind == JsonValueKind.Object)
{
list.Add(ParseCvss(cvss40, "4.0"));
}
else if (metric.TryGetProperty("cvssV3_1", out var cvss31) && cvss31.ValueKind == JsonValueKind.Object)
{
list.Add(ParseCvss(cvss31, "3.1"));
}
else if (metric.TryGetProperty("cvssV3", out var cvss3) && cvss3.ValueKind == JsonValueKind.Object)
{
list.Add(ParseCvss(cvss3, "3.0"));
}
else if (metric.TryGetProperty("cvssV2", out var cvss2) && cvss2.ValueKind == JsonValueKind.Object)
{
list.Add(ParseCvss(cvss2, "2.0"));
}
}
return list;
}
private static CveCvssMetricDto ParseCvss(JsonElement element, string fallbackVersion)
{
var version = GetString(element, "version") ?? fallbackVersion;
var vector = GetString(element, "vectorString") ?? GetString(element, "vector");
var baseScore = GetDouble(element, "baseScore");
var severity = GetString(element, "baseSeverity") ?? GetString(element, "severity");
return new CveCvssMetricDto
{
Version = version,
Vector = vector,
BaseScore = baseScore,
BaseSeverity = severity,
};
}
private static JsonElement TryGetProperty(JsonElement element, string propertyName)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
{
return property;
}
return default;
}
private static string? GetString(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!element.TryGetProperty(propertyName, out var property))
{
return null;
}
return property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number when property.TryGetDouble(out var number) => number.ToString(CultureInfo.InvariantCulture),
_ => null,
};
}
private static DateTimeOffset? GetDate(JsonElement element, string propertyName)
{
var value = GetString(element, propertyName);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed.ToUniversalTime()
: null;
}
private static double? GetDouble(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!element.TryGetProperty(propertyName, out var property))
{
return null;
}
if (property.ValueKind == JsonValueKind.Number && property.TryGetDouble(out var number))
{
return number;
}
if (property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
return null;
}
}

View File

@@ -0,0 +1,43 @@
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.Connector.Cve;
internal static class CveJobKinds
{
public const string Fetch = "source:cve:fetch";
public const string Parse = "source:cve:parse";
public const string Map = "source:cve:map";
}
internal sealed class CveFetchJob : IJob
{
private readonly CveConnector _connector;
public CveFetchJob(CveConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.FetchAsync(context.Services, cancellationToken);
}
internal sealed class CveParseJob : IJob
{
private readonly CveConnector _connector;
public CveParseJob(CveConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.ParseAsync(context.Services, cancellationToken);
}
internal sealed class CveMapJob : IJob
{
private readonly CveConnector _connector;
public CveMapJob(CveConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.MapAsync(context.Services, cancellationToken);
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|Define CVE data source + contract|BE-Conn-CVE|Research|**DONE (2025-10-10)** Connector targets the CVE Services JSON 5 API with authenticated windowed queries documented in `CveOptions` (`CVE-API-*` headers, pagination semantics, failure backoff).|
|Fetch/cursor implementation|BE-Conn-CVE|Source.Common, Storage.Mongo|**DONE (2025-10-10)** Time-window + page-aware cursor with SourceFetchService fetching list/detail pairs, resumable state persisted via `CveCursor`.|
|DTOs & parser|BE-Conn-CVE|Source.Common|**DONE (2025-10-10)** `CveRecordParser` and DTOs capture aliases, references, metrics, vendor ranges; sanitises text and timestamps.|
|Canonical mapping & range primitives|BE-Conn-CVE|Models|**DONE (2025-10-10)** `CveMapper` emits canonical advisories, vendor range primitives, SemVer/range statuses, references, CVSS normalization.<br>2025-10-11 research trail: confirm subsequent MR adds `NormalizedVersions` shaped like `[{"scheme":"semver","type":"range","min":"<min>","minInclusive":true,"max":"<max>","maxInclusive":false,"notes":"nvd:CVE-2025-XXXX"}]` so storage provenance joins continue to work.|
|Deterministic tests & fixtures|QA|Testing|**DONE (2025-10-10)** Added `StellaOps.Concelier.Connector.Cve.Tests` harness with canned fixtures + snapshot regression covering fetch/parse/map.|
|Observability & docs|DevEx|Docs|**DONE (2025-10-10)** Diagnostics meter (`cve.fetch.*`, etc.) wired; options/usage documented via `CveServiceCollectionExtensions`.|
|Operator rollout playbook|BE-Conn-CVE, Ops|Docs|**DONE (2025-10-12)** Refreshed `docs/ops/concelier-cve-kev-operations.md` with credential checklist, smoke book, PromQL guardrails, and linked Grafana pack (`docs/ops/concelier-cve-kev-grafana-dashboard.json`).|
|Live smoke & monitoring|QA, BE-Conn-CVE|WebService, Observability|**DONE (2025-10-15)** Executed connector harness smoke using CVE Services sample window (CVE-2024-0001), confirmed fetch/parse/map telemetry (`cve.fetch.*`, `cve.map.success`) all incremented once, and archived the summary log + Grafana import guidance in `docs/ops/concelier-cve-kev-operations.md` (“Staging smoke 2025-10-15”).|
|FEEDCONN-CVE-02-003 Normalized versions rollout|BE-Conn-CVE|Models `FEEDMODELS-SCHEMA-01-003`, Normalization playbook|**DONE (2025-10-12)** Confirmed SemVer primitives map to normalized rules with `cve:{cveId}:{identifier}` notes and refreshed snapshots; `dotnet test src/StellaOps.Concelier.Connector.Cve.Tests` passes on net10 preview.|