Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Implement the CERT/CC (Carnegie Mellon CERT Coordination Center) advisory connector so Concelier can ingest US CERT coordination bulletins.
|
||||
|
||||
## Scope
|
||||
- Identify CERT/CC advisory publication format (VU#, blog, RSS, JSON) and define fetch cadence/windowing.
|
||||
- Implement fetch, parse, and mapping jobs with cursor persistence and dedupe.
|
||||
- Normalise advisory content (summary, impacted vendors, products, recommended mitigations, CVEs).
|
||||
- Produce canonical `Advisory` objects including aliases, references, affected packages, and range primitive metadata.
|
||||
- Supply fixtures and deterministic regression tests.
|
||||
|
||||
## Participants
|
||||
- `Source.Common` (HTTP/fetch utilities, DTO storage).
|
||||
- `Storage.Mongo` (raw/document/DTO/advisory stores and state).
|
||||
- `Concelier.Models` (canonical structures).
|
||||
- `Concelier.Testing` (integration tests and snapshots).
|
||||
|
||||
## Interfaces & Contracts
|
||||
- Job kinds: `certcc:fetch`, `certcc:parse`, `certcc:map`.
|
||||
- Persist upstream caching metadata (ETag/Last-Modified) when available.
|
||||
- Aliases should capture CERT/CC VU IDs and referenced CVEs.
|
||||
|
||||
## In/Out of scope
|
||||
In scope:
|
||||
- End-to-end connector with range primitive instrumentation and telemetry.
|
||||
|
||||
Out of scope:
|
||||
- ICS-CERT alerts (handled by dedicated connector) or blog posts unrelated to advisories.
|
||||
|
||||
## Observability & Security Expectations
|
||||
- Log fetch and mapping statistics; surface failures with backoff.
|
||||
- Sanitise HTML sources before persistence.
|
||||
- Respect upstream throttling via retry/backoff.
|
||||
|
||||
## Tests
|
||||
- Add `StellaOps.Concelier.Connector.CertCc.Tests` to cover fetch/parse/map with canned fixtures.
|
||||
- Snapshot canonical advisories and support UPDATE flag for regeneration.
|
||||
- Ensure deterministic ordering and timestamp normalisation.
|
||||
@@ -0,0 +1,779 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
using StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
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.CertCc;
|
||||
|
||||
public sealed class CertCcConnector : IFeedConnector
|
||||
{
|
||||
private static readonly JsonSerializerOptions DtoSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
private static readonly byte[] EmptyArrayPayload = Encoding.UTF8.GetBytes("[]");
|
||||
private static readonly string[] DetailEndpoints = { "note", "vendors", "vuls", "vendors-vuls" };
|
||||
|
||||
private readonly CertCcSummaryPlanner _summaryPlanner;
|
||||
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 CertCcOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CertCcConnector> _logger;
|
||||
private readonly CertCcDiagnostics _diagnostics;
|
||||
|
||||
public CertCcConnector(
|
||||
CertCcSummaryPlanner summaryPlanner,
|
||||
SourceFetchService fetchService,
|
||||
RawDocumentStorage rawDocumentStorage,
|
||||
IDocumentStore documentStore,
|
||||
IDtoStore dtoStore,
|
||||
IAdvisoryStore advisoryStore,
|
||||
ISourceStateRepository stateRepository,
|
||||
IOptions<CertCcOptions> options,
|
||||
CertCcDiagnostics diagnostics,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<CertCcConnector> logger)
|
||||
{
|
||||
_summaryPlanner = summaryPlanner ?? throw new ArgumentNullException(nameof(summaryPlanner));
|
||||
_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 => CertCcConnectorPlugin.SourceName;
|
||||
|
||||
public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var pendingDocuments = cursor.PendingDocuments.ToHashSet();
|
||||
var pendingMappings = cursor.PendingMappings.ToHashSet();
|
||||
var pendingNotes = new HashSet<string>(cursor.PendingNotes, StringComparer.OrdinalIgnoreCase);
|
||||
var processedNotes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var remainingBudget = _options.MaxNotesPerFetch;
|
||||
|
||||
// Resume notes that previously failed before fetching new summaries.
|
||||
if (pendingNotes.Count > 0 && remainingBudget > 0)
|
||||
{
|
||||
var replay = pendingNotes.ToArray();
|
||||
foreach (var noteId in replay)
|
||||
{
|
||||
if (remainingBudget <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!processedNotes.Add(noteId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await HasPendingDocumentBundleAsync(noteId, pendingDocuments, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
pendingNotes.Remove(noteId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await FetchNoteBundleAsync(noteId, null, pendingDocuments, pendingNotes, cancellationToken).ConfigureAwait(false);
|
||||
if (!pendingNotes.Contains(noteId))
|
||||
{
|
||||
remainingBudget--;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var plan = _summaryPlanner.CreatePlan(cursor.SummaryState);
|
||||
_diagnostics.PlanEvaluated(plan.Window, plan.Requests.Count);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var request in plan.Requests)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var shouldProcessNotes = remainingBudget > 0;
|
||||
|
||||
try
|
||||
{
|
||||
_diagnostics.SummaryFetchAttempt(request.Scope);
|
||||
var metadata = BuildSummaryMetadata(request);
|
||||
var existingSummary = await _documentStore.FindBySourceAndUriAsync(SourceName, request.Uri.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
var fetchRequest = new SourceFetchRequest(
|
||||
CertCcOptions.HttpClientName,
|
||||
SourceName,
|
||||
HttpMethod.Get,
|
||||
request.Uri,
|
||||
metadata,
|
||||
existingSummary?.Etag,
|
||||
existingSummary?.LastModified,
|
||||
null,
|
||||
new[] { "application/json" });
|
||||
|
||||
var result = await _fetchService.FetchAsync(fetchRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (result.IsNotModified)
|
||||
{
|
||||
_diagnostics.SummaryFetchUnchanged(request.Scope);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.IsSuccess || result.Document is null)
|
||||
{
|
||||
_diagnostics.SummaryFetchFailure(request.Scope);
|
||||
continue;
|
||||
}
|
||||
|
||||
_diagnostics.SummaryFetchSuccess(request.Scope);
|
||||
|
||||
if (!shouldProcessNotes)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var noteTokens = await ReadSummaryNotesAsync(result.Document, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var token in noteTokens)
|
||||
{
|
||||
if (remainingBudget <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var noteId = TryNormalizeNoteToken(token, out var vuIdentifier);
|
||||
if (string.IsNullOrEmpty(noteId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!processedNotes.Add(noteId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await FetchNoteBundleAsync(noteId, vuIdentifier, pendingDocuments, pendingNotes, cancellationToken).ConfigureAwait(false);
|
||||
if (!pendingNotes.Contains(noteId))
|
||||
{
|
||||
remainingBudget--;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_diagnostics.SummaryFetchFailure(request.Scope);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failureCursor = cursor
|
||||
.WithPendingSummaries(Array.Empty<Guid>())
|
||||
.WithPendingNotes(pendingNotes)
|
||||
.WithPendingDocuments(pendingDocuments)
|
||||
.WithPendingMappings(pendingMappings)
|
||||
.WithLastRun(now);
|
||||
|
||||
await UpdateCursorAsync(failureCursor, cancellationToken).ConfigureAwait(false);
|
||||
await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
var updatedCursor = cursor
|
||||
.WithSummaryState(plan.NextState)
|
||||
.WithPendingSummaries(Array.Empty<Guid>())
|
||||
.WithPendingNotes(pendingNotes)
|
||||
.WithPendingDocuments(pendingDocuments)
|
||||
.WithPendingMappings(pendingMappings)
|
||||
.WithLastRun(now);
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> HasPendingDocumentBundleAsync(string noteId, HashSet<Guid> pendingDocuments, CancellationToken cancellationToken)
|
||||
{
|
||||
if (pendingDocuments.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var required = new HashSet<string>(DetailEndpoints, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var documentId in pendingDocuments)
|
||||
{
|
||||
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
if (document?.Metadata is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.Metadata.TryGetValue("certcc.noteId", out var metadataNoteId) ||
|
||||
!string.Equals(metadataNoteId, noteId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var endpoint = document.Metadata.TryGetValue("certcc.endpoint", out var endpointValue)
|
||||
? endpointValue
|
||||
: "note";
|
||||
|
||||
required.Remove(endpoint);
|
||||
if (required.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.EnableDetailMapping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cursor.PendingDocuments.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pendingDocuments = cursor.PendingDocuments.ToHashSet();
|
||||
var pendingMappings = cursor.PendingMappings.ToHashSet();
|
||||
|
||||
var groups = new Dictionary<string, NoteDocumentGroup>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var documentId in cursor.PendingDocuments)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
pendingDocuments.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryGetMetadata(document, "certcc.noteId", out var noteId) || string.IsNullOrWhiteSpace(noteId))
|
||||
{
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
pendingDocuments.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var endpoint = TryGetMetadata(document, "certcc.endpoint", out var endpointValue)
|
||||
? endpointValue
|
||||
: "note";
|
||||
|
||||
var group = groups.TryGetValue(noteId, out var existing)
|
||||
? existing
|
||||
: (groups[noteId] = new NoteDocumentGroup(noteId));
|
||||
|
||||
group.Add(endpoint, document);
|
||||
}
|
||||
|
||||
foreach (var group in groups.Values)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (group.Note is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var noteBytes = await DownloadDocumentAsync(group.Note, cancellationToken).ConfigureAwait(false);
|
||||
var vendorsBytes = group.Vendors is null
|
||||
? EmptyArrayPayload
|
||||
: await DownloadDocumentAsync(group.Vendors, cancellationToken).ConfigureAwait(false);
|
||||
var vulsBytes = group.Vuls is null
|
||||
? EmptyArrayPayload
|
||||
: await DownloadDocumentAsync(group.Vuls, cancellationToken).ConfigureAwait(false);
|
||||
var vendorStatusesBytes = group.VendorStatuses is null
|
||||
? EmptyArrayPayload
|
||||
: await DownloadDocumentAsync(group.VendorStatuses, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var dto = CertCcNoteParser.Parse(noteBytes, vendorsBytes, vulsBytes, vendorStatusesBytes);
|
||||
var json = JsonSerializer.Serialize(dto, DtoSerializerOptions);
|
||||
var payload = MongoDB.Bson.BsonDocument.Parse(json);
|
||||
|
||||
_diagnostics.ParseSuccess(
|
||||
dto.Vendors.Count,
|
||||
dto.VendorStatuses.Count,
|
||||
dto.Vulnerabilities.Count);
|
||||
|
||||
var dtoRecord = new DtoRecord(
|
||||
Guid.NewGuid(),
|
||||
group.Note.Id,
|
||||
SourceName,
|
||||
"certcc.vince.note.v1",
|
||||
payload,
|
||||
_timeProvider.GetUtcNow());
|
||||
|
||||
await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
|
||||
await _documentStore.UpdateStatusAsync(group.Note.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Add(group.Note.Id);
|
||||
pendingDocuments.Remove(group.Note.Id);
|
||||
|
||||
if (group.Vendors is not null)
|
||||
{
|
||||
await _documentStore.UpdateStatusAsync(group.Vendors.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
|
||||
pendingDocuments.Remove(group.Vendors.Id);
|
||||
}
|
||||
|
||||
if (group.Vuls is not null)
|
||||
{
|
||||
await _documentStore.UpdateStatusAsync(group.Vuls.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
|
||||
pendingDocuments.Remove(group.Vuls.Id);
|
||||
}
|
||||
|
||||
if (group.VendorStatuses is not null)
|
||||
{
|
||||
await _documentStore.UpdateStatusAsync(group.VendorStatuses.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
|
||||
pendingDocuments.Remove(group.VendorStatuses.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_diagnostics.ParseFailure();
|
||||
_logger.LogError(ex, "CERT/CC parse failed for note {NoteId}", group.NoteId);
|
||||
if (group.Note is not null)
|
||||
{
|
||||
await _documentStore.UpdateStatusAsync(group.Note.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
pendingDocuments.Remove(group.Note.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var updatedCursor = cursor
|
||||
.WithPendingDocuments(pendingDocuments)
|
||||
.WithPendingMappings(pendingMappings);
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.EnableDetailMapping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cursor.PendingMappings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pendingMappings = cursor.PendingMappings.ToHashSet();
|
||||
|
||||
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)
|
||||
{
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = dtoRecord.Payload.ToJson();
|
||||
var dto = JsonSerializer.Deserialize<CertCcNoteDto>(json, DtoSerializerOptions);
|
||||
if (dto is null)
|
||||
{
|
||||
throw new InvalidOperationException($"CERT/CC DTO payload deserialized as null for document {documentId}.");
|
||||
}
|
||||
|
||||
var advisory = CertCcMapper.Map(dto, document, dtoRecord, SourceName);
|
||||
var affectedCount = advisory.AffectedPackages.Length;
|
||||
var normalizedRuleCount = advisory.AffectedPackages.Sum(static package => package.NormalizedVersions.Length);
|
||||
await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
|
||||
_diagnostics.MapSuccess(affectedCount, normalizedRuleCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_diagnostics.MapFailure();
|
||||
_logger.LogError(ex, "CERT/CC mapping failed for document {DocumentId}", documentId);
|
||||
await _documentStore.UpdateStatusAsync(documentId, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
pendingMappings.Remove(documentId);
|
||||
}
|
||||
|
||||
var updatedCursor = cursor.WithPendingMappings(pendingMappings);
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task FetchNoteBundleAsync(
|
||||
string noteId,
|
||||
string? vuIdentifier,
|
||||
HashSet<Guid> pendingDocuments,
|
||||
HashSet<string> pendingNotes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var missingEndpoints = new List<(string Endpoint, HttpStatusCode? Status)>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var endpoint in DetailEndpoints)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var uri = BuildDetailUri(noteId, endpoint);
|
||||
var metadata = BuildDetailMetadata(noteId, vuIdentifier, endpoint);
|
||||
var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, uri.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var request = new SourceFetchRequest(CertCcOptions.HttpClientName, SourceName, uri)
|
||||
{
|
||||
Metadata = metadata,
|
||||
ETag = existing?.Etag,
|
||||
LastModified = existing?.LastModified,
|
||||
AcceptHeaders = new[] { "application/json" },
|
||||
};
|
||||
|
||||
SourceFetchResult result;
|
||||
_diagnostics.DetailFetchAttempt(endpoint);
|
||||
try
|
||||
{
|
||||
result = await _fetchService.FetchAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException httpEx)
|
||||
{
|
||||
var status = httpEx.StatusCode ?? TryParseStatusCodeFromMessage(httpEx.Message);
|
||||
if (ShouldTreatAsMissing(status, endpoint))
|
||||
{
|
||||
_diagnostics.DetailFetchMissing(endpoint);
|
||||
missingEndpoints.Add((endpoint, status));
|
||||
continue;
|
||||
}
|
||||
|
||||
_diagnostics.DetailFetchFailure(endpoint);
|
||||
throw;
|
||||
}
|
||||
Guid documentId;
|
||||
if (result.IsSuccess && result.Document is not null)
|
||||
{
|
||||
_diagnostics.DetailFetchSuccess(endpoint);
|
||||
documentId = result.Document.Id;
|
||||
}
|
||||
else if (result.IsNotModified)
|
||||
{
|
||||
_diagnostics.DetailFetchUnchanged(endpoint);
|
||||
if (existing is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
documentId = existing.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
_diagnostics.DetailFetchFailure(endpoint);
|
||||
_logger.LogWarning(
|
||||
"CERT/CC detail endpoint {Endpoint} returned {StatusCode} for note {NoteId}; will retry.",
|
||||
endpoint,
|
||||
(int)result.StatusCode,
|
||||
noteId);
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"CERT/CC endpoint '{endpoint}' returned {(int)result.StatusCode} ({result.StatusCode}) for note {noteId}.",
|
||||
null,
|
||||
result.StatusCode);
|
||||
}
|
||||
|
||||
pendingDocuments.Add(documentId);
|
||||
|
||||
if (_options.DetailRequestDelay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(_options.DetailRequestDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingEndpoints.Count > 0)
|
||||
{
|
||||
var formatted = string.Join(
|
||||
", ",
|
||||
missingEndpoints.Select(item =>
|
||||
item.Status.HasValue
|
||||
? $"{item.Endpoint} ({(int)item.Status.Value})"
|
||||
: item.Endpoint));
|
||||
|
||||
_logger.LogWarning(
|
||||
"CERT/CC detail fetch completed with missing endpoints for note {NoteId}: {Endpoints}",
|
||||
noteId,
|
||||
formatted);
|
||||
}
|
||||
|
||||
pendingNotes.Remove(noteId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CERT/CC detail fetch failed for note {NoteId}", noteId);
|
||||
pendingNotes.Add(noteId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSummaryMetadata(CertCcSummaryRequest request)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["certcc.scope"] = request.Scope.ToString().ToLowerInvariant(),
|
||||
["certcc.year"] = request.Year.ToString("D4", CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
if (request.Month.HasValue)
|
||||
{
|
||||
metadata["certcc.month"] = request.Month.Value.ToString("D2", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildDetailMetadata(string noteId, string? vuIdentifier, string endpoint)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["certcc.endpoint"] = endpoint,
|
||||
["certcc.noteId"] = noteId,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vuIdentifier))
|
||||
{
|
||||
metadata["certcc.vuid"] = vuIdentifier;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> ReadSummaryNotesAsync(DocumentRecord document, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!document.GridFsId.HasValue)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var payload = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
|
||||
return CertCcSummaryParser.ParseNotes(payload);
|
||||
}
|
||||
|
||||
private async Task<byte[]> DownloadDocumentAsync(DocumentRecord document, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!document.GridFsId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"Document {document.Id} has no GridFS payload.");
|
||||
}
|
||||
|
||||
return await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Uri BuildDetailUri(string noteId, string endpoint)
|
||||
{
|
||||
var suffix = endpoint switch
|
||||
{
|
||||
"note" => $"{noteId}/",
|
||||
"vendors" => $"{noteId}/vendors/",
|
||||
"vuls" => $"{noteId}/vuls/",
|
||||
"vendors-vuls" => $"{noteId}/vendors/vuls/",
|
||||
_ => $"{noteId}/",
|
||||
};
|
||||
|
||||
return new Uri(_options.BaseApiUri, suffix);
|
||||
}
|
||||
|
||||
private static string? TryNormalizeNoteToken(string token, out string? vuIdentifier)
|
||||
{
|
||||
vuIdentifier = null;
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = token.Trim();
|
||||
var digits = new string(trimmed.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
vuIdentifier = trimmed.StartsWith("vu", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmed.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||
: $"VU#{digits}";
|
||||
|
||||
return digits;
|
||||
}
|
||||
|
||||
private static bool TryGetMetadata(DocumentRecord document, string key, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (document.Metadata is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!document.Metadata.TryGetValue(key, out var metadataValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = metadataValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<CertCcCursor> GetCursorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
|
||||
return CertCcCursor.FromBson(record?.Cursor);
|
||||
}
|
||||
|
||||
private async Task UpdateCursorAsync(CertCcCursor cursor, CancellationToken cancellationToken)
|
||||
{
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), completedAt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class NoteDocumentGroup
|
||||
{
|
||||
public NoteDocumentGroup(string noteId)
|
||||
{
|
||||
NoteId = noteId;
|
||||
}
|
||||
|
||||
public string NoteId { get; }
|
||||
|
||||
public DocumentRecord? Note { get; private set; }
|
||||
|
||||
public DocumentRecord? Vendors { get; private set; }
|
||||
|
||||
public DocumentRecord? Vuls { get; private set; }
|
||||
|
||||
public DocumentRecord? VendorStatuses { get; private set; }
|
||||
|
||||
public void Add(string endpoint, DocumentRecord document)
|
||||
{
|
||||
switch (endpoint)
|
||||
{
|
||||
case "note":
|
||||
Note = document;
|
||||
break;
|
||||
case "vendors":
|
||||
Vendors = document;
|
||||
break;
|
||||
case "vuls":
|
||||
Vuls = document;
|
||||
break;
|
||||
case "vendors-vuls":
|
||||
VendorStatuses = document;
|
||||
break;
|
||||
default:
|
||||
Note ??= document;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldTreatAsMissing(HttpStatusCode? statusCode, string endpoint)
|
||||
{
|
||||
if (statusCode is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (statusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
|
||||
{
|
||||
return !string.Equals(endpoint, "note", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Treat vendors/vendors-vuls/vuls 403 as optional air-gapped responses.
|
||||
if (statusCode == HttpStatusCode.Forbidden && !string.Equals(endpoint, "note", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static HttpStatusCode? TryParseStatusCodeFromMessage(string? message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string marker = "status ";
|
||||
var index = message.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
index += marker.Length;
|
||||
var end = index;
|
||||
while (end < message.Length && char.IsDigit(message[end]))
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end == index)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (int.TryParse(message[index..end], NumberStyles.Integer, CultureInfo.InvariantCulture, out var code) &&
|
||||
Enum.IsDefined(typeof(HttpStatusCode), code))
|
||||
{
|
||||
return (HttpStatusCode)code;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc;
|
||||
|
||||
public sealed class CertCcConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = "cert-cc";
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
=> services.GetService<CertCcConnector>() is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return services.GetRequiredService<CertCcConnector>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc;
|
||||
|
||||
public sealed class CertCcDependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
private const string ConfigurationSection = "concelier:sources:cert-cc";
|
||||
|
||||
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddCertCcConnector(options =>
|
||||
{
|
||||
configuration.GetSection(ConfigurationSection).Bind(options);
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
services.AddTransient<CertCcFetchJob>();
|
||||
|
||||
services.PostConfigure<JobSchedulerOptions>(options =>
|
||||
{
|
||||
EnsureJob(options, CertCcJobKinds.Fetch, typeof(CertCcFetchJob));
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
using StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc;
|
||||
|
||||
public static class CertCcServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCertCcConnector(this IServiceCollection services, Action<CertCcOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<CertCcOptions>()
|
||||
.Configure(configure)
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
services.AddSourceHttpClient(CertCcOptions.HttpClientName, static (sp, clientOptions) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<CertCcOptions>>().Value;
|
||||
clientOptions.BaseAddress = options.BaseApiUri;
|
||||
clientOptions.UserAgent = "StellaOps.Concelier.CertCc/1.0";
|
||||
clientOptions.Timeout = TimeSpan.FromSeconds(20);
|
||||
clientOptions.AllowedHosts.Clear();
|
||||
clientOptions.AllowedHosts.Add(options.BaseApiUri.Host);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<CertCcSummaryPlanner>();
|
||||
services.TryAddSingleton<CertCcDiagnostics>();
|
||||
services.AddTransient<CertCcConnector>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Connector options governing CERT/CC fetch cadence and API endpoints.
|
||||
/// </summary>
|
||||
public sealed class CertCcOptions
|
||||
{
|
||||
public const string HttpClientName = "certcc";
|
||||
|
||||
/// <summary>
|
||||
/// Root URI for the VINCE Vulnerability Notes API (must end with a slash).
|
||||
/// </summary>
|
||||
public Uri BaseApiUri { get; set; } = new("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
|
||||
|
||||
/// <summary>
|
||||
/// Sliding window settings controlling which summary endpoints are requested.
|
||||
/// </summary>
|
||||
public TimeWindowCursorOptions SummaryWindow { get; set; } = new()
|
||||
{
|
||||
WindowSize = TimeSpan.FromDays(30),
|
||||
Overlap = TimeSpan.FromDays(3),
|
||||
InitialBackfill = TimeSpan.FromDays(365),
|
||||
MinimumWindowSize = TimeSpan.FromDays(1),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of monthly summary endpoints to request in a single plan.
|
||||
/// </summary>
|
||||
public int MaxMonthlySummaries { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of vulnerability notes (detail bundles) to process per fetch pass.
|
||||
/// </summary>
|
||||
public int MaxNotesPerFetch { get; set; } = 25;
|
||||
|
||||
/// <summary>
|
||||
/// Optional delay inserted between successive detail requests to respect upstream throttling.
|
||||
/// </summary>
|
||||
public TimeSpan DetailRequestDelay { get; set; } = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>
|
||||
/// When disabled, parse/map stages skip detail mapping—useful for dry runs or migration staging.
|
||||
/// </summary>
|
||||
public bool EnableDetailMapping { get; set; } = true;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (BaseApiUri is null || !BaseApiUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("CertCcOptions.BaseApiUri must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (!BaseApiUri.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("CertCcOptions.BaseApiUri must end with a trailing slash.");
|
||||
}
|
||||
|
||||
SummaryWindow ??= new TimeWindowCursorOptions();
|
||||
SummaryWindow.EnsureValid();
|
||||
|
||||
if (MaxMonthlySummaries <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("CertCcOptions.MaxMonthlySummaries must be positive.");
|
||||
}
|
||||
|
||||
if (MaxNotesPerFetch <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("CertCcOptions.MaxNotesPerFetch must be positive.");
|
||||
}
|
||||
|
||||
if (DetailRequestDelay < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("CertCcOptions.DetailRequestDelay cannot be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# FEEDCONN-CERTCC-02-009 – VINCE Detail & Map Reintegration Plan
|
||||
|
||||
- **Author:** BE-Conn-CERTCC (current on-call)
|
||||
- **Date:** 2025-10-11
|
||||
- **Scope:** Restore VINCE detail parsing and canonical mapping in Concelier without destabilising downstream Merge/Export pipelines.
|
||||
|
||||
## 1. Current State Snapshot (2025-10-11)
|
||||
|
||||
- ✅ Fetch pipeline, VINCE summary planner, and detail queue are live; documents land with `DocumentStatuses.PendingParse`.
|
||||
- ✅ DTO aggregate (`CertCcNoteDto`) plus mapper emit vendor-centric `normalizedVersions` (`scheme=certcc.vendor`) and provenance aligned with `src/Concelier/__Libraries/StellaOps.Concelier.Models/PROVENANCE_GUIDELINES.md`.
|
||||
- ✅ Regression coverage exists for fetch/parse/map flows (`CertCcConnectorSnapshotTests`), but snapshot regeneration is gated on harness refresh (FEEDCONN-CERTCC-02-007) and QA handoff (FEEDCONN-CERTCC-02-008).
|
||||
- ⚠️ Parse/map jobs are not scheduled; production still operates in fetch-only mode.
|
||||
- ⚠️ Downstream Merge team is finalising normalized range ingestion per `src/FASTER_MODELING_AND_NORMALIZATION.md`; we must avoid publishing canonical records until they certify compatibility.
|
||||
|
||||
## 2. Required Dependencies & Coordinated Tasks
|
||||
|
||||
| Dependency | Owner(s) | Blocking Condition | Handshake |
|
||||
|------------|----------|--------------------|-----------|
|
||||
| FEEDCONN-CERTCC-02-004 (Canonical mapping & range primitives hardening) | BE-Conn-CERTCC + Models | Ensure mapper emits deterministic `normalizedVersions` array and provenance field masks | Daily sync with Models/Merge leads; share fixture diff before each enablement phase |
|
||||
| FEEDCONN-CERTCC-02-007 (Connector test harness remediation) | BE-Conn-CERTCC, QA | Restore `AddSourceCommon` harness + canned VINCE fixtures so we can shadow-run parse/map | Required before Phase 1 |
|
||||
| FEEDCONN-CERTCC-02-008 (Snapshot coverage handoff) | QA | Snapshot refresh process green to surface regressions | Required before Phase 2 |
|
||||
| FEEDCONN-CERTCC-02-010 (Partial-detail graceful degradation) | BE-Conn-CERTCC | Resiliency for missing VINCE endpoints to avoid job wedging after reintegration | Should land before Phase 2 cutover |
|
||||
|
||||
## 3. Phased Rollout Plan
|
||||
|
||||
| Phase | Window (UTC) | Actions | Success Signals | Rollback |
|
||||
|-------|--------------|---------|-----------------|----------|
|
||||
| **0 – Pre-flight validation** | 2025-10-11 → 2025-10-12 | • Finish FEEDCONN-CERTCC-02-007 harness fixes and regenerate fixtures.<br>• Run `dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests` with `UPDATE_CERTCC_FIXTURES=0` to confirm deterministic baselines.<br>• Generate sample advisory batch (`dotnet test … --filter SnapshotSmoke`) and deliver JSON diff to Merge for schema verification (`normalizedVersions[].scheme == certcc.vendor`, provenance masks populated). | • Harness tests green locally and in CI.<br>• Merge sign-off that sample advisories conform to `FASTER_MODELING_AND_NORMALIZATION.md`. | N/A (no production enablement yet). |
|
||||
| **1 – Shadow parse/map in staging** | Target start 2025-10-13 | • Register `source:cert-cc:parse` and `source:cert-cc:map` jobs, but gate them behind new config flag `concelier:sources:cert-cc:enableDetailMapping` (default `false`).<br>• Deploy (restart required for options rebinding), enable flag, and point connector at staging Mongo with isolated collection (`advisories_certcc_shadow`).<br>• Run connector for ≥2 cycles; compare advisory counts vs. fetch-only baseline and validate `concelier.range.primitives` metrics include `scheme=certcc.vendor`. | • No uncaught exceptions in staging logs.<br>• Shadow advisories match expected vendor counts (±5%).<br>• `certcc.summary.fetch.*` + new `certcc.map.duration.ms` metrics stable. | Disable flag; staging returns to fetch-only. No production impact. |
|
||||
| **2 – Controlled production enablement** | Target start 2025-10-14 | • Redeploy production with flag enabled, start with job concurrency `1`, and reduce `MaxNotesPerFetch` to 5 for first 24 h.<br>• Observe metrics dashboards hourly (fetch/map latency, pending queues, Mongo write throughput).<br>• QA to replay latest snapshots and confirm no deterministic drift.<br>• Publish advisory sample (top 10 changed docs) to Merge Slack channel for validation. | • Pending parse/mapping queues drain within expected SLA (<30 min).<br>• No increase in merge dedupe anomalies.<br>• Mongo writes stay within 10% of baseline. | Toggle flag off, re-run fetch-only. Clear `pendingMappings` via connector cursor reset if stuck. |
|
||||
| **3 – Full production & cleanup** | Target start 2025-10-15 | • Restore `MaxNotesPerFetch` to configured default (20).<br>• Remove temporary throttles and leave flag enabled by default.<br>• Update `README.md` rollout notes; close FEEDCONN-CERTCC-02-009.<br>• Kick off post-merge audit with Merge to ensure new advisories dedupe with other sources. | • Stable operations for ≥48 h, no degradation alerts.<br>• Merge confirms conflict resolver behaviour unchanged. | If regression detected, revert to Phase 2 state or disable jobs; retain plan for reuse. |
|
||||
|
||||
## 4. Monitoring & Validation Checklist
|
||||
|
||||
- Dashboards: `certcc.*` meters (plan, summary fetch, detail fetch) plus `concelier.range.primitives` with tag `scheme=certcc.vendor`.
|
||||
- Logs: ensure Parse/Map jobs emit `correlationId` aligned with fetch events for traceability.
|
||||
- Data QA: run `tools/dump_advisory` against two VINCE notes (one multi-vendor, one single-vendor) every phase to spot-check normalized versions ordering and provenance.
|
||||
- Storage: verify Mongo TTL/size for `raw_documents` and `dtos`—detail payload volume increases by ~3× when mapping resumes.
|
||||
|
||||
## 5. Rollback / Contingency Playbook
|
||||
|
||||
1. Disable `concelier:sources:cert-cc:enableDetailMapping` flag (and optionally set `MaxNotesPerFetch=0` for a single cycle) to halt new detail ingestion.
|
||||
2. Run connector once to update cursor; verify `pendingMappings` drains.
|
||||
3. If advisories already persisted, coordinate with Merge to soft-delete affected `certcc/*` advisories by advisory key hash (no schema rollback required).
|
||||
4. Re-run Phase 1 shadow validation before retrying.
|
||||
|
||||
## 6. Communication Cadence
|
||||
|
||||
- Daily check-in with Models/Merge leads (09:30 EDT) to surface normalizedVersions/provenance diffs.
|
||||
- Post-phase reports in `#concelier-certcc` Slack channel summarising metrics, advisory counts, and outstanding issues.
|
||||
- Escalate blockers >12 h via Runbook SEV-3 path and annotate `TASKS.md`.
|
||||
|
||||
## 7. Open Questions / Next Actions
|
||||
|
||||
- [ ] Confirm whether Merge requires additional provenance field masks before Phase 2 (waiting on feedback from 2025-10-11 sample).
|
||||
- [ ] Decide if CSAF endpoint ingestion (optional) should piggyback on Phase 3 or stay deferred.
|
||||
- [ ] Validate that FEEDCONN-CERTCC-02-010 coverage handles mixed 200/404 VINCE endpoints during partial outages.
|
||||
|
||||
Once Dependencies (Section 2) are cleared and Phase 3 completes, update `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Connector.CertCc/TASKS.md` and close FEEDCONN-CERTCC-02-009.
|
||||
@@ -0,0 +1,20 @@
|
||||
# FEEDCONN-CERTCC-02-012 – Schema Sync & Snapshot Regeneration
|
||||
|
||||
## Summary
|
||||
- Re-ran `StellaOps.Concelier.Connector.CertCc.Tests` with `UPDATE_CERTCC_FIXTURES=1`; fixtures now capture SemVer-style normalized versions (`scheme=certcc.vendor`) and `provenance.decisionReason` values emitted by the mapper.
|
||||
- Recorded HTTP request ordering is persisted in `certcc-requests.snapshot.json` to keep Merge aware of the deterministic fetch plan.
|
||||
- Advisories snapshot (`certcc-advisories.snapshot.json`) reflects the dual-write storage changes (normalized versions + provenance) introduced by FEEDMODELS-SCHEMA-* and FEEDSTORAGE-DATA-*.
|
||||
|
||||
## Artifacts
|
||||
- `src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests/Fixtures/certcc-advisories.snapshot.json`
|
||||
- `src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests/Fixtures/certcc-documents.snapshot.json`
|
||||
- `src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests/Fixtures/certcc-requests.snapshot.json`
|
||||
- `src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests/Fixtures/certcc-state.snapshot.json`
|
||||
|
||||
## Validation steps
|
||||
```bash
|
||||
dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests
|
||||
UPDATE_CERTCC_FIXTURES=1 dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests
|
||||
```
|
||||
|
||||
The first command verifies deterministic behavior; the second regenerates fixtures if a future schema change occurs. Share the four snapshot files above with Merge for their backfill diff.
|
||||
@@ -0,0 +1,187 @@
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
internal sealed record CertCcCursor(
|
||||
TimeWindowCursorState SummaryState,
|
||||
IReadOnlyCollection<Guid> PendingSummaries,
|
||||
IReadOnlyCollection<string> PendingNotes,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings,
|
||||
DateTimeOffset? LastRun)
|
||||
{
|
||||
private static readonly Guid[] EmptyGuidArray = Array.Empty<Guid>();
|
||||
private static readonly string[] EmptyStringArray = Array.Empty<string>();
|
||||
|
||||
public static CertCcCursor Empty { get; } = new(
|
||||
TimeWindowCursorState.Empty,
|
||||
EmptyGuidArray,
|
||||
EmptyStringArray,
|
||||
EmptyGuidArray,
|
||||
EmptyGuidArray,
|
||||
null);
|
||||
|
||||
public BsonDocument ToBsonDocument()
|
||||
{
|
||||
var document = new BsonDocument();
|
||||
|
||||
var summary = new BsonDocument();
|
||||
SummaryState.WriteTo(summary, "start", "end");
|
||||
document["summary"] = summary;
|
||||
|
||||
document["pendingSummaries"] = new BsonArray(PendingSummaries.Select(static id => id.ToString()));
|
||||
document["pendingNotes"] = new BsonArray(PendingNotes.Select(static note => note));
|
||||
document["pendingDocuments"] = new BsonArray(PendingDocuments.Select(static id => id.ToString()));
|
||||
document["pendingMappings"] = new BsonArray(PendingMappings.Select(static id => id.ToString()));
|
||||
|
||||
if (LastRun.HasValue)
|
||||
{
|
||||
document["lastRun"] = LastRun.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static CertCcCursor FromBson(BsonDocument? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
TimeWindowCursorState summaryState = TimeWindowCursorState.Empty;
|
||||
if (document.TryGetValue("summary", out var summaryValue) && summaryValue is BsonDocument summaryDocument)
|
||||
{
|
||||
summaryState = TimeWindowCursorState.FromBsonDocument(summaryDocument, "start", "end");
|
||||
}
|
||||
|
||||
var pendingSummaries = ReadGuidArray(document, "pendingSummaries");
|
||||
var pendingNotes = ReadStringArray(document, "pendingNotes");
|
||||
var pendingDocuments = ReadGuidArray(document, "pendingDocuments");
|
||||
var pendingMappings = ReadGuidArray(document, "pendingMappings");
|
||||
|
||||
DateTimeOffset? lastRun = null;
|
||||
if (document.TryGetValue("lastRun", out var lastRunValue))
|
||||
{
|
||||
lastRun = lastRunValue.BsonType switch
|
||||
{
|
||||
BsonType.DateTime => DateTime.SpecifyKind(lastRunValue.ToUniversalTime(), DateTimeKind.Utc),
|
||||
BsonType.String when DateTimeOffset.TryParse(lastRunValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
return new CertCcCursor(summaryState, pendingSummaries, pendingNotes, pendingDocuments, pendingMappings, lastRun);
|
||||
}
|
||||
|
||||
public CertCcCursor WithSummaryState(TimeWindowCursorState state)
|
||||
=> this with { SummaryState = state ?? TimeWindowCursorState.Empty };
|
||||
|
||||
public CertCcCursor WithPendingSummaries(IEnumerable<Guid>? ids)
|
||||
=> this with { PendingSummaries = NormalizeGuidSet(ids) };
|
||||
|
||||
public CertCcCursor WithPendingNotes(IEnumerable<string>? notes)
|
||||
=> this with { PendingNotes = NormalizeStringSet(notes) };
|
||||
|
||||
public CertCcCursor WithPendingDocuments(IEnumerable<Guid>? ids)
|
||||
=> this with { PendingDocuments = NormalizeGuidSet(ids) };
|
||||
|
||||
public CertCcCursor WithPendingMappings(IEnumerable<Guid>? ids)
|
||||
=> this with { PendingMappings = NormalizeGuidSet(ids) };
|
||||
|
||||
public CertCcCursor WithLastRun(DateTimeOffset? timestamp)
|
||||
=> this with { LastRun = timestamp };
|
||||
|
||||
private static Guid[] ReadGuidArray(BsonDocument document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not BsonArray array || array.Count == 0)
|
||||
{
|
||||
return EmptyGuidArray;
|
||||
}
|
||||
|
||||
var results = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (TryReadGuid(element, out var parsed))
|
||||
{
|
||||
results.Add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return results.Count == 0 ? EmptyGuidArray : results.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static string[] ReadStringArray(BsonDocument document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not BsonArray array || array.Count == 0)
|
||||
{
|
||||
return EmptyStringArray;
|
||||
}
|
||||
|
||||
var results = new List<string>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
switch (element)
|
||||
{
|
||||
case BsonString bsonString when !string.IsNullOrWhiteSpace(bsonString.AsString):
|
||||
results.Add(bsonString.AsString.Trim());
|
||||
break;
|
||||
case BsonDocument bsonDocument when bsonDocument.TryGetValue("value", out var inner) && inner.IsString:
|
||||
results.Add(inner.AsString.Trim());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results.Count == 0
|
||||
? EmptyStringArray
|
||||
: results
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool TryReadGuid(BsonValue value, out Guid guid)
|
||||
{
|
||||
if (value is BsonString bsonString && Guid.TryParse(bsonString.AsString, out guid))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value is BsonBinaryData binary)
|
||||
{
|
||||
try
|
||||
{
|
||||
guid = binary.ToGuid();
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// ignore and fall back to byte array parsing
|
||||
}
|
||||
|
||||
var bytes = binary.AsByteArray;
|
||||
if (bytes.Length == 16)
|
||||
{
|
||||
guid = new Guid(bytes);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
guid = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Guid[] NormalizeGuidSet(IEnumerable<Guid>? ids)
|
||||
=> ids?.Where(static id => id != Guid.Empty).Distinct().ToArray() ?? EmptyGuidArray;
|
||||
|
||||
private static string[] NormalizeStringSet(IEnumerable<string>? values)
|
||||
=> values is null
|
||||
? EmptyStringArray
|
||||
: values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Emits CERT/CC-specific telemetry for summary planning and fetch activity.
|
||||
/// </summary>
|
||||
public sealed class CertCcDiagnostics : IDisposable
|
||||
{
|
||||
private const string MeterName = "StellaOps.Concelier.Connector.CertCc";
|
||||
private const string MeterVersion = "1.0.0";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _planWindows;
|
||||
private readonly Counter<long> _planRequests;
|
||||
private readonly Histogram<double> _planWindowDays;
|
||||
private readonly Counter<long> _summaryFetchAttempts;
|
||||
private readonly Counter<long> _summaryFetchSuccess;
|
||||
private readonly Counter<long> _summaryFetchUnchanged;
|
||||
private readonly Counter<long> _summaryFetchFailures;
|
||||
private readonly Counter<long> _detailFetchAttempts;
|
||||
private readonly Counter<long> _detailFetchSuccess;
|
||||
private readonly Counter<long> _detailFetchUnchanged;
|
||||
private readonly Counter<long> _detailFetchMissing;
|
||||
private readonly Counter<long> _detailFetchFailures;
|
||||
private readonly Counter<long> _parseSuccess;
|
||||
private readonly Counter<long> _parseFailures;
|
||||
private readonly Histogram<long> _parseVendorCount;
|
||||
private readonly Histogram<long> _parseStatusCount;
|
||||
private readonly Histogram<long> _parseVulnerabilityCount;
|
||||
private readonly Counter<long> _mapSuccess;
|
||||
private readonly Counter<long> _mapFailures;
|
||||
private readonly Histogram<long> _mapAffectedPackageCount;
|
||||
private readonly Histogram<long> _mapNormalizedVersionCount;
|
||||
|
||||
public CertCcDiagnostics()
|
||||
{
|
||||
_meter = new Meter(MeterName, MeterVersion);
|
||||
_planWindows = _meter.CreateCounter<long>(
|
||||
name: "certcc.plan.windows",
|
||||
unit: "windows",
|
||||
description: "Number of summary planning windows evaluated.");
|
||||
_planRequests = _meter.CreateCounter<long>(
|
||||
name: "certcc.plan.requests",
|
||||
unit: "requests",
|
||||
description: "Total CERT/CC summary endpoints queued by the planner.");
|
||||
_planWindowDays = _meter.CreateHistogram<double>(
|
||||
name: "certcc.plan.window_days",
|
||||
unit: "day",
|
||||
description: "Duration of each planning window in days.");
|
||||
_summaryFetchAttempts = _meter.CreateCounter<long>(
|
||||
name: "certcc.summary.fetch.attempts",
|
||||
unit: "operations",
|
||||
description: "Number of VINCE summary fetch attempts.");
|
||||
_summaryFetchSuccess = _meter.CreateCounter<long>(
|
||||
name: "certcc.summary.fetch.success",
|
||||
unit: "operations",
|
||||
description: "Number of VINCE summary fetches persisted to storage.");
|
||||
_summaryFetchUnchanged = _meter.CreateCounter<long>(
|
||||
name: "certcc.summary.fetch.not_modified",
|
||||
unit: "operations",
|
||||
description: "Number of VINCE summary fetches returning HTTP 304.");
|
||||
_summaryFetchFailures = _meter.CreateCounter<long>(
|
||||
name: "certcc.summary.fetch.failures",
|
||||
unit: "operations",
|
||||
description: "Number of VINCE summary fetches that failed after retries.");
|
||||
_detailFetchAttempts = _meter.CreateCounter<long>(
|
||||
name: "certcc.detail.fetch.attempts",
|
||||
unit: "operations",
|
||||
description: "Number of VINCE detail fetch attempts.");
|
||||
_detailFetchSuccess = _meter.CreateCounter<long>(
|
||||
name: "certcc.detail.fetch.success",
|
||||
unit: "operations",
|
||||
description: "Number of VINCE detail fetches that returned payloads.");
|
||||
_detailFetchUnchanged = _meter.CreateCounter<long>(
|
||||
name: "certcc.detail.fetch.unchanged",
|
||||
unit: "operations",
|
||||
description: "Number of VINCE detail fetches returning HTTP 304.");
|
||||
_detailFetchMissing = _meter.CreateCounter<long>(
|
||||
name: "certcc.detail.fetch.missing",
|
||||
unit: "operations",
|
||||
description: "Number of optional VINCE detail endpoints missing but tolerated.");
|
||||
_detailFetchFailures = _meter.CreateCounter<long>(
|
||||
name: "certcc.detail.fetch.failures",
|
||||
unit: "operations",
|
||||
description: "Number of VINCE detail fetches that failed after retries.");
|
||||
_parseSuccess = _meter.CreateCounter<long>(
|
||||
name: "certcc.parse.success",
|
||||
unit: "documents",
|
||||
description: "Number of VINCE note bundles parsed into DTOs.");
|
||||
_parseFailures = _meter.CreateCounter<long>(
|
||||
name: "certcc.parse.failures",
|
||||
unit: "documents",
|
||||
description: "Number of VINCE note bundles that failed to parse.");
|
||||
_parseVendorCount = _meter.CreateHistogram<long>(
|
||||
name: "certcc.parse.vendors.count",
|
||||
unit: "vendors",
|
||||
description: "Distribution of vendor statements per VINCE note.");
|
||||
_parseStatusCount = _meter.CreateHistogram<long>(
|
||||
name: "certcc.parse.statuses.count",
|
||||
unit: "entries",
|
||||
description: "Distribution of vendor status entries per VINCE note.");
|
||||
_parseVulnerabilityCount = _meter.CreateHistogram<long>(
|
||||
name: "certcc.parse.vulnerabilities.count",
|
||||
unit: "entries",
|
||||
description: "Distribution of vulnerability records per VINCE note.");
|
||||
_mapSuccess = _meter.CreateCounter<long>(
|
||||
name: "certcc.map.success",
|
||||
unit: "advisories",
|
||||
description: "Number of canonical advisories emitted by the CERT/CC mapper.");
|
||||
_mapFailures = _meter.CreateCounter<long>(
|
||||
name: "certcc.map.failures",
|
||||
unit: "advisories",
|
||||
description: "Number of CERT/CC advisory mapping attempts that failed.");
|
||||
_mapAffectedPackageCount = _meter.CreateHistogram<long>(
|
||||
name: "certcc.map.affected.count",
|
||||
unit: "packages",
|
||||
description: "Distribution of affected packages emitted per CERT/CC advisory.");
|
||||
_mapNormalizedVersionCount = _meter.CreateHistogram<long>(
|
||||
name: "certcc.map.normalized_versions.count",
|
||||
unit: "rules",
|
||||
description: "Distribution of normalized version rules emitted per CERT/CC advisory.");
|
||||
}
|
||||
|
||||
public void PlanEvaluated(TimeWindow window, int requestCount)
|
||||
{
|
||||
_planWindows.Add(1);
|
||||
|
||||
if (requestCount > 0)
|
||||
{
|
||||
_planRequests.Add(requestCount);
|
||||
}
|
||||
|
||||
var duration = window.Duration;
|
||||
if (duration > TimeSpan.Zero)
|
||||
{
|
||||
_planWindowDays.Record(duration.TotalDays);
|
||||
}
|
||||
}
|
||||
|
||||
public void SummaryFetchAttempt(CertCcSummaryScope scope)
|
||||
=> _summaryFetchAttempts.Add(1, ScopeTag(scope));
|
||||
|
||||
public void SummaryFetchSuccess(CertCcSummaryScope scope)
|
||||
=> _summaryFetchSuccess.Add(1, ScopeTag(scope));
|
||||
|
||||
public void SummaryFetchUnchanged(CertCcSummaryScope scope)
|
||||
=> _summaryFetchUnchanged.Add(1, ScopeTag(scope));
|
||||
|
||||
public void SummaryFetchFailure(CertCcSummaryScope scope)
|
||||
=> _summaryFetchFailures.Add(1, ScopeTag(scope));
|
||||
|
||||
public void DetailFetchAttempt(string endpoint)
|
||||
=> _detailFetchAttempts.Add(1, EndpointTag(endpoint));
|
||||
|
||||
public void DetailFetchSuccess(string endpoint)
|
||||
=> _detailFetchSuccess.Add(1, EndpointTag(endpoint));
|
||||
|
||||
public void DetailFetchUnchanged(string endpoint)
|
||||
=> _detailFetchUnchanged.Add(1, EndpointTag(endpoint));
|
||||
|
||||
public void DetailFetchMissing(string endpoint)
|
||||
=> _detailFetchMissing.Add(1, EndpointTag(endpoint));
|
||||
|
||||
public void DetailFetchFailure(string endpoint)
|
||||
=> _detailFetchFailures.Add(1, EndpointTag(endpoint));
|
||||
|
||||
public void ParseSuccess(int vendorCount, int statusCount, int vulnerabilityCount)
|
||||
{
|
||||
_parseSuccess.Add(1);
|
||||
if (vendorCount >= 0)
|
||||
{
|
||||
_parseVendorCount.Record(vendorCount);
|
||||
}
|
||||
if (statusCount >= 0)
|
||||
{
|
||||
_parseStatusCount.Record(statusCount);
|
||||
}
|
||||
if (vulnerabilityCount >= 0)
|
||||
{
|
||||
_parseVulnerabilityCount.Record(vulnerabilityCount);
|
||||
}
|
||||
}
|
||||
|
||||
public void ParseFailure()
|
||||
=> _parseFailures.Add(1);
|
||||
|
||||
public void MapSuccess(int affectedPackageCount, int normalizedVersionCount)
|
||||
{
|
||||
_mapSuccess.Add(1);
|
||||
if (affectedPackageCount >= 0)
|
||||
{
|
||||
_mapAffectedPackageCount.Record(affectedPackageCount);
|
||||
}
|
||||
if (normalizedVersionCount >= 0)
|
||||
{
|
||||
_mapNormalizedVersionCount.Record(normalizedVersionCount);
|
||||
}
|
||||
}
|
||||
|
||||
public void MapFailure()
|
||||
=> _mapFailures.Add(1);
|
||||
|
||||
private static KeyValuePair<string, object?> ScopeTag(CertCcSummaryScope scope)
|
||||
=> new("scope", scope.ToString().ToLowerInvariant());
|
||||
|
||||
private static KeyValuePair<string, object?> EndpointTag(string endpoint)
|
||||
=> new("endpoint", string.IsNullOrWhiteSpace(endpoint) ? "note" : endpoint.ToLowerInvariant());
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
internal static class CertCcMapper
|
||||
{
|
||||
private const string AdvisoryPrefix = "certcc";
|
||||
private const string VendorNormalizedVersionScheme = "certcc.vendor";
|
||||
|
||||
public static Advisory Map(
|
||||
CertCcNoteDto dto,
|
||||
DocumentRecord document,
|
||||
DtoRecord dtoRecord,
|
||||
string sourceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentNullException.ThrowIfNull(dtoRecord);
|
||||
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
||||
|
||||
var recordedAt = dtoRecord.ValidatedAt.ToUniversalTime();
|
||||
var fetchedAt = document.FetchedAt.ToUniversalTime();
|
||||
|
||||
var metadata = dto.Metadata ?? CertCcNoteMetadata.Empty;
|
||||
|
||||
var advisoryKey = BuildAdvisoryKey(metadata);
|
||||
var title = string.IsNullOrWhiteSpace(metadata.Title) ? advisoryKey : metadata.Title.Trim();
|
||||
var summary = ExtractSummary(metadata);
|
||||
|
||||
var aliases = BuildAliases(dto).ToArray();
|
||||
var references = BuildReferences(dto, metadata, sourceName, recordedAt).ToArray();
|
||||
var affectedPackages = BuildAffectedPackages(dto, metadata, sourceName, recordedAt).ToArray();
|
||||
|
||||
var provenance = new[]
|
||||
{
|
||||
new AdvisoryProvenance(sourceName, "document", document.Uri, fetchedAt),
|
||||
new AdvisoryProvenance(sourceName, "map", metadata.VuId ?? metadata.IdNumber ?? advisoryKey, recordedAt),
|
||||
};
|
||||
|
||||
return new Advisory(
|
||||
advisoryKey,
|
||||
title,
|
||||
summary,
|
||||
language: "en",
|
||||
metadata.Published?.ToUniversalTime(),
|
||||
metadata.Updated?.ToUniversalTime(),
|
||||
severity: null,
|
||||
exploitKnown: false,
|
||||
aliases,
|
||||
references,
|
||||
affectedPackages,
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance);
|
||||
}
|
||||
|
||||
private static string BuildAdvisoryKey(CertCcNoteMetadata metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return $"{AdvisoryPrefix}/{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
var vuKey = NormalizeVuId(metadata.VuId);
|
||||
if (vuKey.Length > 0)
|
||||
{
|
||||
return $"{AdvisoryPrefix}/{vuKey}";
|
||||
}
|
||||
|
||||
var id = SanitizeToken(metadata.IdNumber);
|
||||
if (id.Length > 0)
|
||||
{
|
||||
return $"{AdvisoryPrefix}/vu-{id}";
|
||||
}
|
||||
|
||||
return $"{AdvisoryPrefix}/{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string NormalizeVuId(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var digits = new string(value.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length > 0)
|
||||
{
|
||||
return $"vu-{digits}";
|
||||
}
|
||||
|
||||
var sanitized = value.Trim().ToLowerInvariant();
|
||||
sanitized = sanitized.Replace("vu#", "vu-", StringComparison.OrdinalIgnoreCase);
|
||||
sanitized = sanitized.Replace('#', '-');
|
||||
sanitized = sanitized.Replace(' ', '-');
|
||||
|
||||
return SanitizeToken(sanitized);
|
||||
}
|
||||
|
||||
private static string SanitizeToken(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var filtered = new string(trimmed
|
||||
.Select(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' ? ch : '-')
|
||||
.ToArray());
|
||||
|
||||
return filtered.Trim('-').ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static readonly Regex HtmlTagRegex = new("<[^>]+>", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex WhitespaceRegex = new("[ \t\f\r]+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex ParagraphRegex = new("<\\s*/?\\s*p[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
private static string? ExtractSummary(CertCcNoteMetadata metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var summary = string.IsNullOrWhiteSpace(metadata.Summary) ? metadata.Overview : metadata.Summary;
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return HtmlToPlainText(summary);
|
||||
}
|
||||
|
||||
private static string HtmlToPlainText(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = html
|
||||
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<li>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("</li>", "\n", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
normalized = ParagraphRegex.Replace(normalized, "\n");
|
||||
|
||||
var withoutTags = HtmlTagRegex.Replace(normalized, " ");
|
||||
var decoded = WebUtility.HtmlDecode(withoutTags) ?? string.Empty;
|
||||
var collapsedSpaces = WhitespaceRegex.Replace(decoded, " ");
|
||||
var collapsedNewlines = Regex.Replace(collapsedSpaces, "\n{2,}", "\n", RegexOptions.Compiled);
|
||||
return collapsedNewlines.Trim();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildAliases(CertCcNoteDto dto)
|
||||
{
|
||||
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var metadata = dto.Metadata ?? CertCcNoteMetadata.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.VuId))
|
||||
{
|
||||
aliases.Add(metadata.VuId.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.IdNumber))
|
||||
{
|
||||
aliases.Add($"VU#{metadata.IdNumber.Trim()}");
|
||||
}
|
||||
|
||||
foreach (var cve in metadata.CveIds ?? Array.Empty<string>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
aliases.Add(cve.Trim());
|
||||
}
|
||||
|
||||
foreach (var vulnerability in dto.Vulnerabilities ?? Array.Empty<CertCcVulnerabilityDto>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerability.CveId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
aliases.Add(vulnerability.CveId.Trim());
|
||||
}
|
||||
|
||||
return aliases.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<AdvisoryReference> BuildReferences(
|
||||
CertCcNoteDto dto,
|
||||
CertCcNoteMetadata metadata,
|
||||
string sourceName,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
var references = new List<AdvisoryReference>();
|
||||
var canonicalUri = !string.IsNullOrWhiteSpace(metadata.PrimaryUrl)
|
||||
? metadata.PrimaryUrl!
|
||||
: (string.IsNullOrWhiteSpace(metadata.IdNumber)
|
||||
? "https://www.kb.cert.org/vuls/"
|
||||
: $"https://www.kb.cert.org/vuls/id/{metadata.IdNumber.Trim()}/");
|
||||
|
||||
var provenance = new AdvisoryProvenance(sourceName, "reference", canonicalUri, recordedAt);
|
||||
|
||||
TryAddReference(references, canonicalUri, "advisory", "certcc.note", null, provenance);
|
||||
|
||||
foreach (var url in metadata.PublicUrls ?? Array.Empty<string>())
|
||||
{
|
||||
TryAddReference(references, url, "reference", "certcc.public", null, provenance);
|
||||
}
|
||||
|
||||
foreach (var vendor in dto.Vendors ?? Array.Empty<CertCcVendorDto>())
|
||||
{
|
||||
foreach (var url in vendor.References ?? Array.Empty<string>())
|
||||
{
|
||||
TryAddReference(references, url, "reference", "certcc.vendor", vendor.Vendor, provenance);
|
||||
}
|
||||
|
||||
var statementText = vendor.Statement ?? string.Empty;
|
||||
var patches = CertCcVendorStatementParser.Parse(statementText);
|
||||
foreach (var patch in patches)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(patch.RawLine) && TryFindEmbeddedUrl(patch.RawLine!, out var rawUrl))
|
||||
{
|
||||
TryAddReference(references, rawUrl, "reference", "certcc.vendor.statement", vendor.Vendor, provenance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var status in dto.VendorStatuses ?? Array.Empty<CertCcVendorStatusDto>())
|
||||
{
|
||||
foreach (var url in status.References ?? Array.Empty<string>())
|
||||
{
|
||||
TryAddReference(references, url, "reference", "certcc.vendor.status", status.Vendor, provenance);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status.Statement) && TryFindEmbeddedUrl(status.Statement!, out var embedded))
|
||||
{
|
||||
TryAddReference(references, embedded, "reference", "certcc.vendor.status", status.Vendor, provenance);
|
||||
}
|
||||
}
|
||||
|
||||
return references
|
||||
.GroupBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(static group => group
|
||||
.OrderBy(static reference => reference.Kind ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static reference => reference.SourceTag ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.First())
|
||||
.OrderBy(static reference => reference.Kind ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void TryAddReference(
|
||||
ICollection<AdvisoryReference> references,
|
||||
string? url,
|
||||
string kind,
|
||||
string? sourceTag,
|
||||
string? summary,
|
||||
AdvisoryProvenance provenance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var candidate = url.Trim();
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = parsed.ToString();
|
||||
|
||||
try
|
||||
{
|
||||
references.Add(new AdvisoryReference(normalized, kind, sourceTag, summary, provenance));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// ignore invalid references
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryFindEmbeddedUrl(string text, out string? url)
|
||||
{
|
||||
url = null;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var tokens = text.Split(new[] { ' ', '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var trimmed = token.Trim().TrimEnd('.', ',', ')', ';', ']', '}');
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
url = parsed.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<AffectedPackage> BuildAffectedPackages(
|
||||
CertCcNoteDto dto,
|
||||
CertCcNoteMetadata metadata,
|
||||
string sourceName,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
var vendors = dto.Vendors ?? Array.Empty<CertCcVendorDto>();
|
||||
var statuses = dto.VendorStatuses ?? Array.Empty<CertCcVendorStatusDto>();
|
||||
|
||||
if (vendors.Count == 0 && statuses.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackage>();
|
||||
}
|
||||
|
||||
var statusLookup = statuses
|
||||
.GroupBy(static status => NormalizeVendorKey(status.Vendor))
|
||||
.ToDictionary(static group => group.Key, static group => group.ToArray(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var packages = new List<AffectedPackage>();
|
||||
|
||||
foreach (var vendor in vendors.OrderBy(static v => v.Vendor, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var key = NormalizeVendorKey(vendor.Vendor);
|
||||
var vendorStatuses = statusLookup.TryGetValue(key, out var value)
|
||||
? value
|
||||
: Array.Empty<CertCcVendorStatusDto>();
|
||||
|
||||
if (BuildVendorPackage(vendor, vendorStatuses, sourceName, recordedAt) is { } package)
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
|
||||
statusLookup.Remove(key);
|
||||
}
|
||||
|
||||
foreach (var remaining in statusLookup.Values)
|
||||
{
|
||||
if (remaining.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vendorName = remaining[0].Vendor;
|
||||
var fallbackVendor = new CertCcVendorDto(
|
||||
vendorName,
|
||||
ContactDate: null,
|
||||
StatementDate: null,
|
||||
Updated: remaining
|
||||
.Select(static status => status.DateUpdated)
|
||||
.Where(static update => update.HasValue)
|
||||
.OrderByDescending(static update => update)
|
||||
.FirstOrDefault(),
|
||||
Statement: remaining
|
||||
.Select(static status => status.Statement)
|
||||
.FirstOrDefault(static statement => !string.IsNullOrWhiteSpace(statement)),
|
||||
Addendum: null,
|
||||
References: remaining
|
||||
.SelectMany(static status => status.References ?? Array.Empty<string>())
|
||||
.ToArray());
|
||||
|
||||
if (BuildVendorPackage(fallbackVendor, remaining, sourceName, recordedAt) is { } package)
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
|
||||
return packages
|
||||
.OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AffectedPackage? BuildVendorPackage(
|
||||
CertCcVendorDto vendor,
|
||||
IReadOnlyList<CertCcVendorStatusDto> statuses,
|
||||
string sourceName,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
var vendorName = string.IsNullOrWhiteSpace(vendor.Vendor)
|
||||
? (statuses.FirstOrDefault()?.Vendor?.Trim() ?? string.Empty)
|
||||
: vendor.Vendor.Trim();
|
||||
|
||||
if (vendorName.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var packageProvenance = new AdvisoryProvenance(sourceName, "vendor", vendorName, recordedAt);
|
||||
var rangeProvenance = new AdvisoryProvenance(sourceName, "vendor-range", vendorName, recordedAt);
|
||||
|
||||
var patches = CertCcVendorStatementParser.Parse(vendor.Statement ?? string.Empty);
|
||||
var normalizedVersions = BuildNormalizedVersions(vendorName, patches);
|
||||
var vendorStatuses = BuildStatuses(vendorName, statuses, sourceName, recordedAt);
|
||||
var primitives = BuildRangePrimitives(vendor, vendorStatuses, patches);
|
||||
|
||||
var range = new AffectedVersionRange(
|
||||
rangeKind: "vendor",
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: null,
|
||||
provenance: rangeProvenance,
|
||||
primitives: primitives);
|
||||
|
||||
return new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
vendorName,
|
||||
platform: null,
|
||||
versionRanges: new[] { range },
|
||||
normalizedVersions: normalizedVersions,
|
||||
statuses: vendorStatuses,
|
||||
provenance: new[] { packageProvenance });
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
|
||||
string vendorName,
|
||||
IReadOnlyList<CertCcVendorPatch> patches)
|
||||
{
|
||||
if (patches.Count == 0)
|
||||
{
|
||||
return Array.Empty<NormalizedVersionRule>();
|
||||
}
|
||||
|
||||
var rules = new List<NormalizedVersionRule>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var patch in patches)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(patch.Version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var version = patch.Version.Trim();
|
||||
if (!seen.Add($"{patch.Product}|{version}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var notes = string.IsNullOrWhiteSpace(patch.Product)
|
||||
? vendorName
|
||||
: $"{vendorName}::{patch.Product.Trim()}";
|
||||
|
||||
rules.Add(new NormalizedVersionRule(
|
||||
VendorNormalizedVersionScheme,
|
||||
NormalizedVersionRuleTypes.Exact,
|
||||
value: version,
|
||||
notes: notes));
|
||||
}
|
||||
|
||||
return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedPackageStatus> BuildStatuses(
|
||||
string vendorName,
|
||||
IReadOnlyList<CertCcVendorStatusDto> statuses,
|
||||
string sourceName,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
if (statuses.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackageStatus>();
|
||||
}
|
||||
|
||||
var result = new List<AffectedPackageStatus>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
if (!AffectedPackageStatusCatalog.TryNormalize(status.Status, out var normalized))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cve = status.CveId?.Trim() ?? string.Empty;
|
||||
var key = string.IsNullOrWhiteSpace(cve)
|
||||
? normalized
|
||||
: $"{normalized}|{cve}";
|
||||
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var provenance = new AdvisoryProvenance(
|
||||
sourceName,
|
||||
"vendor-status",
|
||||
string.IsNullOrWhiteSpace(cve) ? vendorName : $"{vendorName}:{cve}",
|
||||
recordedAt);
|
||||
|
||||
result.Add(new AffectedPackageStatus(normalized, provenance));
|
||||
}
|
||||
|
||||
return result
|
||||
.OrderBy(static status => status.Status, StringComparer.Ordinal)
|
||||
.ThenBy(static status => status.Provenance.Value ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static RangePrimitives? BuildRangePrimitives(
|
||||
CertCcVendorDto vendor,
|
||||
IReadOnlyList<AffectedPackageStatus> statuses,
|
||||
IReadOnlyList<CertCcVendorPatch> patches)
|
||||
{
|
||||
var extensions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AddVendorExtension(extensions, "certcc.vendor.name", vendor.Vendor);
|
||||
AddVendorExtension(extensions, "certcc.vendor.statement.raw", HtmlToPlainText(vendor.Statement ?? string.Empty), 2048);
|
||||
AddVendorExtension(extensions, "certcc.vendor.addendum", HtmlToPlainText(vendor.Addendum ?? string.Empty), 1024);
|
||||
AddVendorExtension(extensions, "certcc.vendor.contactDate", FormatDate(vendor.ContactDate));
|
||||
AddVendorExtension(extensions, "certcc.vendor.statementDate", FormatDate(vendor.StatementDate));
|
||||
AddVendorExtension(extensions, "certcc.vendor.updated", FormatDate(vendor.Updated));
|
||||
|
||||
if (vendor.References is { Count: > 0 })
|
||||
{
|
||||
AddVendorExtension(extensions, "certcc.vendor.references", string.Join(" ", vendor.References));
|
||||
}
|
||||
|
||||
if (statuses.Count > 0)
|
||||
{
|
||||
var serialized = string.Join(";", statuses
|
||||
.Select(static status => status.Provenance.Value is { Length: > 0 }
|
||||
? $"{status.Provenance.Value.Split(':').Last()}={status.Status}"
|
||||
: status.Status));
|
||||
|
||||
AddVendorExtension(extensions, "certcc.vendor.statuses", serialized);
|
||||
}
|
||||
|
||||
if (patches.Count > 0)
|
||||
{
|
||||
var serialized = string.Join(";", patches.Select(static patch =>
|
||||
{
|
||||
var product = string.IsNullOrWhiteSpace(patch.Product) ? "unknown" : patch.Product.Trim();
|
||||
return $"{product}={patch.Version.Trim()}";
|
||||
}));
|
||||
|
||||
AddVendorExtension(extensions, "certcc.vendor.patches", serialized, 2048);
|
||||
}
|
||||
|
||||
return extensions.Count == 0
|
||||
? null
|
||||
: new RangePrimitives(null, null, null, extensions);
|
||||
}
|
||||
|
||||
private static void AddVendorExtension(IDictionary<string, string> extensions, string key, string? value, int maxLength = 512)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length > maxLength)
|
||||
{
|
||||
trimmed = trimmed[..maxLength].Trim();
|
||||
}
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
extensions[key] = trimmed;
|
||||
}
|
||||
|
||||
private static string? FormatDate(DateTimeOffset? value)
|
||||
=> value?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string NormalizeVendorKey(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
internal sealed record CertCcNoteDto(
|
||||
CertCcNoteMetadata Metadata,
|
||||
IReadOnlyList<CertCcVendorDto> Vendors,
|
||||
IReadOnlyList<CertCcVendorStatusDto> VendorStatuses,
|
||||
IReadOnlyList<CertCcVulnerabilityDto> Vulnerabilities)
|
||||
{
|
||||
public static CertCcNoteDto Empty { get; } = new(
|
||||
CertCcNoteMetadata.Empty,
|
||||
Array.Empty<CertCcVendorDto>(),
|
||||
Array.Empty<CertCcVendorStatusDto>(),
|
||||
Array.Empty<CertCcVulnerabilityDto>());
|
||||
}
|
||||
|
||||
internal sealed record CertCcNoteMetadata(
|
||||
string? VuId,
|
||||
string IdNumber,
|
||||
string Title,
|
||||
string? Overview,
|
||||
string? Summary,
|
||||
DateTimeOffset? Published,
|
||||
DateTimeOffset? Updated,
|
||||
DateTimeOffset? Created,
|
||||
int? Revision,
|
||||
IReadOnlyList<string> CveIds,
|
||||
IReadOnlyList<string> PublicUrls,
|
||||
string? PrimaryUrl)
|
||||
{
|
||||
public static CertCcNoteMetadata Empty { get; } = new(
|
||||
VuId: null,
|
||||
IdNumber: string.Empty,
|
||||
Title: string.Empty,
|
||||
Overview: null,
|
||||
Summary: null,
|
||||
Published: null,
|
||||
Updated: null,
|
||||
Created: null,
|
||||
Revision: null,
|
||||
CveIds: Array.Empty<string>(),
|
||||
PublicUrls: Array.Empty<string>(),
|
||||
PrimaryUrl: null);
|
||||
}
|
||||
|
||||
internal sealed record CertCcVendorDto(
|
||||
string Vendor,
|
||||
DateTimeOffset? ContactDate,
|
||||
DateTimeOffset? StatementDate,
|
||||
DateTimeOffset? Updated,
|
||||
string? Statement,
|
||||
string? Addendum,
|
||||
IReadOnlyList<string> References)
|
||||
{
|
||||
public static CertCcVendorDto Empty { get; } = new(
|
||||
Vendor: string.Empty,
|
||||
ContactDate: null,
|
||||
StatementDate: null,
|
||||
Updated: null,
|
||||
Statement: null,
|
||||
Addendum: null,
|
||||
References: Array.Empty<string>());
|
||||
}
|
||||
|
||||
internal sealed record CertCcVendorStatusDto(
|
||||
string Vendor,
|
||||
string CveId,
|
||||
string Status,
|
||||
string? Statement,
|
||||
IReadOnlyList<string> References,
|
||||
DateTimeOffset? DateAdded,
|
||||
DateTimeOffset? DateUpdated)
|
||||
{
|
||||
public static CertCcVendorStatusDto Empty { get; } = new(
|
||||
Vendor: string.Empty,
|
||||
CveId: string.Empty,
|
||||
Status: string.Empty,
|
||||
Statement: null,
|
||||
References: Array.Empty<string>(),
|
||||
DateAdded: null,
|
||||
DateUpdated: null);
|
||||
}
|
||||
|
||||
internal sealed record CertCcVulnerabilityDto(
|
||||
string CveId,
|
||||
string? Description,
|
||||
DateTimeOffset? DateAdded,
|
||||
DateTimeOffset? DateUpdated)
|
||||
{
|
||||
public static CertCcVulnerabilityDto Empty { get; } = new(
|
||||
CveId: string.Empty,
|
||||
Description: null,
|
||||
DateAdded: null,
|
||||
DateUpdated: null);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Markdig;
|
||||
using StellaOps.Concelier.Connector.Common.Html;
|
||||
using StellaOps.Concelier.Connector.Common.Url;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
internal static class CertCcNoteParser
|
||||
{
|
||||
private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
|
||||
.UseAdvancedExtensions()
|
||||
.UseSoftlineBreakAsHardlineBreak()
|
||||
.DisableHtml()
|
||||
.Build();
|
||||
|
||||
private static readonly HtmlContentSanitizer HtmlSanitizer = new();
|
||||
private static readonly Regex HtmlTagRegex = new("<[^>]+>", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public static CertCcNoteDto Parse(
|
||||
ReadOnlySpan<byte> noteJson,
|
||||
ReadOnlySpan<byte> vendorsJson,
|
||||
ReadOnlySpan<byte> vulnerabilitiesJson,
|
||||
ReadOnlySpan<byte> vendorStatusesJson)
|
||||
{
|
||||
using var noteDocument = JsonDocument.Parse(noteJson.ToArray());
|
||||
var (metadata, detailUri) = ParseNoteMetadata(noteDocument.RootElement);
|
||||
|
||||
using var vendorsDocument = JsonDocument.Parse(vendorsJson.ToArray());
|
||||
var vendors = ParseVendors(vendorsDocument.RootElement, detailUri);
|
||||
|
||||
using var vulnerabilitiesDocument = JsonDocument.Parse(vulnerabilitiesJson.ToArray());
|
||||
var vulnerabilities = ParseVulnerabilities(vulnerabilitiesDocument.RootElement);
|
||||
|
||||
using var statusesDocument = JsonDocument.Parse(vendorStatusesJson.ToArray());
|
||||
var statuses = ParseVendorStatuses(statusesDocument.RootElement);
|
||||
|
||||
return new CertCcNoteDto(metadata, vendors, statuses, vulnerabilities);
|
||||
}
|
||||
|
||||
public static CertCcNoteDto ParseNote(ReadOnlySpan<byte> noteJson)
|
||||
{
|
||||
using var noteDocument = JsonDocument.Parse(noteJson.ToArray());
|
||||
var (metadata, _) = ParseNoteMetadata(noteDocument.RootElement);
|
||||
return new CertCcNoteDto(metadata, Array.Empty<CertCcVendorDto>(), Array.Empty<CertCcVendorStatusDto>(), Array.Empty<CertCcVulnerabilityDto>());
|
||||
}
|
||||
|
||||
private static (CertCcNoteMetadata Metadata, Uri DetailUri) ParseNoteMetadata(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new JsonException("CERT/CC note payload must be a JSON object.");
|
||||
}
|
||||
|
||||
var vuId = GetString(root, "vuid");
|
||||
var idNumber = GetString(root, "idnumber") ?? throw new JsonException("CERT/CC note missing idnumber.");
|
||||
var title = GetString(root, "name") ?? throw new JsonException("CERT/CC note missing name.");
|
||||
var detailUri = BuildDetailUri(idNumber);
|
||||
|
||||
var overview = NormalizeMarkdownToPlainText(root, "overview", detailUri);
|
||||
var summary = NormalizeMarkdownToPlainText(root, "clean_desc", detailUri);
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
summary = NormalizeMarkdownToPlainText(root, "impact", detailUri);
|
||||
}
|
||||
|
||||
var published = ParseDate(root, "publicdate") ?? ParseDate(root, "datefirstpublished");
|
||||
var updated = ParseDate(root, "dateupdated");
|
||||
var created = ParseDate(root, "datecreated");
|
||||
var revision = ParseInt(root, "revision");
|
||||
|
||||
var cveIds = ExtractCveIds(root, "cveids");
|
||||
var references = ExtractReferenceList(root, "public", detailUri);
|
||||
|
||||
var metadata = new CertCcNoteMetadata(
|
||||
VuId: string.IsNullOrWhiteSpace(vuId) ? null : vuId.Trim(),
|
||||
IdNumber: idNumber.Trim(),
|
||||
Title: title.Trim(),
|
||||
Overview: overview,
|
||||
Summary: summary,
|
||||
Published: published?.ToUniversalTime(),
|
||||
Updated: updated?.ToUniversalTime(),
|
||||
Created: created?.ToUniversalTime(),
|
||||
Revision: revision,
|
||||
CveIds: cveIds,
|
||||
PublicUrls: references,
|
||||
PrimaryUrl: detailUri.ToString());
|
||||
|
||||
return (metadata, detailUri);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CertCcVendorDto> ParseVendors(JsonElement root, Uri baseUri)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() == 0)
|
||||
{
|
||||
return Array.Empty<CertCcVendorDto>();
|
||||
}
|
||||
|
||||
var parsed = new List<CertCcVendorDto>(root.GetArrayLength());
|
||||
foreach (var element in root.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vendor = GetString(element, "vendor");
|
||||
if (string.IsNullOrWhiteSpace(vendor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var statement = NormalizeFreeformText(GetString(element, "statement"));
|
||||
var addendum = NormalizeFreeformText(GetString(element, "addendum"));
|
||||
var references = ExtractReferenceStringList(GetString(element, "references"), baseUri);
|
||||
|
||||
parsed.Add(new CertCcVendorDto(
|
||||
vendor.Trim(),
|
||||
ContactDate: ParseDate(element, "contact_date"),
|
||||
StatementDate: ParseDate(element, "statement_date"),
|
||||
Updated: ParseDate(element, "dateupdated"),
|
||||
Statement: statement,
|
||||
Addendum: addendum,
|
||||
References: references));
|
||||
}
|
||||
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
return Array.Empty<CertCcVendorDto>();
|
||||
}
|
||||
|
||||
return parsed
|
||||
.OrderBy(static vendor => vendor.Vendor, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CertCcVulnerabilityDto> ParseVulnerabilities(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() == 0)
|
||||
{
|
||||
return Array.Empty<CertCcVulnerabilityDto>();
|
||||
}
|
||||
|
||||
var parsed = new List<CertCcVulnerabilityDto>(root.GetArrayLength());
|
||||
foreach (var element in root.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cve = GetString(element, "cve");
|
||||
if (string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed.Add(new CertCcVulnerabilityDto(
|
||||
NormalizeCve(cve),
|
||||
Description: NormalizeFreeformText(GetString(element, "description")),
|
||||
DateAdded: ParseDate(element, "date_added"),
|
||||
DateUpdated: ParseDate(element, "dateupdated")));
|
||||
}
|
||||
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
return Array.Empty<CertCcVulnerabilityDto>();
|
||||
}
|
||||
|
||||
return parsed
|
||||
.OrderBy(static vuln => vuln.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CertCcVendorStatusDto> ParseVendorStatuses(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() == 0)
|
||||
{
|
||||
return Array.Empty<CertCcVendorStatusDto>();
|
||||
}
|
||||
|
||||
var parsed = new List<CertCcVendorStatusDto>(root.GetArrayLength());
|
||||
foreach (var element in root.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vendor = GetString(element, "vendor");
|
||||
var cve = GetString(element, "vul");
|
||||
var status = GetString(element, "status");
|
||||
if (string.IsNullOrWhiteSpace(vendor) || string.IsNullOrWhiteSpace(cve) || string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var references = ExtractReferenceStringList(GetString(element, "references"), baseUri: null);
|
||||
parsed.Add(new CertCcVendorStatusDto(
|
||||
vendor.Trim(),
|
||||
NormalizeCve(cve),
|
||||
status.Trim(),
|
||||
NormalizeFreeformText(GetString(element, "statement")),
|
||||
references,
|
||||
DateAdded: ParseDate(element, "date_added"),
|
||||
DateUpdated: ParseDate(element, "dateupdated")));
|
||||
}
|
||||
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
return Array.Empty<CertCcVendorStatusDto>();
|
||||
}
|
||||
|
||||
return parsed
|
||||
.OrderBy(static entry => entry.CveId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static entry => entry.Vendor, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string? NormalizeMarkdownToPlainText(JsonElement element, string propertyName, Uri baseUri)
|
||||
=> NormalizeMarkdownToPlainText(GetString(element, propertyName), baseUri);
|
||||
|
||||
private static string? NormalizeMarkdownToPlainText(string? markdown, Uri baseUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(markdown))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = NormalizeLineEndings(markdown.Trim());
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var html = Markdig.Markdown.ToHtml(normalized, MarkdownPipeline);
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sanitized = HtmlSanitizer.Sanitize(html, baseUri);
|
||||
if (string.IsNullOrWhiteSpace(sanitized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plain = ConvertHtmlToPlainText(sanitized);
|
||||
return string.IsNullOrWhiteSpace(plain) ? null : plain;
|
||||
}
|
||||
|
||||
private static string? NormalizeFreeformText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = NormalizeLineEndings(value).Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lines = normalized
|
||||
.Split('\n')
|
||||
.Select(static line => line.TrimEnd())
|
||||
.ToArray();
|
||||
|
||||
return string.Join('\n', lines).Trim();
|
||||
}
|
||||
|
||||
private static string ConvertHtmlToPlainText(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var decoded = WebUtility.HtmlDecode(html);
|
||||
decoded = decoded
|
||||
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
decoded = Regex.Replace(decoded, "</p>", "\n\n", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
decoded = Regex.Replace(decoded, "</div>", "\n", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
decoded = Regex.Replace(decoded, "<li>", "- ", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
decoded = Regex.Replace(decoded, "</li>", "\n", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
decoded = Regex.Replace(decoded, "</tr>", "\n", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
decoded = Regex.Replace(decoded, "</td>", " \t", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
decoded = HtmlTagRegex.Replace(decoded, string.Empty);
|
||||
decoded = NormalizeLineEndings(decoded);
|
||||
|
||||
var lines = decoded
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(static line => line.Trim())
|
||||
.ToArray();
|
||||
|
||||
return string.Join('\n', lines).Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractReferenceList(JsonElement element, string propertyName, Uri baseUri)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var raw) || raw.ValueKind != JsonValueKind.Array || raw.GetArrayLength() == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var references = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var candidate in raw.EnumerateArray())
|
||||
{
|
||||
if (candidate.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = candidate.GetString();
|
||||
if (UrlNormalizer.TryNormalize(text, baseUri, out var normalized, stripFragment: true, forceHttps: false) && normalized is not null)
|
||||
{
|
||||
references.Add(normalized.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
if (references.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return references
|
||||
.OrderBy(static url => url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractReferenceStringList(string? value, Uri? baseUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var buffer = ArrayPool<string>.Shared.Rent(16);
|
||||
try
|
||||
{
|
||||
var count = 0;
|
||||
var span = value.AsSpan();
|
||||
var start = 0;
|
||||
|
||||
for (var index = 0; index < span.Length; index++)
|
||||
{
|
||||
var ch = span[index];
|
||||
if (ch == '\r' || ch == '\n')
|
||||
{
|
||||
if (index > start)
|
||||
{
|
||||
AppendSegment(span, start, index - start, baseUri, buffer, ref count);
|
||||
}
|
||||
|
||||
if (ch == '\r' && index + 1 < span.Length && span[index + 1] == '\n')
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (start < span.Length)
|
||||
{
|
||||
AppendSegment(span, start, span.Length - start, baseUri, buffer, ref count);
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return buffer.AsSpan(0, count)
|
||||
.ToArray()
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static url => url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<string>.Shared.Return(buffer, clearArray: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendSegment(ReadOnlySpan<char> span, int start, int length, Uri? baseUri, string[] buffer, ref int count)
|
||||
{
|
||||
var segment = span.Slice(start, length).ToString().Trim();
|
||||
if (segment.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!UrlNormalizer.TryNormalize(segment, baseUri, out var normalized, stripFragment: true, forceHttps: false) || normalized is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (count >= buffer.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
buffer[count++] = normalized.ToString();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractCveIds(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var raw) || raw.ValueKind != JsonValueKind.Array || raw.GetArrayLength() == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var values = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in raw.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = entry.GetString();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values.Add(NormalizeCve(text));
|
||||
}
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return values
|
||||
.OrderBy(static id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeCve(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (!trimmed.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = $"CVE-{trimmed}";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(trimmed.Length);
|
||||
foreach (var ch in trimmed)
|
||||
{
|
||||
builder.Append(char.ToUpperInvariant(ch));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
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 => property.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDate(JsonElement element, string propertyName)
|
||||
{
|
||||
var text = GetString(element, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed.ToUniversalTime()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int? ParseInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var text = GetString(element, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : (int?)null;
|
||||
}
|
||||
|
||||
private static Uri BuildDetailUri(string idNumber)
|
||||
{
|
||||
var sanitized = idNumber.Trim();
|
||||
return new Uri($"https://www.kb.cert.org/vuls/id/{sanitized}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string value)
|
||||
{
|
||||
if (value.IndexOf('\r') < 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
internal static class CertCcSummaryParser
|
||||
{
|
||||
public static IReadOnlyList<string> ParseNotes(byte[] payload)
|
||||
{
|
||||
if (payload is null || payload.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(payload, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
});
|
||||
|
||||
var notesElement = document.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object when document.RootElement.TryGetProperty("notes", out var notes) => notes,
|
||||
JsonValueKind.Array => document.RootElement,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => default,
|
||||
_ => throw new JsonException("CERT/CC summary payload must contain a 'notes' array."),
|
||||
};
|
||||
|
||||
if (notesElement.ValueKind != JsonValueKind.Array || notesElement.GetArrayLength() == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var results = new List<string>(notesElement.GetArrayLength());
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var element in notesElement.EnumerateArray())
|
||||
{
|
||||
var token = ExtractToken(element);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = token.Trim();
|
||||
var dedupKey = CreateDedupKey(normalized);
|
||||
if (seen.Add(dedupKey))
|
||||
{
|
||||
results.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return results.Count == 0 ? Array.Empty<string>() : results;
|
||||
}
|
||||
|
||||
private static string CreateDedupKey(string token)
|
||||
{
|
||||
var digits = string.Concat(token.Where(char.IsDigit));
|
||||
return digits.Length > 0
|
||||
? digits
|
||||
: token.Trim().ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static string? ExtractToken(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var number)
|
||||
? number.ToString(CultureInfo.InvariantCulture)
|
||||
: element.GetRawText(),
|
||||
JsonValueKind.Object => ExtractFromObject(element),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractFromObject(JsonElement element)
|
||||
{
|
||||
foreach (var propertyName in PropertyCandidates)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = property.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly string[] PropertyCandidates =
|
||||
{
|
||||
"note",
|
||||
"notes",
|
||||
"id",
|
||||
"idnumber",
|
||||
"noteId",
|
||||
"vu",
|
||||
"vuid",
|
||||
"vuId",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
public sealed record CertCcSummaryPlan(
|
||||
TimeWindow Window,
|
||||
IReadOnlyList<CertCcSummaryRequest> Requests,
|
||||
TimeWindowCursorState NextState);
|
||||
|
||||
public enum CertCcSummaryScope
|
||||
{
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
public sealed record CertCcSummaryRequest(
|
||||
Uri Uri,
|
||||
CertCcSummaryScope Scope,
|
||||
int Year,
|
||||
int? Month);
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.CertCc.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common.Cursors;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Computes which CERT/CC summary endpoints should be fetched for the next export window.
|
||||
/// </summary>
|
||||
public sealed class CertCcSummaryPlanner
|
||||
{
|
||||
private readonly CertCcOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CertCcSummaryPlanner(
|
||||
IOptions<CertCcOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Validate();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public CertCcSummaryPlan CreatePlan(TimeWindowCursorState? state)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = TimeWindowCursorPlanner.GetNextWindow(now, state, _options.SummaryWindow);
|
||||
var nextState = (state ?? TimeWindowCursorState.Empty).WithWindow(window);
|
||||
|
||||
var months = EnumerateYearMonths(window.Start, window.End)
|
||||
.Take(_options.MaxMonthlySummaries)
|
||||
.ToArray();
|
||||
|
||||
if (months.Length == 0)
|
||||
{
|
||||
return new CertCcSummaryPlan(window, Array.Empty<CertCcSummaryRequest>(), nextState);
|
||||
}
|
||||
|
||||
var requests = new List<CertCcSummaryRequest>(months.Length * 2);
|
||||
foreach (var month in months)
|
||||
{
|
||||
requests.Add(new CertCcSummaryRequest(
|
||||
BuildMonthlyUri(month.Year, month.Month),
|
||||
CertCcSummaryScope.Monthly,
|
||||
month.Year,
|
||||
month.Month));
|
||||
}
|
||||
|
||||
foreach (var year in months.Select(static value => value.Year).Distinct().OrderBy(static year => year))
|
||||
{
|
||||
requests.Add(new CertCcSummaryRequest(
|
||||
BuildYearlyUri(year),
|
||||
CertCcSummaryScope.Yearly,
|
||||
year,
|
||||
Month: null));
|
||||
}
|
||||
|
||||
return new CertCcSummaryPlan(window, requests, nextState);
|
||||
}
|
||||
|
||||
private Uri BuildMonthlyUri(int year, int month)
|
||||
{
|
||||
var path = $"{year:D4}/{month:D2}/summary/";
|
||||
return new Uri(_options.BaseApiUri, path);
|
||||
}
|
||||
|
||||
private Uri BuildYearlyUri(int year)
|
||||
{
|
||||
var path = $"{year:D4}/summary/";
|
||||
return new Uri(_options.BaseApiUri, path);
|
||||
}
|
||||
|
||||
private static IEnumerable<(int Year, int Month)> EnumerateYearMonths(DateTimeOffset start, DateTimeOffset end)
|
||||
{
|
||||
if (end <= start)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var cursor = new DateTime(start.Year, start.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var limit = new DateTime(end.Year, end.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
if (end.Day != 1 || end.TimeOfDay != TimeSpan.Zero)
|
||||
{
|
||||
limit = limit.AddMonths(1);
|
||||
}
|
||||
|
||||
while (cursor < limit)
|
||||
{
|
||||
yield return (cursor.Year, cursor.Month);
|
||||
cursor = cursor.AddMonths(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc.Internal;
|
||||
|
||||
internal static class CertCcVendorStatementParser
|
||||
{
|
||||
private static readonly string[] PairSeparators =
|
||||
{
|
||||
"\t",
|
||||
" - ",
|
||||
" – ",
|
||||
" — ",
|
||||
" : ",
|
||||
": ",
|
||||
" :",
|
||||
":",
|
||||
};
|
||||
|
||||
private static readonly char[] BulletPrefixes = { '-', '*', '•', '+', '\t' };
|
||||
private static readonly char[] ProductDelimiters = { '/', ',', ';', '&' };
|
||||
|
||||
// Matches dotted numeric versions and simple alphanumeric suffixes (e.g., 4.4.3.6, 3.9.9.12, 10.2a)
|
||||
private static readonly Regex VersionTokenRegex = new(@"(?<![A-Za-z0-9])(\d+(?:\.\d+){1,3}(?:[A-Za-z0-9\-]+)?)", RegexOptions.Compiled);
|
||||
|
||||
public static IReadOnlyList<CertCcVendorPatch> Parse(string? statement)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statement))
|
||||
{
|
||||
return Array.Empty<CertCcVendorPatch>();
|
||||
}
|
||||
|
||||
var patches = new List<CertCcVendorPatch>();
|
||||
var lines = statement
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
line = TrimBulletPrefix(line);
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TrySplitLine(line, out var productSegment, out var versionSegment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var versions = ExtractVersions(versionSegment);
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var products = ExtractProducts(productSegment);
|
||||
if (products.Count == 0)
|
||||
{
|
||||
products.Add(string.Empty);
|
||||
}
|
||||
|
||||
if (versions.Count == products.Count)
|
||||
{
|
||||
for (var index = 0; index < versions.Count; index++)
|
||||
{
|
||||
patches.Add(new CertCcVendorPatch(products[index], versions[index], line));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (versions.Count > 1 && products.Count > versions.Count && products.Count % versions.Count == 0)
|
||||
{
|
||||
var groupSize = products.Count / versions.Count;
|
||||
for (var versionIndex = 0; versionIndex < versions.Count; versionIndex++)
|
||||
{
|
||||
var start = versionIndex * groupSize;
|
||||
var end = start + groupSize;
|
||||
var version = versions[versionIndex];
|
||||
for (var productIndex = start; productIndex < end && productIndex < products.Count; productIndex++)
|
||||
{
|
||||
patches.Add(new CertCcVendorPatch(products[productIndex], version, line));
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var primaryVersion = versions[0];
|
||||
foreach (var product in products)
|
||||
{
|
||||
patches.Add(new CertCcVendorPatch(product, primaryVersion, line));
|
||||
}
|
||||
}
|
||||
|
||||
if (patches.Count == 0)
|
||||
{
|
||||
return Array.Empty<CertCcVendorPatch>();
|
||||
}
|
||||
|
||||
return patches
|
||||
.Where(static patch => !string.IsNullOrWhiteSpace(patch.Version))
|
||||
.Distinct(CertCcVendorPatch.Comparer)
|
||||
.OrderBy(static patch => patch.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static patch => patch.Version, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string TrimBulletPrefix(string value)
|
||||
{
|
||||
var trimmed = value.TrimStart(BulletPrefixes).Trim();
|
||||
return trimmed.Length == 0 ? value.Trim() : trimmed;
|
||||
}
|
||||
|
||||
private static bool TrySplitLine(string line, out string productSegment, out string versionSegment)
|
||||
{
|
||||
foreach (var separator in PairSeparators)
|
||||
{
|
||||
var parts = line.Split(separator, 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
productSegment = parts[0];
|
||||
versionSegment = parts[1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var whitespaceSplit = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (whitespaceSplit.Length >= 2)
|
||||
{
|
||||
productSegment = string.Join(' ', whitespaceSplit[..^1]);
|
||||
versionSegment = whitespaceSplit[^1];
|
||||
return true;
|
||||
}
|
||||
|
||||
productSegment = string.Empty;
|
||||
versionSegment = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<string> ExtractProducts(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var normalized = segment.Replace('\t', ' ').Trim();
|
||||
var tokens = normalized
|
||||
.Split(ProductDelimiters, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(static token => token.Trim())
|
||||
.Where(static token => token.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static List<string> ExtractVersions(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var matches = VersionTokenRegex.Matches(segment);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var versions = new List<string>(matches.Count);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
if (match.Groups.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = match.Groups[1].Value.Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
versions.Add(value);
|
||||
}
|
||||
|
||||
return versions
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(32)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CertCcVendorPatch(string Product, string Version, string? RawLine)
|
||||
{
|
||||
public static IEqualityComparer<CertCcVendorPatch> Comparer { get; } = new CertCcVendorPatchComparer();
|
||||
|
||||
private sealed class CertCcVendorPatchComparer : IEqualityComparer<CertCcVendorPatch>
|
||||
{
|
||||
public bool Equals(CertCcVendorPatch? x, CertCcVendorPatch? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.Product, y.Product, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Version, y.Version, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public int GetHashCode(CertCcVendorPatch obj)
|
||||
{
|
||||
var product = obj.Product?.ToLowerInvariant() ?? string.Empty;
|
||||
var version = obj.Version?.ToLowerInvariant() ?? string.Empty;
|
||||
return HashCode.Combine(product, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertCc;
|
||||
|
||||
internal static class CertCcJobKinds
|
||||
{
|
||||
public const string Fetch = "source:cert-cc:fetch";
|
||||
}
|
||||
|
||||
internal sealed class CertCcFetchJob : IJob
|
||||
{
|
||||
private readonly CertCcConnector _connector;
|
||||
|
||||
public CertCcFetchJob(CertCcConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.FetchAsync(context.Services, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.CertCc.Tests")]
|
||||
@@ -0,0 +1,63 @@
|
||||
# CERT/CC Vulnerability Notes – Source Research
|
||||
|
||||
## Canonical publication endpoints
|
||||
|
||||
- **Public portal** – `https://www.kb.cert.org/vuls/` lists recently published Vulnerability Notes and exposes a “Subscribe to our feed” link for automation entry points.citeturn0search0
|
||||
- **Atom feed** – `https://www.kb.cert.org/vulfeed` returns an Atom 1.0 feed of the same notes (`<title>`, `<updated>`, `<summary>` HTML payload). Feed metadata advertises `rel="self"` at `https://kb.cert.org/vuls/atomfeed/`. Use conditional GET headers (`If-Modified-Since`, `If-None-Match`) to avoid refetching unchanged entries.citeturn0search2
|
||||
|
||||
## VINCE Vulnerability Note API
|
||||
|
||||
The VINCE documentation describes an unauthenticated REST-style API for structured retrieval:citeturn1view0
|
||||
|
||||
| Endpoint | Payload | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GET /vuls/api/{id}/` | Canonical note metadata (title, overview, markdown segments, timestamps, aliases). | Use numeric ID (e.g., `257161`). |
|
||||
| `GET /vuls/api/{id}/vuls/` | Per-CVE vulnerability records tied to the note. | Includes CVE, description, timestamps. |
|
||||
| `GET /vuls/api/{id}/vendors/` | Vendor statements per advisory. | Provides status text and optional references. |
|
||||
| `GET /vuls/api/{id}/vendors/vuls/` | Vendor × vulnerability status matrix. | “known_affected” vs “known_not_affected” semantics. |
|
||||
| `GET /vuls/api/vuls/cve/{cve}/` | Reverse lookup by CVE. | Returns combined note + vendor context. |
|
||||
| `GET /vuls/api/{year}/summary/` | Annual summary listing (`count`, `notes[]`). | Year-month variants exist (`/{year}/{month}/summary/`). |
|
||||
| `GET /vuls/api/{id}/csaf/` | CSAF 2.0 export generated by VINCE. | Useful for downstream CSAF tooling. |
|
||||
|
||||
Operational considerations:
|
||||
|
||||
- API responses are JSON (UTF-8) and publicly accessible; no authentication tokens or cookies are required.citeturn1view0
|
||||
- Monthly and annual summary endpoints enable incremental crawling without diffing the Atom feed.
|
||||
- Expect high-volume notes to expose dozens of vendor records—prepare batching and pagination at the connector layer even though the API returns full arrays today.
|
||||
- Apply polite backoff: the documentation does not publish explicit rate limits, but the kb.cert.org infrastructure throttles bursts; mirror existing backoff strategy (exponential with jitter) used by other connectors.
|
||||
- Detail fetch tolerates missing optional endpoints (`vendors`, `vendors-vuls`, `vuls`) by logging a warning and continuing with partial data; repeated 4xx responses will not wedge the cursor.
|
||||
|
||||
## Telemetry & monitoring
|
||||
|
||||
The connector exposes an OpenTelemetry meter named `StellaOps.Concelier.Connector.CertCc`. Key instruments include:
|
||||
|
||||
- Planning: `certcc.plan.windows`, `certcc.plan.requests`, and `certcc.plan.window_days`.
|
||||
- Summary fetch: `certcc.summary.fetch.attempts`, `.success`, `.not_modified`, `.failures`.
|
||||
- Detail fetch: `certcc.detail.fetch.attempts`, `.success`, `.unchanged`, `.missing`, `.failures` with an `endpoint` dimension (note/vendors/vuls/vendors-vuls).
|
||||
- Parsing: `certcc.parse.success`, `.failures`, plus histograms for vendor/status/vulnerability counts.
|
||||
- Mapping: `certcc.map.success`, `.failures`, and histograms `certcc.map.affected.count` / `certcc.map.normalized_versions.count`.
|
||||
|
||||
Structured logs surface correlation IDs across fetch, parse, and map stages. Failures emit warnings for tolerated missing endpoints and errors for retry-worthy conditions so operators can hook them into existing alert policies.
|
||||
|
||||
## Historical data sets
|
||||
|
||||
CERT/CC publishes a Vulnerability Data Archive (JSON exports plus tooling) for deep history or backfills. The archive is hosted on the SEI site with mirrored GitHub repositories containing normalized JSON conversions.citeturn0search3turn0search4
|
||||
|
||||
## Snapshot regression workflow
|
||||
|
||||
The connector ships deterministic fixtures so QA and Merge teams can replay fetch→parse→map without live calls. Use the following flow when validating changes or refreshing snapshots:
|
||||
|
||||
1. `dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests` – runs the connector snapshot suite against canned VINCE responses.
|
||||
2. `UPDATE_CERTCC_FIXTURES=1 dotnet test src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests` – regenerates fixtures under `src/Concelier/__Tests/StellaOps.Concelier.Connector.CertCc.Tests/Fixtures/*.snapshot.json` and mirrors them in the test output directory (`bin/Debug/net10.0/Source/CertCc/Fixtures`).
|
||||
- The harness now records every HTTP request; `certcc-requests.snapshot.json` must list summaries/months in canonical order.
|
||||
- Expect `certcc-advisories.snapshot.json` to include normalized versions (`scheme=certcc.vendor`) and provenance decision reasons.
|
||||
3. Review diffs and attach `certcc-*.snapshot.json` plus test logs when handing off to Merge.
|
||||
|
||||
Fixtures are sorted and timestamps normalized to UTC ISO‑8601 to preserve determinism across machines.
|
||||
|
||||
## Next steps for the connector
|
||||
|
||||
1. Implement Atom polling for quick detection, with VINCE API lookups for structured details. `CertCcSummaryPlanner` already computes the VINCE year/month summary URIs to fetch per window; wire this into the fetch job and persist the resulting `TimeWindowCursorState`.
|
||||
2. Persist `updated` timestamps and VINCE `revision` counters to drive resume logic.
|
||||
3. Capture vendor statements/CSAF exports to populate range primitives once model hooks exist.
|
||||
4. Evaluate using the data archive for seed fixtures covering legacy notes (pre-2010).***
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.31.0" />
|
||||
|
||||
<ProjectReference Include="../../../__Libraries/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" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|Document CERT/CC advisory sources|BE-Conn-CERTCC|Research|**DONE (2025-10-10)** – Catalogued Atom feed + VINCE API endpoints and archive references in `README.md`; include polling/backoff guidance.|
|
||||
|Fetch pipeline & state tracking|BE-Conn-CERTCC|Source.Common, Storage.Mongo|**DONE (2025-10-12)** – Summary planner + fetch job persist monthly/yearly VINCE JSON to `DocumentStore`, hydrate the `TimeWindowCursorState`, and snapshot regression (`dotnet test` 2025-10-12) confirmed deterministic resume behaviour.|
|
||||
|VINCE note detail fetcher|BE-Conn-CERTCC|Source.Common, Storage.Mongo|**DONE (2025-10-12)** – Detail bundle fetch now enqueues VU identifiers and persists note/vendors/vuls/vendors-vuls documents with ETag/Last-Modified metadata, tolerating missing optional endpoints without wedging the cursor.|
|
||||
|DTO & parser implementation|BE-Conn-CERTCC|Source.Common|**DONE (2025-10-12)** – VINCE DTO aggregate materialises note/vendor/vulnerability payloads, normalises markdown to HTML-safe fragments, and surfaces vendor impact statements covered by parser unit tests.|
|
||||
|Canonical mapping & range primitives|BE-Conn-CERTCC|Models|**DONE (2025-10-12)** – Mapper emits aliases (VU#, CVE), vendor range primitives, and normalizedVersions (`scheme=certcc.vendor`) with provenance masks; `certcc-advisories.snapshot.json` validates canonical output after schema sync.|
|
||||
|Deterministic fixtures/tests|QA|Testing|**DONE (2025-10-11)** – Snapshot harness regenerated (`certcc-*.snapshot.json`), request ordering assertions added, and `UPDATE_CERTCC_FIXTURES` workflow verified for CI determinism.|
|
||||
|Connector test harness remediation|BE-Conn-CERTCC, QA|Testing|**DONE (2025-10-11)** – Connector test harness now rebuilds `FakeTimeProvider`, wires `AddSourceCommon`, and drives canned VINCE responses across fetch→parse→map with recorded-request assertions.|
|
||||
|Snapshot coverage handoff|QA|Models, Merge|**DONE (2025-10-11)** – Fixtures + request/advisory snapshots refreshed, README documents `UPDATE_CERTCC_FIXTURES` workflow, and recorded-request ordering is enforced for QA handoff.|
|
||||
|FEEDCONN-CERTCC-02-010 Partial-detail graceful degradation|BE-Conn-CERTCC|Connector plan|**DONE (2025-10-12)** – Detail fetch now catches 404/410/403 responses for optional endpoints, logs missing bundles, feeds empty payloads into parsing, and ships regression coverage for mixed responses.|
|
||||
|FEEDCONN-CERTCC-02-012 Schema sync & snapshot regen follow-up|QA, BE-Conn-CERTCC|Models `FEEDMODELS-SCHEMA-01-001`/`-002`/`-003`, Storage `FEEDSTORAGE-DATA-02-001`|**DONE (2025-10-12)** – Snapshot suite rerun, fixtures updated, and handoff notes (`FEEDCONN-CERTCC-02-012_HANDOFF.md`) document normalizedVersions/provenance expectations for Merge backfill.|
|
||||
|Telemetry & documentation|DevEx|Docs|**DONE (2025-10-12)** – `CertCcDiagnostics` now publishes summary/detail/parse/map metrics, README documents meter names, and structured logging guidance is captured for Ops handoff.|
|
||||
Reference in New Issue
Block a user